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:
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"
);
}