1use super::*;
2
3impl MembershipChain {
4 pub fn signed_change_in_stream(
7 &self,
8 signer: &UserKeypair,
9 stream_id: AuthorStreamId,
10 change: StoreAuthorityChange,
11 created_at: String,
12 ) -> Result<MembershipEntry, MembershipError> {
13 let author = keys::public_key_hex(signer);
14 let author_grant = self
15 .active_owner_grant(&author)
16 .ok_or_else(|| MembershipError::SignerIsNotOwner(author.clone()))?;
17 let (seq, previous_hash) = self.next_stream_position(&author, &author_grant, stream_id)?;
18 let entry = Signed::sign(
19 MembershipEntryBody {
20 store_id: self
21 .store_id()
22 .expect("validated chain has a Store id")
23 .to_string(),
24 author_pubkey: author,
25 author_owner_grant: author_grant,
26 stream_id,
27 seq,
28 previous_hash,
29 dependencies: self.effective_frontier(),
30 created_at,
31 change,
32 provider_admin: None,
33 },
34 signer,
35 );
36 let mut candidate = self.clone();
37 candidate.add_entry(entry.clone())?;
38 Ok(entry)
39 }
40
41 pub fn signed_set_member_with_anchor_and_wrapped_key_in_stream(
42 &self,
43 signer: &UserKeypair,
44 stream_id: AuthorStreamId,
45 user_pubkey: String,
46 provider_account_email: Option<String>,
47 role: MemberRole,
48 membership: Option<GrantStreamAnchor>,
49 wrapped_key: WrappedStoreKeyRef,
50 created_at: String,
51 ) -> Result<MembershipEntry, MembershipError> {
52 let role = StoreMembershipRoleGrant::from_direct_assignment(role)?;
53 let grant_id = self.next_member_grant_id_in_stream(signer, stream_id, &user_pubkey)?;
54 self.signed_set_role_grant_with_anchor_and_wrapped_key_in_stream(
55 signer,
56 stream_id,
57 user_pubkey,
58 provider_account_email,
59 role,
60 grant_id,
61 membership,
62 wrapped_key,
63 created_at,
64 )
65 }
66
67 #[allow(clippy::too_many_arguments)]
68 fn signed_set_role_grant_with_anchor_and_wrapped_key_in_stream(
69 &self,
70 signer: &UserKeypair,
71 stream_id: AuthorStreamId,
72 user_pubkey: String,
73 provider_account_email: Option<String>,
74 role: StoreMembershipRoleGrant,
75 grant_id: MembershipGrantId,
76 membership: Option<GrantStreamAnchor>,
77 wrapped_key: WrappedStoreKeyRef,
78 created_at: String,
79 ) -> Result<MembershipEntry, MembershipError> {
80 let replaces = self.active_grant_ids(&user_pubkey);
81 let retirement_barriers = self.membership_retirement_barriers(&replaces, None)?;
82 if role.is_owner() != membership.is_some() {
83 return Err(MembershipError::InvalidOwnerMembershipAnchor(
84 self.entries.len(),
85 ));
86 }
87 self.signed_change_in_stream(
88 signer,
89 stream_id,
90 StoreAuthorityChange::SetMember {
91 user_pubkey,
92 provider_account_email,
93 role,
94 grant_id,
95 membership,
96 replaces,
97 retirement_barriers,
98 retirement_device_state: None,
99 wrapped_key,
100 },
101 created_at,
102 )
103 }
104
105 #[allow(clippy::too_many_arguments)]
106 pub fn signed_finalize_owner_promotion_in_stream(
107 &self,
108 root: &StoreRootRef,
109 promoter: &StoreDeviceRegistration,
110 candidate: &StoreDeviceRegistration,
111 acceptance: OwnerPromotionAcceptance,
112 signer: &UserKeypair,
113 wrapped_key: WrappedStoreKeyRef,
114 created_at: String,
115 ) -> Result<MembershipEntry, MembershipError> {
116 acceptance
117 .request
118 .verify(root, promoter)
119 .map_err(|_| MembershipError::InvalidOwnerPromotion)?;
120 acceptance
121 .verify(candidate)
122 .map_err(|_| MembershipError::InvalidOwnerPromotion)?;
123 let request = &acceptance.request;
124 let author = keys::public_key_hex(signer);
125 let OwnerPromotionFinalization {
126 author_stream,
127 seq: requested_seq,
128 previous_hash: requested_previous_hash,
129 } = request.finalization;
130 let (expected_seq, expected_previous_hash) =
131 self.next_stream_position(&author, &request.promoter_owner_grant, author_stream)?;
132 let Some(member) = self.active_grant(&request.member_grant) else {
133 return Err(MembershipError::InvalidOwnerPromotion);
134 };
135 let membership = &acceptance.anchors.membership;
136 let root_id = root.store_root_id.to_string();
137 if author != promoter.author_pubkey
138 || self.store_id() != Some(root_id.as_str())
139 || self.active_owner_grant(&author) != Some(request.promoter_owner_grant.clone())
140 || member.member_pubkey != request.member_pubkey
141 || member.role != StoreMembershipRoleGrant::Member
142 || self.active_grant_ids(&request.member_pubkey)
143 != BTreeSet::from([request.member_grant.clone()])
144 || expected_seq != requested_seq
145 || expected_previous_hash != requested_previous_hash
146 || self
147 .resolved()
148 .grants
149 .contains_key(&request.intended_owner_grant)
150 {
151 return Err(MembershipError::InvalidOwnerPromotion);
152 }
153 self.signed_set_role_grant_with_anchor_and_wrapped_key_in_stream(
154 signer,
155 author_stream,
156 request.member_pubkey.clone(),
157 member.provider_account_email.clone(),
158 StoreMembershipRoleGrant::Owner {
159 recovery: OwnerRecoveryAnchorRef::Promotion {
160 acceptance: Box::new(acceptance.clone()),
161 },
162 },
163 request.intended_owner_grant.clone(),
164 Some(membership.clone()),
165 wrapped_key,
166 created_at,
167 )
168 }
169
170 pub fn signed_remove_member_with_wrapped_keys_in_stream(
171 &self,
172 signer: &UserKeypair,
173 stream_id: AuthorStreamId,
174 user_pubkey: String,
175 wrapped_keys: Vec<WrappedStoreKeyRef>,
176 created_at: String,
177 ) -> Result<MembershipEntry, MembershipError> {
178 self.signed_remove_member_with_barrier_state(
179 signer,
180 stream_id,
181 user_pubkey,
182 wrapped_keys,
183 None,
184 created_at,
185 )
186 }
187
188 pub fn signed_remove_member_with_owner_barrier_state(
189 &self,
190 signer: &UserKeypair,
191 stream_id: AuthorStreamId,
192 user_pubkey: String,
193 wrapped_keys: Vec<WrappedStoreKeyRef>,
194 device_state: StoreDeviceStateRef,
195 created_at: String,
196 ) -> Result<MembershipEntry, MembershipError> {
197 self.signed_remove_member_with_barrier_state(
198 signer,
199 stream_id,
200 user_pubkey,
201 wrapped_keys,
202 Some(device_state),
203 created_at,
204 )
205 }
206
207 fn signed_remove_member_with_barrier_state(
208 &self,
209 signer: &UserKeypair,
210 stream_id: AuthorStreamId,
211 user_pubkey: String,
212 wrapped_keys: Vec<WrappedStoreKeyRef>,
213 retirement_device_state: Option<StoreDeviceStateRef>,
214 created_at: String,
215 ) -> Result<MembershipEntry, MembershipError> {
216 let removes = self.active_grant_ids(&user_pubkey);
217 if removes.is_empty() {
218 return Err(MembershipError::NotAMember(user_pubkey));
219 }
220 let retains_owner = self.resolved().grants.iter().any(|(grant, state)| {
221 !removes.contains(grant) && state.active().is_some_and(|record| record.role.is_owner())
222 });
223 if !retains_owner {
224 return Err(MembershipError::NoActiveOwner);
225 }
226 let retirement_barriers =
227 self.membership_retirement_barriers(&removes, retirement_device_state.as_ref())?;
228 self.signed_change_in_stream(
229 signer,
230 stream_id,
231 StoreAuthorityChange::RemoveMember {
232 user_pubkey,
233 removes,
234 retirement_barriers,
235 retirement_device_state,
236 wrapped_keys,
237 },
238 created_at,
239 )
240 }
241
242 #[cfg(any(test, feature = "test-utils"))]
243 pub fn signed_set_member_in_stream(
244 &self,
245 signer: &UserKeypair,
246 stream_id: AuthorStreamId,
247 user_pubkey: String,
248 provider_account_email: Option<String>,
249 role: MemberRole,
250 created_at: String,
251 ) -> Result<MembershipEntry, MembershipError> {
252 let role = StoreMembershipRoleGrant::from_direct_assignment(role)?;
253 let grant_id = self.next_member_grant_id_in_stream(signer, stream_id, &user_pubkey)?;
254 let dependencies = self.effective_frontier();
255 let wrapped_key = test_wrapped_key_ref(
256 &keys::public_key_hex(signer),
257 &user_pubkey,
258 membership_causal_generation(&self.entries, &dependencies),
259 b"Merge membership test wrap",
260 );
261 self.signed_set_role_grant_with_anchor_and_wrapped_key_in_stream(
262 signer,
263 stream_id,
264 user_pubkey,
265 provider_account_email,
266 role,
267 grant_id,
268 None,
269 wrapped_key,
270 created_at,
271 )
272 }
273
274 #[cfg(any(test, feature = "test-utils"))]
275 pub(crate) fn signed_promote_member_in_stream_for_test(
276 &self,
277 signer: &UserKeypair,
278 stream_id: AuthorStreamId,
279 user_pubkey: String,
280 created_at: String,
281 ) -> Result<MembershipEntry, MembershipError> {
282 let author_pubkey = keys::public_key_hex(signer);
283 let dependencies = self.effective_frontier();
284 let wrapped_key = test_wrapped_key_ref(
285 &author_pubkey,
286 &user_pubkey,
287 membership_causal_generation(&self.entries, &dependencies),
288 b"Merge Owner-promotion test wrap",
289 );
290 self.signed_promote_member_in_stream_with_wrapped_key_for_test(
291 signer,
292 stream_id,
293 user_pubkey,
294 wrapped_key,
295 created_at,
296 )
297 }
298
299 #[cfg(any(test, feature = "test-utils"))]
300 pub(crate) fn signed_promote_member_in_stream_with_wrapped_key_for_test(
301 &self,
302 signer: &UserKeypair,
303 stream_id: AuthorStreamId,
304 user_pubkey: String,
305 wrapped_key: WrappedStoreKeyRef,
306 created_at: String,
307 ) -> Result<MembershipEntry, MembershipError> {
308 let author_pubkey = keys::public_key_hex(signer);
309 let promoter_owner_grant = self
310 .active_owner_grant(&author_pubkey)
311 .ok_or_else(|| MembershipError::SignerIsNotOwner(author_pubkey.clone()))?;
312 let member_grants = self.active_grant_ids(&user_pubkey);
313 let Some(member_grant) = member_grants.iter().next().cloned() else {
314 return Err(MembershipError::InvalidOwnerPromotion);
315 };
316 if member_grants.len() != 1
317 || self
318 .active_grant(&member_grant)
319 .is_none_or(|record| record.role != StoreMembershipRoleGrant::Member)
320 {
321 return Err(MembershipError::InvalidOwnerPromotion);
322 }
323 let (seq, previous_hash) =
324 self.next_stream_position(&author_pubkey, &promoter_owner_grant, stream_id)?;
325 let promotion_id = OwnerPromotionId::from_generated(format!(
326 "test promotion {author_pubkey} {user_pubkey} {stream_id:?} {seq}"
327 ));
328 let store_root_hash = ObjectHash::digest(
329 self.store_id()
330 .expect("validated membership chain has a Store id")
331 .as_bytes(),
332 );
333 let intended_owner_grant = crate::store_commit::derive_owner_promotion_grant(
334 store_root_hash,
335 promotion_id,
336 &user_pubkey,
337 );
338 let membership_state_hash = self.resolved().state_hash;
339 let object = |name: &str| {
340 let slot = crate::objects::ObjectSlot::logical(format!(
341 "test/owner-promotion/{promotion_id:?}/{name}.json"
342 ))
343 .expect("test Owner-promotion slot is valid");
344 ExactObjectRef::new(slot, 1, ObjectHash::digest(name.as_bytes()))
345 };
346 let registration = |name: &str| StoreDeviceRegistrationRef {
347 device_id: ObjectHash::digest(name.as_bytes())
348 .to_string()
349 .parse()
350 .expect("digest is a valid Store device id"),
351 registration_hash: ObjectHash::digest(format!("{name} registration").as_bytes()),
352 object: object(&format!("{name}-registration")),
353 };
354 let candidate_stream = AuthorStreamId::from_bytes([0xA5; 32]);
355 let activation_commit = crate::store_commit::StoreBatchCommitRef {
356 coord: crate::store_commit::StoreCommitCoord {
357 stream_id: candidate_stream,
358 sequence: 1,
359 },
360 commit_hash: ObjectHash::digest(b"test Owner-promotion activation commit"),
361 object: object("activation-commit"),
362 };
363 let membership = GrantStreamAnchor::StoreMembership {
364 first_slot: crate::objects::ObjectSlot::logical(format!(
365 "{}.json",
366 crate::store_commit::membership_head_slot_prefix(
367 &user_pubkey,
368 &intended_owner_grant,
369 stream_id,
370 1,
371 )
372 ))
373 .expect("test membership head slot is valid"),
374 };
375 let request = OwnerPromotionRequest::unsigned_for_test(OwnerPromotionRequestBody {
376 promotion_id,
377 store_root_hash,
378 promoter_registration: registration("promoter"),
379 promoter_owner_grant: promoter_owner_grant.clone(),
380 member_pubkey: user_pubkey.clone(),
381 member_grant,
382 member_registration: registration("member"),
383 intended_owner_grant: intended_owner_grant.clone(),
384 predecessor_membership: crate::circle_control::StoreMembershipStateRef::from_parts(
385 Vec::new(),
386 Vec::new(),
387 membership_state_hash,
388 )
389 .expect("construct test predecessor membership"),
390 predecessor_devices: StoreDeviceStateRef::from_resolved(
391 crate::store_commit::CommitFrontier(BTreeMap::new()),
392 &crate::store_commit::ResolvedStoreDeviceState {
393 devices: BTreeMap::new(),
394 recovery: Vec::new(),
395 state_hash: ObjectHash::digest(b"test Owner-promotion device state"),
396 },
397 )
398 .expect("construct test predecessor device state"),
399 finalization: OwnerPromotionFinalization {
400 author_stream: stream_id,
401 seq,
402 previous_hash,
403 },
404 publication_slot: crate::objects::ObjectSlot::logical(format!(
405 "{}.json",
406 crate::store_commit::owner_promotion_request_publication_semantic_prefix(
407 promotion_id
408 ),
409 ))
410 .expect("test promotion publication slot"),
411 });
412 let acceptance =
413 OwnerPromotionAcceptance::unsigned_for_test(OwnerPromotionAcceptanceBody {
414 request: Box::new(request),
415 activation: OwnerPromotionRequestActivation {
416 commit: activation_commit,
417 publication: crate::store_commit::StorePublicationRef {
418 store_root_hash,
419 position: crate::store_commit::StorePublicationPosition::new(1)
420 .expect("test publication position"),
421 entry_hash: ObjectHash::digest(
422 b"test Owner-promotion activation publication",
423 ),
424 object: object("activation-publication"),
425 },
426 },
427 anchors: OwnerPromotionAnchors {
428 membership: membership.clone(),
429 recovery: GrantStreamAnchor::OwnerRecovery {
430 first_slot: crate::objects::ObjectSlot::logical(format!(
431 "test/owner-promotion/{promotion_id:?}/recovery/1.json"
432 ))
433 .expect("test recovery slot is valid"),
434 },
435 },
436 });
437 self.signed_set_role_grant_with_anchor_and_wrapped_key_in_stream(
438 signer,
439 stream_id,
440 user_pubkey,
441 None,
442 StoreMembershipRoleGrant::Owner {
443 recovery: OwnerRecoveryAnchorRef::Promotion {
444 acceptance: Box::new(acceptance),
445 },
446 },
447 intended_owner_grant,
448 Some(membership),
449 wrapped_key,
450 created_at,
451 )
452 }
453
454 #[cfg(any(test, feature = "test-utils"))]
455 pub(crate) fn add_owner_for_test(
456 &mut self,
457 signer: &UserKeypair,
458 stream_id: AuthorStreamId,
459 user_pubkey: String,
460 created_at: String,
461 ) -> Result<(), MembershipError> {
462 let member = self.signed_set_member_in_stream(
463 signer,
464 stream_id,
465 user_pubkey.clone(),
466 None,
467 MemberRole::Member,
468 format!("{created_at}: Member grant"),
469 )?;
470 self.add_entry(member)?;
471 let promotion = self.signed_promote_member_in_stream_for_test(
472 signer,
473 stream_id,
474 user_pubkey,
475 created_at,
476 )?;
477 self.add_entry(promotion)
478 }
479
480 #[cfg(any(test, feature = "test-utils"))]
481 pub fn signed_remove_member_in_stream(
482 &self,
483 signer: &UserKeypair,
484 stream_id: AuthorStreamId,
485 user_pubkey: String,
486 created_at: String,
487 ) -> Result<MembershipEntry, MembershipError> {
488 let owner = keys::public_key_hex(signer);
489 let dependencies = self.effective_frontier();
490 let generation = membership_causal_generation(&self.entries, &dependencies)
491 .checked_add(1)
492 .ok_or(MembershipError::InvalidWrappedKeys(self.entries.len()))?;
493 let wrapped_keys = self
494 .current_members()
495 .into_iter()
496 .filter(|(member, _)| member != &user_pubkey)
497 .map(|(member, _)| {
498 test_wrapped_key_ref(&owner, &member, generation, b"Merge removal test wrap")
499 })
500 .collect();
501 let removes = self.active_grant_ids(&user_pubkey);
502 let mut recovery = removes
503 .iter()
504 .filter_map(|grant| {
505 self.resolved()
506 .grants
507 .get(grant)
508 .and_then(GrantState::active)
509 .filter(|record| record.role.is_owner())
510 .map(|record| OwnerRecoveryCursor {
511 owner_grant: grant.clone(),
512 position: OwnerRecoveryPosition::At {
513 node: OwnerRecoveryNodeRef {
514 owner_pubkey: record.member_pubkey.clone(),
515 owner_grant: grant.clone(),
516 sequence: 1,
517 node_hash: ObjectHash::digest(
518 format!("test recovery node {grant}").as_bytes(),
519 ),
520 object: ExactObjectRef::new(
521 crate::objects::ObjectSlot::logical(format!(
522 "test/recovery/{grant}/1.json"
523 ))
524 .expect("test recovery node slot is valid"),
525 1,
526 ObjectHash::digest(format!("test recovery {grant}").as_bytes()),
527 ),
528 },
529 },
530 })
531 })
532 .collect::<Vec<_>>();
533 recovery.sort();
534 let device_state = (!recovery.is_empty()).then(|| {
535 StoreDeviceStateRef::from_resolved(
536 crate::store_commit::CommitFrontier(BTreeMap::new()),
537 &crate::store_commit::ResolvedStoreDeviceState {
538 devices: BTreeMap::new(),
539 recovery,
540 state_hash: ObjectHash::digest(b"test membership retirement device state"),
541 },
542 )
543 .expect("construct test membership retirement device state")
544 });
545 self.signed_remove_member_with_barrier_state(
546 signer,
547 stream_id,
548 user_pubkey,
549 wrapped_keys,
550 device_state,
551 created_at,
552 )
553 }
554}