radrootsd

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

commit d34043369342d2c74efab0fac61983b8846ccda8
parent bb53bc2b0fd18edc98ed3778d69c682a040396c9
Author: triesap <tyson@radroots.org>
Date:   Wed,  8 Jul 2026 01:16:24 +0000

transport: scope explicit principals by kind

- add canonical explicit transport kind allowlists to publish principals and storage
- enforce explicit-target kind authorization before job creation
- update principal initialization and JSON-RPC test principals for the new field
- cover Nostr, Reticulum preview, and disallowed-kind behavior in transport tests

Diffstat:
Msrc/app/cli.rs | 2++
Msrc/app/runtime.rs | 75++++++++++++++++++++++++++++++++++++++-------------------------------------
Msrc/core/transport_publish.rs | 335++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/transport/jsonrpc/auth.rs | 1+
Msrc/transport/jsonrpc/methods/mod.rs | 1+
Msrc/transport/jsonrpc/methods/transport_publish.rs | 1+
Msrc/transport/jsonrpc/server.rs | 1+
Mtests/source_boundary.rs | 23++++++++++++++++++++++-
8 files changed, 391 insertions(+), 48 deletions(-)

diff --git a/src/app/cli.rs b/src/app/cli.rs @@ -56,6 +56,8 @@ pub struct PrincipalInitArgs { #[arg(long)] pub allowed_target_policy: Vec<String>, #[arg(long)] + pub allowed_explicit_transport_kind: Vec<String>, + #[arg(long)] pub allowed_nostr_source_policy: Vec<String>, #[arg(long)] pub job_visibility: String, diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -7,6 +7,11 @@ use tracing::{info, warn}; use crate::app::identity_storage::load_service_identity; use crate::app::{cli, config, paths}; use crate::core::Radrootsd; +use crate::core::transport_publish::{ + PublishPrincipalInit, TransportPublishStore, generate_bearer_token, hash_bearer_token, + parse_explicit_transport_kind, parse_nostr_source_policy, parse_target_policy, + write_token_file, +}; use crate::transport::jsonrpc; #[cfg(not(test))] use crate::transport::nostr::listener::spawn_nip46_listener; @@ -386,45 +391,41 @@ async fn handle_command(command: cli::Command, settings: &config::Settings) -> R cli::Command::TransportPublish(command) => match command.command { cli::TransportPublishSubcommand::Principal(command) => match command.command { cli::PrincipalSubcommand::Init(args) => { - let token = crate::core::transport_publish::generate_bearer_token(); - let token_hash = - crate::core::transport_publish::hash_bearer_token(token.as_str()); - let store = crate::core::transport_publish::TransportPublishStore::open( + let token = generate_bearer_token(); + let token_hash = hash_bearer_token(token.as_str()); + let store = TransportPublishStore::open( settings.config.transport_publish.database_path.clone(), )?; - let principal = store.create_principal( - crate::core::transport_publish::PublishPrincipalInit { - label: args.label, - token_hash, - allowed_pubkeys: args.allowed_pubkey, - allowed_kinds: args.allowed_kind, - allowed_target_policies: args - .allowed_target_policy - .iter() - .map(|policy| { - crate::core::transport_publish::parse_target_policy( - policy.as_str(), - ) - }) - .collect::<Result<Vec<_>, _>>()?, - allowed_nostr_source_policies: args - .allowed_nostr_source_policy - .iter() - .map(|policy| { - crate::core::transport_publish::parse_nostr_source_policy( - policy.as_str(), - ) - }) - .collect::<Result<Vec<_>, _>>()?, - allow_request_targets: args.allow_request_targets, - job_visibility: args.job_visibility.parse()?, - expires_at_unix: None, - }, - )?; - crate::core::transport_publish::write_token_file( - &args.token_file, - token.as_str(), - )?; + let allowed_target_policies = args + .allowed_target_policy + .iter() + .map(|policy| parse_target_policy(policy.as_str())) + .collect::<Result<Vec<_>, _>>()?; + let allowed_explicit_transport_kinds = args + .allowed_explicit_transport_kind + .iter() + .map(|transport_kind| { + parse_explicit_transport_kind(transport_kind.as_str()) + }) + .collect::<Result<Vec<_>, _>>()?; + let allowed_nostr_source_policies = args + .allowed_nostr_source_policy + .iter() + .map(|policy| parse_nostr_source_policy(policy.as_str())) + .collect::<Result<Vec<_>, _>>()?; + let principal = store.create_principal(PublishPrincipalInit { + label: args.label, + token_hash, + allowed_pubkeys: args.allowed_pubkey, + allowed_kinds: args.allowed_kind, + allowed_target_policies, + allowed_explicit_transport_kinds, + allowed_nostr_source_policies, + allow_request_targets: args.allow_request_targets, + job_visibility: args.job_visibility.parse()?, + expires_at_unix: None, + })?; + write_token_file(&args.token_file, token.as_str())?; println!( "{}", serde_json::json!({ diff --git a/src/core/transport_publish.rs b/src/core/transport_publish.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::future::Future; use std::net::IpAddr; @@ -41,7 +41,7 @@ use crate::app::config::TransportPublishConfig; const TOKEN_PREFIX: &str = "rrd_tp_"; const TOKEN_HASH_PREFIX: &str = "sha256:"; -const SCHEMA_VERSION: i64 = 1; +const SCHEMA_VERSION: i64 = 2; const TRANSPORT_KIND_NOSTR: &str = "nostr"; const TRANSPORT_KIND_RETICULUM: &str = "reticulum"; @@ -604,6 +604,7 @@ pub struct PublishPrincipalInit { pub allowed_pubkeys: Vec<String>, pub allowed_kinds: Vec<u32>, pub allowed_target_policies: Vec<TransportPublishTargetPolicyName>, + pub allowed_explicit_transport_kinds: Vec<String>, pub allowed_nostr_source_policies: Vec<NostrPublishTargetSourcePolicy>, pub allow_request_targets: bool, pub job_visibility: PublishJobVisibility, @@ -617,6 +618,7 @@ pub struct PublishPrincipal { pub allowed_pubkeys: Vec<String>, pub allowed_kinds: Vec<u32>, pub allowed_target_policies: Vec<TransportPublishTargetPolicyName>, + pub allowed_explicit_transport_kinds: Vec<String>, pub allowed_nostr_source_policies: Vec<NostrPublishTargetSourcePolicy>, pub allow_request_targets: bool, pub job_visibility: PublishJobVisibility, @@ -658,6 +660,25 @@ impl PublishPrincipal { "principal is not allowed to provide request targets".to_owned(), )); } + for target in targets { + let kind = + RadrootsTransportKind::parse_canonical(target.transport_kind.as_str()) + .map_err(|error| { + TransportPublishError::InvalidScope(format!( + "principal explicit target kind check failed: {error}" + )) + })?; + let transport_kind = kind.canonical_label(); + if !self + .allowed_explicit_transport_kinds + .iter() + .any(|allowed| allowed == &transport_kind) + { + return Err(TransportPublishError::InvalidScope(format!( + "principal is not allowed to use explicit transport target kind `{transport_kind}`" + ))); + } + } } TransportPublishTargetPolicy::Nostr { source_policy, @@ -825,6 +846,7 @@ impl TransportPublishStore { allowed_pubkeys_json TEXT NOT NULL, allowed_kinds_json TEXT NOT NULL, allowed_target_policies_json TEXT NOT NULL, + allowed_explicit_transport_kinds_json TEXT NOT NULL, allowed_nostr_source_policies_json TEXT NOT NULL, allow_request_targets INTEGER NOT NULL, job_visibility TEXT NOT NULL, @@ -875,6 +897,7 @@ impl TransportPublishStore { ); "#, )?; + ensure_transport_publish_schema(&connection)?; recover_interrupted_publish_jobs(&connection)?; connection.pragma_update(None, "user_version", SCHEMA_VERSION)?; Ok(Self { @@ -902,6 +925,7 @@ impl TransportPublishStore { allowed_pubkeys_json, allowed_kinds_json, allowed_target_policies_json, + allowed_explicit_transport_kinds_json, allowed_nostr_source_policies_json, allow_request_targets, job_visibility, @@ -909,7 +933,7 @@ impl TransportPublishStore { revoked_at_unix, created_at_unix ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, NULL, ?11) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, NULL, ?12) "#, params![ principal_id, @@ -918,6 +942,7 @@ impl TransportPublishStore { serde_json::to_string(&input.allowed_pubkeys)?, serde_json::to_string(&input.allowed_kinds)?, serde_json::to_string(&input.allowed_target_policies)?, + serde_json::to_string(&input.allowed_explicit_transport_kinds)?, serde_json::to_string(&input.allowed_nostr_source_policies)?, input.allow_request_targets, input.job_visibility.to_string(), @@ -949,6 +974,7 @@ impl TransportPublishStore { allowed_pubkeys_json, allowed_kinds_json, allowed_target_policies_json, + allowed_explicit_transport_kinds_json, allowed_nostr_source_policies_json, allow_request_targets, job_visibility, @@ -982,6 +1008,7 @@ impl TransportPublishStore { allowed_pubkeys_json, allowed_kinds_json, allowed_target_policies_json, + allowed_explicit_transport_kinds_json, allowed_nostr_source_policies_json, allow_request_targets, job_visibility, @@ -1331,6 +1358,22 @@ struct PublishJobRow { view: TransportPublishJobView, } +fn ensure_transport_publish_schema(connection: &Connection) -> Result<(), TransportPublishError> { + let has_explicit_kinds = connection + .prepare("PRAGMA table_info(transport_publish_principals)")? + .query_map([], |row| row.get::<_, String>(1))? + .collect::<Result<Vec<_>, _>>()? + .into_iter() + .any(|column| column == "allowed_explicit_transport_kinds_json"); + if !has_explicit_kinds { + connection.execute( + "ALTER TABLE transport_publish_principals ADD COLUMN allowed_explicit_transport_kinds_json TEXT NOT NULL DEFAULT '[]'", + [], + )?; + } + Ok(()) +} + fn recover_interrupted_publish_jobs(connection: &Connection) -> Result<(), TransportPublishError> { let now = current_unix_millis(); connection.execute( @@ -1377,18 +1420,19 @@ fn job_select_sql(tail: &str) -> String { } fn principal_from_row(row: &Row<'_>) -> Result<PublishPrincipal, rusqlite::Error> { - let visibility: String = row.get(7)?; + let visibility: String = row.get(8)?; Ok(PublishPrincipal { principal_id: row.get(0)?, label: row.get(1)?, allowed_pubkeys: json_column(row, 2)?, allowed_kinds: json_column(row, 3)?, allowed_target_policies: json_column(row, 4)?, - allowed_nostr_source_policies: json_column(row, 5)?, - allow_request_targets: row.get(6)?, + allowed_explicit_transport_kinds: json_column(row, 5)?, + allowed_nostr_source_policies: json_column(row, 6)?, + allow_request_targets: row.get(7)?, job_visibility: PublishJobVisibility::from_str(visibility.as_str()) - .map_err(|error| conversion_error(7, error))?, - expires_at_unix: row.get(8)?, + .map_err(|error| conversion_error(8, error))?, + expires_at_unix: row.get(9)?, }) } @@ -1505,6 +1549,29 @@ fn validate_principal_init(input: &PublishPrincipalInit) -> Result<(), Transport "principal must include at least one allowed target policy".to_owned(), )); } + let allows_explicit_targets = input + .allowed_target_policies + .contains(&TransportPublishTargetPolicyName::ExplicitTargets); + if allows_explicit_targets && input.allowed_explicit_transport_kinds.is_empty() { + return Err(TransportPublishError::InvalidScope( + "principal must include at least one allowed explicit transport kind".to_owned(), + )); + } + if !allows_explicit_targets && !input.allowed_explicit_transport_kinds.is_empty() { + return Err(TransportPublishError::InvalidScope( + "principal cannot include explicit transport kinds without explicit target policy" + .to_owned(), + )); + } + let mut explicit_transport_kinds = BTreeSet::new(); + for transport_kind in &input.allowed_explicit_transport_kinds { + let canonical = parse_explicit_transport_kind(transport_kind)?; + if canonical != *transport_kind || !explicit_transport_kinds.insert(canonical) { + return Err(TransportPublishError::InvalidScope( + "allowed explicit transport kinds must be canonical and unique".to_owned(), + )); + } + } if input .allowed_target_policies .contains(&TransportPublishTargetPolicyName::Nostr) @@ -1567,6 +1634,20 @@ pub fn parse_target_policy( } } +pub fn parse_explicit_transport_kind(value: &str) -> Result<String, TransportPublishError> { + let kind = RadrootsTransportKind::parse_canonical(value).map_err(|error| { + TransportPublishError::InvalidScope(format!( + "unknown explicit transport kind `{value}`: {error}" + )) + })?; + if kind == RadrootsTransportKind::Proxy { + return Err(TransportPublishError::InvalidScope( + "proxy cannot be used as a daemon explicit target transport kind".to_owned(), + )); + } + Ok(kind.canonical_label()) +} + fn signed_event_from_wire( event: &SignedNostrEventWire, ) -> Result<RadrootsSignedNostrEvent, TransportPublishError> { @@ -1998,8 +2079,9 @@ fn current_unix_millis() -> i64 { mod tests { use super::{ PublishJobInsert, PublishJobVisibility, PublishPrincipal, PublishPrincipalInit, - TransportPublish, TransportPublishError, TransportPublishStore, generate_bearer_token, - hash_bearer_token, parse_nostr_source_policy, + SCHEMA_VERSION, TRANSPORT_KIND_NOSTR, TRANSPORT_KIND_RETICULUM, TransportPublish, + TransportPublishError, TransportPublishStore, generate_bearer_token, hash_bearer_token, + parse_nostr_source_policy, }; use crate::app::config::{ NostrRelayUrlPolicy, TransportPublishConfig, TransportPublishNostrConfig, @@ -2126,6 +2208,7 @@ mod tests { allowed_pubkeys: vec![pubkey], allowed_kinds: vec![30_402], allowed_target_policies: vec![TransportPublishTargetPolicyName::Nostr], + allowed_explicit_transport_kinds: Vec::new(), allowed_nostr_source_policies: nostr_source_policies, allow_request_targets, job_visibility: visibility, @@ -2139,6 +2222,23 @@ mod tests { pubkey: String, visibility: PublishJobVisibility, ) -> PublishPrincipal { + explicit_target_principal_with_kinds( + proxy, + pubkey, + vec![ + TRANSPORT_KIND_NOSTR.to_owned(), + TRANSPORT_KIND_RETICULUM.to_owned(), + ], + visibility, + ) + } + + fn explicit_target_principal_with_kinds( + proxy: &TransportPublish, + pubkey: String, + allowed_explicit_transport_kinds: Vec<String>, + visibility: PublishJobVisibility, + ) -> PublishPrincipal { proxy .store .create_principal(PublishPrincipalInit { @@ -2147,6 +2247,7 @@ mod tests { allowed_pubkeys: vec![pubkey], allowed_kinds: vec![30_402], allowed_target_policies: vec![TransportPublishTargetPolicyName::ExplicitTargets], + allowed_explicit_transport_kinds, allowed_nostr_source_policies: Vec::new(), allow_request_targets: true, job_visibility: visibility, @@ -2165,6 +2266,69 @@ mod tests { } } + #[test] + fn explicit_target_principals_require_canonical_allowed_transport_kinds() { + let store = TransportPublishStore::memory().expect("store"); + let base = PublishPrincipalInit { + label: "explicit-target-tester".to_owned(), + token_hash: hash_bearer_token(generate_bearer_token().as_str()), + allowed_pubkeys: vec!["a".repeat(64)], + allowed_kinds: vec![30_402], + allowed_target_policies: vec![TransportPublishTargetPolicyName::ExplicitTargets], + allowed_explicit_transport_kinds: Vec::new(), + allowed_nostr_source_policies: Vec::new(), + allow_request_targets: true, + job_visibility: PublishJobVisibility::Own, + expires_at_unix: None, + }; + + assert!(matches!( + store.create_principal(base.clone()), + Err(TransportPublishError::InvalidScope(message)) + if message.contains("allowed explicit transport kind") + )); + + let mut uppercase = base.clone(); + uppercase.allowed_explicit_transport_kinds = vec!["Nostr".to_owned()]; + assert!(matches!( + store.create_principal(uppercase), + Err(TransportPublishError::InvalidScope(message)) + if message.contains("explicit transport kind") + )); + + let mut duplicate = base.clone(); + duplicate.allowed_explicit_transport_kinds = vec![ + TRANSPORT_KIND_NOSTR.to_owned(), + TRANSPORT_KIND_NOSTR.to_owned(), + ]; + assert!(matches!( + store.create_principal(duplicate), + Err(TransportPublishError::InvalidScope(message)) + if message.contains("canonical and unique") + )); + + let mut proxy_kind = base.clone(); + proxy_kind.allowed_explicit_transport_kinds = vec!["proxy".to_owned()]; + assert!(matches!( + store.create_principal(proxy_kind), + Err(TransportPublishError::InvalidScope(message)) + if message.contains("proxy cannot be used") + )); + + let mut nostr_policy_with_explicit_kinds = base; + nostr_policy_with_explicit_kinds.allowed_target_policies = + vec![TransportPublishTargetPolicyName::Nostr]; + nostr_policy_with_explicit_kinds.allowed_explicit_transport_kinds = + vec![TRANSPORT_KIND_NOSTR.to_owned()]; + nostr_policy_with_explicit_kinds.allowed_nostr_source_policies = + vec![NostrPublishTargetSourcePolicy::DaemonDefaultOnly]; + assert!(matches!( + store.create_principal(nostr_policy_with_explicit_kinds), + Err(TransportPublishError::InvalidScope(message)) + if message.contains("without explicit target policy") + )); + } + #[derive(Default)] struct StaticPublishRelayResolver { results: BTreeMap<String, Result<Vec<IpAddr>, String>>, @@ -2255,6 +2419,7 @@ mod tests { allowed_pubkeys: vec!["a".repeat(64)], allowed_kinds: vec![30_402], allowed_target_policies: vec![TransportPublishTargetPolicyName::Nostr], + allowed_explicit_transport_kinds: Vec::new(), allowed_nostr_source_policies: vec![ NostrPublishTargetSourcePolicy::DaemonDefaultOnly, ], @@ -2322,6 +2487,7 @@ mod tests { allowed_pubkeys: vec![pubkey], allowed_kinds: vec![30_402], allowed_target_policies: vec![TransportPublishTargetPolicyName::Nostr], + allowed_explicit_transport_kinds: Vec::new(), allowed_nostr_source_policies: vec![ NostrPublishTargetSourcePolicy::DaemonDefaultOnly, ], @@ -2357,6 +2523,86 @@ mod tests { assert!(recovered.targets.is_empty()); } + #[test] + fn transport_store_open_migrates_principal_schema_with_empty_explicit_kind_allowlist() { + let directory = tempfile::tempdir().expect("tempdir"); + let database_path = directory.path().join("publish-proxy-v1.sqlite"); + let token_hash = hash_bearer_token(generate_bearer_token().as_str()); + { + let connection = rusqlite::Connection::open(database_path.as_path()).expect("open"); + connection + .execute_batch( + r#" + PRAGMA user_version = 1; + CREATE TABLE transport_publish_principals ( + principal_id TEXT PRIMARY KEY NOT NULL, + label TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + allowed_pubkeys_json TEXT NOT NULL, + allowed_kinds_json TEXT NOT NULL, + allowed_target_policies_json TEXT NOT NULL, + allowed_nostr_source_policies_json TEXT NOT NULL, + allow_request_targets INTEGER NOT NULL, + job_visibility TEXT NOT NULL, + expires_at_unix INTEGER, + revoked_at_unix INTEGER, + created_at_unix INTEGER NOT NULL + ); + "#, + ) + .expect("schema"); + connection + .execute( + r#" + INSERT INTO transport_publish_principals ( + principal_id, + label, + token_hash, + allowed_pubkeys_json, + allowed_kinds_json, + allowed_target_policies_json, + allowed_nostr_source_policies_json, + allow_request_targets, + job_visibility, + expires_at_unix, + revoked_at_unix, + created_at_unix + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, NULL, NULL, ?10) + "#, + rusqlite::params![ + "principal-v1", + "v1", + token_hash, + serde_json::to_string(&vec!["a".repeat(64)]).expect("pubkeys"), + serde_json::to_string(&vec![30_402]).expect("kinds"), + serde_json::to_string(&vec![TransportPublishTargetPolicyName::Nostr]) + .expect("policies"), + serde_json::to_string(&vec![ + NostrPublishTargetSourcePolicy::DaemonDefaultOnly + ]) + .expect("source policies"), + false, + PublishJobVisibility::Own.to_string(), + 1_i64, + ], + ) + .expect("principal"); + } + + let store = TransportPublishStore::open(database_path.clone()).expect("migrate"); + let principal = store + .principal_by_id("principal-v1") + .expect("lookup") + .expect("principal"); + assert!(principal.allowed_explicit_transport_kinds.is_empty()); + let version = rusqlite::Connection::open(database_path.as_path()) + .expect("open migrated") + .pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0)) + .expect("user version"); + assert_eq!(version, SCHEMA_VERSION); + } + #[tokio::test] async fn publish_event_verifies_and_records_daemon_default_outcome() { let identity = RadrootsIdentity::generate(); @@ -2889,6 +3135,75 @@ mod tests { } #[tokio::test] + async fn publish_event_records_explicit_nostr_target_when_kind_allowed() { + let identity = RadrootsIdentity::generate(); + let (proxy, adapter) = transport_publish(config_with_defaults(vec![RELAY_PRIMARY])); + let principal = + explicit_target_principal(&proxy, identity.public_key_hex(), PublishJobVisibility::Own); + let event = signed_event(&identity, "{}"); + let raw_event = serde_json::to_string(&event).expect("raw event"); + let request = TransportPublishEventRequest { + event, + target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ + TransportPublishTarget::nostr(RELAY_PRIMARY), + ]), + delivery_policy: TransportPublishDeliveryPolicy::Any, + idempotency_key: None, + timeout_ms: Some(5_000), + }; + + let response = proxy + .publish_event(&principal, request) + .await + .expect("publish"); + + assert_eq!( + response.job.status, + TransportPublishJobStatus::DeliverySatisfied + ); + assert_eq!(response.job.targets.len(), 1); + assert_eq!( + response.job.targets[0].source, + TransportPublishTargetSource::Request + ); + assert_eq!(response.job.targets[0].endpoint_uri, RELAY_PRIMARY); + assert_eq!(adapter.captured_raw_events(), vec![raw_event]); + } + + #[tokio::test] + async fn publish_event_rejects_explicit_target_kind_not_allowed_before_recording_job() { + let identity = RadrootsIdentity::generate(); + let (proxy, adapter) = transport_publish(TransportPublishConfig::default()); + let principal = explicit_target_principal_with_kinds( + &proxy, + identity.public_key_hex(), + vec![TRANSPORT_KIND_NOSTR.to_owned()], + PublishJobVisibility::Own, + ); + + let err = proxy + .publish_event( + &principal, + reticulum_publish_request( + signed_event(&identity, "{}"), + TransportPublishPreviewBehavior::RejectDeliveryAttempts, + ), + ) + .await + .expect_err("disallowed explicit target kind"); + + assert!(matches!(err, TransportPublishError::InvalidScope(_))); + assert!(adapter.captured_raw_events().is_empty()); + assert!( + proxy + .store + .list_jobs_for_principal(&principal, 10) + .expect("jobs") + .is_empty() + ); + } + + #[tokio::test] async fn publish_event_records_reticulum_deferred_as_terminal_nonfailure() { let identity = RadrootsIdentity::generate(); let (proxy, adapter) = transport_publish(TransportPublishConfig::default()); diff --git a/src/transport/jsonrpc/auth.rs b/src/transport/jsonrpc/auth.rs @@ -82,6 +82,7 @@ mod tests { allowed_pubkeys: vec!["a".repeat(64)], allowed_kinds: vec![30_402], allowed_target_policies: vec![TransportPublishTargetPolicyName::Nostr], + allowed_explicit_transport_kinds: Vec::new(), allowed_nostr_source_policies: vec![ NostrPublishTargetSourcePolicy::DaemonDefaultOnly, ], diff --git a/src/transport/jsonrpc/methods/mod.rs b/src/transport/jsonrpc/methods/mod.rs @@ -116,6 +116,7 @@ mod tests { allowed_target_policies: vec![ radroots_transport_publish_protocol::TransportPublishTargetPolicyName::Nostr, ], + allowed_explicit_transport_kinds: Vec::new(), allowed_nostr_source_policies: vec![ radroots_transport_publish_protocol::NostrPublishTargetSourcePolicy::DaemonDefaultOnly, ], diff --git a/src/transport/jsonrpc/methods/transport_publish.rs b/src/transport/jsonrpc/methods/transport_publish.rs @@ -197,6 +197,7 @@ mod tests { allowed_pubkeys: vec![identity.public_key_hex()], allowed_kinds: vec![30_402], allowed_target_policies: vec![TransportPublishTargetPolicyName::Nostr], + allowed_explicit_transport_kinds: Vec::new(), allowed_nostr_source_policies: vec![ NostrPublishTargetSourcePolicy::DaemonDefaultOnly, ], diff --git a/src/transport/jsonrpc/server.rs b/src/transport/jsonrpc/server.rs @@ -176,6 +176,7 @@ mod tests { allowed_pubkeys: vec![identity.public_key_hex()], allowed_kinds: vec![30_402], allowed_target_policies: vec![TransportPublishTargetPolicyName::Nostr], + allowed_explicit_transport_kinds: Vec::new(), allowed_nostr_source_policies: vec![ NostrPublishTargetSourcePolicy::DaemonDefaultOnly, ], diff --git a/tests/source_boundary.rs b/tests/source_boundary.rs @@ -26,7 +26,7 @@ const FORBIDDEN_DAEMON_TRANSPORT_CONCEPTS: &[ForbiddenConcept] = &[ reason: "publish.event is replaced by transport.publish.event", }, ForbiddenConcept { - pattern: "transport_kinds", + pattern: "\"transport_kinds\"", reason: "capabilities must expose per-transport readiness instead of kind-only lists", }, ForbiddenConcept { @@ -111,6 +111,27 @@ fn transport_publish_sources_reject_proxy_explicit_targets() { } #[test] +fn transport_publish_sources_require_principal_explicit_kind_scope() { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let daemon_source = read_source(manifest_dir.join("src/core/transport_publish.rs").as_path()); + + for required in [ + "allowed_explicit_transport_kinds_json", + "pub allowed_explicit_transport_kinds: Vec<String>,", + "parse_explicit_transport_kind", + "principal must include at least one allowed explicit transport kind", + "principal is not allowed to use explicit transport target kind", + "publish_event_records_explicit_nostr_target_when_kind_allowed", + "publish_event_rejects_explicit_target_kind_not_allowed_before_recording_job", + ] { + assert!( + daemon_source.contains(required), + "daemon transport publish sources must retain explicit target kind-scope witness `{required}`" + ); + } +} + +#[test] fn transport_publish_capabilities_expose_per_transport_readiness() { let methods_source = read_source( Path::new(env!("CARGO_MANIFEST_DIR"))