diff --git a/Cargo.lock b/Cargo.lock index 6905f02..f183ae3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,6 +104,12 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + [[package]] name = "async-compression" version = "0.4.42" @@ -116,6 +122,28 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -767,6 +795,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -1042,13 +1076,38 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", "tracing", ] +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.2", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -1223,7 +1282,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2", + "h2 0.3.27", "http 0.2.12", "http-body 0.4.6", "httparse", @@ -1247,6 +1306,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2 0.4.15", "http 1.4.2", "http-body 1.0.1", "httparse", @@ -1255,6 +1315,7 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", ] [[package]] @@ -1271,6 +1332,19 @@ dependencies = [ "tokio-rustls 0.24.1", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper 1.10.1", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-tls" version = "0.5.0" @@ -1291,12 +1365,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "bytes", + "futures-channel", + "futures-util", "http 1.4.2", "http-body 1.0.1", "hyper 1.10.1", + "libc", "pin-project-lite", + "socket2 0.6.4", "tokio", "tower-service", + "tracing", ] [[package]] @@ -1442,6 +1521,16 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1478,6 +1567,15 @@ dependencies = [ "serde", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1719,6 +1817,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "native-tls" version = "0.2.18" @@ -1954,6 +2058,16 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap 2.14.0", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -2081,6 +2195,16 @@ dependencies = [ "termtree", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.118", +] + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -2114,6 +2238,122 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.118", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + +[[package]] +name = "protoc-bin-vendored" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-s390_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-aarch_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", +] + +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c" + +[[package]] +name = "protoc-bin-vendored-linux-s390_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" + [[package]] name = "quanta" version = "0.12.6" @@ -2247,7 +2487,7 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2", + "h2 0.3.27", "http 0.2.12", "http-body 0.4.6", "hyper 0.14.32", @@ -2707,6 +2947,8 @@ dependencies = [ "k256", "mockall", "monero", + "prost", + "protoc-bin-vendored", "rand", "reqwest", "rmp-serde", @@ -2720,6 +2962,8 @@ dependencies = [ "tokio", "tokio-rustls 0.25.0", "tokio-test", + "tonic", + "tonic-build", "tower 0.4.13", "tower-http", "tower_governor", @@ -2828,7 +3072,7 @@ dependencies = [ "futures-util", "hashlink", "hex", - "indexmap", + "indexmap 2.14.0", "ipnetwork", "log", "memchr", @@ -3334,6 +3578,50 @@ dependencies = [ "tokio", ] +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2 0.4.15", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "hyper 1.10.1", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "socket2 0.5.10", + "tokio", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.118", +] + [[package]] name = "tower" version = "0.4.13" @@ -3342,8 +3630,11 @@ checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" dependencies = [ "futures-core", "futures-util", + "indexmap 1.9.3", "pin-project", "pin-project-lite", + "rand", + "slab", "tokio", "tokio-util", "tower-layer", @@ -3612,7 +3903,7 @@ version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde", "serde_json", "utoipa-gen", diff --git a/Cargo.toml b/Cargo.toml index af9c386..7643558 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -107,6 +107,17 @@ async-trait = "0.1" validator = { version = "0.16", features = ["derive"] } bech32 = "0.12.0" +# gRPC (nostr-rs-relay event-admission hook, `nauthz`). Runtime-gated by +# RELAY_ENABLED; reuses the hyper/tower/h2 stack axum already pulls in. +tonic = "0.12" +prost = "0.13" + +[build-dependencies] +# Compile proto/nauthz.proto. protoc is vendored so building from source needs +# no system protobuf-compiler. +tonic-build = "0.12" +protoc-bin-vendored = "3" + [dev-dependencies] tokio-test = "0.4" mockall = "0.12" diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..89fd3b2 --- /dev/null +++ b/build.rs @@ -0,0 +1,14 @@ +//! Compile the gRPC proto(s). `protoc` is vendored (protoc-bin-vendored) so a +//! source build needs no system protobuf-compiler. + +fn main() { + // Point tonic-build/prost at the vendored protoc. + if let Ok(protoc) = protoc_bin_vendored::protoc_bin_path() { + std::env::set_var("PROTOC", protoc); + } + println!("cargo:rerun-if-changed=proto/nauthz.proto"); + tonic_build::configure() + .build_client(false) // we only serve the admission service, never call it + .compile_protos(&["proto/nauthz.proto"], &["proto"]) + .expect("compile proto/nauthz.proto"); +} diff --git a/proto/nauthz.proto b/proto/nauthz.proto new file mode 100644 index 0000000..ad02a78 --- /dev/null +++ b/proto/nauthz.proto @@ -0,0 +1,50 @@ +// nostr-rs-relay event-admission (nauthz) gRPC contract. +// +// This MUST match the proto nostr-rs-relay ships (its `[grpc] +// event_admission_server` calls `Authorization.EventAdmit` once per inbound +// event). Keep field numbers/types identical or the relay cannot talk to us. +// Upstream: nostr-rs-relay/proto/nauthz.proto. +syntax = "proto3"; + +package nauthz; + +// Authorization for actions against a relay. +service Authorization { + // Determine if an event should be admitted to the relay. + rpc EventAdmit(EventRequest) returns (EventReply); +} + +message Event { + bytes id = 1; + bytes pubkey = 2; + fixed64 created_at = 3; + uint64 kind = 4; + string content = 5; + repeated TagEntry tags = 6; + bytes sig = 7; +} + +message TagEntry { + repeated string values = 1; +} + +// Event data + connection metadata for the admission decision. +message EventRequest { + Event event = 1; + optional string ip_addr = 2; + optional string origin = 3; + optional string user_agent = 4; + optional bytes auth_pubkey = 5; + optional string nip05 = 6; +} + +message EventReply { + Decision decision = 1; + optional string message = 2; +} + +enum Decision { + DECISION_UNSPECIFIED = 0; + DECISION_PERMIT = 1; + DECISION_DENY = 2; +} diff --git a/src/infra/db/users.rs b/src/infra/db/users.rs index e0ff9ee..7044299 100644 --- a/src/infra/db/users.rs +++ b/src/infra/db/users.rs @@ -114,6 +114,20 @@ impl Database { .await?) } + /// Whether an x-only pubkey (canonical lowercase hex) is linked to any user — + /// the fast membership check behind the relay write-admission policy. No PII + /// returned, so it is safe to call per inbound relay event. + #[instrument(skip(self, nostr_pubkey))] + pub async fn is_registered_npub(&self, nostr_pubkey: &str) -> Result { + let exists = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS (SELECT 1 FROM users WHERE nostr_pubkey = $1)", + ) + .bind(nostr_pubkey) + .fetch_one(self.pool()) + .await?; + Ok(exists) + } + /// Replace a user's `pubkey_hash` (derivation-scheme rotation, keyed by the /// unchanged `seed_fingerprint`). Peppered. #[instrument(skip(self, new_pubkey_hash))] diff --git a/src/infra/relay/mod.rs b/src/infra/relay/mod.rs index 1946d67..ae366ce 100644 --- a/src/infra/relay/mod.rs +++ b/src/infra/relay/mod.rs @@ -11,6 +11,7 @@ //! write policy, enforces admission via `nostr-rs-relay`'s gRPC event-admission //! hook, evaluated by the pure engine in [`policy`]. +pub mod nauthz; mod policy; pub use policy::{decide, leading_zero_bits, Admit, EventMeta, WritePolicy, GIFT_WRAP_KIND}; diff --git a/src/infra/relay/nauthz.rs b/src/infra/relay/nauthz.rs new file mode 100644 index 0000000..a037bb4 --- /dev/null +++ b/src/infra/relay/nauthz.rs @@ -0,0 +1,124 @@ +//! gRPC event-admission service — the `nostr-rs-relay` `nauthz` hook. +//! +//! `nostr-rs-relay` (configured with `[grpc] event_admission_server`) calls +//! `Authorization.EventAdmit` once per inbound event, before storing it. We +//! resolve the author + recipient registration against the DB and run the pure +//! [`super::policy`] engine, returning PERMIT/DENY. This is the adapter-specific +//! transport for the write policy; a different relay backend could enforce the +//! same policy over a different mechanism. +//! +//! Runs on a loopback socket (like the admin plane); never exposed publicly. + +use std::sync::Arc; + +use tonic::{Request, Response, Status}; + +use crate::infra::db::Database; + +use super::{decide, Admit, EventMeta, RelayProvider}; + +/// Generated from `proto/nauthz.proto` (see `build.rs`). +pub mod proto { + tonic::include_proto!("nauthz"); +} + +use proto::authorization_server::{Authorization, AuthorizationServer}; +use proto::{Decision, EventReply, EventRequest}; + +/// The admission decider: the configured relay policy + the user DB. +pub struct AdmissionService { + relay: Arc, + db: Database, +} + +impl AdmissionService { + pub fn new(relay: Arc, db: Database) -> Self { + Self { relay, db } + } +} + +fn permit() -> EventReply { + EventReply { + decision: Decision::Permit as i32, + message: None, + } +} + +fn deny(msg: &str) -> EventReply { + EventReply { + decision: Decision::Deny as i32, + message: Some(msg.to_string()), + } +} + +#[tonic::async_trait] +impl Authorization for AdmissionService { + async fn event_admit( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let Some(event) = req.event else { + // Fail closed on a malformed request. + return Ok(Response::new(deny("missing event"))); + }; + + // Canonical lowercase hex (matches how we store/verify npubs). + let author_hex = hex::encode(&event.pubkey); + let id_hex = hex::encode(&event.id); + + // Author registration. + let author_registered = self + .db + .is_registered_npub(&author_hex) + .await + .unwrap_or(false); + + // Recipient registration: any `p` tag that is a registered npub. Only + // resolved when needed (skipped once the author is already registered). + let mut recipient_registered = false; + if !author_registered { + for tag in &event.tags { + if tag.values.len() >= 2 && tag.values[0] == "p" { + let p = tag.values[1].to_lowercase(); + if self.db.is_registered_npub(&p).await.unwrap_or(false) { + recipient_registered = true; + break; + } + } + } + } + + let meta = EventMeta { + author_pubkey: &author_hex, + kind: event.kind, + id_hex: &id_hex, + }; + let reply = match decide( + &meta, + self.relay.write_policy(), + self.relay.inbound_pow_bits(), + author_registered, + recipient_registered, + ) { + Admit::Permit => permit(), + Admit::Deny(msg) => deny(msg), + }; + Ok(Response::new(reply)) + } +} + +/// Serve the admission service on `addr` (a loopback socket). Returns when the +/// server stops or errors. +pub async fn serve( + addr: std::net::SocketAddr, + relay: Arc, + db: Database, +) -> Result<(), Box> { + let svc = AdmissionService { relay, db }; + tonic::transport::Server::builder() + .add_service(AuthorizationServer::new(svc)) + .serve(addr) + .await?; + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 7ca5f4f..ea4e681 100644 --- a/src/main.rs +++ b/src/main.rs @@ -231,6 +231,31 @@ async fn main() -> Result<(), Box> { }); } + // Nostr relay event-admission (nauthz) gRPC service — only for a non-`open` + // write policy (open needs no admission). Loopback socket, like the admin + // plane; nostr-rs-relay is configured to call it per inbound event. + if let Some(relay) = state.relay.clone() { + use smirk_backend_core::infra::relay::WritePolicy; + if relay.write_policy() != WritePolicy::Open { + let addr: std::net::SocketAddr = state + .config + .messaging + .relay + .admission_bind + .parse() + .map_err(|e| format!("invalid RELAY_ADMISSION_BIND: {e}"))?; + let db = state.db.clone(); + tracing::info!("relay event-admission (nauthz) listening on {addr}"); + tokio::spawn(async move { + if let Err(e) = + smirk_backend_core::infra::relay::nauthz::serve(addr, relay, db).await + { + tracing::error!(error = %e, "relay admission service exited"); + } + }); + } + } + let app = build_router(state); let listener = tokio::net::TcpListener::bind(&addr).await?; diff --git a/tests/relay_admission.rs b/tests/relay_admission.rs new file mode 100644 index 0000000..2322dda --- /dev/null +++ b/tests/relay_admission.rs @@ -0,0 +1,123 @@ +//! L1 integration: the relay event-admission (nauthz) gRPC handler. +//! +//! Drives `AdmissionService::event_admit` directly (no gRPC transport — that is +//! covered by the docker-relay E2E) to prove the handler wiring: p-tag +//! extraction, the DB registration lookup, and the policy decision, end to end +//! against live Postgres. The pure policy branches are covered in unit tests. + +mod common; + +use smirk_backend_core::infra::relay::nauthz::proto::authorization_server::Authorization; +use smirk_backend_core::infra::relay::nauthz::{proto, AdmissionService}; +use smirk_backend_core::infra::relay::{self, RelayProvider}; +use smirk_backend_core::models::db::NewUser; +use std::sync::Arc; +use tonic::Request; + +/// A 64-char lowercase-hex pubkey/id (valid for hex::decode). +fn hex64(seed: &str) -> String { + let base = format!("{seed}{}", "0".repeat(64)); + base[..64].to_string() +} + +async fn register_npub(db: &smirk_backend_core::infra::db::Database, npub_hex: &str) { + db.create_user(NewUser { + username: None, + pubkey_hash: Some(format!("pk-{}", uuid::Uuid::new_v4())), + nostr_pubkey: Some(npub_hex.to_string()), + wallet_birthday: None, + seed_fingerprint: None, + xmr_start_height: None, + wow_start_height: None, + }) + .await + .unwrap(); +} + +fn inbox_outbox_relay(base: &smirk_backend_core::config::Config) -> Arc { + let mut cfg = base.clone(); + cfg.messaging.relay.enabled = true; + cfg.messaging.relay.write_policy = "inbox-outbox".into(); + cfg.messaging.relay.advertised_url = "wss://relay.example.org".into(); + cfg.messaging.relay.inbound_pow_bits = 0; + relay::from_config(&cfg).unwrap().unwrap() +} + +fn event(author_hex: &str, kind: u64, p_tags: &[&str], id_hex: &str) -> proto::EventRequest { + proto::EventRequest { + event: Some(proto::Event { + id: hex::decode(id_hex).unwrap(), + pubkey: hex::decode(author_hex).unwrap(), + created_at: 0, + kind, + content: String::new(), + tags: p_tags + .iter() + .map(|p| proto::TagEntry { + values: vec!["p".to_string(), (*p).to_string()], + }) + .collect(), + sig: vec![], + }), + ip_addr: None, + origin: None, + user_agent: None, + auth_pubkey: None, + nip05: None, + } +} + +async fn admit(svc: &AdmissionService, req: proto::EventRequest) -> bool { + let reply = svc + .event_admit(Request::new(req)) + .await + .unwrap() + .into_inner(); + reply.decision == proto::Decision::Permit as i32 +} + +#[tokio::test] +async fn is_registered_npub_membership() { + let app = require_app!(); + let npub = hex64("aaaa"); + assert!(!app.state.db.is_registered_npub(&npub).await.unwrap()); + register_npub(&app.state.db, &npub).await; + assert!(app.state.db.is_registered_npub(&npub).await.unwrap()); +} + +#[tokio::test] +async fn admission_inbox_outbox_end_to_end() { + let app = require_app!(); + let svc = AdmissionService::new(inbox_outbox_relay(&app.state.config), app.state.db.clone()); + + let author = hex64("b1"); // registered author (outbox) + let recipient = hex64("b2"); // registered recipient (inbox) + let stranger = hex64("b3"); // unregistered + register_npub(&app.state.db, &author).await; + register_npub(&app.state.db, &recipient).await; + let ff = "ff".repeat(32); + + // Outbox: a registered author's own event → PERMIT. + assert!( + admit(&svc, event(&author, 1, &[], &ff)).await, + "registered author outbox" + ); + + // Inbox: an unregistered author's gift-wrap (1059) to a registered recipient → PERMIT. + assert!( + admit(&svc, event(&stranger, 1059, &[&recipient], &ff)).await, + "gift-wrap to registered recipient" + ); + + // External non-gift-wrap → DENY. + assert!( + !admit(&svc, event(&stranger, 1, &[&recipient], &ff)).await, + "external non-gift-wrap rejected" + ); + + // Gift-wrap to an UNREGISTERED recipient → DENY. + assert!( + !admit(&svc, event(&stranger, 1059, &[&hex64("b9")], &ff)).await, + "gift-wrap to unregistered recipient rejected" + ); +}