tangle


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

commit 284956c41a459bfbd331790de6ea2fd880863375
parent 441a7a1b60871d2c11f02470c09a73b617e5fbd2
Author: triesap <tyson@radroots.org>
Date:   Thu, 16 Jul 2026 03:24:03 +0000

runtime: expose auth context to projection hooks

- add authenticated pubkeys to historical and live projection contexts
- keep compatibility constructors defaulting to unauthenticated contexts
- restore relay URL override validation for alias auth boundaries
- cover auth URL override and projection auth propagation with tests

Diffstat:
Mcrates/tangle_runtime/src/relay/auth.rs | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/tangle_runtime/src/runtime.rs | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
2 files changed, 201 insertions(+), 12 deletions(-)

diff --git a/crates/tangle_runtime/src/relay/auth.rs b/crates/tangle_runtime/src/relay/auth.rs @@ -78,6 +78,15 @@ impl BaseAuthState { Ok(RelayMessage::Auth(challenge)) } + 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.relay_url = relay_url; + Ok(()) + } + #[cfg(test)] pub fn authenticate( &mut self, @@ -355,6 +364,50 @@ mod tests { } #[test] + fn auth_state_accepts_relay_url_override_for_alias_boundaries() { + let mut auth = + BaseAuthState::new("wss://relay.radroots.test", 60, 600).expect("auth state"); + auth.issue_challenge("challenge-a", UnixTimestamp::new(100)) + .expect("challenge"); + + assert_eq!( + auth.authenticate( + &signed_event( + 7, + 22_242, + auth_tags_for("wss://alias.radroots.test", "challenge-a"), + 120 + ), + UnixTimestamp::new(120) + ) + .expect_err("canonical relay") + .prefixed_message(), + "auth-required: auth relay does not match canonical relay URL" + ); + + auth.accept_relay_url("wss://alias.radroots.test") + .expect("alias relay"); + assert!( + auth.authenticate( + &signed_event( + 7, + 22_242, + auth_tags_for("wss://alias.radroots.test", "challenge-a"), + 120 + ), + UnixTimestamp::new(120) + ) + .is_ok() + ); + assert_eq!( + auth.accept_relay_url(" ") + .expect_err("blank relay") + .prefixed_message(), + "invalid: auth relay URL must not be empty" + ); + } + + #[test] fn auth_state_rejects_invalid_event_shape_and_signature() { let mut auth = BaseAuthState::new("wss://relay.radroots.test", 60, 600).expect("auth state"); 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, }; @@ -284,6 +284,7 @@ pub enum RelayRequestedKinds { #[derive(Debug, Clone, PartialEq, Eq)] pub struct RelayLiveProjectionContext { projection: RelayProjectionContext, + authenticated_pubkeys: BTreeSet<PublicKeyHex>, source_store_offset: u64, event: RelayEventContext, } @@ -294,8 +295,23 @@ impl RelayLiveProjectionContext { source_store_offset: u64, event: RelayEventContext, ) -> Self { + Self::new_with_authenticated_pubkeys( + projection, + source_store_offset, + event, + BTreeSet::new(), + ) + } + + pub fn new_with_authenticated_pubkeys( + projection: RelayProjectionContext, + source_store_offset: u64, + event: RelayEventContext, + authenticated_pubkeys: impl IntoIterator<Item = PublicKeyHex>, + ) -> Self { Self { projection, + authenticated_pubkeys: authenticated_pubkeys.into_iter().collect(), source_store_offset, event, } @@ -305,6 +321,10 @@ impl RelayLiveProjectionContext { &self.projection } + pub fn authenticated_pubkeys(&self) -> &BTreeSet<PublicKeyHex> { + &self.authenticated_pubkeys + } + pub fn source_store_offset(&self) -> u64 { self.source_store_offset } @@ -492,6 +512,7 @@ struct RelayLiveProjectionDelivery<'a> { pub struct RelayEventProjectionContext { subscription_id: SubscriptionId, projection: RelayProjectionContext, + authenticated_pubkeys: BTreeSet<PublicKeyHex>, source: RelayEventProjectionSource, matched_filters: Vec<RelayMatchedFilterContext>, event: RelayEventContext, @@ -521,9 +542,28 @@ impl RelayEventProjectionContext { matched_filters: Vec<RelayMatchedFilterContext>, event: RelayEventContext, ) -> Self { + Self::new_with_matched_filters_and_authenticated_pubkeys( + subscription_id, + projection, + source, + matched_filters, + event, + BTreeSet::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: impl IntoIterator<Item = PublicKeyHex>, + ) -> Self { Self { subscription_id, projection, + authenticated_pubkeys: authenticated_pubkeys.into_iter().collect(), source, matched_filters, event, @@ -538,6 +578,10 @@ impl RelayEventProjectionContext { &self.projection } + pub fn authenticated_pubkeys(&self) -> &BTreeSet<PublicKeyHex> { + &self.authenticated_pubkeys + } + pub fn source(&self) -> RelayEventProjectionSource { self.source } @@ -1156,16 +1200,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(), + ); match self.hooks.project_event(&context) { RelayEventProjectionDecision::Emit => Ok(Some(event.to_owned())), RelayEventProjectionDecision::Suppress => Ok(None), @@ -2015,10 +2061,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(), ); 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 +3802,95 @@ mod tests { } #[tokio::test] + async fn runtime_projection_context_includes_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::Live, + 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::Member, "challenge-a", 120).await; + let authenticated_pubkeys = auth.authenticated_pubkeys().clone(); + let event = tangle_v2_event( + FixtureKey::Member, + 1_714_124_433, + 1, + Vec::new(), + "authenticated projection", + ) + .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("projection-auth-query").expect("subscription"); + 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, + &RelayProjectionContext::named("auth-context").expect("projection"), + ) + .await + .expect("query"); + assert!(matches!( + report.into_messages().as_slice(), + [ + RuntimeRelayMessage::Event { subscription_id, .. }, + RuntimeRelayMessage::Protocol(RelayMessage::Eose(eose)) + ] if subscription_id == &query_sub && eose == &query_sub + )); + + let live_sub = SubscriptionId::new("projection-auth-live").expect("subscription"); + let mut subscriptions = LiveSubscriptionSet::new(8, 64).expect("subscriptions"); + subscriptions + .subscribe(live_sub.clone(), vec![pocket_filter(json!({"kinds": [1]}))]) + .expect("subscribe"); + assert!(matches!( + handle + .fanout_event_offset_with_projection_context( + offset, + &mut subscriptions, + &auth, + &RelayProjectionContext::named("auth-context").expect("projection"), + ) + .await + .expect("fanout") + .as_slice(), + [RuntimeRelayMessage::Event { subscription_id, .. }] if subscription_id == &live_sub + )); + + let contexts = hooks.contexts(); + assert!(contexts.iter().any(|context| context.source() + == RelayEventProjectionSource::HistoricalQuery + && context.authenticated_pubkeys() == &authenticated_pubkeys)); + assert!(contexts.iter().any(|context| matches!( + context.source(), + RelayEventProjectionSource::LiveFanout { .. } + ) && context.authenticated_pubkeys() + == &authenticated_pubkeys)); + let live_contexts = hooks.live_contexts(); + assert_eq!(live_contexts.len(), 1); + assert_eq!( + live_contexts[0].authenticated_pubkeys(), + &authenticated_pubkeys + ); + + 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);