tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit 09d27aa598a35c66d8c4e32e472a2d73c2a8b712
parent 441a7a1b60871d2c11f02470c09a73b617e5fbd2
Author: triesap <tyson@radroots.org>
Date:   Sat,  4 Jul 2026 02:49:03 +0000

runtime: expose authenticated projection context

- add authenticated pubkeys to projection hook contexts
- support explicit AUTH relay URL aliases
- cover alias auth and projection auth propagation
- update affected auth rejection assertions

Diffstat:
Mcrates/tangle_runtime/src/relay/auth.rs | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/tangle_runtime/src/runtime.rs | 224++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/tangle_runtime/src/server.rs | 2+-
Mcrates/tangle_runtime/tests/base_relay_v2.rs | 2+-
4 files changed, 278 insertions(+), 22 deletions(-)

diff --git a/crates/tangle_runtime/src/relay/auth.rs b/crates/tangle_runtime/src/relay/auth.rs @@ -27,6 +27,7 @@ pub fn generate_auth_challenge() -> Result<String, BaseRelayError> { #[derive(Debug, Clone, PartialEq, Eq)] pub struct BaseAuthState { relay_url: String, + accepted_relay_urls: BTreeSet<String>, challenge_ttl_seconds: u64, created_at_skew_seconds: u64, challenge: Option<BaseAuthChallenge>, @@ -55,6 +56,7 @@ impl BaseAuthState { } Ok(Self { relay_url, + accepted_relay_urls: BTreeSet::new(), challenge_ttl_seconds, created_at_skew_seconds, challenge: None, @@ -62,6 +64,15 @@ impl BaseAuthState { }) } + pub fn accept_relay_url(&mut self, relay_url: impl Into<String>) -> Result<(), BaseRelayError> { + let relay_url = relay_url.into(); + if relay_url.trim().is_empty() { + return Err(BaseRelayError::invalid("auth relay URL must not be empty")); + } + self.accepted_relay_urls.insert(relay_url); + Ok(()) + } + pub fn issue_challenge( &mut self, challenge: impl Into<String>, @@ -92,9 +103,9 @@ impl BaseAuthState { .challenge .as_ref() .ok_or_else(|| BaseRelayError::auth_required("auth challenge is missing"))?; - if auth.relay() != self.relay_url { + if !self.accepts_relay_url(auth.relay()) { return Err(BaseRelayError::auth_required( - "auth relay does not match canonical relay URL", + "auth relay does not match accepted relay URL", )); } if auth.challenge() != challenge.value { @@ -140,9 +151,9 @@ impl BaseAuthState { .challenge .as_ref() .ok_or_else(|| BaseRelayError::auth_required("auth challenge is missing"))?; - if auth.relay() != self.relay_url { + if !self.accepts_relay_url(auth.relay()) { return Err(BaseRelayError::auth_required( - "auth relay does not match canonical relay URL", + "auth relay does not match accepted relay URL", )); } if auth.challenge() != challenge.value { @@ -178,6 +189,10 @@ impl BaseAuthState { pub fn authenticated_pubkeys(&self) -> &BTreeSet<PublicKeyHex> { &self.authenticated_pubkeys } + + fn accepts_relay_url(&self, relay_url: &str) -> bool { + relay_url == self.relay_url || self.accepted_relay_urls.contains(relay_url) + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -465,6 +480,51 @@ mod tests { } #[test] + fn auth_state_accepts_explicit_additional_relay_urls() { + let mut auth = + BaseAuthState::new("wss://relay.radroots.test", 60, 600).expect("auth state"); + assert_eq!( + auth.accept_relay_url("") + .expect_err("empty alias") + .prefixed_message(), + "invalid: auth relay URL must not be empty" + ); + auth.accept_relay_url("wss://relay.radroots.test/es") + .expect("alias"); + auth.issue_challenge("challenge-a", UnixTimestamp::new(100)) + .expect("challenge"); + + let alias_event = signed_event( + 7, + 22_242, + auth_tags_for("wss://relay.radroots.test/es", "challenge-a"), + 105, + ); + let base_event = signed_auth_event(8, "challenge-a", 106); + let alias_pocket_event = signed_pocket_event( + 9, + 22_242, + pocket_auth_tags_for("wss://relay.radroots.test/es", "challenge-a"), + 107, + ); + + let alias_pubkey = auth + .authenticate(&alias_event, UnixTimestamp::new(105)) + .expect("alias event"); + let base_pubkey = auth + .authenticate(&base_event, UnixTimestamp::new(106)) + .expect("base event"); + let alias_pocket_pubkey = auth + .authenticate_pocket(&alias_pocket_event, UnixTimestamp::new(107)) + .expect("alias pocket event"); + + assert!(auth.authenticated_pubkeys().contains(&alias_pubkey)); + assert!(auth.authenticated_pubkeys().contains(&base_pubkey)); + assert!(auth.authenticated_pubkeys().contains(&alias_pocket_pubkey)); + assert_eq!(auth.authenticated_pubkeys().len(), 3); + } + + #[test] fn auth_state_preserves_chorus_auth_parity() { let mut auth = BaseAuthState::new("wss://relay.radroots.test", 20, 10).expect("auth state"); auth.issue_challenge("challenge-a", UnixTimestamp::new(100)) @@ -527,7 +587,7 @@ mod tests { ) .expect_err("relay") .prefixed_message(), - "auth-required: auth relay does not match canonical relay URL" + "auth-required: auth relay does not match accepted relay URL" ); assert_eq!( auth.authenticate( @@ -670,7 +730,7 @@ mod tests { 105, ), 105, - "auth-required: auth relay does not match canonical relay URL", + "auth-required: auth relay does not match accepted relay URL", ), ( signed_pocket_auth_event(9, "wrong", 105), diff --git a/crates/tangle_runtime/src/runtime.rs b/crates/tangle_runtime/src/runtime.rs @@ -45,7 +45,7 @@ use tangle_groups::{ GroupAuthContext, GroupEventClass, GroupId, KIND_GROUP_JOIN_REQUEST, StoreOffset, validate_client_group_event_structure, }; -use tangle_protocol::{Kind, RelayMessage, SubscriptionId, UnixTimestamp}; +use tangle_protocol::{Kind, PublicKeyHex, RelayMessage, SubscriptionId, UnixTimestamp}; use tangle_store_pocket::{ PocketEvent, PocketFilter, PocketOwnedEvent, PocketOwnedFilter, PocketStoreHandle, PocketTime, }; @@ -218,6 +218,7 @@ pub struct RelayQueryProjectionContext { subscription_id: SubscriptionId, projection: RelayProjectionContext, filters: Vec<RelayMatchedFilterContext>, + authenticated_pubkeys: Vec<PublicKeyHex>, } impl RelayQueryProjectionContext { @@ -226,10 +227,20 @@ impl RelayQueryProjectionContext { projection: RelayProjectionContext, filters: Vec<RelayMatchedFilterContext>, ) -> Self { + Self::new_with_authenticated_pubkeys(subscription_id, projection, filters, Vec::new()) + } + + pub fn new_with_authenticated_pubkeys( + subscription_id: SubscriptionId, + projection: RelayProjectionContext, + filters: Vec<RelayMatchedFilterContext>, + authenticated_pubkeys: Vec<PublicKeyHex>, + ) -> Self { Self { subscription_id, projection, filters, + authenticated_pubkeys, } } @@ -244,6 +255,10 @@ impl RelayQueryProjectionContext { pub fn filters(&self) -> &[RelayMatchedFilterContext] { &self.filters } + + pub fn authenticated_pubkeys(&self) -> &[PublicKeyHex] { + &self.authenticated_pubkeys + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -286,6 +301,7 @@ pub struct RelayLiveProjectionContext { projection: RelayProjectionContext, source_store_offset: u64, event: RelayEventContext, + authenticated_pubkeys: Vec<PublicKeyHex>, } impl RelayLiveProjectionContext { @@ -294,10 +310,20 @@ impl RelayLiveProjectionContext { source_store_offset: u64, event: RelayEventContext, ) -> Self { + Self::new_with_authenticated_pubkeys(projection, source_store_offset, event, Vec::new()) + } + + pub fn new_with_authenticated_pubkeys( + projection: RelayProjectionContext, + source_store_offset: u64, + event: RelayEventContext, + authenticated_pubkeys: Vec<PublicKeyHex>, + ) -> Self { Self { projection, source_store_offset, event, + authenticated_pubkeys, } } @@ -312,6 +338,10 @@ impl RelayLiveProjectionContext { pub fn event(&self) -> &RelayEventContext { &self.event } + + pub fn authenticated_pubkeys(&self) -> &[PublicKeyHex] { + &self.authenticated_pubkeys + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -495,6 +525,7 @@ pub struct RelayEventProjectionContext { source: RelayEventProjectionSource, matched_filters: Vec<RelayMatchedFilterContext>, event: RelayEventContext, + authenticated_pubkeys: Vec<PublicKeyHex>, } impl RelayEventProjectionContext { @@ -505,12 +536,31 @@ impl RelayEventProjectionContext { matched_filter: RelayMatchedFilterContext, event: RelayEventContext, ) -> Self { - Self::new_with_matched_filters( + Self::new_with_authenticated_pubkeys( + subscription_id, + projection, + source, + matched_filter, + event, + Vec::new(), + ) + } + + pub fn new_with_authenticated_pubkeys( + subscription_id: SubscriptionId, + projection: RelayProjectionContext, + source: RelayEventProjectionSource, + matched_filter: RelayMatchedFilterContext, + event: RelayEventContext, + authenticated_pubkeys: Vec<PublicKeyHex>, + ) -> Self { + Self::new_with_matched_filters_and_authenticated_pubkeys( subscription_id, projection, source, vec![matched_filter], event, + authenticated_pubkeys, ) } @@ -521,12 +571,31 @@ impl RelayEventProjectionContext { matched_filters: Vec<RelayMatchedFilterContext>, event: RelayEventContext, ) -> Self { + Self::new_with_matched_filters_and_authenticated_pubkeys( + subscription_id, + projection, + source, + matched_filters, + event, + Vec::new(), + ) + } + + pub fn new_with_matched_filters_and_authenticated_pubkeys( + subscription_id: SubscriptionId, + projection: RelayProjectionContext, + source: RelayEventProjectionSource, + matched_filters: Vec<RelayMatchedFilterContext>, + event: RelayEventContext, + authenticated_pubkeys: Vec<PublicKeyHex>, + ) -> Self { Self { subscription_id, projection, source, matched_filters, event, + authenticated_pubkeys, } } @@ -555,6 +624,10 @@ impl RelayEventProjectionContext { pub fn event(&self) -> &RelayEventContext { &self.event } + + pub fn authenticated_pubkeys(&self) -> &[PublicKeyHex] { + &self.authenticated_pubkeys + } } impl RelayEventStoredContext { @@ -1156,16 +1229,18 @@ impl RelayRuntimeShared { auth, matched_filters, } = request; - let context = RelayEventProjectionContext::new_with_matched_filters( - subscription_id.clone(), - projection.clone(), - source, - matched_filters - .iter() - .map(|(matched_filter, _)| RelayMatchedFilterContext::from_base(matched_filter)) - .collect(), - RelayEventContext::from_pocket_event(event)?, - ); + let context = + RelayEventProjectionContext::new_with_matched_filters_and_authenticated_pubkeys( + subscription_id.clone(), + projection.clone(), + source, + matched_filters + .iter() + .map(|(matched_filter, _)| RelayMatchedFilterContext::from_base(matched_filter)) + .collect(), + RelayEventContext::from_pocket_event(event)?, + auth.authenticated_pubkeys().iter().cloned().collect(), + ); match self.hooks.project_event(&context) { RelayEventProjectionDecision::Emit => Ok(Some(event.to_owned())), RelayEventProjectionDecision::Suppress => Ok(None), @@ -1933,7 +2008,7 @@ impl RelayRuntimeHandle { projection: &RelayProjectionContext, ) -> Result<BaseRelayQueryReport, BaseRelayError> { let started_at = Instant::now(); - let context = RelayQueryProjectionContext::new( + let context = RelayQueryProjectionContext::new_with_authenticated_pubkeys( subscription_id.clone(), projection.clone(), filters @@ -1943,6 +2018,7 @@ impl RelayRuntimeHandle { RelayMatchedFilterContext::from_base(&matched_filter_context(index, filter)) }) .collect(), + auth.authenticated_pubkeys().iter().cloned().collect(), ); let plan = self.inner.hooks.plan_query(&context); let report = match plan.limit() { @@ -2015,10 +2091,11 @@ impl RelayRuntimeHandle { messages: &mut messages, }; self.fanout_projected_live_event(&pocket_event, offset.as_u64(), &mut delivery)?; - let context = RelayLiveProjectionContext::new( + let context = RelayLiveProjectionContext::new_with_authenticated_pubkeys( projection.clone(), offset.as_u64(), RelayEventContext::from_pocket_event(&pocket_event)?, + auth.authenticated_pubkeys().iter().cloned().collect(), ); for candidate in self.inner.hooks.live_projection_candidates(&context) { let Ok(candidate_event) = self.inner.store.event_by_offset(candidate.store_offset()) @@ -3755,6 +3832,125 @@ mod tests { } #[tokio::test] + async fn runtime_projection_contexts_include_authenticated_pubkeys() { + let root = temp_root("runtime-projection-authenticated-pubkeys"); + let _ = std::fs::remove_dir_all(&root); + let hooks = Arc::new(ProjectingHooks::new( + "auth-context", + ProjectionHookScope::Historical, + None, + RelayEventProjectionDecision::Emit, + )); + let handle = RelayRuntimeHandle::new( + RelayRuntime::open_with_hooks(runtime_config(&root, 8), hooks.clone()) + .expect("runtime"), + ); + let mut offsets = handle.subscribe_events().await; + let mut auth = + authenticated_runtime_state(&handle, FixtureKey::Owner, "challenge-auth-context", 100) + .await; + let expected_pubkeys = auth + .authenticated_pubkeys() + .iter() + .cloned() + .collect::<Vec<_>>(); + let event = tangle_v2_event( + FixtureKey::Member, + 1_714_124_433, + 1, + Vec::new(), + "authenticated projection context", + ) + .expect("event"); + assert_accepted_reply( + runtime_event_reply(&handle, event.clone(), &mut auth, 1_714_124_433).await, + &event, + ); + let offset = offsets.try_recv().expect("offset"); + let query_sub = SubscriptionId::new("query-auth-context").expect("subscription"); + let projection = RelayProjectionContext::named("auth-context").expect("projection"); + + let report = handle + .query_req_with_auth_report_with_projection_context( + query_sub.clone(), + vec![pocket_filter(json!({"ids": [event.id().as_str()]}))], + false, + &auth, + &projection, + ) + .await + .expect("query"); + assert!(matches!( + report.into_messages().as_slice(), + [ + RuntimeRelayMessage::Event { + subscription_id, + event: found + }, + RuntimeRelayMessage::Protocol(RelayMessage::Eose(eose)) + ] if subscription_id == &query_sub + && found.id().as_hex_string() == event.id().as_str() + && eose == &query_sub + )); + + let mut subscriptions = LiveSubscriptionSet::new(8, 64).expect("subscriptions"); + subscriptions + .subscribe( + SubscriptionId::new("live-auth-context").expect("subscription"), + vec![pocket_filter(json!({"kinds": [1]}))], + ) + .expect("subscribe"); + assert_eq!( + handle + .fanout_event_offset_with_projection_context( + offset, + &mut subscriptions, + &auth, + &projection, + ) + .await + .expect("fanout") + .len(), + 1 + ); + + let query_contexts = hooks.query_contexts(); + assert_eq!(query_contexts.len(), 1); + assert_eq!( + query_contexts[0].authenticated_pubkeys(), + expected_pubkeys.as_slice() + ); + let live_contexts = hooks.live_contexts(); + assert_eq!(live_contexts.len(), 1); + assert_eq!( + live_contexts[0].authenticated_pubkeys(), + expected_pubkeys.as_slice() + ); + let contexts = hooks.contexts(); + assert_eq!(contexts.len(), 2); + assert_eq!( + contexts[0].source(), + RelayEventProjectionSource::HistoricalQuery + ); + assert_eq!( + contexts[0].authenticated_pubkeys(), + expected_pubkeys.as_slice() + ); + assert_eq!( + contexts[1].source(), + RelayEventProjectionSource::LiveFanout { + store_offset: offset.as_u64() + } + ); + assert_eq!( + contexts[1].authenticated_pubkeys(), + expected_pubkeys.as_slice() + ); + + let _ = std::fs::remove_dir_all(root); + } + + #[tokio::test] async fn runtime_projection_replaces_with_existing_stored_events_only() { let root = temp_root("runtime-projection-replace"); let _ = std::fs::remove_dir_all(&root); diff --git a/crates/tangle_runtime/src/server.rs b/crates/tangle_runtime/src/server.rs @@ -860,7 +860,7 @@ mod tests { "OK", alpha_auth.id().as_str(), false, - "auth-required: auth relay does not match canonical relay URL" + "auth-required: auth relay does not match accepted relay URL" ]) ); diff --git a/crates/tangle_runtime/tests/base_relay_v2.rs b/crates/tangle_runtime/tests/base_relay_v2.rs @@ -648,7 +648,7 @@ fn auth_integration_covers_challenge_edges() { ) .expect_err("relay") .prefixed_message(), - "auth-required: auth relay does not match canonical relay URL" + "auth-required: auth relay does not match accepted relay URL" ); }