radrootsd

JSON-RPC bridge for Radroots event publishing
git clone https://radroots.dev/git/radrootsd.git
Log | Files | Refs | README | LICENSE

commit 70de1195c050823a05ec79b3526adcbed728a6f7
parent af8f36aa91b2e08fc2b4e8b7928d8f6304ff1288
Author: triesap <tyson@radroots.org>
Date:   Tue, 25 Aug 2026 08:16:04 +0000

api: seal the daemon entrypoint boundary

- hide implementation modules behind a root-only facade
- expose a stable source-free daemon run error
- remove unused state and registry capabilities
- freeze and verify the exact standalone API surface

Diffstat:
MAGENTS.md | 5+++--
MREADME | 9++++++---
Acontracts/api_baselines/radrootsd.txt | 9+++++++++
Ascripts/verify-boundaries.sh | 26++++++++++++++++++++++++++
Msrc/app/config.rs | 5++++-
Msrc/app/mod.rs | 4+---
Msrc/app/runtime.rs | 2--
Msrc/core/nip46/mod.rs | 4++--
Msrc/core/state.rs | 26+++-----------------------
Msrc/core/transport_publish.rs | 3+++
Msrc/lib.rs | 44+++++++++++++++++++++++++++++++++++++++++---
Msrc/main.rs | 2+-
Msrc/transport/jsonrpc/context.rs | 7++-----
Msrc/transport/jsonrpc/methods/mod.rs | 13+++++--------
Msrc/transport/jsonrpc/methods/transport_publish.rs | 7++-----
Msrc/transport/jsonrpc/mod.rs | 2+-
Msrc/transport/jsonrpc/registry.rs | 4----
Msrc/transport/jsonrpc/server.rs | 7++-----
Msrc/transport/nostr/listener.rs | 19+++++++++++++++----
Atests/package_boundary.rs | 31+++++++++++++++++++++++++++++++
20 files changed, 157 insertions(+), 72 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -79,8 +79,9 @@ --check`, `cargo check --workspace --all-targets --locked`, `cargo test --workspace --all-targets --locked`, `cargo clippy --workspace --all-targets --locked -- -D warnings`, warnings-denied Rustdoc, and - `scripts/verify-supply-chain.sh`. Route them through extbuild when it is - enabled. Nix and OCI remain deferred and unclaimed. + `scripts/verify-boundaries.sh` plus `scripts/verify-supply-chain.sh`. Route + them through extbuild when it is enabled. Nix and OCI remain deferred and + unclaimed. - The supply-chain gate uses exact cargo-deny 0.19.8 and cargo-vet 0.10.2. Its checked-in exemptions are visible accepted review debt, not claims of independent source audits; only the justified Nostr 0.44 advisory exceptions diff --git a/README b/README @@ -21,12 +21,15 @@ cargo check --workspace --all-targets --locked cargo test --workspace --all-targets --locked cargo clippy --workspace --all-targets --locked -- -D warnings RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --locked +scripts/verify-boundaries.sh scripts/verify-supply-chain.sh ``` -The supply-chain command checks the locked dependency, license, advisory, and -immutable public-source policy. Nix and OCI are deferred and unclaimed through -RCLD-RSHR-170. +The boundary command byte-compares the root-only public API against its +reviewed baseline and rejects forbidden repository roots or credential +material. The supply-chain command checks the locked dependency, license, +advisory, and immutable public-source policy. Nix and OCI are deferred and +unclaimed through RCLD-RSHR-170. ## Copyright diff --git a/contracts/api_baselines/radrootsd.txt b/contracts/api_baselines/radrootsd.txt @@ -0,0 +1,9 @@ +pub mod radrootsd +pub struct radrootsd::RadrootsdRunError +impl core::error::Error for radrootsd::RadrootsdRunError +impl core::fmt::Debug for radrootsd::RadrootsdRunError +pub fn radrootsd::RadrootsdRunError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for radrootsd::RadrootsdRunError +pub fn radrootsd::RadrootsdRunError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub const fn radrootsd::crate_name() -> &'static str +pub async fn radrootsd::run_daemon() -> core::result::Result<(), radrootsd::RadrootsdRunError> diff --git a/scripts/verify-boundaries.sh b/scripts/verify-boundaries.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +for forbidden_root in docs .github .act; do + test ! -e "$forbidden_root" + test ! -L "$forbidden_root" +done +test "$(cargo public-api --version)" = "cargo-public-api 0.52.0" +temporary_api="$(mktemp)" +trap 'rm -f "$temporary_api"' EXIT +cargo +nightly-2026-07-16 public-api --all-features -sss -p radrootsd >"$temporary_api" +cmp "$temporary_api" contracts/api_baselines/radrootsd.txt + +if git ls-files | grep -E -i '(^|/)(\.env|id_rsa|id_ed25519|credentials|[^/]+\.(pem|key|p12|pfx|jks|keystore))$' >/dev/null; then + echo "boundary_invalid: sensitive credential path is tracked" >&2 + exit 1 +fi +if git grep -I -n -E -e '-----BEGIN ([A-Z0-9 ]+ )?PRIVATE KEY-----|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9_]{36,}|nsec1[023456789acdefghjklmnpqrstuvwxyz]{40,}' -- src >/dev/null; then + echo "boundary_invalid: production source contains credential material" >&2 + exit 1 +fi + +echo "boundary ok: root-only API, fresh baseline, no forbidden or credential surface" diff --git a/src/app/config.rs b/src/app/config.rs @@ -4,9 +4,11 @@ use radroots_event::profile::{AuthoredProfile, Nip05Identifier}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; +#[cfg(not(test))] +use super::paths::process_path_selection; use super::paths::{ PathProfile, PathResolver, RadrootsdRuntimePaths, default_transport_publish_database_path, - process_path_selection, resolve_runtime_paths_with_resolver, + resolve_runtime_paths_with_resolver, }; #[derive(Debug, Serialize, Deserialize, Clone)] @@ -224,6 +226,7 @@ fn load_settings_from_path_with_resolver( Ok(settings) } +#[cfg(not(test))] pub fn load_settings_from_path(path: impl AsRef<Path>) -> Result<Settings> { let path = path.as_ref(); let (profile, repo_local_root) = process_path_selection()?; diff --git a/src/app/mod.rs b/src/app/mod.rs @@ -4,6 +4,4 @@ pub(crate) mod identity_storage; mod paths; mod runtime; -pub use cli::Args; -pub use config::Settings; -pub use runtime::run; +pub(crate) use runtime::run; diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -565,7 +565,6 @@ pub async fn run() -> Result<()> { )?; let radrootsd = Radrootsd::new( identity.clone(), - settings.metadata.clone(), settings.config.transport_publish.clone(), settings.config.nip46.clone(), ); @@ -736,7 +735,6 @@ mod tests { let identity = DaemonIdentity::generate(); let state = Radrootsd::new( identity, - settings.metadata.clone(), settings.config.transport_publish.clone(), settings.config.nip46.clone(), ) diff --git a/src/core/nip46/mod.rs b/src/core/nip46/mod.rs @@ -1,3 +1,3 @@ -pub mod session; +pub(crate) mod session; -pub use session::{Nip46Session, Nip46SessionStore}; +pub(crate) use session::Nip46Session; diff --git a/src/core/state.rs b/src/core/state.rs @@ -1,5 +1,5 @@ use crate::app::identity_storage::DaemonIdentity; -use crate::host_nostr::{DaemonNostrClient, Keys, Metadata, PublicKey}; +use crate::host_nostr::{DaemonNostrClient, Keys, PublicKey}; use anyhow::Result; use crate::app::config::{Nip46Config, TransportPublishConfig}; @@ -10,8 +10,6 @@ pub struct Radrootsd { pub(crate) client: DaemonNostrClient, pub keys: Keys, pub pubkey: PublicKey, - pub metadata: Metadata, - pub info: serde_json::Value, pub transport_publish: TransportPublish, pub(crate) nip46_sessions: crate::core::nip46::session::Nip46SessionStore, pub nip46_config: Nip46Config, @@ -20,17 +18,12 @@ pub struct Radrootsd { impl Radrootsd { pub(crate) fn new( identity: DaemonIdentity, - metadata: Metadata, transport_publish_config: TransportPublishConfig, nip46_config: Nip46Config, ) -> Result<Self> { let keys: Keys = identity.keys().clone(); let pubkey = keys.public_key(); let client = DaemonNostrClient::with_keys(keys.clone()); - let info = serde_json::json!({ - "version": env!("CARGO_PKG_VERSION"), - "build": option_env!("GIT_HASH").unwrap_or("unknown"), - }); #[cfg(test)] let transport_publish = TransportPublish::memory(transport_publish_config)?; #[cfg(not(test))] @@ -41,8 +34,6 @@ impl Radrootsd { client, keys, pubkey, - metadata, - info, transport_publish, nip46_sessions, nip46_config, @@ -55,32 +46,21 @@ mod tests { use super::Radrootsd; use crate::app::config::{Nip46Config, TransportPublishConfig}; use crate::app::identity_storage::DaemonIdentity; - use crate::host_nostr::Metadata; #[test] fn new_sets_core_fields() { let identity = DaemonIdentity::generate(); - let metadata: Metadata = - serde_json::from_str(r#"{"name":"radrootsd-test"}"#).expect("metadata"); let transport_publish_cfg = TransportPublishConfig::default(); let cfg = Nip46Config::default(); - let state = Radrootsd::new( - identity.clone(), - metadata.clone(), - transport_publish_cfg.clone(), - cfg.clone(), - ) - .expect("state"); + let state = Radrootsd::new(identity.clone(), transport_publish_cfg.clone(), cfg.clone()) + .expect("state"); assert_eq!(state.pubkey, identity.public_key()); - assert_eq!(state.metadata, metadata); assert_eq!( state.transport_publish.config.enabled, transport_publish_cfg.enabled ); assert_eq!(state.nip46_config.session_ttl_secs, cfg.session_ttl_secs); assert_eq!(state.nip46_config.perms, cfg.perms); - assert_eq!(state.info["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(state.info["build"], "unknown"); } } diff --git a/src/core/transport_publish.rs b/src/core/transport_publish.rs @@ -170,6 +170,7 @@ pub struct TransportPublish { } impl TransportPublish { + #[cfg(not(test))] pub fn open(config: TransportPublishConfig) -> Result<Self, TransportPublishError> { let store = TransportPublishStore::open(config.database_path.clone())?; let publish_jobs = Arc::new(Semaphore::new(config.max_concurrent_publish_jobs)); @@ -183,6 +184,7 @@ impl TransportPublish { }) } + #[cfg(test)] pub fn memory(config: TransportPublishConfig) -> Result<Self, TransportPublishError> { let store = TransportPublishStore::memory()?; let publish_jobs = Arc::new(Semaphore::new(config.max_concurrent_publish_jobs)); @@ -992,6 +994,7 @@ impl TransportPublishStore { Self::from_connection(connection) } + #[cfg(test)] pub fn memory() -> Result<Self, TransportPublishError> { Self::from_connection(connect_sqlite(SqliteConnectOptions::new().in_memory(true))?) } diff --git a/src/lib.rs b/src/lib.rs @@ -1,11 +1,39 @@ #![forbid(unsafe_code)] #![cfg_attr(coverage_nightly, feature(coverage_attribute))] -pub mod app; -pub mod core; +use std::{error::Error, fmt}; + +mod app; +mod core; mod host_nostr; #[cfg_attr(coverage_nightly, coverage(off))] -pub mod transport; +mod transport; + +/// Stable, redacted failure returned by the daemon entry point. +pub struct RadrootsdRunError { + _private: (), +} + +impl fmt::Debug for RadrootsdRunError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RadrootsdRunError") + } +} + +impl fmt::Display for RadrootsdRunError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("daemon execution failed") + } +} + +impl Error for RadrootsdRunError {} + +/// Runs the daemon without exposing implementation-owned modules or errors. +pub async fn run_daemon() -> Result<(), RadrootsdRunError> { + app::run() + .await + .map_err(|_source| RadrootsdRunError { _private: () }) +} pub const fn crate_name() -> &'static str { env!("CARGO_PKG_NAME") @@ -14,8 +42,18 @@ pub const fn crate_name() -> &'static str { #[cfg(test)] #[cfg_attr(coverage_nightly, coverage(off))] mod tests { + use std::error::Error; + #[test] fn crate_name_matches_package() { assert_eq!(super::crate_name(), "radrootsd"); } + + #[test] + fn run_error_is_source_free_and_redacted() { + let error = super::RadrootsdRunError { _private: () }; + assert_eq!(format!("{error}"), "daemon execution failed"); + assert_eq!(format!("{error:?}"), "RadrootsdRunError"); + assert!(error.source().is_none()); + } } diff --git a/src/main.rs b/src/main.rs @@ -56,7 +56,7 @@ async fn run() -> Result<()> { #[cfg(not(test))] #[cfg_attr(coverage_nightly, coverage(off))] async fn run() -> Result<()> { - radrootsd::app::run().await + radrootsd::run_daemon().await.map_err(anyhow::Error::new) } #[cfg(test)] diff --git a/src/transport/jsonrpc/context.rs b/src/transport/jsonrpc/context.rs @@ -2,16 +2,13 @@ use crate::core::Radrootsd; -use super::registry::MethodRegistry; - #[derive(Clone)] pub struct RpcContext { pub state: Radrootsd, - pub methods: MethodRegistry, } impl RpcContext { - pub fn new(state: Radrootsd, methods: MethodRegistry) -> Self { - Self { state, methods } + pub fn new(state: Radrootsd) -> Self { + Self { state } } } diff --git a/src/transport/jsonrpc/methods/mod.rs b/src/transport/jsonrpc/methods/mod.rs @@ -25,7 +25,6 @@ pub fn register_all( #[cfg(test)] mod tests { use crate::app::identity_storage::DaemonIdentity; - use crate::host_nostr::Metadata; use jsonrpsee::server::RpcModule; use radroots_protocol::radrootsd::transport_publish::v5::RETICULUM_UNAVAILABLE_MESSAGE as RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE; @@ -41,8 +40,6 @@ mod tests { fn state(transport_publish_enabled: bool, nip46_public_jsonrpc_enabled: bool) -> Radrootsd { let identity = DaemonIdentity::generate(); - let metadata: Metadata = - serde_json::from_str(r#"{"name":"radrootsd-test"}"#).expect("metadata"); let transport_publish = TransportPublishConfig { enabled: transport_publish_enabled, ..TransportPublishConfig::default() @@ -51,13 +48,13 @@ mod tests { public_jsonrpc_enabled: nip46_public_jsonrpc_enabled, ..Nip46Config::default() }; - Radrootsd::new(identity, metadata, transport_publish, nip46).expect("state") + Radrootsd::new(identity, transport_publish, nip46).expect("state") } #[test] fn register_all_exposes_transport_publish_methods_by_default() { let registry = MethodRegistry::default(); - let ctx = RpcContext::new(state(true, false), registry.clone()); + let ctx = RpcContext::new(state(true, false)); let mut root = RpcModule::new(ctx.clone()); register_all(&mut root, ctx, registry).expect("register"); @@ -75,7 +72,7 @@ mod tests { #[test] fn register_all_exposes_nip46_when_public_jsonrpc_is_enabled() { let registry = MethodRegistry::default(); - let ctx = RpcContext::new(state(true, true), registry.clone()); + let ctx = RpcContext::new(state(true, true)); let mut root = RpcModule::new(ctx.clone()); register_all(&mut root, ctx, registry).expect("register"); @@ -86,7 +83,7 @@ mod tests { #[tokio::test] async fn publish_capabilities_rejects_unauthenticated_requests() { let registry = MethodRegistry::default(); - let ctx = RpcContext::new(state(true, false), registry.clone()); + let ctx = RpcContext::new(state(true, false)); let mut root = RpcModule::new(ctx.clone()); register_all(&mut root, ctx, registry).expect("register"); @@ -103,7 +100,7 @@ mod tests { #[tokio::test] async fn publish_capabilities_accepts_authenticated_requests() { let registry = MethodRegistry::default(); - let ctx = RpcContext::new(state(true, false), registry.clone()); + let ctx = RpcContext::new(state(true, false)); let principal = ctx .state .transport_publish diff --git a/src/transport/jsonrpc/methods/transport_publish.rs b/src/transport/jsonrpc/methods/transport_publish.rs @@ -134,7 +134,7 @@ mod tests { use crate::core::transport_publish::{ PublishJobVisibility, PublishPrincipalInit, generate_bearer_token, hash_bearer_token, }; - use crate::host_nostr::{Metadata, Timestamp}; + use crate::host_nostr::Timestamp; use crate::transport::jsonrpc::auth::{ TransportPublishAuthorization, authorize_transport_publish_request, }; @@ -164,11 +164,8 @@ mod tests { ) -> (RpcModule<RpcContext>, RpcContext, String, String) { let identity = DaemonIdentity::generate(); let signed_event = signed_event(&identity); - let metadata: Metadata = - serde_json::from_str(r#"{"name":"radrootsd-test"}"#).expect("metadata"); let state = Radrootsd::new( identity.clone(), - metadata, transport_publish_config, Nip46Config::default(), ) @@ -200,7 +197,7 @@ mod tests { }) .expect("principal"); let registry = MethodRegistry::default(); - let ctx = RpcContext::new(state, registry.clone()); + let ctx = RpcContext::new(state); let mut module = module(ctx.clone(), registry).expect("module"); module .extensions_mut() diff --git a/src/transport/jsonrpc/mod.rs b/src/transport/jsonrpc/mod.rs @@ -29,7 +29,7 @@ pub async fn start_rpc( ) -> Result<ServerHandle> { state.transport_publish.config.validate()?; let registry = MethodRegistry::default(); - let ctx = RpcContext::new(state, registry.clone()); + let ctx = RpcContext::new(state); let transport_publish_store = ctx.state.transport_publish.store.clone(); let mut root = RpcModule::new(ctx.clone()); diff --git a/src/transport/jsonrpc/registry.rs b/src/transport/jsonrpc/registry.rs @@ -16,8 +16,4 @@ impl MethodRegistry { methods.push(name.to_string()); methods.sort(); } - - pub fn list(&self) -> Vec<String> { - self.inner.read().unwrap_or_else(|e| e.into_inner()).clone() - } } diff --git a/src/transport/jsonrpc/server.rs b/src/transport/jsonrpc/server.rs @@ -131,7 +131,7 @@ mod tests { PublishJobVisibility, PublishPrincipalInit, PublishRelayResolveFuture, PublishRelayResolver, generate_bearer_token, hash_bearer_token, }; - use crate::host_nostr::{Metadata, Timestamp}; + use crate::host_nostr::Timestamp; use crate::transport::jsonrpc::methods; use crate::transport::jsonrpc::{MethodRegistry, RpcContext}; use crate::transport::relay_publish::MockRelayPublishAdapter as RadrootsMockRelayPublishAdapter; @@ -192,11 +192,8 @@ mod tests { RadrootsMockRelayPublishAdapter, ) { let identity = DaemonIdentity::generate(); - let metadata: Metadata = - serde_json::from_str(r#"{"name":"radrootsd-test"}"#).expect("metadata"); let mut state = Radrootsd::new( identity.clone(), - metadata, transport_publish_config, Nip46Config::default(), ) @@ -274,7 +271,7 @@ mod tests { let addr = unused_addr(); let store = state.transport_publish.store.clone(); let registry = MethodRegistry::default(); - let ctx = RpcContext::new(state, registry.clone()); + let ctx = RpcContext::new(state); let mut root = RpcModule::new(ctx.clone()); methods::register_all(&mut root, ctx, registry).expect("register methods"); let handle = start_server(addr, &rpc_cfg, store, root) diff --git a/src/transport/nostr/listener.rs b/src/transport/nostr/listener.rs @@ -1,24 +1,34 @@ +#[cfg(not(test))] use std::time::Duration; -use anyhow::{Result, anyhow}; +use anyhow::Result; +#[cfg(not(test))] +use anyhow::anyhow; +#[cfg(not(test))] use nostr::JsonUtil; use nostr::nips::nip04; use nostr::nips::nip44; -use nostr::nips::nip46::{ - NostrConnectMessage, NostrConnectRequest, NostrConnectResponse, ResponseResult, -}; +#[cfg(not(test))] +use nostr::nips::nip46::NostrConnectMessage; +use nostr::nips::nip46::{NostrConnectRequest, NostrConnectResponse, ResponseResult}; +#[cfg(not(test))] use tokio::sync::broadcast; +#[cfg(not(test))] use tracing::{info, warn}; use crate::core::nip46::session::{ Nip46Session, PendingNostrRequest, session_expires_at, sign_event_allowed, }; use crate::core::state::Radrootsd; +#[cfg(not(test))] use crate::host_nostr::{Filter, Kind, RelayPoolNotification, Timestamp, with_filter_tag}; +#[cfg(not(test))] use crate::transport::nostr::protocol::sign_nip46_message; +#[cfg(not(test))] const DEFAULT_TIMEOUT_SECS: u64 = 10; +#[cfg(not(test))] pub fn spawn_nip46_listener(radrootsd: Radrootsd) { tokio::spawn(async move { if let Err(error) = run_nip46_listener(radrootsd).await { @@ -27,6 +37,7 @@ pub fn spawn_nip46_listener(radrootsd: Radrootsd) { }); } +#[cfg(not(test))] async fn run_nip46_listener(radrootsd: Radrootsd) -> Result<()> { radrootsd.client.connect().await; radrootsd diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs @@ -0,0 +1,31 @@ +#![forbid(unsafe_code)] + +const ROOT: &str = include_str!("../src/lib.rs"); +const PUBLIC_API: &str = include_str!("../contracts/api_baselines/radrootsd.txt"); + +#[test] +fn implementation_modules_are_private_and_api_is_owned() { + for module in ["app", "core", "host_nostr", "transport"] { + assert!(ROOT.contains(&format!("mod {module};"))); + assert!(!ROOT.contains(&format!("pub mod {module};"))); + assert!(!PUBLIC_API.contains(&format!("radrootsd::{module}::"))); + } + assert!(PUBLIC_API.contains("pub struct radrootsd::RadrootsdRunError")); + assert!(PUBLIC_API.contains("pub async fn radrootsd::run_daemon()")); + for dependency in ["anyhow::", "jsonrpsee::", "nostr::", "sqlx::"] { + assert!(!PUBLIC_API.contains(dependency), "leaked {dependency}"); + } +} + +#[test] +fn public_error_is_redacted_and_source_free() { + let source = std::fs::read_to_string("src/lib.rs").expect("root source"); + for required in [ + "formatter.write_str(\"RadrootsdRunError\")", + "formatter.write_str(\"daemon execution failed\")", + "impl Error for RadrootsdRunError {}", + ] { + assert!(source.contains(required), "missing {required}"); + } + assert!(!PUBLIC_API.contains("std::io::Error")); +}