mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 15:31:38 +02:00
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:
+175
@@ -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();
|
||||
|
||||
@@ -8,3 +8,4 @@ pub mod grin;
|
||||
pub mod lws;
|
||||
pub mod payment;
|
||||
pub mod prices;
|
||||
pub mod relay;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>>>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user