Skip to main content

coven_protocol/membership/
authoring.rs

1use super::*;
2
3impl MembershipChain {
4    /// Sign a change at the complete observed authority frontier. The chain
5    /// reducer checks the change against the signer's active Owner grant.
6    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}