Skip to main content

coven_domain/joining/
client.rs

1//! Join an existing shared store using a recipient-sealed device invitation.
2//!
3//! Shared across all platforms (macOS, iOS, CLI).
4
5use std::sync::Arc;
6
7use tokio::sync::watch;
8use tracing::{info, warn};
9
10use coven_database::supported_version;
11use coven_database::Migration;
12use coven_database::{CovenMigrationPolicy, Database};
13#[cfg(feature = "oauth-providers")]
14use coven_foundation::config::CloudProvider;
15use coven_foundation::config::{Config, ConfigError, HomeStorage};
16use coven_foundation::stage_timing::StageTimings;
17use coven_foundation::store_dir::{StoreDir, StoreLayout};
18use coven_keys::encryption::{EncryptionError, EncryptionService, MasterKeyring};
19use coven_keys::identity_custody::IdentityCustody;
20use coven_keys::keys::{
21    CloudHomeCredentials, DeviceIdentityCustody, KeyError, MasterKeyCustody, StoreKeys, UserKeypair,
22};
23use coven_protocol::synced_schema::SyncedTable;
24use coven_replication::sync::store::{
25    MembershipMutationError, PreparedDeviceJoinSnapshot, PreparedSnapshotBootstrap, PullError,
26    SnapshotError,
27};
28use coven_replication::sync::MemberAdmission;
29use coven_storage::cloud::{CloudHomeError, CloudHomeJoinInfo, ExactCloudHome};
30use coven_storage::{BlobPathScheme, CloudCipher, CloudSyncConnection};
31
32/// Why joining or restoring a store failed. Both are the same operation —
33/// bootstrap a store from the cloud — differing only in their entry data (an
34/// admission that wraps the store key vs a restore code that carries the bucket
35/// credentials), so they share one error shape rather than two that duplicate
36/// most of their variants and then have to map between each other.
37#[derive(Debug, thiserror::Error)]
38pub enum BootstrapError {
39    #[error("cloud home: {0}")]
40    CloudHome(#[from] CloudHomeError),
41    #[error("encryption: {0}")]
42    Encryption(#[from] EncryptionError),
43    #[error("membership mutation: {0}")]
44    MembershipMutation(#[source] Box<MembershipMutationError>),
45    #[error("snapshot: {0}")]
46    Snapshot(SnapshotError),
47    #[error("pull: {0}")]
48    Pull(#[from] PullError),
49    #[error("Store pull: {0}")]
50    StorePull(#[from] coven_replication::sync::store::StorePullError),
51    #[error("Store device registration: {0}")]
52    StoreRegistration(#[from] coven_replication::sync::store::StoreRegistrationError),
53    #[error("Store device join: {0}")]
54    DeviceJoin(#[from] coven_replication::sync::DeviceJoinError),
55    #[error("Store device join transport: {0}")]
56    DeviceJoinTransport(#[from] coven_replication::sync::store::DeviceJoinTransportError),
57    #[error("storage: {0}")]
58    Storage(#[from] coven_protocol::objects::StorageError),
59    #[error("config: {0}")]
60    Config(#[from] ConfigError),
61    #[error("keyring: {0}")]
62    Key(#[from] KeyError),
63    #[error("I/O: {0}")]
64    Io(#[from] std::io::Error),
65    #[error("device invitation: {0}")]
66    DeviceInvite(#[from] crate::joining::DeviceInviteError),
67    #[error("device pairing: {0}")]
68    Pairing(#[from] crate::joining::DevicePairingTransportError),
69    #[error("device pairing state: {0}")]
70    PairingState(#[from] crate::joining::DevicePairingError),
71    #[error("device join invite version {0} is not supported")]
72    UnsupportedDeviceInviteVersion(u32),
73    #[error("invalid store id: {0}")]
74    InvalidStoreId(#[from] coven_foundation::store_dir::PathTokenError),
75    #[error("invalid restore code: {0}")]
76    RestoreCode(#[from] crate::restoration::RestoreCodeError),
77    #[error("store already exists locally: {0}")]
78    StoreExists(String),
79    /// A hard crash left a store directory with no saved config, and clearing
80    /// that torn-bootstrap residue before retrying failed — so the retry can't
81    /// proceed over the leftover directory or keyring entries.
82    #[error("could not clear a torn bootstrap for {store_id}: {failures}")]
83    TornBootstrapCleanup {
84        store_id: String,
85        failures: BootstrapCleanupFailures,
86    },
87    #[error("could not remove cancelled join state for {store_id}: {failures}")]
88    CancelledJoinCleanup {
89        store_id: String,
90        failures: BootstrapCleanupFailures,
91    },
92    #[error("provider: {0}")]
93    Provider(String),
94    #[cfg(feature = "oauth-providers")]
95    #[error("OAuth client configuration: {0}")]
96    OAuthClient(#[from] coven_storage::oauth::OAuthClientCredsError),
97    #[error("{provider:?} cannot provide exact protocol and blob slots with this configuration")]
98    ExactSlotsUnavailable {
99        provider: coven_foundation::config::CloudProvider,
100    },
101    #[error("database open: {0}")]
102    DatabaseOpen(#[from] coven_database::OpenError),
103    #[error("invalid signing key: {0}")]
104    InvalidSigningKey(#[from] SigningKeyError),
105    /// The caller's cancel signal fired at a phase boundary, so the join or
106    /// restore stopped before saving the store. This returns through the same
107    /// failure-cleanup path a real error takes — removing the partly-created
108    /// store directory and any per-store keyring entries written so far — so a
109    /// cancelled bootstrap leaves no residue in either place.
110    #[error("the operation was cancelled")]
111    Cancelled,
112    /// Bootstrap failed AND cleaning up what it had durably written also failed.
113    /// Both are carried: `cause` is the original bootstrap failure that
114    /// triggered the cleanup, `cleanup` is why the cleanup itself failed — the
115    /// cause is preserved as a value, not flattened into a string.
116    #[error("could not clean up the partial store after bootstrap failed: {cleanup} (bootstrap error: {cause})")]
117    Cleanup {
118        cleanup: BootstrapCleanupFailures,
119        cause: Box<BootstrapError>,
120    },
121}
122
123impl From<SnapshotError> for BootstrapError {
124    fn from(error: SnapshotError) -> Self {
125        match error {
126            SnapshotError::Cancelled => Self::Cancelled,
127            error => Self::Snapshot(error),
128        }
129    }
130}
131
132#[derive(Debug, thiserror::Error)]
133pub enum SigningKeyError {
134    #[error("{0}")]
135    Material(#[from] coven_foundation::code_envelope::FixedHexError),
136    #[error("activated continuation has no device signing key")]
137    MissingContinuationSigner,
138    #[error("Owner recovery cannot carry an activated device signer")]
139    UnexpectedOwnerRecoverySigner,
140}
141
142#[derive(Debug, thiserror::Error)]
143pub enum BootstrapCleanupFailure {
144    #[error("store directory: {0}")]
145    StoreDirectory(#[source] std::io::Error),
146    #[error("master key: {0}")]
147    MasterKey(#[source] KeyError),
148    #[error("identity: {0}")]
149    Identity(#[source] KeyError),
150    #[error("cloud home credentials: {0}")]
151    CloudHomeCredentials(#[source] KeyError),
152}
153
154#[derive(Debug)]
155pub struct BootstrapCleanupFailures(Vec<BootstrapCleanupFailure>);
156
157impl BootstrapCleanupFailures {
158    fn is_empty(&self) -> bool {
159        self.0.is_empty()
160    }
161}
162
163impl std::fmt::Display for BootstrapCleanupFailures {
164    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        for (index, failure) in self.0.iter().enumerate() {
166            if index > 0 {
167                formatter.write_str("; ")?;
168            }
169            write!(formatter, "{failure}")?;
170        }
171        Ok(())
172    }
173}
174
175impl std::error::Error for BootstrapCleanupFailures {
176    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
177        self.0.first().map(|failure| failure as _)
178    }
179}
180
181impl From<MembershipMutationError> for BootstrapError {
182    fn from(error: MembershipMutationError) -> Self {
183        Self::MembershipMutation(Box::new(error))
184    }
185}
186
187/// The complete local cleanup capability for one bootstrap attempt.
188pub(crate) struct BootstrapCleanup<'a> {
189    store_dir: &'a StoreDir,
190    store_keys: &'a StoreKeys,
191    custody: &'a dyn MasterKeyCustody,
192    identity_custody: &'a dyn DeviceIdentityCustody,
193}
194
195impl<'a> BootstrapCleanup<'a> {
196    pub(crate) fn new(
197        store_dir: &'a StoreDir,
198        store_keys: &'a StoreKeys,
199        custody: &'a dyn MasterKeyCustody,
200        identity_custody: &'a dyn DeviceIdentityCustody,
201    ) -> Self {
202        Self {
203            store_dir,
204            store_keys,
205            custody,
206            identity_custody,
207        }
208    }
209
210    /// Refuse a completed store and remove all local state from a torn attempt.
211    pub(crate) fn refuse_completed_or_clear(&self, store_id: &str) -> Result<(), BootstrapError> {
212        if self.store_dir.config_path().exists() {
213            return Err(BootstrapError::StoreExists(store_id.to_string()));
214        }
215
216        if self.store_dir.exists() {
217            warn!(
218                store_dir = %self.store_dir.display(),
219                "clearing a torn bootstrap: a store directory with no saved config, left by a restore that a crash interrupted before completion"
220            );
221            let failures = self.remove();
222            if !failures.is_empty() {
223                return Err(BootstrapError::TornBootstrapCleanup {
224                    store_id: store_id.to_string(),
225                    failures,
226                });
227            }
228        }
229
230        Ok(())
231    }
232
233    /// Remove partial local state and preserve the initiating bootstrap error.
234    pub(crate) fn after_failure(&self, cause: BootstrapError) -> BootstrapError {
235        let failures = self.remove();
236        if failures.is_empty() {
237            cause
238        } else {
239            BootstrapError::Cleanup {
240                cleanup: failures,
241                cause: Box::new(cause),
242            }
243        }
244    }
245
246    /// Remove every local artifact the bound bootstrap attempt may have written.
247    pub(crate) fn remove(&self) -> BootstrapCleanupFailures {
248        let mut failures = Vec::new();
249
250        if let Err(error) = self.store_dir.remove_tree() {
251            failures.push(BootstrapCleanupFailure::StoreDirectory(error));
252        }
253        if let Err(error) = self.custody.forget() {
254            failures.push(BootstrapCleanupFailure::MasterKey(error));
255        }
256        if let Err(error) = self.identity_custody.forget() {
257            failures.push(BootstrapCleanupFailure::Identity(error));
258        }
259        if let Err(error) = self.store_keys.delete_cloud_home_credentials() {
260            failures.push(BootstrapCleanupFailure::CloudHomeCredentials(error));
261        }
262
263        BootstrapCleanupFailures(failures)
264    }
265}
266
267async fn build_cloud_home_for_join(
268    join_info: &CloudHomeJoinInfo,
269    lib_ks: &StoreKeys,
270    cloud_homes: &coven_storage::cloud::CloudHomeFactory,
271    oauth_tokens: Option<coven_storage::oauth::OAuthTokens>,
272    cloudkit_ops: Option<Arc<dyn coven_storage::cloud::cloudkit::CloudKitOps>>,
273    clock: coven_foundation::clock::ClockRef,
274    exact_upload_verification: coven_foundation::config::ExactUploadVerification,
275) -> Result<Arc<dyn ExactCloudHome>, BootstrapError> {
276    use coven_storage::cloud::*;
277
278    #[cfg(not(feature = "oauth-providers"))]
279    let _ = (&lib_ks, cloud_homes, &oauth_tokens, &clock);
280    #[cfg(feature = "oauth-providers")]
281    let credential_custody =
282        coven_keys::keys::CloudHomeCredentialsOwner::new(lib_ks.clone()).current();
283
284    match join_info {
285        CloudHomeJoinInfo::S3 {
286            bucket,
287            region,
288            endpoint,
289            access_key,
290            secret_key,
291            key_prefix,
292        } => {
293            let s3 = cloud_homes
294                .open_s3(
295                    bucket.clone(),
296                    region.clone(),
297                    endpoint.clone(),
298                    access_key.clone(),
299                    secret_key.clone(),
300                    key_prefix.clone(),
301                    exact_upload_verification,
302                    clock.clone(),
303                )
304                .await?;
305            Ok(Arc::new(s3))
306        }
307        #[cfg(feature = "oauth-providers")]
308        CloudHomeJoinInfo::GoogleDrive { folder_id } => {
309            let tokens = oauth_tokens.ok_or_else(|| {
310                BootstrapError::Provider("Google Drive join requires an OAuth token".to_string())
311            })?;
312            let oauth_config = cloud_homes.oauth_config_for(CloudProvider::GoogleDrive)?;
313            let session = oauth_session::OAuthSession::new(
314                tokens,
315                credential_custody.clone(),
316                clock,
317                oauth_config,
318                "Google Drive",
319            );
320            Ok(Arc::new(google_drive::GoogleDriveCloudHome::new(
321                folder_id.clone(),
322                session,
323                exact_upload_verification,
324            )))
325        }
326        #[cfg(feature = "oauth-providers")]
327        CloudHomeJoinInfo::Dropbox { folder_path } => {
328            let tokens = oauth_tokens.ok_or_else(|| {
329                BootstrapError::Provider("Dropbox join requires an OAuth token".to_string())
330            })?;
331            let oauth_config = cloud_homes.oauth_config_for(CloudProvider::Dropbox)?;
332            let session = oauth_session::OAuthSession::new(
333                tokens,
334                credential_custody.clone(),
335                clock,
336                oauth_config,
337                "Dropbox",
338            );
339            Ok(Arc::new(dropbox::DropboxCloudHome::new(
340                folder_path.clone(),
341                session,
342                exact_upload_verification,
343            )))
344        }
345        #[cfg(feature = "oauth-providers")]
346        CloudHomeJoinInfo::OneDrive {
347            drive_id,
348            folder_id,
349        } => {
350            let tokens = oauth_tokens.ok_or_else(|| {
351                BootstrapError::Provider("OneDrive join requires an OAuth token".to_string())
352            })?;
353            let oauth_config = cloud_homes.oauth_config_for(CloudProvider::OneDrive)?;
354            let session = oauth_session::OAuthSession::new(
355                tokens,
356                credential_custody,
357                clock,
358                oauth_config,
359                "OneDrive",
360            );
361            Ok(Arc::new(onedrive::OneDriveCloudHome::new(
362                drive_id.clone(),
363                folder_id.clone(),
364                session,
365                exact_upload_verification,
366            )))
367        }
368        #[cfg(not(feature = "oauth-providers"))]
369        CloudHomeJoinInfo::GoogleDrive { .. }
370        | CloudHomeJoinInfo::Dropbox { .. }
371        | CloudHomeJoinInfo::OneDrive { .. } => Err(BootstrapError::Provider(
372            "OAuth cloud providers are not supported in this build".to_string(),
373        )),
374        CloudHomeJoinInfo::CloudKit => {
375            let ops = cloudkit_ops.ok_or_else(|| {
376                BootstrapError::Provider("CloudKit driver not provided".to_string())
377            })?;
378            Ok(Arc::new(cloudkit::CloudKitCloudHome::new_private(
379                ops,
380                exact_upload_verification,
381            )))
382        }
383        CloudHomeJoinInfo::CloudKitShare {
384            share_url,
385            owner_name,
386            zone_name,
387        } => {
388            let ops = cloudkit_ops.ok_or_else(|| {
389                BootstrapError::Provider("CloudKit driver not provided".to_string())
390            })?;
391            let accepted = cloudkit::accept_share(ops.clone(), share_url.clone()).await?;
392            if accepted.owner_name != *owner_name || accepted.zone_name != *zone_name {
393                return Err(BootstrapError::Provider(format!(
394                    "CloudKit accepted share zone mismatch: invite owner/zone {owner_name}/{zone_name}, accepted {}/{}",
395                    accepted.owner_name, accepted.zone_name
396                )));
397            }
398            let home = Arc::new(cloudkit::CloudKitCloudHome::new_shared(
399                ops.clone(),
400                owner_name.clone(),
401                zone_name.clone(),
402                exact_upload_verification,
403            ));
404            Ok(home)
405        }
406    }
407}
408
409pub(crate) enum EnrollmentProviderAccess {
410    Supplied(Option<coven_storage::oauth::OAuthTokens>),
411    Stored,
412    #[cfg(any(test, feature = "test-utils"))]
413    InjectedHome,
414}
415
416#[cfg(feature = "oauth-providers")]
417pub(crate) fn enrollment_oauth_tokens(
418    join_info: &CloudHomeJoinInfo,
419    store_keys: &StoreKeys,
420    access: EnrollmentProviderAccess,
421) -> Result<Option<coven_storage::oauth::OAuthTokens>, BootstrapError> {
422    let provider = match join_info {
423        CloudHomeJoinInfo::GoogleDrive { .. } => "Google Drive",
424        CloudHomeJoinInfo::Dropbox { .. } => "Dropbox",
425        CloudHomeJoinInfo::OneDrive { .. } => "OneDrive",
426        CloudHomeJoinInfo::S3 { .. }
427        | CloudHomeJoinInfo::CloudKit
428        | CloudHomeJoinInfo::CloudKitShare { .. } => return Ok(None),
429    };
430    match access {
431        EnrollmentProviderAccess::Supplied(Some(tokens)) => {
432            store_keys.set_cloud_home_oauth_tokens(&tokens)?;
433            Ok(Some(tokens))
434        }
435        EnrollmentProviderAccess::Supplied(None) | EnrollmentProviderAccess::Stored => store_keys
436            .get_cloud_home_oauth_tokens()?
437            .map(Some)
438            .ok_or_else(|| {
439                BootstrapError::Provider(format!(
440                    "{provider} device enrollment requires OAuth authorization"
441                ))
442            }),
443        #[cfg(any(test, feature = "test-utils"))]
444        EnrollmentProviderAccess::InjectedHome => Ok(None),
445    }
446}
447
448#[cfg(not(feature = "oauth-providers"))]
449pub(crate) fn enrollment_oauth_tokens(
450    join_info: &CloudHomeJoinInfo,
451    _store_keys: &StoreKeys,
452    access: EnrollmentProviderAccess,
453) -> Result<Option<coven_storage::oauth::OAuthTokens>, BootstrapError> {
454    match access {
455        EnrollmentProviderAccess::Supplied(tokens) => drop(tokens),
456        EnrollmentProviderAccess::Stored => {}
457        #[cfg(any(test, feature = "test-utils"))]
458        EnrollmentProviderAccess::InjectedHome => {}
459    }
460    match join_info {
461        CloudHomeJoinInfo::GoogleDrive { .. }
462        | CloudHomeJoinInfo::Dropbox { .. }
463        | CloudHomeJoinInfo::OneDrive { .. } => Err(BootstrapError::Provider(
464            "OAuth cloud providers are not supported in this build".to_string(),
465        )),
466        CloudHomeJoinInfo::S3 { .. }
467        | CloudHomeJoinInfo::CloudKit
468        | CloudHomeJoinInfo::CloudKitShare { .. } => Ok(None),
469    }
470}
471
472/// A joining device's local half of the four-transfer admission exchange.
473/// The journal lives outside the incomplete store directory, so every method
474/// can be retried after process termination without losing its exact predecessor.
475pub(crate) struct DeviceJoinClient {
476    admission: MemberAdmission,
477    member_pubkey: String,
478    layout: StoreLayout,
479    synced_tables: Vec<SyncedTable>,
480    migrations: Vec<Migration>,
481    coven_migration_policy: CovenMigrationPolicy,
482    exact_upload_verification: coven_foundation::config::ExactUploadVerification,
483    transfer_limits: coven_protocol::blob::TransferLimits,
484    store_keys: StoreKeys,
485    custody: Arc<dyn MasterKeyCustody>,
486    identity_custody: Arc<dyn DeviceIdentityCustody>,
487    cloud_homes: coven_storage::cloud::CloudHomeFactory,
488    oauth_tokens: Option<coven_storage::oauth::OAuthTokens>,
489    cloudkit_ops: Option<Arc<dyn coven_storage::cloud::cloudkit::CloudKitOps>>,
490    clock: coven_foundation::clock::ClockRef,
491    #[cfg(any(test, feature = "test-utils"))]
492    test_home: Option<Arc<dyn ExactCloudHome>>,
493}
494
495struct DeviceJoinStorage {
496    storage: Arc<dyn coven_storage::CloudSyncObjectStorage>,
497    keyring: MasterKeyring,
498    /// The owner-anchored membership chain the keyring open already walked and
499    /// verified. Installing this device's owner anchor needs the same chain, so
500    /// it is kept rather than walked from the cloud a second time.
501    membership: coven_protocol::membership::MembershipChain,
502}
503
504impl DeviceJoinClient {
505    #[allow(clippy::too_many_arguments)]
506    pub(crate) fn new(
507        admission: MemberAdmission,
508        member_pubkey: String,
509        layout: StoreLayout,
510        synced_tables: Vec<SyncedTable>,
511        migrations: Vec<Migration>,
512        coven_migration_policy: CovenMigrationPolicy,
513        exact_upload_verification: coven_foundation::config::ExactUploadVerification,
514        transfer_limits: coven_protocol::blob::TransferLimits,
515        key_custody: coven_keys::custody::KeyCustody,
516        identity_custody: IdentityCustody,
517        oauth_clients: coven_storage::oauth::OAuthClients,
518        oauth_tokens: Option<coven_storage::oauth::OAuthTokens>,
519        cloudkit_ops: Option<Arc<dyn coven_storage::cloud::cloudkit::CloudKitOps>>,
520        clock: coven_foundation::clock::ClockRef,
521    ) -> Result<Self, BootstrapError> {
522        if admission.wrapped_key.recipient_pubkey != member_pubkey {
523            return Err(crate::joining::DeviceInviteError::RecipientMismatch.into());
524        }
525        coven_storage::cloud::setup::require_exact_slot_capabilities_join_info(
526            &admission.join_info,
527            exact_upload_verification,
528        )
529        .map_err(|provider| BootstrapError::ExactSlotsUnavailable { provider })?;
530        coven_foundation::store_dir::validate_path_token(&admission.store_id)?;
531        let store_dir = layout.store_dir(&admission.store_id);
532        let store_keys = StoreKeys::bind(admission.store_id.clone());
533        let custody = key_custody.resolve(&store_keys, &store_dir);
534        let identity_custody = identity_custody.resolve(&store_keys, &store_dir);
535        Ok(Self {
536            admission,
537            member_pubkey,
538            layout,
539            synced_tables,
540            migrations,
541            coven_migration_policy,
542            exact_upload_verification,
543            transfer_limits,
544            store_keys,
545            custody,
546            identity_custody,
547            cloud_homes: coven_storage::cloud::CloudHomeFactory::new(oauth_clients),
548            oauth_tokens,
549            cloudkit_ops,
550            clock,
551            #[cfg(any(test, feature = "test-utils"))]
552            test_home: None,
553        })
554    }
555
556    #[cfg(any(test, feature = "test-utils"))]
557    pub(crate) fn with_test_bootstrap_home(mut self, home: Arc<dyn ExactCloudHome>) -> Self {
558        self.test_home = Some(home);
559        self
560    }
561
562    pub(crate) async fn prepare_provider_access_request(
563        &self,
564        offer: coven_replication::sync::DeviceJoinOffer,
565    ) -> Result<coven_replication::sync::DeviceProviderAccessRequest, BootstrapError> {
566        self.require_offer(&offer)?;
567        let signer = coven_keys::keys::peek_pending_identity(&offer.member_pubkey)?;
568        let storage: Arc<dyn coven_storage::CloudSyncObjectStorage> =
569            Arc::new(self.transport_storage().await?);
570        let pending = self.open_pending_journal()?;
571        let observation = coven_replication::sync::store::PendingDeviceJoinObservation::open(
572            &pending,
573            &storage,
574            &offer.store_root,
575            offer.attempt_id,
576        )
577        .await?;
578        let authority = coven_replication::sync::store::PendingDeviceJoinAuthority::open(
579            observation,
580            &signer,
581            offer,
582        )
583        .await?;
584        Ok(authority.prepare_provider_access_request().await?)
585    }
586
587    pub(crate) async fn accept_device_join_abandonment(
588        &self,
589        abandonment: coven_replication::sync::DeviceJoinAbandonment,
590    ) -> Result<coven_replication::sync::DeviceJoinAbandonment, BootstrapError> {
591        let storage: Arc<dyn coven_storage::CloudSyncObjectStorage> =
592            Arc::new(self.transport_storage().await?);
593        let pending = self.open_pending_journal()?;
594        let mut observation = coven_replication::sync::store::PendingDeviceJoinObservation::open(
595            &pending,
596            &storage,
597            &self.admission.store_root,
598            abandonment.abandonment.attempt_id,
599        )
600        .await?;
601        Ok(observation.observe_abandonment(abandonment).await?)
602    }
603
604    /// The library this join produced, when it already exists.
605    ///
606    /// This is the joining device's finished marker. It used to be a journal
607    /// row that outlived the join it described; the config file is written by
608    /// the same completion, says the same thing, and is the file the library is
609    /// opened from — so the row was a second copy of it that nothing else read.
610    pub(crate) fn completed_library(&self) -> Result<Option<Config>, BootstrapError> {
611        let store_dir = self.layout.store_dir(&self.admission.store_id);
612        if !store_dir.config_path().exists() {
613            return Ok(None);
614        }
615        let config = Config::load_from_config_yaml(&store_dir)?;
616        if config.store_id != self.admission.store_id {
617            return Err(coven_replication::sync::DeviceJoinError::JournalConflict.into());
618        }
619        Ok(Some(config))
620    }
621
622    pub(crate) fn device_join_status(
623        &self,
624        attempt_id: coven_protocol::DeviceJoinAttemptId,
625    ) -> Result<Option<coven_replication::sync::DeviceJoinStatus>, BootstrapError> {
626        let pending = self.open_pending_journal()?;
627        Ok(pending.status(attempt_id)?)
628    }
629
630    #[cfg(test)]
631    pub(crate) fn pending_journal_records_for_test(
632        &self,
633    ) -> Result<
634        Vec<coven_protocol::store_commit::device_join_journal::DeviceJoinJournalRecord>,
635        BootstrapError,
636    > {
637        Ok(self.open_pending_journal()?.records()?)
638    }
639
640    #[cfg(test)]
641    pub(crate) fn resume_device_joins(
642        &self,
643    ) -> Result<Vec<coven_replication::sync::DeviceJoinAction>, BootstrapError> {
644        let pending = self.open_pending_journal()?;
645        Ok(pending.actions()?)
646    }
647
648    pub(crate) async fn prepare_registration_request(
649        &self,
650        approval: coven_replication::sync::DeviceProviderAdmissionApproval,
651    ) -> Result<coven_replication::sync::DeviceRegistrationRequest, BootstrapError> {
652        let offer = &approval.request.offer;
653        self.require_offer(offer)?;
654        let signer = coven_keys::keys::peek_pending_identity(&offer.member_pubkey)?;
655        let storage: Arc<dyn coven_storage::CloudSyncObjectStorage> =
656            Arc::new(self.transport_storage().await?);
657        let pending = self.open_pending_journal()?;
658        let observation = coven_replication::sync::store::PendingDeviceJoinObservation::open(
659            &pending,
660            &storage,
661            &offer.store_root,
662            offer.attempt_id,
663        )
664        .await?;
665        let mut authority = coven_replication::sync::store::PendingDeviceJoinAuthority::open(
666            observation,
667            &signer,
668            offer.as_ref().clone(),
669        )
670        .await?;
671        Ok(authority.prepare_registration_request(approval).await?)
672    }
673
674    pub(crate) fn record_same_principal_registration_request(
675        &self,
676        approval: coven_replication::sync::DeviceProviderAdmissionApproval,
677    ) -> Result<coven_replication::sync::DeviceRegistrationRequest, BootstrapError> {
678        let offer = approval.request.offer.as_ref().clone();
679        self.require_offer(&offer)?;
680        let pending = self.open_pending_journal()?;
681        Ok(coven_replication::sync::store::PendingDeviceJoinAuthority::record_same_principal_registration_request(
682            &pending,
683            &offer,
684            approval,
685        )?)
686    }
687
688    /// Report the breakdown whichever way the bootstrap ends. A join that took
689    /// four minutes is exactly the one whose stage timings are wanted, and a
690    /// join that failed partway still reached the stages it reached.
691    pub(crate) async fn bootstrap_pending_device(
692        &self,
693        bootstrap: coven_replication::sync::ProviderReadyDeviceBootstrap,
694        on_progress: &coven_replication::sync::JoiningDeviceJoinProgressObserver,
695        cancel: &watch::Receiver<bool>,
696    ) -> Result<
697        coven_protocol::store_commit::device_join_exchange::DeviceJoinReadiness,
698        BootstrapError,
699    > {
700        // Opened before the run so the run counts through it. Opening one is
701        // local, so nothing measurable happens ahead of the first stage.
702        let cloud = self.build_cloud_home().await?;
703        let mut timings =
704            StageTimings::counting("Device join bootstrap", cloud.provider_requests());
705        let outcome = Box::pin(self.bootstrap_pending_device_staged(
706            cloud,
707            bootstrap,
708            on_progress,
709            cancel,
710            &mut timings,
711        ))
712        .await;
713        timings.report();
714        outcome
715    }
716
717    async fn bootstrap_pending_device_staged(
718        &self,
719        cloud: Arc<dyn ExactCloudHome>,
720        bootstrap: coven_replication::sync::ProviderReadyDeviceBootstrap,
721        on_progress: &coven_replication::sync::JoiningDeviceJoinProgressObserver,
722        cancel: &watch::Receiver<bool>,
723        timings: &mut StageTimings,
724    ) -> Result<
725        coven_protocol::store_commit::device_join_exchange::DeviceJoinReadiness,
726        BootstrapError,
727    > {
728        let offer = &bootstrap.bootstrap.request.approval().request.offer;
729        self.require_offer(offer)?;
730        let attempt_id = bootstrap.bootstrap.publication_authorization.attempt_id;
731        let pending = self.open_pending_journal()?;
732        if *cancel.borrow() {
733            return Err(BootstrapError::Cancelled);
734        }
735        let signer = coven_keys::keys::peek_pending_identity(&offer.member_pubkey)?;
736        let join = timings
737            .stage("open Store storage", self.build_storage(cloud, &signer))
738            .await?;
739        let store_dir = self.layout.store_dir(&self.admission.store_id);
740        if let Some(readiness) = pending.completed_joiner_readiness(attempt_id)? {
741            if store_dir.db_path().exists() {
742                return Ok(readiness);
743            }
744        }
745        if store_dir.config_path().exists() {
746            return Err(BootstrapError::StoreExists(self.admission.store_id.clone()));
747        }
748        store_dir.ensure_created()?;
749        let db_path = store_dir.db_path();
750        let history_verifier = timings
751            .stage(
752                "read Store root",
753                coven_replication::sync::store::HistoryConstructionAuthority::for_snapshot()
754                    .open_pinned(join.storage.as_ref(), &offer.store_root),
755            )
756            .await
757            .map_err(SnapshotError::from)?;
758        // Selecting the snapshot resolves membership at the admission floor on
759        // this verifier, which is a second one and holds nothing the first
760        // walked. The rollup is what keeps that from being a second walk.
761        timings
762            .stage(
763                "read the membership rollup",
764                history_verifier.adopt_published_membership_rollup(),
765            )
766            .await;
767        let snapshot = timings
768            .stage(
769                "download snapshot",
770                PreparedSnapshotBootstrap::prepare(
771                    &join.storage,
772                    history_verifier,
773                    &self.admission.membership_floor,
774                    supported_version(&self.migrations),
775                    &db_path,
776                    &signer,
777                    std::sync::Arc::clone(on_progress),
778                    cancel,
779                ),
780            )
781            .await?;
782        on_progress(coven_replication::sync::JoiningDeviceJoinProgress::InstallingSnapshot);
783        let routing_encryption = EncryptionService::from(join.keyring.clone());
784        let device_id = bootstrap
785            .bootstrap
786            .request
787            .expected_registration()
788            .device_id
789            .to_string();
790        let opened = timings
791            .stage(
792                "install snapshot",
793                snapshot.install(
794                    &store_dir,
795                    self.synced_tables.clone(),
796                    coven_protocol::blob::BLOB_TOMBSTONE_GRACE,
797                    self.transfer_limits,
798                    device_id,
799                    self.clock.clone(),
800                    &self.migrations,
801                    self.coven_migration_policy,
802                    Some(&routing_encryption),
803                ),
804            )
805            .await?;
806        let published_at = self.clock.now().to_rfc3339();
807        let mut joining = timings
808            .stage(
809                "load membership",
810                opened.begin_device_join(&pending, offer.as_ref().clone()),
811            )
812            .await?;
813        Ok(timings
814            .stage(
815                "install history",
816                joining.bootstrap(bootstrap, &published_at, Some(&routing_encryption)),
817            )
818            .await?)
819    }
820
821    pub(crate) async fn complete_device_join(
822        &self,
823        activation: coven_replication::sync::DeviceJoinActivation,
824        on_progress: &coven_replication::sync::JoiningDeviceJoinProgressObserver,
825    ) -> Result<Config, BootstrapError> {
826        // Opened before the run so the run counts through it. Opening one is
827        // local, so nothing measurable happens ahead of the first stage.
828        let cloud = self.build_cloud_home().await?;
829        let mut timings =
830            StageTimings::counting("Device join completion", cloud.provider_requests());
831        let outcome = Box::pin(self.complete_device_join_staged(
832            cloud,
833            activation,
834            on_progress,
835            &mut timings,
836        ))
837        .await;
838        timings.report();
839        outcome
840    }
841
842    async fn complete_device_join_staged(
843        &self,
844        cloud: Arc<dyn ExactCloudHome>,
845        activation: coven_replication::sync::DeviceJoinActivation,
846        on_progress: &coven_replication::sync::JoiningDeviceJoinProgressObserver,
847        timings: &mut StageTimings,
848    ) -> Result<Config, BootstrapError> {
849        let attempt_id = activation.attempt_id;
850        let pending = self.open_pending_journal()?;
851        let store_dir = self.layout.store_dir(&self.admission.store_id);
852        let completed_config = if store_dir.config_path().exists() {
853            Some(Config::load_from_config_yaml(&store_dir)?)
854        } else {
855            None
856        };
857        if completed_config
858            .as_ref()
859            .is_some_and(|config| config.store_id != self.admission.store_id)
860        {
861            return Err(coven_replication::sync::DeviceJoinError::JournalConflict.into());
862        }
863        let signer = match completed_config.as_ref() {
864            Some(_) => coven_keys::keys::require_identity(self.identity_custody.as_ref())?,
865            None => coven_keys::keys::peek_pending_identity(&self.member_pubkey)?,
866        };
867        let join = timings
868            .stage("open Store storage", self.build_storage(cloud, &signer))
869            .await?;
870        let pending_readiness = pending.observe_joiner_activation_if_pending(&activation)?;
871        let device_id = match (pending_readiness.as_ref(), completed_config.as_ref()) {
872            (Some(readiness), _) => readiness.proof.registration.device_id.to_string(),
873            (None, Some(config)) => config.device_id.clone(),
874            (None, None) => {
875                return Err(coven_replication::sync::DeviceJoinError::JournalConflict.into())
876            }
877        };
878        let db_path = store_dir.db_path();
879        let db = Database::open(
880            &db_path,
881            self.synced_tables.clone(),
882            coven_protocol::blob::BLOB_TOMBSTONE_GRACE,
883            self.transfer_limits,
884            device_id.clone(),
885            self.clock.clone(),
886            self.coven_migration_policy,
887            &self.migrations,
888        )?;
889        let database = coven_database::StoreDatabase::from_database(db.clone());
890        let routing_encryption = EncryptionService::from(join.keyring.clone());
891        let observation = timings
892            .stage(
893                "read Store root",
894                coven_replication::sync::store::PendingDeviceJoinObservation::open(
895                    &pending,
896                    &join.storage,
897                    &self.admission.store_root,
898                    attempt_id,
899                ),
900            )
901            .await?;
902        let mut joining = timings
903            .stage(
904                "load membership",
905                observation.into_joining_store(
906                    database,
907                    &store_dir,
908                    signer.clone(),
909                    Some(join.membership.clone()),
910                ),
911            )
912            .await?;
913        on_progress(coven_replication::sync::JoiningDeviceJoinProgress::CatchingUp);
914        timings
915            .stage(
916                "pull history",
917                joining.pull_store_history(Some(&routing_encryption)),
918            )
919            .await?;
920        let joined = timings
921            .stage(
922                "materialize activation",
923                joining.materialize(activation.clone()),
924            )
925            .await?;
926        if pending_readiness
927            .as_ref()
928            .is_some_and(|readiness| joined.registration != readiness.proof.registration)
929            || joined.registration.device_id.to_string() != device_id
930        {
931            return Err(coven_replication::sync::DeviceJoinError::JournalConflict.into());
932        }
933        on_progress(coven_replication::sync::JoiningDeviceJoinProgress::SavingLibrary);
934        self.custody.persist(&join.keyring)?;
935        self.identity_custody.establish(&signer)?;
936        if let Some(credentials) = derive_credentials(&self.admission.join_info) {
937            self.store_keys.set_cloud_home_credentials(&credentials)?;
938        }
939        let cipher = CloudCipher::Encrypted(join.keyring.clone().into());
940        let mut config = super::build_config(
941            &self.admission.store_id,
942            &device_id,
943            &self.admission.store_name,
944            &self.admission.join_info,
945            &cipher,
946        );
947        config.cloud_home.exact_upload_verification = self.exact_upload_verification;
948        config.save_to_config_yaml(&store_dir)?;
949        timings
950            .stage("close join journal", joining.complete(activation))
951            .await?;
952        coven_keys::keys::discard_pending_identity(&self.member_pubkey)?;
953        info!(store_id = %self.admission.store_id, "joined Store device");
954        Ok(config)
955    }
956
957    pub(crate) async fn install_same_principal_device_join(
958        &self,
959        join: coven_replication::sync::SamePrincipalDeviceJoin,
960        on_progress: &coven_replication::sync::JoiningDeviceJoinProgressObserver,
961        cancel: &watch::Receiver<bool>,
962    ) -> Result<Config, BootstrapError> {
963        // Opened before the run so the run counts through it. Opening one is
964        // local, so nothing measurable happens ahead of the first stage.
965        let cloud = self.build_cloud_home().await?;
966        let mut timings =
967            StageTimings::counting("Same-provider device join", cloud.provider_requests());
968        let outcome = Box::pin(self.install_same_principal_device_join_staged(
969            cloud,
970            join,
971            on_progress,
972            cancel,
973            &mut timings,
974        ))
975        .await;
976        timings.report();
977        outcome
978    }
979
980    async fn install_same_principal_device_join_staged(
981        &self,
982        cloud: Arc<dyn ExactCloudHome>,
983        join: coven_replication::sync::SamePrincipalDeviceJoin,
984        on_progress: &coven_replication::sync::JoiningDeviceJoinProgressObserver,
985        cancel: &watch::Receiver<bool>,
986        timings: &mut StageTimings,
987    ) -> Result<Config, BootstrapError> {
988        join.verify_shape()
989            .map_err(coven_replication::sync::DeviceJoinError::from)?;
990        let offer = &join.bootstrap.bootstrap.request.approval().request.offer;
991        self.require_offer(offer)?;
992        if *cancel.borrow() {
993            return Err(BootstrapError::Cancelled);
994        }
995        let pending = self.open_pending_journal()?;
996        let store_dir = self.layout.store_dir(&self.admission.store_id);
997        if store_dir.config_path().exists() {
998            return Err(BootstrapError::StoreExists(self.admission.store_id.clone()));
999        }
1000        let signer = coven_keys::keys::peek_pending_identity(&offer.member_pubkey)?;
1001        let storage = timings
1002            .stage("open Store storage", self.build_storage(cloud, &signer))
1003            .await?;
1004        store_dir.ensure_created()?;
1005        let prepared = timings
1006            .stage(
1007                "download snapshot",
1008                PreparedDeviceJoinSnapshot::prepare(
1009                    &storage.storage,
1010                    (*join.installation).clone(),
1011                    supported_version(&self.migrations),
1012                    &store_dir.db_path(),
1013                    on_progress,
1014                    cancel,
1015                ),
1016            )
1017            .await?;
1018        if *cancel.borrow() {
1019            return Err(BootstrapError::Cancelled);
1020        }
1021        on_progress(coven_replication::sync::JoiningDeviceJoinProgress::InstallingSnapshot);
1022        let routing_encryption = EncryptionService::from(storage.keyring.clone());
1023        let device_id = join
1024            .bootstrap
1025            .bootstrap
1026            .request
1027            .expected_registration()
1028            .device_id
1029            .to_string();
1030        let installed = timings
1031            .stage("install snapshot", async {
1032                prepared.install(
1033                    self.synced_tables.clone(),
1034                    coven_protocol::blob::BLOB_TOMBSTONE_GRACE,
1035                    self.transfer_limits,
1036                    device_id.clone(),
1037                    self.clock.clone(),
1038                    &self.migrations,
1039                    self.coven_migration_policy,
1040                    &routing_encryption,
1041                )
1042            })
1043            .await?;
1044        let completion = timings.stage("install history", coven_replication::sync::store::PendingDeviceJoinAuthority::prepare_same_principal_completion(
1045            &pending,
1046            &storage.storage,
1047            &store_dir,
1048            &signer,
1049            join,
1050            installed,
1051            &self.clock.now().to_rfc3339(),
1052            Some(&routing_encryption),
1053            Some(storage.membership.clone()),
1054        ))
1055        .await?;
1056        if completion.joined().registration.device_id.to_string() != device_id {
1057            return Err(coven_replication::sync::DeviceJoinError::JournalConflict.into());
1058        }
1059        on_progress(coven_replication::sync::JoiningDeviceJoinProgress::SavingLibrary);
1060        self.custody.persist(&storage.keyring)?;
1061        self.identity_custody.establish(&signer)?;
1062        if let Some(credentials) = derive_credentials(&self.admission.join_info) {
1063            self.store_keys.set_cloud_home_credentials(&credentials)?;
1064        }
1065        let cipher = CloudCipher::Encrypted(storage.keyring.clone().into());
1066        let mut config = super::build_config(
1067            &self.admission.store_id,
1068            &device_id,
1069            &self.admission.store_name,
1070            &self.admission.join_info,
1071            &cipher,
1072        );
1073        config.cloud_home.exact_upload_verification = self.exact_upload_verification;
1074        config.save_to_config_yaml(&store_dir)?;
1075        timings
1076            .stage("close join journal", completion.complete())
1077            .await?;
1078        coven_keys::keys::discard_pending_identity(&self.member_pubkey)?;
1079        info!(store_id = %self.admission.store_id, "joined Store device");
1080        Ok(config)
1081    }
1082
1083    fn require_offer(
1084        &self,
1085        offer: &coven_replication::sync::DeviceJoinOffer,
1086    ) -> Result<(), BootstrapError> {
1087        if offer.store_root != self.admission.store_root
1088            || offer.member_pubkey != self.member_pubkey
1089        {
1090            return Err(coven_replication::sync::DeviceJoinError::OfferMismatch.into());
1091        }
1092        Ok(())
1093    }
1094
1095    fn open_pending_journal(
1096        &self,
1097    ) -> Result<coven_replication::sync::DeviceJoinJournalDatabase, BootstrapError> {
1098        let directory = self.layout.stores_root().join(".pending-device-joins");
1099        Ok(coven_replication::sync::DeviceJoinJournalDatabase::open(
1100            directory.join(format!("{}.sqlite", self.admission.store_id)),
1101        )?)
1102    }
1103
1104    /// Open the home this join reads through, counting every operation asked
1105    /// of it.
1106    ///
1107    /// A join opens two storages over this one home — a plaintext one to pin
1108    /// the Store root and walk the membership chain, then an encrypted one for
1109    /// everything after — so counting here rather than per storage is what puts
1110    /// the whole join's operations in one running total. The home a test
1111    /// supplies is left alone and counts nothing.
1112    async fn build_cloud_home(&self) -> Result<Arc<dyn ExactCloudHome>, BootstrapError> {
1113        #[cfg(any(test, feature = "test-utils"))]
1114        if let Some(home) = &self.test_home {
1115            return Ok(home.clone());
1116        }
1117        let home = build_cloud_home_for_join(
1118            &self.admission.join_info,
1119            &self.store_keys,
1120            &self.cloud_homes,
1121            self.oauth_tokens.clone(),
1122            self.cloudkit_ops.clone(),
1123            self.clock.clone(),
1124            self.exact_upload_verification,
1125        )
1126        .await?;
1127        Ok(Arc::new(coven_storage::cloud::CountingCloudHome::new(home)))
1128    }
1129
1130    /// Plaintext storage over the joining device's cloud home.
1131    ///
1132    /// Both the pre-key bootstrap reads and the device-join transport go
1133    /// through this: the transport's objects carry their own per-attempt seal,
1134    /// so they need no store key — which is what lets a joiner publish its
1135    /// access request before it has unwrapped the store keyring at all.
1136    pub(super) async fn transport_storage(&self) -> Result<CloudSyncConnection, BootstrapError> {
1137        let signer = coven_keys::keys::peek_pending_identity(&self.member_pubkey)?;
1138        let cloud = self.build_cloud_home().await?;
1139        self.plaintext_storage(cloud, &signer)
1140    }
1141
1142    fn plaintext_storage(
1143        &self,
1144        home: Arc<dyn ExactCloudHome>,
1145        signer: &UserKeypair,
1146    ) -> Result<CloudSyncConnection, BootstrapError> {
1147        Ok(CloudSyncConnection::new(
1148            home,
1149            CloudCipher::Plaintext,
1150            BlobPathScheme::for_storage(HomeStorage::Opaque),
1151            self.admission.store_id.clone(),
1152            signer.clone(),
1153        ))
1154    }
1155
1156    /// Unwrap the store keyring over the home this join already opened.
1157    ///
1158    /// Timed as one step by its callers, where it has been the second-largest
1159    /// on a live join. Constructing the home is local — no bucket check, no
1160    /// auth probe — and its caller does it, so all of this time is the two
1161    /// reads that pin the Store root and its founder, the membership rollup,
1162    /// the membership chain walk, and the wrapped-key reads.
1163    ///
1164    /// The walk used to grow with the Store's whole membership history: a
1165    /// listing and a read per head, then a read per entry, back to the founding
1166    /// entry. It now takes everything up to the newest published snapshot's
1167    /// membership frontier from that snapshot's rollup, in one read, and walks
1168    /// the provider only for what was published after it — so what is left is
1169    /// the probe that finds each stream's end, and the tail itself.
1170    async fn build_storage(
1171        &self,
1172        cloud: Arc<dyn ExactCloudHome>,
1173        signer: &UserKeypair,
1174    ) -> Result<DeviceJoinStorage, BootstrapError> {
1175        let mut timings =
1176            StageTimings::counting("Device join Store storage", cloud.provider_requests());
1177        let outcome = Box::pin(self.build_storage_staged(cloud, signer, &mut timings)).await;
1178        timings.report();
1179        outcome
1180    }
1181
1182    async fn build_storage_staged(
1183        &self,
1184        cloud: Arc<dyn ExactCloudHome>,
1185        signer: &UserKeypair,
1186        timings: &mut StageTimings,
1187    ) -> Result<DeviceJoinStorage, BootstrapError> {
1188        let bootstrap_storage = self.plaintext_storage(cloud.clone(), signer)?;
1189        let recipient = hex::encode(signer.public_key());
1190        if self.admission.wrapped_key.recipient_pubkey != recipient {
1191            return Err(
1192                coven_replication::sync::store::MembershipMutationError::Crypto(
1193                    "admission wrapped-key ref names another recipient".to_string(),
1194                )
1195                .into(),
1196            );
1197        }
1198        self.admission
1199            .membership_floor
1200            .validate()
1201            .map_err(coven_replication::sync::store::MembershipMutationError::MembershipFloor)?;
1202        let mut history = timings
1203            .stage(
1204                "pin the Store root",
1205                coven_replication::sync::store::HistoryConstructionAuthority::admission()
1206                    .open_pinned(&bootstrap_storage, &self.admission.store_root),
1207            )
1208            .await
1209            .map_err(coven_replication::sync::store::MembershipMutationError::from)?;
1210        // What the walk below would otherwise fetch two round trips at a time,
1211        // in one read. Advisory: a Store with no published rollup, or one that
1212        // does not authenticate, leaves the walk exactly as it was.
1213        timings
1214            .stage(
1215                "read the membership rollup",
1216                history.adopt_published_membership_rollup(),
1217            )
1218            .await;
1219        let chain = timings
1220            .stage(
1221                "walk the membership chain",
1222                history.load_exact_anchored_membership(
1223                    &self.admission.membership_floor.0,
1224                    Some(&self.admission.owner_pubkey),
1225                ),
1226            )
1227            .await
1228            .map_err(coven_replication::sync::store::MembershipMutationError::from)?;
1229        let encryption = timings
1230            .stage(
1231                "open the keyring",
1232                coven_replication::sync::store::StoreKeyrings::new(
1233                    &bootstrap_storage,
1234                    self.admission.store_root.clone(),
1235                )
1236                .open_containing(signer, &chain, &self.admission.wrapped_key),
1237            )
1238            .await?;
1239        let keyring = MasterKeyring::from(encryption.clone());
1240        let storage = CloudSyncConnection::new(
1241            cloud,
1242            CloudCipher::Encrypted(encryption),
1243            BlobPathScheme::for_storage(HomeStorage::Opaque),
1244            self.admission.store_id.clone(),
1245            signer.clone(),
1246        );
1247        Ok(DeviceJoinStorage {
1248            storage: Arc::new(storage),
1249            keyring,
1250            membership: chain,
1251        })
1252    }
1253}
1254
1255/// The credentials to persist for this join, or `None` when the provider needs
1256/// no stored secret (OAuth tokens are already saved; CloudKit uses the container).
1257pub(crate) fn derive_credentials(join_info: &CloudHomeJoinInfo) -> Option<CloudHomeCredentials> {
1258    match join_info {
1259        CloudHomeJoinInfo::S3 {
1260            access_key,
1261            secret_key,
1262            ..
1263        } => Some(CloudHomeCredentials::S3 {
1264            access_key: access_key.clone(),
1265            secret_key: secret_key.clone(),
1266        }),
1267        _ => None,
1268    }
1269}
1270
1271#[cfg(test)]
1272#[path = "client_tests.rs"]
1273mod tests;