Skip to main content

coven_sync/
sequence_invites.rs

1//! The inviting device's one-time invitation, its join request and the
2//! approval that binds the request's exact pair (§13.2, §13.3).
3
4use super::{SequenceSync, Session};
5use crate::sequence_sync::sequence_operations::SequenceOperation;
6use crate::sequence_sync::OperationState;
7use crate::{
8    Invite, InviteAccess, InviteLifetime, InviteTarget, JoinRequest, OperationKind,
9    OperationOutcome, OperationRejection, PendingReason, PendingRecord, PendingSubject,
10    ProviderAccessAction, SyncError,
11};
12use approval::Child;
13use coven_crypto::{ConfirmationNumber, InviteSecret, MemberIdentity, SecretText, StoreKeyring};
14use coven_database::{
15    operation_kind, DbError, Journal, NewOperation, OperationId, OperationRecord, SqlContext,
16};
17use coven_format::{
18    admin::{MemberRole, StoreChange},
19    codes::InviteCode,
20    invite_end::InviteEnd,
21    MemberAccess, Object,
22};
23use coven_foundation::id_source::{DeviceId, IdSource, InviteId, MemberId};
24use coven_merge::Audience;
25use coven_storage::{
26    CloudProvider, InviteStorage, ObjectPath, S3Credentials, StorageFailure, StorageInvitation,
27};
28use serde::{Deserialize, Serialize};
29use std::{collections::BTreeSet, time::SystemTime};
30
31/// How an invited person reaches storage, as the invite's journal keeps it.
32#[derive(Serialize, Deserialize)]
33pub(crate) enum InviteGrant {
34    /// The provider shares the store with this account.
35    Account { email: String },
36    /// An S3 key the person made in the provider's console for this invite.
37    S3Key(S3Credentials),
38}
39
40impl InviteGrant {
41    fn member_access(&self) -> MemberAccess {
42        match self {
43            Self::Account { email } => MemberAccess::ProviderAccount(email.clone()),
44            Self::S3Key(key) => {
45                MemberAccess::S3AccessKey { access_key_id: key.access_key_id.clone() }
46            }
47        }
48    }
49}
50
51/// What approving the invite's request binds to its member id.
52#[derive(Clone, Copy, Serialize, Deserialize)]
53pub(crate) enum InviteBinding {
54    /// A fresh id with this role and the invite's access.
55    Add { role: MemberRole },
56    /// An existing active id keeps its role, circles, devices and recorded
57    /// access; only its pair changes.
58    Replace {
59        #[serde(with = "crate::pending_encoding::public_keys")]
60        expected: coven_crypto::MemberPublicKeys,
61    },
62}
63
64#[derive(Serialize, Deserialize)]
65pub(crate) enum InviteState {
66    /// The app's write made the invite. A pass checks it again against the
67    /// history it caught up with before granting anything.
68    Requested,
69    /// The invite's share is being recorded in history, before its grant:
70    /// `share` is the operation claiming it, journaled with this state.
71    Sharing { share: OperationId },
72    /// Storage access is being granted.
73    Grant,
74    /// The code is shown; the request, once read and checked, waits for the app.
75    Waiting {
76        /// The code as first shown, which every later showing repeats: the
77        /// joining device binds its saved request to the exact code (§13.2).
78        code: SecretText,
79        request: Option<Vec<u8>>,
80    },
81    /// This checked request is approved: `binding` is the operation that
82    /// claims its admission, journaled with this state.
83    Approving { code: SecretText, request: Vec<u8>, binding: OperationId },
84    /// The invite ended: put its marker in the request's place and settle
85    /// its account's sharing. `share` is the share's record the invite was
86    /// claiming when it ended, whose claim it settles without claiming again;
87    /// `end`, once journaled with this state, is the operation claiming the
88    /// share's end. Both end with the invite. An S3 key it carried is the
89    /// person's to delete in the provider's console; coven records nothing
90    /// about it (§14.3). `refusal`, when the invite ended because the provider
91    /// refused to share the store for a reason only a person can fix, is what
92    /// the invite's operation ends with, for the app to acknowledge (§13.2).
93    Settled {
94        share: Option<OperationId>,
95        end: Option<OperationId>,
96        refusal: Option<coven_foundation::sharing::SharingRefusal>,
97    },
98}
99
100#[derive(Serialize, Deserialize)]
101pub(crate) struct InviteWork {
102    id: InviteId,
103    /// The device that made the invite, under which its request lives. A
104    /// reset carries the invite to an installation with another id, which
105    /// ends the request where it is (§13.2, §19.3).
106    device: DeviceId,
107    member: MemberId,
108    /// The inviting admin's name, which the invite's code carries.
109    inviter: String,
110    binding: InviteBinding,
111    access: InviteGrant,
112    secret: SecretText,
113    expires_at: std::time::SystemTime,
114    state: InviteState,
115}
116
117impl OperationState for InviteWork {}
118
119impl InviteWork {
120    fn read(record: &OperationRecord) -> Result<Self, SyncError> {
121        Ok(serde_json::from_slice(&record.data)?)
122    }
123    /// The refusal that ended the invite, once it has settled.
124    fn refusal(&self) -> Option<coven_foundation::sharing::SharingRefusal> {
125        match &self.state {
126            InviteState::Settled { refusal, .. } => *refusal,
127            _ => None,
128        }
129    }
130    fn secret(&self) -> Result<InviteSecret, SyncError> {
131        Ok(InviteSecret::from_bytes(serde_json::from_str(self.secret.as_str())?))
132    }
133    /// Whether the invite adds a new person or recovers a member: the kind of
134    /// request it takes.
135    fn kind(&self) -> coven_format::codes::InviteKind {
136        match self.binding {
137            InviteBinding::Add { .. } => coven_format::codes::InviteKind::NewPerson,
138            InviteBinding::Replace { .. } => coven_format::codes::InviteKind::Recovery,
139        }
140    }
141    fn account(&self) -> Option<&str> {
142        match &self.access {
143            InviteGrant::Account { email } => Some(email),
144            _ => None,
145        }
146    }
147    /// Whether the invite ends at its expiry: it waits for its check, grants
148    /// access, or waits for its request or the app's decision. Approval,
149    /// once begun, finishes.
150    fn expires(&self) -> bool {
151        matches!(
152            self.state,
153            InviteState::Requested
154                | InviteState::Sharing { .. }
155                | InviteState::Grant
156                | InviteState::Waiting { .. }
157        )
158    }
159    fn expired(&self, now: std::time::SystemTime) -> bool {
160        self.expires() && now >= self.expires_at
161    }
162
163    /// End the invite, as expiry, a decline or a cancel does: its settled
164    /// step carries the share's record it was claiming, which ends with it.
165    fn end(&mut self) {
166        let share = match self.state {
167            InviteState::Sharing { share } => Some(share),
168            _ => None,
169        };
170        self.state = InviteState::Settled { share, end: None, refusal: None };
171    }
172
173    /// The invite's step from `record` to `state`, built from the id of the
174    /// operation it starts, which `state` names: the two commit together
175    /// (`start_child`).
176    fn naming(
177        record: &OperationRecord,
178        state: impl FnOnce(OperationId) -> InviteState + Send + 'static,
179    ) -> impl FnOnce(OperationId) -> Result<coven_database::OperationUpdate, SyncError> + Send + 'static
180    {
181        let record = record.clone();
182        move |child| {
183            let mut work = InviteWork::read(&record)?;
184            work.state = state(child);
185            work.update(&record)
186        }
187    }
188
189    /// The admission approving `request` claims: admitting the invite's
190    /// member with the request's pair, or recovering the member with it, and
191    /// registering the device the request names (§13.2).
192    fn binding_change(&self, request: &[u8]) -> Result<StoreChange, SyncError> {
193        let request = decode_request(request)?;
194        let (keys, device, device_name) = (request.keys, request.device, request.device_name);
195        Ok(match self.binding {
196            InviteBinding::Add { role } => StoreChange::AdmitMember {
197                member: self.member,
198                keys,
199                role,
200                access: self.access.member_access(),
201                device,
202                device_name,
203            },
204            // A recovery on S3 records the invite's new key as the member's
205            // access in the same entry; on another provider the member keeps
206            // the account they reach storage with (§13.2, §14.3).
207            InviteBinding::Replace { expected } => StoreChange::RecoverMember {
208                member: self.member,
209                expected,
210                keys,
211                access: match &self.access {
212                    InviteGrant::S3Key(_) => Some(self.access.member_access()),
213                    InviteGrant::Account { .. } => None,
214                },
215                device,
216                device_name,
217            },
218        })
219    }
220
221    /// The invite as the app sees it, at `stage`, with the operation that
222    /// names it.
223    fn shown(&self, operation: OperationId, stage: crate::InviteStage) -> Invite {
224        Invite {
225            id: self.id,
226            operation,
227            member: self.member,
228            kind: self.kind(),
229            access: self.access.member_access(),
230            expires_at: self.expires_at,
231            stage,
232        }
233    }
234
235    fn present_request(&self, bytes: &[u8]) -> Result<JoinRequest, SyncError> {
236        let request = decode_request(bytes)?;
237        Ok(JoinRequest {
238            invite: request.invite,
239            member: request.member,
240            confirmation: ConfirmationNumber::of(request.invite, &request.keys),
241            keys: request.keys,
242            device_name: request.device_name,
243            joiner: request.joiner,
244            provider_account_email: self.account().map(str::to_owned),
245            record: bytes.to_vec(),
246        })
247    }
248}
249
250/// The invite a `coven_invites` row lists: its journal entry, and the
251/// operation that names it.
252fn listed(data: &[u8], operation: OperationId) -> Result<Invite, SyncError> {
253    let work: InviteWork = serde_json::from_slice(data)?;
254    let shown = |code: &SecretText| code.as_str().to_owned();
255    let stage = match &work.state {
256        InviteState::Requested | InviteState::Sharing { .. } | InviteState::Grant => {
257            crate::InviteStage::Granting
258        }
259        InviteState::Waiting { code, request } => crate::InviteStage::Waiting {
260            code: shown(code),
261            request: request.as_deref().map(|bytes| work.present_request(bytes)).transpose()?,
262        },
263        InviteState::Approving { code, request, binding } => crate::InviteStage::Approving {
264            code: shown(code),
265            request: work.present_request(request)?,
266            binding: *binding,
267        },
268        InviteState::Settled { .. } => {
269            return Err(coven_database::DbError::Invariant {
270                what: "coven_invites lists invites that haven't ended",
271            }
272            .into())
273        }
274    };
275    Ok(work.shown(operation, stage))
276}
277
278impl Invite {
279    /// Read `coven_invites`, as an app's live query does:
280    /// `handle.subscribe(Invite::list)`. This device's invites that haven't
281    /// ended, in the order they were made (§13.2).
282    pub fn list(sql: coven_database::SqlReadContext<'_>) -> Result<Vec<Self>, DbError> {
283        Ok(sql.query(
284            "SELECT invite,operation FROM coven_invites ORDER BY operation",
285            [],
286            |row| {
287                let data: Vec<u8> = row.get(0)?;
288                listed(&data, OperationId(row.get(1)?)).map_err(|error| {
289                    coven_database::SqliteError::FromSqlConversionFailure(
290                        0,
291                        coven_database::types::Type::Blob,
292                        Box::new(error),
293                    )
294                })
295            },
296        )?)
297    }
298
299    /// The code the invite shows.
300    #[cfg(any(test, feature = "test-utils"))]
301    pub fn shown_code(&self) -> &str {
302        match &self.stage {
303            crate::InviteStage::Granting => panic!("the invite shows no code before its grant"),
304            crate::InviteStage::Waiting { code, .. }
305            | crate::InviteStage::Approving { code, .. } => code,
306        }
307    }
308
309    /// The checked request, waiting for the app or being approved.
310    #[cfg(any(test, feature = "test-utils"))]
311    pub fn shown_request(&self) -> Option<&JoinRequest> {
312        match &self.stage {
313            crate::InviteStage::Granting => None,
314            crate::InviteStage::Waiting { request, .. } => request.as_ref(),
315            crate::InviteStage::Approving { request, .. } => Some(request),
316        }
317    }
318
319    /// Follow `coven_invites` with a live query, as an app does.
320    #[cfg(any(test, feature = "test-utils"))]
321    pub fn follow(
322        database: &coven_database::Database,
323    ) -> coven_database::test_utils::Followed<Vec<Self>, DbError> {
324        coven_database::test_utils::Followed::new(database.subscribe(Self::list))
325    }
326}
327
328impl SequenceSync {
329    /// Make an invite as the app's write step does, then run a pass, and
330    /// return the invite once it shows its code, or its pending record as
331    /// the error.
332    #[cfg(test)]
333    pub(crate) async fn create_invite(
334        &self,
335        target: InviteTarget,
336        access: InviteAccess,
337        lifetime: InviteLifetime,
338    ) -> Result<Invite, SyncError> {
339        let inviter = "Ana".to_owned();
340        let steps = self.operation_steps();
341        let location = self.storage.config().clone();
342        coven_storage::record_config(&self.directory, &location)?;
343        let (invite, _, operation) = self
344            .database
345            .write_as(move |sql| {
346                let made = NewInvite { target, access, lifetime, inviter };
347                steps.create_invite(&sql, made)
348            })
349            .await?;
350        self.pass().await?;
351        let shown = self.database.read(Invite::list).await?.into_iter().find(|listed| {
352            listed.id == invite && !matches!(listed.stage, crate::InviteStage::Granting)
353        });
354        if let Some(shown) = shown {
355            return Ok(shown);
356        }
357        match self.database.operation_pending::<PendingRecord>(operation).await? {
358            Some(pending) => Err(SyncError::Pending(Box::new(pending))),
359            None => Err(SyncError::InvitationChanged),
360        }
361    }
362
363    /// Approve the request as the app's write step does, then run the pass
364    /// that binds it, returning its rejection as the error.
365    #[cfg(test)]
366    pub(crate) async fn approve_join_request(
367        &self,
368        request: &JoinRequest,
369    ) -> Result<(), SyncError> {
370        let request = request.clone();
371        let steps = self.operation_steps();
372        let outcome = self
373            .journal_and_pass(move |sql| steps.approve_join_request(sql, &request).map(Some))
374            .await?
375            .expect("an approval always journals its binding");
376        crate::operation_outcome::into_slot(outcome).map(drop)
377    }
378
379    /// Decline the request as the app's write step does, then run the pass
380    /// that deletes it.
381    #[cfg(test)]
382    pub(crate) async fn decline_join_request(
383        &self,
384        request: &JoinRequest,
385    ) -> Result<(), SyncError> {
386        let request = request.clone();
387        let steps = self.operation_steps();
388        self.settle_and_pass(move |sql| steps.decline_join_request(sql, &request)).await
389    }
390
391    /// Cancel the invite as the app's write step does, then run the pass
392    /// that deletes its request.
393    #[cfg(test)]
394    pub(crate) async fn cancel_invite(&self, invite: InviteId) -> Result<(), SyncError> {
395        let steps = self.operation_steps();
396        self.settle_and_pass(move |sql| steps.cancel_invite(sql, invite)).await
397    }
398
399    #[cfg(test)]
400    async fn settle_and_pass(
401        &self,
402        step: impl FnOnce(&SqlContext<'_, '_>) -> Result<(), SyncError> + Send + 'static,
403    ) -> Result<(), SyncError> {
404        self.database.write_as(move |sql| step(&sql)).await?;
405        self.pass().await.map(drop)
406    }
407
408    /// Advance this device's invites in a pass: grant, read the request
409    /// under backoff, revoke on expiry, and finish decided work.
410    pub(crate) async fn resume_invites(
411        &self,
412        session: &mut Session,
413        ring: &mut StoreKeyring,
414        member: &MemberIdentity,
415    ) -> Result<(), SyncError> {
416        for record in self.database.operations().await? {
417            if record.kind != operation_kind::INVITE {
418                continue;
419            }
420            // Expiry takes back the grant even while a request waits for the app.
421            let expired = InviteWork::read(&record)?.expired(self.clock.now());
422            if !expired && !self.operation_ready(&record, ring).await? {
423                continue;
424            }
425            if let Err(error) = self.run_invite(session, ring, member, record.id).await {
426                if self.database.operation(record.id).await?.is_none() {
427                    return Err(error);
428                }
429            }
430            self.state.check()?;
431        }
432        // Ended invites' markers, once no joining device can send a first
433        // request to their paths (`end_request`).
434        for path in self.database.due_object_removals(seconds(self.clock.now())?).await? {
435            let object = ObjectPath::parse(&path).map_err(|error| {
436                StorageFailure::Protocol.with_source(format!("a recorded removal's path: {error}"))
437            })?;
438            self.storage.delete_if_present(&object).await?;
439            self.database.object_removed(path).await?;
440        }
441        Ok(())
442    }
443
444    /// Advance the invite as far as it goes now. `step` follows the step
445    /// of the invite's row this attempt last read, which a failure belongs
446    /// to; `None` until it has read the row.
447    async fn advance_invite(
448        &self,
449        session: &mut Session,
450        ring: &mut StoreKeyring,
451        member: &MemberIdentity,
452        id: OperationId,
453        step: &mut Option<u32>,
454    ) -> Result<InviteProgress, SyncError> {
455        loop {
456            let record = self
457                .database
458                .operation(id)
459                .await?
460                .ok_or(coven_database::DbError::OperationChanged(id))?;
461            *step = Some(record.last_step);
462            let mut work = InviteWork::read(&record)?;
463            if work.expired(self.clock.now()) {
464                work.end();
465                self.database.advance_operation(work.update(&record)?).await?;
466                continue;
467            }
468            match &work.state {
469                InviteState::Requested => {
470                    // A refusal waits for the app to cancel the invite: it is
471                    // the invite's pending record, and fails no pass.
472                    if let Err(refusal) = self.check_invite(session, member, &work) {
473                        return Ok(InviteProgress::Waiting(self.error_reason(&refusal)));
474                    }
475                    // Recorded before the grant, so no share is ever left
476                    // that only this device knows of.
477                    if let Some(account) = work.account() {
478                        let started =
479                            self.start_invite_share(session, member, &record, &work, account);
480                        match started.await? {
481                            Child::Journaled(_) => continue,
482                            Child::Decided(OperationOutcome::Rejected(reason)) => {
483                                return Ok(InviteProgress::Waiting(
484                                    self.error_reason(&SyncError::Rejected(reason)),
485                                ))
486                            }
487                            Child::Decided(_) => (),
488                        }
489                    }
490                    self.grant(session, ring, member, &record, work, None, step).await?;
491                }
492                InviteState::Sharing { share } => {
493                    let share = *share;
494                    let outcome = self.run_administration(session, ring, member, share).await?;
495                    match outcome {
496                        OperationOutcome::Landed(_) | OperationOutcome::Satisfied => {
497                            let shared = Some((share, outcome));
498                            self.grant(session, ring, member, &record, work, shared, step).await?;
499                        }
500                        // The invite checks again, and waits on the refusal.
501                        OperationOutcome::Rejected(_) => {
502                            work.state = InviteState::Requested;
503                            let update = work.update(&record)?;
504                            self.database
505                                .finish_operation_advancing(share, outcome, update)
506                                .await?;
507                        }
508                        OperationOutcome::Stopped(_) => {
509                            return Err(coven_database::DbError::Invariant {
510                                what: "a stop for good ends the invite with its claims",
511                            }
512                            .into())
513                        }
514                        OperationOutcome::Cancelled | OperationOutcome::Undone(_) => {
515                            return Err(coven_database::DbError::Invariant {
516                                what: "only storage setup is cancelled or undone",
517                            }
518                            .into())
519                        }
520                        OperationOutcome::SharingRefused { .. } => {
521                            return Err(coven_database::DbError::Invariant {
522                                what: "only an invite ends with a sharing refusal",
523                            }
524                            .into())
525                        }
526                    }
527                }
528                InviteState::Grant => {
529                    let invitation = match &work.access {
530                        InviteGrant::Account { email } => {
531                            let access = self
532                                .database
533                                .provider_access(email.clone())
534                                .await?
535                                .ok_or(coven_database::DbError::DamagedDatabase)?;
536                            if access.request.is_some() || access.granted != Some(true) {
537                                // A grant that failed keeps its cause in the
538                                // invite's pending record too.
539                                let failed = match &access.request {
540                                    Some(request) => {
541                                        self.database
542                                            .operation_pending::<PendingRecord>(request.operation)
543                                            .await?
544                                    }
545                                    None => None,
546                                };
547                                if let Some(PendingRecord {
548                                    reason:
549                                        PendingReason::Storage {
550                                            failure: StorageFailure::SharingRefused(refusal),
551                                            ..
552                                        },
553                                    ..
554                                }) = &failed
555                                {
556                                    let grant = access
557                                        .request
558                                        .as_ref()
559                                        .ok_or(coven_database::DbError::DamagedDatabase)?
560                                        .operation;
561                                    self.end_refused(id, grant, *refusal).await?;
562                                    continue;
563                                }
564                                return Ok(InviteProgress::Waiting(failed.map_or(
565                                    PendingReason::ProviderPending {
566                                        action: ProviderAccessAction::Grant,
567                                        account: email.clone(),
568                                    },
569                                    |failed| failed.reason,
570                                )));
571                            }
572                            StorageInvitation::decode(
573                                access
574                                    .invitation
575                                    .as_ref()
576                                    .ok_or(coven_database::DbError::DamagedDatabase)?
577                                    .as_bytes(),
578                            )?
579                        }
580                        InviteGrant::S3Key(_) => {
581                            StorageInvitation::for_account(self.storage.config())?
582                        }
583                    };
584                    let code = self.invite_code(session, &work, invitation)?;
585                    work.state = InviteState::Waiting { code, request: None };
586                    self.database.advance_operation(work.update(&record)?).await?;
587                    // The next pass looks for the request, then backs off.
588                    self.state.request_pass();
589                    return Ok(InviteProgress::Shown);
590                }
591                InviteState::Waiting { request: Some(_), .. } => {
592                    return Ok(InviteProgress::Waiting(PendingReason::Paused));
593                }
594                InviteState::Waiting { request: None, code } => {
595                    let path = ObjectPath::join_request(self.store(), work.device, work.id);
596                    let bytes = match self.storage.read(&path).await {
597                        Ok(bytes) => bytes,
598                        Err(error) if error.failure() == StorageFailure::NotFound => {
599                            return Ok(InviteProgress::Waiting(PendingReason::Missing { path }));
600                        }
601                        Err(error) => return Err(error.into()),
602                    };
603                    let request = match self.open_request(&work, &session.history.state, &bytes)? {
604                        Opened::Checked(request) => request,
605                        // No attempt can pass it, and no other request can
606                        // reach the invite's one path while it is there: the
607                        // invite ends, as a decline does (§13.2).
608                        Opened::Refused(refusal) => {
609                            tracing::warn!(?id, %refusal, "a join request failed its checks");
610                            work.end();
611                            self.database.advance_operation(work.update(&record)?).await?;
612                            continue;
613                        }
614                    };
615                    // A checked request waits for the app's decision: someone
616                    // who saw the code may have sent it first (§13.2).
617                    work.state =
618                        InviteState::Waiting { code: code.clone(), request: Some(request) };
619                    self.database.advance_operation(work.update(&record)?).await?;
620                    return Ok(InviteProgress::Waiting(PendingReason::Paused));
621                }
622                InviteState::Approving { binding, .. } => {
623                    let binding = *binding;
624                    // The binding ends in the transaction that moves the
625                    // invite on, so it is journaled while the invite approves.
626                    if self.database.operation(binding).await?.is_none() {
627                        return Err(coven_database::DbError::Invariant {
628                            what: "an approving invite's binding ends with its step",
629                        }
630                        .into());
631                    }
632                    let outcome = self.run_administration(session, ring, member, binding).await?;
633                    // Landed or refused, the invite ends. A refusal is the
634                    // binding's outcome, which `coven_operations` lists for the
635                    // app that approved it; the invite ends as a decline does.
636                    work.state = InviteState::Settled { share: None, end: None, refusal: None };
637                    self.database
638                        .finish_operation_advancing(binding, outcome, work.update(&record)?)
639                        .await?;
640                }
641                InviteState::Settled { share, end, refusal } => {
642                    let (share, end, refusal) = (*share, *end, *refusal);
643                    let kept_until = self.end_request(&work).await?;
644                    // The share's record the invite was claiming when it
645                    // ended: its claim may have landed, and is settled
646                    // before anything else claims (§5.1).
647                    if let Some(share) = share {
648                        self.settle_unneeded_claim(session, ring, member, share).await?;
649                    }
650                    // Applying the share's end records its account's
651                    // revoke, unless the account is still used (§14.2).
652                    let end = match end {
653                        Some(end) => Some(end),
654                        None => {
655                            self.start_share_end(session, member, &record, &work, share).await?
656                        }
657                    };
658                    if let Some(end) = end {
659                        let outcome = self.run_administration(session, ring, member, end).await?;
660                        if let OperationOutcome::Rejected(reason) = &outcome {
661                            tracing::warn!(?id, ?reason, "an invite share's end was refused");
662                        }
663                    }
664                    // The marker goes once a joining device can no longer
665                    // send a first request (`end_request`).
666                    let removal = coven_database::ObjectRemoval {
667                        path: ObjectPath::join_request(self.store(), work.device, work.id)
668                            .as_str()
669                            .to_owned(),
670                        after: kept_until,
671                    };
672                    let children = share.into_iter().chain(end).collect();
673                    // An invite ended by a refusal only a person can fix keeps
674                    // that as its outcome, listed in `coven_operations` until
675                    // the app acknowledges it (§13.2).
676                    let outcome = match refusal {
677                        Some(refusal) => {
678                            let account =
679                                work.account().ok_or(coven_database::DbError::DamagedDatabase)?;
680                            Some((
681                                crate::OperationKind::Invite.listed(),
682                                OperationOutcome::SharingRefused {
683                                    account: account.to_owned(),
684                                    refusal,
685                                },
686                            ))
687                        }
688                        None => None,
689                    };
690                    self.database
691                        .finish_operation_with_removal(id, children, removal, outcome)
692                        .await?;
693                    return Ok(InviteProgress::Finished);
694                }
695            }
696        }
697    }
698
699    /// Move the checked invite to granting its access, in one transaction
700    /// with adopting its account's grant and ending the record of its share,
701    /// `shared`, with that record's outcome; then ask for the grant, unless
702    /// another open invite's grant already shared the account with an
703    /// invitation (§14.2). A grant that fails is the invite's pending record,
704    /// and `step` follows the step this takes, which that failure belongs to.
705    #[expect(
706        clippy::too_many_arguments,
707        reason = "a step of the invite's advance takes the pass's session, keys and member"
708    )]
709    async fn grant(
710        &self,
711        session: &mut Session,
712        ring: &mut StoreKeyring,
713        member: &MemberIdentity,
714        record: &OperationRecord,
715        mut work: InviteWork,
716        shared: Option<(OperationId, OperationOutcome)>,
717        step: &mut Option<u32>,
718    ) -> Result<(), SyncError> {
719        work.state = InviteState::Grant;
720        let changes = match work.account() {
721            Some(account) => vec![crate::sequence_sync::account_change(account, true, true)?],
722            None => Vec::new(),
723        };
724        let update = work.update(record)?;
725        let granting = update.last_step;
726        self.database
727            .advance_operation_with_access::<PendingRecord>(update, changes, shared)
728            .await?;
729        *step = Some(granting);
730        let grant = match work.account() {
731            Some(account) => self
732                .database
733                .provider_access(account.to_owned())
734                .await?
735                .ok_or(coven_database::DbError::DamagedDatabase)?
736                .request
737                .map(|request| request.operation),
738            None => None,
739        };
740        if let Some(grant) = grant {
741            if let Err(error) = self.apply_access(session, ring, member, grant).await {
742                return match sharing_refusal(&error) {
743                    Some(refusal) => self.end_refused(record.id, grant, refusal).await,
744                    None => Err(error),
745                };
746            }
747        }
748        Ok(())
749    }
750
751    /// End the invite whose grant the provider refused for a reason only a
752    /// person can fix, as a failed request check does: it settles, putting
753    /// its marker in the request's place and taking its access back, and
754    /// ends with the refusal as its outcome (§13.2).
755    async fn end_refused(
756        &self,
757        id: OperationId,
758        grant: OperationId,
759        refusal: coven_foundation::sharing::SharingRefusal,
760    ) -> Result<(), SyncError> {
761        let record = self
762            .database
763            .operation(id)
764            .await?
765            .ok_or(coven_database::DbError::OperationChanged(id))?;
766        let mut work = InviteWork::read(&record)?;
767        tracing::warn!(?id, ?refusal, "the provider refused to share the store for an invite");
768        let account = work.account().ok_or(coven_database::DbError::DamagedDatabase)?.to_owned();
769        work.state = InviteState::Settled { share: None, end: None, refusal: Some(refusal) };
770        self.database
771            .advance_operation_refusing_grant(work.update(&record)?, account, grant)
772            .await?;
773        Ok(())
774    }
775
776    /// Put the ended invite's marker in its request's place, and return
777    /// when the marker may go: a week after the invite's expiry, the
778    /// retention grace, so a joining device whose clock lags the inviting
779    /// device's by less than that sends no request after it is gone (§13.2).
780    /// A request sent in between the delete and the create is taken too.
781    async fn end_request(&self, work: &InviteWork) -> Result<u64, SyncError> {
782        let kept_until = kept_until(seconds(work.expires_at)?)?;
783        let marker = InviteEnd { kept_until }.encode();
784        let path = ObjectPath::join_request(self.store(), work.device, work.id);
785        loop {
786            self.storage.delete_if_present(&path).await?;
787            match self.storage.create(&path, &marker).await {
788                Ok(_) => return Ok(kept_until),
789                Err(error) if error.failure() == StorageFailure::AlreadyExists => {
790                    match self.storage.read(&path).await {
791                        // An earlier attempt's marker, or another device's.
792                        Ok(found) if InviteEnd::decode(&found).is_ok() => return Ok(kept_until),
793                        Ok(_) => (),
794                        Err(error) if error.failure() == StorageFailure::NotFound => (),
795                        Err(error) => return Err(error.into()),
796                    }
797                }
798                Err(error) => return Err(error.into()),
799            }
800        }
801    }
802
803    /// The invite checked again against the history this pass caught up
804    /// with, before it grants anything: its maker is still a registered
805    /// admin whose account holds the store's storage, and a replacement's
806    /// member is still active with the pair it expected. The app's write
807    /// checked only the slots its device had applied then (§11.1, §13.2).
808    fn check_invite(
809        &self,
810        session: &Session,
811        member: &MemberIdentity,
812        work: &InviteWork,
813    ) -> Result<(), SyncError> {
814        let me = member.member();
815        let state = &session.history.state;
816        if !self.registered(session) || !state.active_admin(&me) {
817            return Err(SyncError::Rejected(OperationRejection::NotAllowed));
818        }
819        if !session.history.owns_storage(me) {
820            return Err(StorageFailure::NotStoreOwner.into());
821        }
822        if let InviteBinding::Replace { expected } = work.binding {
823            match state.active_member(&work.member) {
824                None => {
825                    return Err(SyncError::Rejected(OperationRejection::TargetGone(
826                        crate::EntryTarget::Member(work.member),
827                    )))
828                }
829                Some(current) if current.keys != expected => {
830                    return Err(SyncError::Rejected(OperationRejection::MemberKeysChanged(
831                        work.member,
832                    )))
833                }
834                Some(_) => (),
835            }
836        }
837        Ok(())
838    }
839
840    /// The code to show, fixed once its access is granted: it names the
841    /// store as now named and this device's applied slot.
842    fn invite_code(
843        &self,
844        session: &Session,
845        work: &InviteWork,
846        invitation: StorageInvitation,
847    ) -> Result<SecretText, SyncError> {
848        let storage = match &work.access {
849            InviteGrant::Account { .. } => InviteStorage::Account(invitation),
850            InviteGrant::S3Key(credentials) => {
851                InviteStorage::S3 { invitation, credentials: credentials.clone() }
852            }
853        };
854        let store =
855            session.history.state.store.as_ref().ok_or(coven_database::DbError::DamagedDatabase)?;
856        let initial_key = session
857            .history
858            .introductions
859            .iter()
860            .find(|intro| intro.audience == Audience::Store)
861            .ok_or(coven_database::DbError::DamagedDatabase)?
862            .key;
863        let code = InviteCode {
864            store: store.id,
865            name: store.name.clone(),
866            inviter: work.inviter.clone(),
867            kind: work.kind(),
868            expires_at: seconds(work.expires_at)?,
869            invite: work.id,
870            member: work.member,
871            secret: work.secret()?,
872            initial_key,
873            inviting_writer: work.device,
874            read_through: session.through,
875            storage: storage.encode()?,
876        }
877        .to_text()?;
878        Ok(SecretText::new(code.to_string()))
879    }
880}
881
882/// How far one call advanced an invite.
883pub(crate) enum InviteProgress {
884    /// Access is granted and the code can be shown.
885    Shown,
886    /// The invite waits on this condition.
887    Waiting(PendingReason),
888    /// The invite's work is done.
889    Finished,
890}
891
892/// The refusal a failed grant carries, when only a person can fix it.
893fn sharing_refusal(error: &SyncError) -> Option<coven_foundation::sharing::SharingRefusal> {
894    match error {
895        SyncError::Storage(error) => match error.failure() {
896            StorageFailure::SharingRefused(refusal) => Some(refusal),
897            _ => None,
898        },
899        _ => None,
900    }
901}
902
903/// When the marker of an invite that expires at `expires`, in seconds since
904/// 1970, may go: the retention grace after it (§13.2). A time the marker's
905/// seconds can't hold is refused as the expiry the clock can't represent is.
906fn kept_until(expires: u64) -> Result<u64, SyncError> {
907    expires.checked_add(super::sequence_retention::GRACE.as_secs()).ok_or_else(|| {
908        StorageFailure::InvalidConfiguration
909            .with_source("the clock cannot represent when an ended invite's marker may go")
910            .into()
911    })
912}
913
914/// A time as whole seconds since the Unix epoch, as codes and markers carry
915/// it.
916fn seconds(at: SystemTime) -> Result<u64, SyncError> {
917    Ok(at
918        .duration_since(std::time::UNIX_EPOCH)
919        .map_err(|error| {
920            StorageFailure::InvalidConfiguration
921                .with_source(format!("an invite's time precedes 1970: {error}"))
922        })?
923        .as_secs())
924}
925
926fn decode_request(bytes: &[u8]) -> Result<coven_format::objects::JoinRequest, SyncError> {
927    match Object::decode(bytes)? {
928        Object::JoinRequest(request) => Ok(request),
929        _ => Err(SyncError::InvitationChanged),
930    }
931}
932
933#[cfg(test)]
934#[path = "sequence_invites_tests.rs"]
935pub(crate) mod tests;
936
937#[path = "sequence_invite_request.rs"]
938mod request;
939use request::Opened;
940
941#[path = "sequence_invite_approval.rs"]
942mod approval;
943
944#[path = "sequence_invite_reset.rs"]
945mod reset;
946
947#[path = "sequence_invite_shares.rs"]
948pub(crate) mod shares;
949
950#[path = "sequence_invite_steps.rs"]
951mod steps;
952pub use steps::NewInvite;
953pub(crate) use steps::{approve_request, end_invite, make_invite};
954
955#[path = "invite_expiry.rs"]
956pub(crate) mod expiry;