1use super::conflict::conflict_retirement_barriers;
2use super::*;
3
4impl MembershipChain {
5 pub(crate) fn resolved_with(
6 &self,
7 store_root_hash: ObjectHash,
8 resolutions: &[(
9 StoreMembershipConflictResolutionRef,
10 StoreMembershipConflictResolution,
11 )],
12 ) -> Result<ResolvedStoreMembership, MembershipError> {
13 match self.status() {
14 MembershipStatus::Resolved(resolved) if resolutions.is_empty() => Ok(resolved.clone()),
15 MembershipStatus::Conflict(conflict) => {
16 resolve_store_membership_conflict(store_root_hash, conflict, resolutions)
17 }
18 MembershipStatus::Resolved(_) => Err(MembershipError::InvalidConflictResolution),
19 }
20 }
21
22 pub fn signed_conflict_resolution(
23 &self,
24 store_root_hash: ObjectHash,
25 selection: MembershipConflictSelection,
26 replacement_membership: GrantStreamAnchor,
27 replacement_acceptance: OwnerConflictResolutionAcceptance,
28 signer: &UserKeypair,
29 ) -> Result<StoreMembershipConflictResolution, MembershipError> {
30 let MembershipStatus::Conflict(conflict) = self.status() else {
31 return Err(MembershipError::Conflict);
32 };
33 let resolver_pubkey = keys::public_key_hex(signer);
34 let (conflict_hash, heads, retired_owner_grants, records, effective_frontier) =
35 match (conflict, &selection) {
36 (
37 MembershipConflict::ConcurrentMemberAssignments {
38 conflict_hash,
39 heads,
40 effective_frontier,
41 conflicting_grants,
42 uncontested_grants,
43 grants,
44 ..
45 },
46 MembershipConflictSelection::MemberAssignment { grant },
47 ) => {
48 if !conflicting_grants.contains_key(grant) {
49 return Err(MembershipError::InvalidConflictResolution);
50 }
51 let retired = uncontested_grants
52 .iter()
53 .filter_map(|(grant, record)| {
54 (record.member_pubkey == resolver_pubkey && record.role.is_owner())
55 .then_some(grant.clone())
56 })
57 .collect::<BTreeSet<_>>();
58 if retired.is_empty() {
59 return Err(MembershipError::SignerIsNotOwner(resolver_pubkey));
60 }
61 (
62 conflict_hash,
63 heads,
64 retired,
65 grants
66 .iter()
67 .map(|(grant, state)| (grant.clone(), state.record().clone()))
68 .collect(),
69 effective_frontier.clone(),
70 )
71 }
72 (
73 MembershipConflict::RevocationCycle {
74 conflict_hash,
75 heads,
76 involved_owner_grants,
77 maximal_valid_branches,
78 ..
79 },
80 MembershipConflictSelection::RevocationBranch {
81 heads: selected_heads,
82 },
83 ) => {
84 let branch = maximal_valid_branches
85 .iter()
86 .find(|branch| branch.heads == *selected_heads)
87 .ok_or(MembershipError::InvalidConflictResolution)?;
88 let resolver_grants = branch
89 .active_grants()
90 .filter_map(|(grant, record)| {
91 (record.member_pubkey == resolver_pubkey && record.role.is_owner())
92 .then_some(grant.clone())
93 })
94 .collect::<BTreeSet<_>>();
95 if resolver_grants.is_empty() {
96 return Err(MembershipError::SignerIsNotOwner(resolver_pubkey));
97 }
98 let mut retired = involved_owner_grants.clone();
99 retired.extend(resolver_grants);
100 let records = maximal_valid_branches
101 .iter()
102 .flat_map(|branch| branch.grants.iter())
103 .map(|(grant, state)| (grant.clone(), state.record().clone()))
104 .collect();
105 let mut frontier = maximal_valid_branches
106 .iter()
107 .flat_map(|branch| branch.effective_frontier.iter().cloned())
108 .collect::<Vec<_>>();
109 frontier.sort();
110 frontier.dedup();
111 (conflict_hash, heads, retired, records, frontier)
112 }
113 _ => return Err(MembershipError::InvalidConflictResolution),
114 };
115 let replacement_grant = derive_store_resolution_grant(conflict_hash, &resolver_pubkey);
116 let retirement_barriers = conflict_retirement_barriers(
117 records,
118 effective_frontier,
119 &replacement_acceptance.device_state,
120 )?;
121 Ok(Signed::sign(
122 StoreMembershipConflictResolutionBody {
123 store_root_hash,
124 conflict_hash: *conflict_hash,
125 conflicting_heads: heads.clone(),
126 retired_owner_grants,
127 retirement_barriers,
128 resolver_pubkey,
129 selection,
130 replacement_grant,
131 replacement_membership,
132 replacement_acceptance,
133 },
134 signer,
135 ))
136 }
137
138 pub fn signed_set_member_with_anchor_and_wrapped_key_in_stream(
139 &self,
140 signer: &UserKeypair,
141 stream_id: AuthorStreamId,
142 user_pubkey: String,
143 provider_account_email: Option<String>,
144 role: MemberRole,
145 membership: Option<GrantStreamAnchor>,
146 wrapped_key: WrappedStoreKeyRef,
147 created_at: String,
148 ) -> Result<MembershipEntry, MembershipError> {
149 let role = StoreMembershipRoleGrant::from_direct_assignment(role)?;
150 let grant_id = self.next_member_grant_id_in_stream(signer, stream_id, &user_pubkey)?;
151 self.signed_set_role_grant_with_anchor_and_wrapped_key_in_stream(
152 signer,
153 stream_id,
154 user_pubkey,
155 provider_account_email,
156 role,
157 grant_id,
158 membership,
159 wrapped_key,
160 created_at,
161 )
162 }
163
164 #[allow(clippy::too_many_arguments)]
165 fn signed_set_role_grant_with_anchor_and_wrapped_key_in_stream(
166 &self,
167 signer: &UserKeypair,
168 stream_id: AuthorStreamId,
169 user_pubkey: String,
170 provider_account_email: Option<String>,
171 role: StoreMembershipRoleGrant,
172 grant_id: MembershipGrantId,
173 membership: Option<GrantStreamAnchor>,
174 wrapped_key: WrappedStoreKeyRef,
175 created_at: String,
176 ) -> Result<MembershipEntry, MembershipError> {
177 let author = keys::public_key_hex(signer);
178 let author_grant = self
179 .active_owner_grant(&author)
180 .ok_or_else(|| MembershipError::SignerIsNotOwner(author.clone()))?;
181 let (seq, previous_hash) = self.next_stream_position(&author, &author_grant, stream_id)?;
182 let replaces = self.active_grant_ids(&user_pubkey);
183 let retirement_barriers = self.membership_retirement_barriers(&replaces, None)?;
184 if role.is_owner() != membership.is_some() {
185 return Err(MembershipError::InvalidOwnerMembershipAnchor(
186 self.entries.len(),
187 ));
188 }
189 let entry = Signed::sign(
190 MembershipEntryBody {
191 store_id: self
192 .store_id()
193 .expect("validated chain has a store id")
194 .to_string(),
195 author_pubkey: author,
196 author_owner_grant: author_grant,
197 stream_id,
198 seq,
199 previous_hash,
200 dependencies: self.effective_frontier(),
201 resolution_dependencies: self.resolution_refs().to_vec(),
202 created_at,
203 change: MembershipChange::SetMember {
204 user_pubkey: user_pubkey.clone(),
205 provider_account_email,
206 role,
207 grant_id,
208 membership,
209 replaces,
210 retirement_barriers,
211 retirement_device_state: None,
212 wrapped_key,
213 },
214 provider_admin: None,
215 },
216 signer,
217 );
218 let mut candidate = self.clone();
219 candidate.add_entry(entry.clone())?;
220 Ok(entry)
221 }
222
223 #[allow(clippy::too_many_arguments)]
224 pub fn signed_finalize_owner_promotion_in_stream(
225 &self,
226 root: &StoreRootRef,
227 promoter: &StoreDeviceRegistration,
228 candidate: &StoreDeviceRegistration,
229 acceptance: OwnerPromotionAcceptance,
230 signer: &UserKeypair,
231 wrapped_key: WrappedStoreKeyRef,
232 created_at: String,
233 ) -> Result<MembershipEntry, MembershipError> {
234 acceptance
235 .request
236 .verify(root, promoter)
237 .map_err(|_| MembershipError::InvalidOwnerPromotion)?;
238 acceptance
239 .verify(candidate)
240 .map_err(|_| MembershipError::InvalidOwnerPromotion)?;
241 let request = &acceptance.request;
242 let author = keys::public_key_hex(signer);
243 let OwnerPromotionFinalization {
244 author_stream,
245 seq: requested_seq,
246 previous_hash: requested_previous_hash,
247 } = request.finalization;
248 let (expected_seq, expected_previous_hash) =
249 self.next_stream_position(&author, &request.promoter_owner_grant, author_stream)?;
250 let Some(member) = self.active_grant(&request.member_grant) else {
251 return Err(MembershipError::InvalidOwnerPromotion);
252 };
253 let membership = &acceptance.anchors.membership;
254 let root_id = root.store_root_id.to_string();
255 if author != promoter.author_pubkey
256 || self.store_id() != Some(root_id.as_str())
257 || self.active_owner_grant(&author) != Some(request.promoter_owner_grant.clone())
258 || member.member_pubkey != request.member_pubkey
259 || member.role != StoreMembershipRoleGrant::Member
260 || self.active_grant_ids(&request.member_pubkey)
261 != BTreeSet::from([request.member_grant.clone()])
262 || expected_seq != requested_seq
263 || expected_previous_hash != requested_previous_hash
264 || self
265 .state
266 .grants
267 .contains_key(&request.intended_owner_grant)
268 {
269 return Err(MembershipError::InvalidOwnerPromotion);
270 }
271 self.signed_set_role_grant_with_anchor_and_wrapped_key_in_stream(
272 signer,
273 author_stream,
274 request.member_pubkey.clone(),
275 member.provider_account_email.clone(),
276 StoreMembershipRoleGrant::Owner {
277 recovery: OwnerRecoveryAnchorRef::Promotion {
278 acceptance: Box::new(acceptance.clone()),
279 },
280 },
281 request.intended_owner_grant.clone(),
282 Some(membership.clone()),
283 wrapped_key,
284 created_at,
285 )
286 }
287
288 pub fn signed_remove_member_with_wrapped_keys_in_stream(
289 &self,
290 signer: &UserKeypair,
291 stream_id: AuthorStreamId,
292 user_pubkey: String,
293 wrapped_keys: Vec<WrappedStoreKeyRef>,
294 created_at: String,
295 ) -> Result<MembershipEntry, MembershipError> {
296 self.signed_remove_member_with_barrier_state(
297 signer,
298 stream_id,
299 user_pubkey,
300 wrapped_keys,
301 None,
302 created_at,
303 )
304 }
305
306 pub fn signed_remove_member_with_owner_barrier_state(
307 &self,
308 signer: &UserKeypair,
309 stream_id: AuthorStreamId,
310 user_pubkey: String,
311 wrapped_keys: Vec<WrappedStoreKeyRef>,
312 device_state: StoreDeviceStateRef,
313 created_at: String,
314 ) -> Result<MembershipEntry, MembershipError> {
315 self.signed_remove_member_with_barrier_state(
316 signer,
317 stream_id,
318 user_pubkey,
319 wrapped_keys,
320 Some(device_state),
321 created_at,
322 )
323 }
324
325 fn signed_remove_member_with_barrier_state(
326 &self,
327 signer: &UserKeypair,
328 stream_id: AuthorStreamId,
329 user_pubkey: String,
330 wrapped_keys: Vec<WrappedStoreKeyRef>,
331 retirement_device_state: Option<StoreDeviceStateRef>,
332 created_at: String,
333 ) -> Result<MembershipEntry, MembershipError> {
334 let removes = self.active_grant_ids(&user_pubkey);
335 if removes.is_empty() {
336 return Err(MembershipError::NotAMember(user_pubkey));
337 }
338 let retains_owner = self.state.grants.iter().any(|(grant, state)| {
339 !removes.contains(grant) && state.active().is_some_and(|record| record.role.is_owner())
340 });
341 if !retains_owner {
342 return Err(MembershipError::NoActiveOwner);
343 }
344 let author = keys::public_key_hex(signer);
345 let author_grant = self
346 .active_owner_grant(&author)
347 .ok_or_else(|| MembershipError::SignerIsNotOwner(author.clone()))?;
348 let (seq, previous_hash) = self.next_stream_position(&author, &author_grant, stream_id)?;
349 let retirement_barriers =
350 self.membership_retirement_barriers(&removes, retirement_device_state.as_ref())?;
351 let entry = Signed::sign(
352 MembershipEntryBody {
353 store_id: self
354 .store_id()
355 .expect("validated chain has a store id")
356 .to_string(),
357 author_pubkey: author,
358 author_owner_grant: author_grant,
359 stream_id,
360 seq,
361 previous_hash,
362 dependencies: self.effective_frontier(),
363 resolution_dependencies: self.resolution_refs().to_vec(),
364 created_at,
365 change: MembershipChange::RemoveMember {
366 user_pubkey,
367 removes,
368 retirement_barriers,
369 retirement_device_state,
370 wrapped_keys,
371 },
372 provider_admin: None,
373 },
374 signer,
375 );
376 let mut candidate = self.clone();
377 candidate.add_entry(entry.clone())?;
378 Ok(entry)
379 }
380
381 pub fn signed_resolution_activation_in_stream(
382 &self,
383 store_root_hash: ObjectHash,
384 signer: &UserKeypair,
385 stream_id: AuthorStreamId,
386 reference: StoreMembershipConflictResolutionRef,
387 resolution: &StoreMembershipConflictResolution,
388 created_at: String,
389 ) -> Result<MembershipEntry, MembershipError> {
390 self.ensure_resolved()?;
391 let MembershipStatus::Resolved(resolved_before) = self.status() else {
392 unreachable!("ensure_resolved accepted a conflict")
393 };
394 let author = keys::public_key_hex(signer);
395 if !resolution.verify_signature()
396 || resolution.store_root_hash != store_root_hash
397 || reference.resolver_pubkey != author
398 || !self.resolution_refs().contains(&reference)
399 || self.active_owner_grant(&author) != Some(resolution.replacement_grant.clone())
400 {
401 return Err(MembershipError::InvalidConflictResolution);
402 }
403 let author_grant = resolution.replacement_grant.clone();
404 if self
405 .raw_stream_tip(&author, &author_grant, stream_id)
406 .is_some()
407 {
408 return Err(MembershipError::ResolutionActivationRequiresFreshStream);
409 }
410 let entry = Signed::sign(
411 MembershipEntryBody {
412 store_id: self
413 .store_id()
414 .expect("validated chain has a store id")
415 .to_string(),
416 author_pubkey: author,
417 author_owner_grant: author_grant,
418 stream_id,
419 seq: 1,
420 previous_hash: None,
421 dependencies: self.effective_frontier(),
422 resolution_dependencies: self.resolution_refs().to_vec(),
423 created_at,
424 change: MembershipChange::ResolutionActivation {
425 resolution: reference,
426 },
427 provider_admin: None,
428 },
429 signer,
430 );
431 let mut candidate = self.clone();
432 candidate.add_entry(entry.clone())?;
433 let MembershipStatus::Resolved(resolved_after) = candidate.status() else {
434 return Err(MembershipError::InvalidConflictResolution);
435 };
436 if resolved_after.state_hash != resolved_before.state_hash {
437 return Err(MembershipError::InvalidConflictResolution);
438 }
439 Ok(entry)
440 }
441
442 #[cfg(any(test, feature = "test-utils"))]
443 pub fn signed_set_member_in_stream(
444 &self,
445 signer: &UserKeypair,
446 stream_id: AuthorStreamId,
447 user_pubkey: String,
448 provider_account_email: Option<String>,
449 role: MemberRole,
450 created_at: String,
451 ) -> Result<MembershipEntry, MembershipError> {
452 let role = StoreMembershipRoleGrant::from_direct_assignment(role)?;
453 let grant_id = self.next_member_grant_id_in_stream(signer, stream_id, &user_pubkey)?;
454 let dependencies = self.effective_frontier();
455 let wrapped_key = test_wrapped_key_ref(
456 &keys::public_key_hex(signer),
457 &user_pubkey,
458 membership_causal_generation(&self.entries, &dependencies),
459 b"Merge membership test wrap",
460 );
461 self.signed_set_role_grant_with_anchor_and_wrapped_key_in_stream(
462 signer,
463 stream_id,
464 user_pubkey,
465 provider_account_email,
466 role,
467 grant_id,
468 None,
469 wrapped_key,
470 created_at,
471 )
472 }
473
474 #[cfg(any(test, feature = "test-utils"))]
475 pub(crate) fn signed_promote_member_in_stream_for_test(
476 &self,
477 signer: &UserKeypair,
478 stream_id: AuthorStreamId,
479 user_pubkey: String,
480 created_at: String,
481 ) -> Result<MembershipEntry, MembershipError> {
482 let author_pubkey = keys::public_key_hex(signer);
483 let dependencies = self.effective_frontier();
484 let wrapped_key = test_wrapped_key_ref(
485 &author_pubkey,
486 &user_pubkey,
487 membership_causal_generation(&self.entries, &dependencies),
488 b"Merge Owner-promotion test wrap",
489 );
490 self.signed_promote_member_in_stream_with_wrapped_key_for_test(
491 signer,
492 stream_id,
493 user_pubkey,
494 wrapped_key,
495 created_at,
496 )
497 }
498
499 #[cfg(any(test, feature = "test-utils"))]
500 pub(crate) fn signed_promote_member_in_stream_with_wrapped_key_for_test(
501 &self,
502 signer: &UserKeypair,
503 stream_id: AuthorStreamId,
504 user_pubkey: String,
505 wrapped_key: WrappedStoreKeyRef,
506 created_at: String,
507 ) -> Result<MembershipEntry, MembershipError> {
508 let author_pubkey = keys::public_key_hex(signer);
509 let promoter_owner_grant = self
510 .active_owner_grant(&author_pubkey)
511 .ok_or_else(|| MembershipError::SignerIsNotOwner(author_pubkey.clone()))?;
512 let member_grants = self.active_grant_ids(&user_pubkey);
513 let Some(member_grant) = member_grants.iter().next().cloned() else {
514 return Err(MembershipError::InvalidOwnerPromotion);
515 };
516 if member_grants.len() != 1
517 || self
518 .active_grant(&member_grant)
519 .is_none_or(|record| record.role != StoreMembershipRoleGrant::Member)
520 {
521 return Err(MembershipError::InvalidOwnerPromotion);
522 }
523 let (seq, previous_hash) =
524 self.next_stream_position(&author_pubkey, &promoter_owner_grant, stream_id)?;
525 let promotion_id = OwnerPromotionId::from_generated(format!(
526 "test promotion {author_pubkey} {user_pubkey} {stream_id:?} {seq}"
527 ));
528 let store_root_hash = ObjectHash::digest(
529 self.store_id()
530 .expect("validated membership chain has a Store id")
531 .as_bytes(),
532 );
533 let intended_owner_grant = crate::store_commit::derive_owner_promotion_grant(
534 store_root_hash,
535 promotion_id,
536 &user_pubkey,
537 );
538 let membership_state_hash = match self.status() {
539 MembershipStatus::Resolved(state) => state.state_hash,
540 MembershipStatus::Conflict(_) => return Err(MembershipError::InvalidOwnerPromotion),
541 };
542 let object = |name: &str| {
543 let slot = crate::objects::ObjectSlot::logical(format!(
544 "test/owner-promotion/{promotion_id:?}/{name}.json"
545 ))
546 .expect("test Owner-promotion slot is valid");
547 ExactObjectRef::new(slot, 1, ObjectHash::digest(name.as_bytes()))
548 };
549 let registration = |name: &str| StoreDeviceRegistrationRef {
550 device_id: ObjectHash::digest(name.as_bytes())
551 .to_string()
552 .parse()
553 .expect("digest is a valid Store device id"),
554 registration_hash: ObjectHash::digest(format!("{name} registration").as_bytes()),
555 object: object(&format!("{name}-registration")),
556 };
557 let candidate_stream = AuthorStreamId::from_bytes([0xA5; 32]);
558 let activation_commit = crate::store_commit::StoreBatchCommitRef {
559 coord: crate::store_commit::StoreCommitCoord {
560 stream_id: candidate_stream,
561 sequence: 1,
562 },
563 commit_hash: ObjectHash::digest(b"test Owner-promotion activation commit"),
564 object: object("activation-commit"),
565 };
566 let membership = GrantStreamAnchor::StoreMembership {
567 first_slot: crate::objects::ObjectSlot::logical(format!(
568 "{}.json",
569 crate::store_commit::membership_head_slot_prefix(
570 &user_pubkey,
571 &intended_owner_grant,
572 stream_id,
573 1,
574 )
575 ))
576 .expect("test membership head slot is valid"),
577 };
578 let request = OwnerPromotionRequest::unsigned_for_test(OwnerPromotionRequestBody {
579 promotion_id,
580 store_root_hash,
581 promoter_registration: registration("promoter"),
582 promoter_owner_grant: promoter_owner_grant.clone(),
583 member_pubkey: user_pubkey.clone(),
584 member_grant,
585 member_registration: registration("member"),
586 intended_owner_grant: intended_owner_grant.clone(),
587 predecessor_membership: crate::circle_control::StoreMembershipStateRef::from_parts(
588 Vec::new(),
589 Vec::new(),
590 Vec::new(),
591 membership_state_hash,
592 )
593 .expect("construct test predecessor membership"),
594 predecessor_devices: StoreDeviceStateRef::from_resolved(
595 crate::store_commit::CommitFrontier(BTreeMap::new()),
596 &crate::store_commit::ResolvedStoreDeviceState {
597 devices: BTreeMap::new(),
598 recovery: Vec::new(),
599 state_hash: ObjectHash::digest(b"test Owner-promotion device state"),
600 },
601 )
602 .expect("construct test predecessor device state"),
603 finalization: OwnerPromotionFinalization {
604 author_stream: stream_id,
605 seq,
606 previous_hash,
607 },
608 });
609 let acceptance =
610 OwnerPromotionAcceptance::unsigned_for_test(OwnerPromotionAcceptanceBody {
611 request: Box::new(request),
612 activation: OwnerPromotionRequestActivation {
613 commit: activation_commit,
614 head: crate::store_commit::StoreDeviceHeadRef {
615 head_hash: ObjectHash::digest(b"test Owner-promotion activation head"),
616 object: object("activation-head"),
617 },
618 },
619 anchors: OwnerPromotionAnchors {
620 membership: membership.clone(),
621 recovery: GrantStreamAnchor::OwnerRecovery {
622 first_slot: crate::objects::ObjectSlot::logical(format!(
623 "test/owner-promotion/{promotion_id:?}/recovery/1.json"
624 ))
625 .expect("test recovery slot is valid"),
626 },
627 },
628 });
629 self.signed_set_role_grant_with_anchor_and_wrapped_key_in_stream(
630 signer,
631 stream_id,
632 user_pubkey,
633 None,
634 StoreMembershipRoleGrant::Owner {
635 recovery: OwnerRecoveryAnchorRef::Promotion {
636 acceptance: Box::new(acceptance),
637 },
638 },
639 intended_owner_grant,
640 Some(membership),
641 wrapped_key,
642 created_at,
643 )
644 }
645
646 #[cfg(any(test, feature = "test-utils"))]
647 pub(crate) fn add_owner_for_test(
648 &mut self,
649 signer: &UserKeypair,
650 stream_id: AuthorStreamId,
651 user_pubkey: String,
652 created_at: String,
653 ) -> Result<(), MembershipError> {
654 let member = self.signed_set_member_in_stream(
655 signer,
656 stream_id,
657 user_pubkey.clone(),
658 None,
659 MemberRole::Member,
660 format!("{created_at}: Member grant"),
661 )?;
662 self.add_entry(member)?;
663 let promotion = self.signed_promote_member_in_stream_for_test(
664 signer,
665 stream_id,
666 user_pubkey,
667 created_at,
668 )?;
669 self.add_entry(promotion)
670 }
671
672 #[cfg(any(test, feature = "test-utils"))]
673 pub fn signed_remove_member_in_stream(
674 &self,
675 signer: &UserKeypair,
676 stream_id: AuthorStreamId,
677 user_pubkey: String,
678 created_at: String,
679 ) -> Result<MembershipEntry, MembershipError> {
680 let owner = keys::public_key_hex(signer);
681 let dependencies = self.effective_frontier();
682 let generation = membership_causal_generation(&self.entries, &dependencies)
683 .checked_add(1)
684 .ok_or(MembershipError::InvalidWrappedKeys(self.entries.len()))?;
685 let wrapped_keys = self
686 .current_members()
687 .into_iter()
688 .filter(|(member, _)| member != &user_pubkey)
689 .map(|(member, _)| {
690 test_wrapped_key_ref(&owner, &member, generation, b"Merge removal test wrap")
691 })
692 .collect();
693 let removes = self.active_grant_ids(&user_pubkey);
694 let mut recovery = removes
695 .iter()
696 .filter_map(|grant| {
697 self.state
698 .grants
699 .get(grant)
700 .and_then(GrantState::active)
701 .filter(|record| record.role.is_owner())
702 .map(|record| OwnerRecoveryCursor {
703 owner_grant: grant.clone(),
704 position: OwnerRecoveryPosition::At {
705 node: OwnerRecoveryNodeRef {
706 owner_pubkey: record.member_pubkey.clone(),
707 owner_grant: grant.clone(),
708 sequence: 1,
709 node_hash: ObjectHash::digest(
710 format!("test recovery node {grant}").as_bytes(),
711 ),
712 object: ExactObjectRef::new(
713 crate::objects::ObjectSlot::logical(format!(
714 "test/recovery/{grant}/1.json"
715 ))
716 .expect("test recovery node slot is valid"),
717 1,
718 ObjectHash::digest(format!("test recovery {grant}").as_bytes()),
719 ),
720 },
721 },
722 })
723 })
724 .collect::<Vec<_>>();
725 recovery.sort();
726 let device_state = (!recovery.is_empty()).then(|| {
727 StoreDeviceStateRef::from_resolved(
728 crate::store_commit::CommitFrontier(BTreeMap::new()),
729 &crate::store_commit::ResolvedStoreDeviceState {
730 devices: BTreeMap::new(),
731 recovery,
732 state_hash: ObjectHash::digest(b"test membership retirement device state"),
733 },
734 )
735 .expect("construct test membership retirement device state")
736 });
737 self.signed_remove_member_with_barrier_state(
738 signer,
739 stream_id,
740 user_pubkey,
741 wrapped_keys,
742 device_state,
743 created_at,
744 )
745 }
746}