tangle


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

commit 1c358170ac329f299e2e8f28804588e50bcfde0c
parent 5c4369d1afd9585fc22d36818efcd43bc3ece72e
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 05:58:41 -0700

groups: generate admin snapshots for role changes

- Compare membership relay-override admin state before and after accepted membership writes.
- Enqueue admin-list snapshots alongside member snapshots when role changes alter the derived admin set.
- Cover relay-override promotion and demotion through the live relay path and stored generated outbox records.
- Validated with cargo fmt --all -- --check, cargo check --workspace --all-targets, cargo test --workspace, and cargo clippy --workspace --all-targets -- -D warnings.

Diffstat:
Mcrates/tangle_runtime/src/groups.rs | 112++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/tangle_runtime/tests/base_relay_v2.rs | 146++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 248 insertions(+), 10 deletions(-)

diff --git a/crates/tangle_runtime/src/groups.rs b/crates/tangle_runtime/src/groups.rs @@ -16,8 +16,8 @@ use tangle_groups::{ GroupPolicyConfig, GroupProjection, GroupReadDecision, GroupReadGate, GroupRuntimeConfig, GroupState, GroupTombstone, KIND_GROUP_CREATE_GROUP, KIND_GROUP_DELETE_EVENT, KIND_GROUP_EDIT_METADATA, KIND_GROUP_JOIN_REQUEST, KIND_GROUP_LEAVE_REQUEST, - KIND_GROUP_MEMBERS, KIND_GROUP_PUT_USER, KIND_GROUP_REMOVE_USER, MemberState, - ProjectedRoleDefinition, ProjectionCheckpoint, StoreOffset, event_deletion_key, + KIND_GROUP_MEMBERS, KIND_GROUP_PUT_USER, KIND_GROUP_REMOVE_USER, MemberState, MemberStatus, + ProjectedRoleDefinition, ProjectionCheckpoint, RoleName, StoreOffset, event_deletion_key, event_view::GroupEventView, group_current_key, member_current_key, projection_checkpoint_key, rebuild_group_projection, role_current_key, tombstone_key, }; @@ -159,12 +159,14 @@ impl GroupService { class: &GroupEventClass, store_offset: StoreOffset, ) -> Result<Vec<StoreOffset>, BaseRelayError> { + let before_membership_admin = + membership_admin_snapshot_state(&self.projection, event, class)?; self.projection .apply_canonical_event(event, store_offset, self.limits)?; if let Some(group_id) = class_group_id(class) { self.persist_group_projection(store, group_id)?; } - for record in self.plan_outbox_records(event, class)? { + for record in self.plan_outbox_records(event, class, before_membership_admin)? { let inserted = self.outbox.merge_idempotent(record.clone())?; if inserted { persist_outbox_record(store, &record)?; @@ -180,6 +182,7 @@ impl GroupService { &self, event: &Event, class: &GroupEventClass, + before_membership_admin: Option<bool>, ) -> Result<Vec<GroupOutboxRecord>, GroupError> { plan_group_outbox_records( event, @@ -187,6 +190,7 @@ impl GroupService { &self.projection, &self.authority, self.member_snapshot_cap, + before_membership_admin, ) } @@ -201,6 +205,8 @@ impl GroupService { events.sort_by_key(CanonicalGroupEvent::tuple); for item in events { let class = tangle_groups::classify_group_event(item.event(), self.limits)?; + let before_membership_admin = + membership_admin_snapshot_state(&projection, item.event(), &class)?; projection.apply_canonical_event(item.event(), item.store_offset(), self.limits)?; if item.event().unsigned().pubkey() == &relay_pubkey { continue; @@ -211,6 +217,7 @@ impl GroupService { &projection, &self.authority, self.member_snapshot_cap, + before_membership_admin, )? { let inserted = self.outbox.merge_idempotent(record.clone())?; if inserted { @@ -359,6 +366,7 @@ fn plan_group_outbox_records( projection: &GroupProjection, authority: &GroupAuthority, member_snapshot_cap: u32, + before_membership_admin: Option<bool>, ) -> Result<Vec<GroupOutboxRecord>, GroupError> { let created_at = event.unsigned().created_at(); match class { @@ -394,9 +402,15 @@ fn plan_group_outbox_records( GroupGeneratedEventBuilder::metadata_snapshot_payload(group, created_at)?, )]) } - KIND_GROUP_PUT_USER | KIND_GROUP_REMOVE_USER => { - member_snapshot_record(event, group_id, projection, created_at, member_snapshot_cap) - } + KIND_GROUP_PUT_USER | KIND_GROUP_REMOVE_USER => member_snapshot_records( + event, + group_id, + projection, + authority, + created_at, + member_snapshot_cap, + before_membership_admin, + ), _ => Ok(Vec::new()), }, GroupEventClass::Normal { group_id } => match event.unsigned().kind().as_u32() { @@ -430,6 +444,35 @@ fn plan_group_outbox_records( } } +fn member_snapshot_records( + event: &Event, + group_id: &GroupId, + projection: &GroupProjection, + authority: &GroupAuthority, + created_at: UnixTimestamp, + member_snapshot_cap: u32, + before_membership_admin: Option<bool>, +) -> Result<Vec<GroupOutboxRecord>, GroupError> { + let mut records = + member_snapshot_record(event, group_id, projection, created_at, member_snapshot_cap)?; + if let Some(before) = before_membership_admin { + let target = membership_target_pubkey(event)?; + let after = member_is_relay_override_admin(projection, group_id, &target); + if before != after { + records.push(pending_record( + event, + GroupOutboxEffect::AdminListSnapshot, + group_id, + None, + GroupGeneratedEventBuilder::admin_list_snapshot_payload( + group_id, projection, authority, created_at, + )?, + )); + } + } + Ok(records) +} + fn member_snapshot_record( event: &Event, group_id: &GroupId, @@ -467,6 +510,63 @@ fn member_snapshot_record( }]) } +fn membership_admin_snapshot_state( + projection: &GroupProjection, + event: &Event, + class: &GroupEventClass, +) -> Result<Option<bool>, GroupError> { + match class { + GroupEventClass::Moderation { kind, group_id } + if matches!(kind.as_u32(), KIND_GROUP_PUT_USER | KIND_GROUP_REMOVE_USER) => + { + let target = membership_target_pubkey(event)?; + Ok(Some(member_is_relay_override_admin( + projection, group_id, &target, + ))) + } + _ => Ok(None), + } +} + +fn member_is_relay_override_admin( + projection: &GroupProjection, + group_id: &GroupId, + pubkey: &PublicKeyHex, +) -> bool { + projection + .member(group_id, pubkey) + .filter(|member| member.status() == MemberStatus::Member) + .is_some_and(|member| { + member + .roles() + .contains(&RoleName::permanent_relay_override()) + }) +} + +fn membership_target_pubkey(event: &Event) -> Result<PublicKeyHex, GroupError> { + for tag in event.unsigned().tags() { + if tag.values().first().is_none_or(|name| name != "p") { + continue; + } + let Some((_, value)) = tag.indexed_pair() else { + return Err(GroupError::invalid( + GroupErrorKind::MalformedTargetTag, + "malformed p target tag", + )); + }; + return PublicKeyHex::new(value).map_err(|reason| { + GroupError::invalid( + GroupErrorKind::MalformedTargetTag, + format!("malformed p target tag: {reason}"), + ) + }); + } + Err(GroupError::invalid( + GroupErrorKind::MissingTargetTag, + "missing p target tag", + )) +} + fn pending_record( event: &Event, effect: GroupOutboxEffect, diff --git a/crates/tangle_runtime/tests/base_relay_v2.rs b/crates/tangle_runtime/tests/base_relay_v2.rs @@ -6,8 +6,8 @@ use tangle_groups::{ GroupId, GroupOutboxRecord, GroupOutboxStatus, GroupRuntimeConfig, KIND_GROUP_ADMINS, KIND_GROUP_DELETE_GROUP, KIND_GROUP_JOIN_REQUEST, KIND_GROUP_LEAVE_REQUEST, KIND_GROUP_MEMBERS, KIND_GROUP_METADATA, KIND_GROUP_PUT_USER, MemberStatus, NIP29_RELAY_GENERATED_KIND_VALUES, - ProjectionCheckpoint, StoreOffset, member_current_key, parse_group_runtime_config_json, - projection_checkpoint_key, + PERMANENT_RELAY_OVERRIDE_ROLE, ProjectionCheckpoint, StoreOffset, member_current_key, + parse_group_runtime_config_json, projection_checkpoint_key, }; use tangle_protocol::{ Event, Filter, RawEventJson, RelayMessage, SubscriptionId, Tag, UnixTimestamp, @@ -26,8 +26,8 @@ use tangle_test_support::{ FixtureKey, TANGLE_V2_RELAY_SECRET_HEX, TANGLE_V2_RELAY_URL, tangle_v2_auth_event, tangle_v2_delete_group_event, tangle_v2_event, tangle_v2_group_config, tangle_v2_group_create_event, tangle_v2_group_event, tangle_v2_group_metadata_event, - tangle_v2_join_event, tangle_v2_leave_event, tangle_v2_put_user_event, - tangle_v2_remove_user_event, + tangle_v2_group_tag, tangle_v2_join_event, tangle_v2_leave_event, tangle_v2_pubkey_tag, + tangle_v2_put_user_event, tangle_v2_remove_user_event, tangle_v2_tag, }; #[test] @@ -277,6 +277,78 @@ fn group_auth_lifecycle_membership_and_flag_flows_pass_in_process() { } #[test] +fn relay_override_role_changes_generate_admin_snapshots() { + let config = test_store_config("role-admin-snapshots"); + let mut relay = BaseRelay::open_with_groups(&config, 8, &group_config()).expect("relay"); + let owner_auth = authenticated(FixtureKey::Owner); + let admin_auth = authenticated(FixtureKey::Admin); + let member = FixtureKey::Member.public_key().as_str().to_owned(); + let owner = FixtureKey::Owner.public_key().as_str().to_owned(); + let admin = FixtureKey::Admin.public_key().as_str().to_owned(); + + accept_group_create(&mut relay, "RoleFarm", &[], 1, &owner_auth); + assert_eq!( + stored_event_ids_for_kind(&config, KIND_GROUP_ADMINS).len(), + 1 + ); + + let promote = tangle_v2_put_user_event_with_roles( + FixtureKey::Admin, + "RoleFarm", + FixtureKey::Member, + 2, + &[PERMANENT_RELAY_OVERRIDE_ROLE], + ); + assert_accepted( + relay + .handle_event_with_auth(promote.clone(), &admin_auth) + .expect("promote"), + &promote, + ); + assert!( + relay + .group_projection() + .expect("projection") + .member(&group("RoleFarm"), &FixtureKey::Member.public_key()) + .expect("member") + .roles() + .iter() + .any(|role| role.as_str() == PERMANENT_RELAY_OVERRIDE_ROLE) + ); + assert_eq!(outbox_status_counts(&config).stored, 4); + assert_eq!( + stored_event_ids_for_kind(&config, KIND_GROUP_ADMINS).len(), + 2 + ); + assert_eq!( + latest_admin_snapshot_pubkeys(&mut relay, "RoleFarm"), + sorted_strings([owner.clone(), admin.clone(), member.clone()]) + ); + + let demote = tangle_v2_put_user_event_with_roles( + FixtureKey::Admin, + "RoleFarm", + FixtureKey::Member, + 3, + &[], + ); + assert_accepted( + relay + .handle_event_with_auth(demote.clone(), &admin_auth) + .expect("demote"), + &demote, + ); + assert_eq!( + stored_event_ids_for_kind(&config, KIND_GROUP_ADMINS).len(), + 3 + ); + assert_eq!( + latest_admin_snapshot_pubkeys(&mut relay, "RoleFarm"), + sorted_strings([owner, admin]) + ); +} + +#[test] fn group_join_requests_are_denied_by_default() { let config = test_store_config("group-public-join-default"); let mut relay = BaseRelay::open_with_groups(&config, 8, &group_config()).expect("relay"); @@ -1204,6 +1276,72 @@ fn accept_group_create( ); } +fn tangle_v2_put_user_event_with_roles( + actor: FixtureKey, + group_id: &str, + target: FixtureKey, + created_at: u64, + roles: &[&str], +) -> Event { + let mut tags = vec![ + tangle_v2_group_tag(group_id).expect("group tag"), + tangle_v2_pubkey_tag(target).expect("pubkey tag"), + ]; + for role in roles { + tags.push(tangle_v2_tag("role", &[*role]).expect("role tag")); + } + tangle_v2_event(actor, created_at, KIND_GROUP_PUT_USER.into(), tags, "").expect("put user") +} + +fn latest_admin_snapshot_pubkeys(relay: &mut BaseRelay, group_id: &str) -> Vec<String> { + let mut events = query_events( + relay, + "admin-snapshots", + vec![filter_group_tag(KIND_GROUP_ADMINS, "d", group_id)], + ); + events.sort_by_key(|event| (event.unsigned().created_at(), event.id().clone())); + let latest = events.last().expect("admin snapshot"); + let mut pubkeys = latest + .unsigned() + .tags() + .iter() + .filter_map(|tag| match tag.values() { + [name, pubkey, ..] if name == "p" => Some(pubkey.clone()), + _ => None, + }) + .collect::<Vec<_>>(); + pubkeys.sort(); + pubkeys +} + +fn query_events(relay: &mut BaseRelay, subscription_id: &str, filters: Vec<Filter>) -> Vec<Event> { + let subscription_id = subscription(subscription_id); + let messages = relay + .handle_req(subscription_id.clone(), filters) + .expect("query"); + let mut events = Vec::new(); + for message in messages { + match message { + RelayMessage::Event { + subscription_id: actual, + event, + } => { + assert_eq!(actual, subscription_id); + events.push(event); + } + RelayMessage::Eose(actual) => assert_eq!(actual, subscription_id), + value => panic!("expected event or EOSE, got {value:?}"), + } + } + events +} + +fn sorted_strings(values: impl IntoIterator<Item = String>) -> Vec<String> { + let mut values = values.into_iter().collect::<Vec<_>>(); + values.sort(); + values +} + fn final_group_name_for_order(name: &str, edits: [&Event; 2]) -> String { let config = test_store_config(name); let mut relay = BaseRelay::open_with_groups(&config, 8, &group_config()).expect("relay");