feat(relay): messaging-plane config + provider seam + pure admission policy engine

First slice of the optional Nostr relay (messaging plane), behind a swappable
seam mirroring the PaymentProvider pattern so nostr-rs-relay can be replaced by
another relay (strfry/external) or another messaging protocol later.

- config: MessagingConfig { relay: RelayConfig } — RELAY_ENABLED (off by default),
  mode (bundled|external), advertised ws(s):// URL, write_policy
  (inbox-outbox default | author-allowlist | open), inbound_pow_bits (NIP-13),
  admission_bind, resource caps. validate() fail-closes (URL required + ws(s)://
  + wss in prod, known policy, PoW <= 40, admission bind for non-open). +7 tests.
- infra/relay: RelayProvider trait + from_config (feature-gated, fail-closed) +
  the default nostr-rs-relay/external adapter; AppState.relay wired
  (lib/main/tests).
- infra/relay/policy: PURE admission engine (WritePolicy, decide, NIP-13
  leading_zero_bits): open permits all; author-allowlist gates on a registered
  npub; inbox-outbox permits a registered author's outbox OR a kind-1059 gift-wrap
  addressed to a registered user (inbox), the external-inbound branch optionally
  behind a PoW gate. Registration lookups are resolved by the caller and passed as
  booleans so the decision is pure + fully unit-tested (10 tests).

The gRPC event-admission service that calls this engine, capabilities/NIP-05
advertising, packaging, and the client DM stack land in the following slices.
This commit is contained in:
jwinterm
2026-07-02 06:29:38 -04:00
parent 1181b5938a
commit a8f1a9e50c
7 changed files with 507 additions and 0 deletions
+175
View File
@@ -150,6 +150,7 @@ pub struct Config {
pub retention: RetentionConfig,
pub restore: RestoreConfig,
pub registration: RegistrationConfig,
pub messaging: MessagingConfig,
}
#[derive(Clone)]
@@ -506,6 +507,44 @@ pub struct PaymentConfig {
pub expires_minutes: u32,
}
/// Messaging plane (identity + encrypted delivery). Today it holds the optional
/// first-party Nostr relay; the seam leaves room for other messaging backends.
#[derive(Clone)]
pub struct MessagingConfig {
pub relay: RelayConfig,
}
/// Optional first-party Nostr relay. When `enabled`, the operator runs a relay
/// (the packaged `nostr-rs-relay`, or an external one) that the backend
/// ADVERTISES (NIP-05 / kind-10050) and, for a non-`open` policy, write-restricts
/// via a gRPC event-admission service. It is the user's INBOX relay, never the
/// only relay — clients also use the public interop relays. Self-hosting concern;
/// off by default. Does not derive `Debug` (URL/policy are fine, but kept
/// consistent with the other adapter configs).
#[derive(Clone)]
pub struct RelayConfig {
/// Master switch for the relay integration.
pub enabled: bool,
/// `bundled` (operator runs the packaged nostr-rs-relay) or `external`
/// (point at any relay the operator already runs). The seam handles both.
pub mode: String,
/// Public `wss://` URL clients connect to + we advertise.
pub advertised_url: String,
/// Admission policy the gRPC service enforces: `inbox-outbox` (default),
/// `author-allowlist`, or `open`.
pub write_policy: String,
/// NIP-13 proof-of-work bits required on cross-ecosystem (non-registered-
/// author) inbound events; `0` disables the PoW gate.
pub inbound_pow_bits: u8,
/// Loopback address the gRPC event-admission service binds (nostr-rs-relay
/// calls it per event). Only used when `write_policy != open`.
pub admission_bind: String,
/// Reject events larger than this many bytes (the relay enforces it too).
pub max_event_bytes: usize,
/// Event retention (days) — advisory for operator housekeeping/advertising.
pub retention_days: u32,
}
impl Config {
/// Load configuration from the environment and validate it (fail-closed).
pub fn from_env() -> Result<Self, AppError> {
@@ -691,6 +730,18 @@ impl Config {
expires_minutes: env_parse("PAYMENT_EXPIRES_MINUTES", 60u32)?,
},
},
messaging: MessagingConfig {
relay: RelayConfig {
enabled: env_bool("RELAY_ENABLED", false),
mode: env_or("RELAY_MODE", "bundled").to_lowercase(),
advertised_url: env_or("RELAY_URL", ""),
write_policy: env_or("RELAY_WRITE_POLICY", "inbox-outbox").to_lowercase(),
inbound_pow_bits: env_parse("RELAY_INBOUND_POW_BITS", 0u8)?,
admission_bind: env_or("RELAY_ADMISSION_BIND", "127.0.0.1:8090"),
max_event_bytes: env_parse("RELAY_MAX_EVENT_BYTES", 65536usize)?,
retention_days: env_parse("RELAY_RETENTION_DAYS", 30u32)?,
},
},
};
cfg.validate()?;
@@ -930,6 +981,49 @@ impl Config {
}
}
// ── Nostr relay (optional messaging plane) ──────────────────────────
if self.messaging.relay.enabled {
let r = &self.messaging.relay;
if !matches!(r.mode.as_str(), "bundled" | "external") {
return Err(cfg_err(format!(
"RELAY_MODE {:?} is not supported; use bundled|external",
r.mode
)));
}
if r.advertised_url.trim().is_empty() {
return Err(cfg_err(
"RELAY_URL is required when RELAY_ENABLED (the ws(s):// URL clients connect to)",
));
}
if !(r.advertised_url.starts_with("wss://") || r.advertised_url.starts_with("ws://")) {
return Err(cfg_err("RELAY_URL must be a ws:// or wss:// URL"));
}
if prod && r.advertised_url.starts_with("ws://") {
return Err(cfg_err("RELAY_URL must be wss:// in production"));
}
if !matches!(
r.write_policy.as_str(),
"inbox-outbox" | "author-allowlist" | "open"
) {
return Err(cfg_err(format!(
"RELAY_WRITE_POLICY {:?} is not supported; use inbox-outbox|author-allowlist|open",
r.write_policy
)));
}
// Bound PoW difficulty: 0 = off; a value this high is unsolvable in
// practice and would silently reject every inbound event.
if r.inbound_pow_bits > 40 {
return Err(cfg_err("RELAY_INBOUND_POW_BITS must be <= 40 (0 disables)"));
}
// A non-`open` policy is enforced by the gRPC admission service, which
// needs a bind address the relay can reach.
if r.write_policy != "open" && r.admission_bind.trim().is_empty() {
return Err(cfg_err(
"RELAY_ADMISSION_BIND is required for a non-open RELAY_WRITE_POLICY",
));
}
}
Ok(())
}
}
@@ -1064,6 +1158,18 @@ mod tests {
expires_minutes: 60,
},
},
messaging: MessagingConfig {
relay: RelayConfig {
enabled: false,
mode: "bundled".into(),
advertised_url: String::new(),
write_policy: "inbox-outbox".into(),
inbound_pow_bits: 0,
admission_bind: "127.0.0.1:8090".into(),
max_event_bytes: 65536,
retention_days: 30,
},
},
}
}
@@ -1344,6 +1450,75 @@ mod tests {
assert!(c.validate().is_err());
}
fn valid_relay() -> RelayConfig {
RelayConfig {
enabled: true,
mode: "bundled".into(),
advertised_url: "wss://relay.example.org".into(),
write_policy: "inbox-outbox".into(),
inbound_pow_bits: 20,
admission_bind: "127.0.0.1:8090".into(),
max_event_bytes: 65536,
retention_days: 30,
}
}
#[test]
fn relay_off_skips_validation() {
let mut c = valid();
c.messaging.relay.enabled = false;
c.messaging.relay.write_policy = "nonsense".into(); // ignored when off
assert!(c.validate().is_ok());
}
#[test]
fn relay_enabled_full_config_passes() {
let mut c = valid();
c.messaging.relay = valid_relay();
assert!(c.validate().is_ok());
}
#[test]
fn relay_enabled_requires_url() {
let mut c = valid();
c.messaging.relay = valid_relay();
c.messaging.relay.advertised_url = String::new();
assert!(c.validate().is_err());
}
#[test]
fn relay_rejects_non_ws_url() {
let mut c = valid();
c.messaging.relay = valid_relay();
c.messaging.relay.advertised_url = "https://relay.example.org".into();
assert!(c.validate().is_err());
}
#[test]
fn relay_rejects_unknown_policy() {
let mut c = valid();
c.messaging.relay = valid_relay();
c.messaging.relay.write_policy = "whitelist-only".into();
assert!(c.validate().is_err());
}
#[test]
fn relay_rejects_excessive_pow() {
let mut c = valid();
c.messaging.relay = valid_relay();
c.messaging.relay.inbound_pow_bits = 64;
assert!(c.validate().is_err());
}
#[test]
fn relay_rejects_ws_in_production() {
let mut c = valid();
c.environment = "production".into();
c.messaging.relay = valid_relay();
c.messaging.relay.advertised_url = "ws://relay.example.org".into();
assert!(c.validate().is_err());
}
#[test]
fn payment_requires_https_provider_url_in_production() {
let mut c = valid();
+1
View File
@@ -8,3 +8,4 @@ pub mod grin;
pub mod lws;
pub mod payment;
pub mod prices;
pub mod relay;
+103
View File
@@ -0,0 +1,103 @@
//! Optional first-party Nostr relay (messaging plane).
//!
//! The seam that lets `nostr-rs-relay` be swapped for another relay (strfry, an
//! external relay) or, later, a different messaging protocol — mirroring
//! `infra::payment` (trait + `from_config` → `Option<Arc<dyn …>>`, feature-gated,
//! fail-closed) and the client-side ChainProvider seam.
//!
//! The backend does not run the relay process (it never spawns subprocesses — it
//! connects to + advertises external services). Instead it: (1) advertises the
//! relay URL + policy (`/capabilities`, NIP-05 hints), and (2) for a non-`open`
//! write policy, enforces admission via `nostr-rs-relay`'s gRPC event-admission
//! hook, evaluated by the pure engine in [`policy`].
mod policy;
pub use policy::{decide, leading_zero_bits, Admit, EventMeta, WritePolicy, GIFT_WRAP_KIND};
use std::sync::Arc;
use crate::config::Config;
use crate::error::AppError;
/// NIPs the relay integration speaks (advertised via `/capabilities`).
/// 1 = basic protocol, 44 = encryption, 59 = gift-wrap, 17 = private DMs.
pub const SUPPORTED_NIPS: &[u16] = &[1, 17, 44, 59];
/// A messaging-relay backend. Kept thin: the write policy + PoW live in the pure
/// [`policy`] engine, and the admission transport (gRPC nauthz) is the adapter's
/// concern — a future strfry/other adapter can enforce the same policy over a
/// different mechanism.
pub trait RelayProvider: Send + Sync {
/// Adapter kind, e.g. `nostr-rs-relay` or `external`.
fn kind(&self) -> &'static str;
/// The public ws(s):// URL clients connect to + we advertise.
fn advertised_url(&self) -> &str;
/// The configured write policy.
fn write_policy(&self) -> WritePolicy;
/// NIP-13 PoW bits required on cross-ecosystem inbound (0 = off).
fn inbound_pow_bits(&self) -> u8;
/// NIPs advertised as supported.
fn supported_nips(&self) -> &'static [u16] {
SUPPORTED_NIPS
}
}
/// Config-selected relay provider, or `None` when the relay is disabled.
/// Mirrors [`crate::infra::payment::from_config`].
pub fn from_config(cfg: &Config) -> Result<Option<Arc<dyn RelayProvider>>, AppError> {
let r = &cfg.messaging.relay;
if !r.enabled {
return Ok(None);
}
// `validate()` already checked the policy string, so this cannot fail in
// practice; fail closed regardless.
let policy = WritePolicy::parse(&r.write_policy).ok_or_else(|| {
AppError::ConfigError(format!("invalid RELAY_WRITE_POLICY: {}", r.write_policy))
})?;
let provider: Arc<dyn RelayProvider> = match r.mode.as_str() {
// 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,
}),
other => {
return Err(AppError::ConfigError(format!(
"unsupported RELAY_MODE: {other}"
)))
}
};
Ok(Some(provider))
}
/// Default adapter: a relay reachable at `advertised_url`. Admission is enforced
/// out-of-band by the gRPC nauthz service using the shared [`policy`] engine.
struct NostrRelayProvider {
kind: &'static str,
advertised_url: String,
policy: WritePolicy,
inbound_pow_bits: u8,
}
impl RelayProvider for NostrRelayProvider {
fn kind(&self) -> &'static str {
self.kind
}
fn advertised_url(&self) -> &str {
&self.advertised_url
}
fn write_policy(&self) -> WritePolicy {
self.policy
}
fn inbound_pow_bits(&self) -> u8 {
self.inbound_pow_bits
}
}
+219
View File
@@ -0,0 +1,219 @@
//! Relay write-admission policy — a PURE decision function.
//!
//! The gRPC admission service (nostr-rs-relay's `nauthz` hook) resolves the two
//! registration booleans against the DB, then calls [`decide`]. Keeping the
//! decision pure makes every branch unit-testable without a relay or a database.
//!
//! Policies (operator-configured, `RELAY_WRITE_POLICY`):
//! * `open` — accept everything (resource caps in the relay itself still apply).
//! * `author-allowlist` — only registered Smirk npubs may publish anything.
//! * `inbox-outbox` (default) — registered npubs publish their own events
//! (outbox); anyone may deliver a NIP-17 gift-wrap (kind 1059) ADDRESSED to a
//! registered user (inbox), optionally behind a NIP-13 proof-of-work gate to
//! blunt cross-ecosystem spam. Everything else is rejected.
/// NIP-59 gift-wrap event kind (the sealed envelope carrying a NIP-17 DM).
pub const GIFT_WRAP_KIND: u64 = 1059;
/// Operator write policy. Parsed from the validated `RELAY_WRITE_POLICY` string.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WritePolicy {
Open,
AuthorAllowlist,
InboxOutbox,
}
impl WritePolicy {
/// Parse the config string. Returns `None` for an unknown value (config
/// `validate()` already rejects those at startup, so callers can `expect`).
pub fn parse(s: &str) -> Option<Self> {
match s {
"open" => Some(Self::Open),
"author-allowlist" => Some(Self::AuthorAllowlist),
"inbox-outbox" => Some(Self::InboxOutbox),
_ => None,
}
}
}
/// The admission decision. `Deny` carries a static, non-sensitive reason (the
/// relay may surface it to the publisher).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Admit {
Permit,
Deny(&'static str),
}
impl Admit {
pub fn is_permit(&self) -> bool {
matches!(self, Admit::Permit)
}
}
/// The event facts the decision needs. The author + recipient registration
/// lookups are resolved by the caller (against the DB) and passed in as booleans
/// so this stays pure.
pub struct EventMeta<'a> {
/// x-only pubkey hex of the event author (canonical lowercase).
pub author_pubkey: &'a str,
/// Event kind.
pub kind: u64,
/// Event id hex (used for the NIP-13 PoW check).
pub id_hex: &'a str,
}
/// NIP-13 difficulty: the number of leading zero BITS of the (32-byte) event id.
pub fn leading_zero_bits(id_hex: &str) -> u32 {
let Ok(bytes) = hex::decode(id_hex) else {
return 0;
};
let mut count = 0u32;
for b in bytes {
if b == 0 {
count += 8;
} else {
count += b.leading_zeros(); // u8::leading_zeros ∈ 0..=7 here
break;
}
}
count
}
/// Decide whether to admit an event.
///
/// `author_registered` = the author pubkey is a registered Smirk npub.
/// `recipient_registered` = at least one `p` tag is a registered Smirk npub.
/// (The caller resolves both against the DB before calling.)
pub fn decide(
meta: &EventMeta,
policy: WritePolicy,
inbound_pow_bits: u8,
author_registered: bool,
recipient_registered: bool,
) -> Admit {
match policy {
WritePolicy::Open => Admit::Permit,
WritePolicy::AuthorAllowlist => {
if author_registered {
Admit::Permit
} else {
Admit::Deny("author is not a registered npub")
}
}
WritePolicy::InboxOutbox => {
if author_registered {
// Outbox: a registered user publishing their own events.
Admit::Permit
} else if meta.kind == GIFT_WRAP_KIND && recipient_registered {
// Inbox: an external author delivering a gift-wrapped DM to one of
// our users. Optionally require proof-of-work to blunt spam.
if inbound_pow_bits > 0
&& leading_zero_bits(meta.id_hex) < u32::from(inbound_pow_bits)
{
Admit::Deny("insufficient proof-of-work for cross-ecosystem delivery")
} else {
Admit::Permit
}
} else {
Admit::Deny(
"external authors may only deliver gift-wrapped DMs to registered users",
)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn meta<'a>(author: &'a str, kind: u64, id: &'a str) -> EventMeta<'a> {
EventMeta {
author_pubkey: author,
kind,
id_hex: id,
}
}
// Placeholder author x-only hex (64 chars).
const A: &str = "1111111111111111111111111111111111111111111111111111111111111111";
// A 64-char id with zero leading-zero bits.
const FF: &str = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
#[test]
fn parse_roundtrip() {
assert_eq!(WritePolicy::parse("open"), Some(WritePolicy::Open));
assert_eq!(
WritePolicy::parse("author-allowlist"),
Some(WritePolicy::AuthorAllowlist)
);
assert_eq!(
WritePolicy::parse("inbox-outbox"),
Some(WritePolicy::InboxOutbox)
);
assert_eq!(WritePolicy::parse("nope"), None);
}
#[test]
fn open_permits_everything() {
let m = meta(A, 1, FF);
assert!(decide(&m, WritePolicy::Open, 0, 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).is_permit());
assert!(!decide(&m, WritePolicy::AuthorAllowlist, 0, 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).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).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).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).is_permit());
}
#[test]
fn leading_zero_bits_counts_correctly() {
assert_eq!(leading_zero_bits(FF), 0);
assert_eq!(leading_zero_bits(&format!("00{}", "ff".repeat(31))), 8);
assert_eq!(leading_zero_bits(&format!("0f{}", "ff".repeat(31))), 4);
assert_eq!(leading_zero_bits(&format!("0000{}", "ff".repeat(30))), 16);
}
#[test]
fn inbound_pow_gates_the_inbox_branch() {
// id with 16 leading zero bits.
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).is_permit());
assert!(!decide(&m, WritePolicy::InboxOutbox, 20, false, true).is_permit());
}
#[test]
fn inbound_pow_does_not_gate_registered_outbox() {
// 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).is_permit());
}
}
+3
View File
@@ -62,6 +62,9 @@ pub struct AppState {
/// Payment processor backing the optional pay-to-register gate; `Some` only
/// when `REGISTRATION_REQUIRE_PAYMENT` is on.
pub payment: Option<Arc<dyn crate::infra::payment::PaymentProvider>>,
/// Optional first-party Nostr relay (messaging plane); `Some` only when
/// `RELAY_ENABLED` is on. Advertised via `/capabilities` + NIP-05 hints.
pub relay: Option<Arc<dyn crate::infra::relay::RelayProvider>>,
/// In-memory website-auth challenges, keyed by nonce. Single-node store;
/// a shared/stateless variant is the load-balanced-fleet path.
pub web_challenges: Arc<RwLock<HashMap<String, WebChallenge>>>,
+3
View File
@@ -38,6 +38,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let chains = ChainClients::from_config(&config)?;
// Payment processor for the pay-to-register gate (None unless it's enabled).
let payment = smirk_backend_core::infra::payment::from_config(&config)?;
// Optional Nostr relay (messaging plane); None unless RELAY_ENABLED.
let relay = smirk_backend_core::infra::relay::from_config(&config)?;
// First-run bootstrap latch (operator §3.2). Only meaningful with the admin
// surface enabled. Fail closed on a tampered latch (restore-to-pre-bootstrap).
@@ -90,6 +92,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
sessions,
chains,
payment,
relay,
web_challenges: Arc::default(),
prices: prices_cache,
admin_sessions,