1use crate::{
4 error::{setup_error, unlock_error},
5 *,
6};
7use coven_crypto::custody::{KeySession, PersonEntries};
8use coven_foundation::locks::lock_unpoisoned;
9use coven_foundation::progress::ProgressReport;
10use coven_storage::{
11 providers::{OAuthExchange, OAuthFlow, StorageConnector},
12 ConnectionCredentials, ProviderAuth, PublicationGate, SetupWaits, Storage, StorageConnection,
13 StorageCredentials,
14};
15use coven_sync::{
16 DeviceCredentials, LastPass, RestoreCodes, SequenceState, SequenceSync, SessionSignIn, SyncLoop,
17};
18use std::{
19 future::Future,
20 sync::{
21 atomic::{AtomicBool, AtomicU64, Ordering},
22 Arc,
23 },
24};
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub enum StoreKeyState {
29 Available,
31 Locked,
33}
34
35#[derive(Clone, Debug)]
37pub struct ConnectedStorage {
38 pub storage: StorageConfig,
40 pub key_state: StoreKeyState,
42}
43
44#[derive(Clone, Debug)]
46pub struct StorageSetup {
47 pub connection: ConnectedStorage,
49 pub restore_code: SecretText,
51}
52
53pub(crate) type SetupProgress = tokio::sync::watch::Sender<Option<Progress<StorageSetupStep>>>;
55
56pub(crate) async fn record_opened(
64 database: &coven_database::Database,
65 clock: &ClockRef,
66 location: coven_database::RecordedLocation<CloudProvider>,
67 finished: Option<std::time::SystemTime>,
68) -> Result<(), SyncError> {
69 coven_sync::record_opened(database, location, finished).await.map_err(SyncError::from)?;
70 coven_sync::InviteExpiry::opened(database, clock.now()).await.map_err(SyncError::from)
71}
72
73pub(crate) struct NoPublications;
75
76impl PublicationGate for NoPublications {
77 fn check(&self, _: &ObjectPath) -> Result<(), StorageError> {
78 Err(StorageFailure::PermissionDenied.with_source("a read-only handle publishes nothing"))
79 }
80}
81
82struct Live {
84 sync: Arc<SequenceSync>,
85 sync_loop: SyncLoop,
86}
87
88enum ConnectionState {
90 Released,
91 Loaded(Arc<StorageConnection>),
92 Active(Live),
93}
94
95struct ConnectionAttempt {
96 epoch: u64,
97 task: crate::task::OwnedTask,
98 #[cfg(test)]
101 parks: coven_sync::Parks,
102}
103
104pub(crate) struct StorageConnections {
105 codes: RestoreCodes,
108 database: coven_database::Database,
109 files: coven_sync::Files,
110 identity: Arc<KeySession<MemberIdentity>>,
111 person: Arc<PersonEntries>,
113 credentials: Arc<DeviceCredentials>,
114 state: Arc<SequenceState>,
116 directory: StoreDir,
117 waits: Arc<coven_sync::RetryWaits>,
118 transfers: Arc<coven_storage::TransferBudget>,
119 keys: std::sync::Mutex<Option<Arc<KeySession<StoreKeyring>>>>,
120 connector: Arc<dyn StorageConnector>,
121 oauth: Option<OAuthFlow>,
122 clients: Option<OAuthExchange>,
124 authentication: tokio::sync::Mutex<Option<Arc<SessionSignIn>>>,
126 live: tokio::sync::Mutex<ConnectionState>,
128 expiry: coven_sync::InviteExpiry,
130 control: std::sync::Mutex<Option<SyncLoop>>,
132 configured: AtomicBool,
135 epoch: AtomicU64,
138 stopped: tokio::sync::Notify,
140 attempts: std::sync::Mutex<Option<ConnectionAttempt>>,
142 device: DeviceId,
143 ids: IdSourceRef,
144 clock: ClockRef,
145 calls: tokio::sync::Mutex<()>,
146 #[cfg(test)]
148 call_waits: tokio::sync::watch::Sender<usize>,
149 #[cfg(test)]
152 close_failure: std::sync::Mutex<Option<SyncError>>,
153 #[cfg(test)]
155 deletion_gate: std::sync::Mutex<Option<Box<dyn FnOnce() + Send>>>,
156 keychain: Arc<coven_crypto::custody::StoreKeychain>,
159 setup: std::sync::Mutex<setup::SetupRun>,
161 setup_cancelled: tokio::sync::Notify,
163 #[cfg(test)]
165 setup_hook: std::sync::Mutex<Option<setup::SetupHook>>,
166}
167
168impl StorageConnections {
169 pub(crate) fn new(
170 database: coven_database::Database,
171 files: coven_sync::Files,
172 identity: Arc<KeySession<MemberIdentity>>,
173 person: Arc<PersonEntries>,
174 credentials: Arc<DeviceCredentials>,
175 codes: RestoreCodes,
176 state: Arc<SequenceState>,
177 directory: StoreDir,
178 waits: Arc<coven_sync::RetryWaits>,
179 transfers: Arc<coven_storage::TransferBudget>,
180 keys: Arc<KeySession<StoreKeyring>>,
181 connector: Arc<dyn StorageConnector>,
182 oauth: Option<OAuthFlow>,
183 clients: Option<OAuthExchange>,
184 authentication: Option<Arc<SessionSignIn>>,
185 storage: Option<Arc<StorageConnection>>,
186 configured: bool,
187 keychain: Arc<coven_crypto::custody::StoreKeychain>,
188 setup_finishing: bool,
189 device: DeviceId,
190 ids: IdSourceRef,
191 clock: ClockRef,
192 ) -> Self {
193 let expiry = coven_sync::InviteExpiry::start(
194 database.clone(),
195 clock.clone(),
196 waits.clone(),
197 state.clone(),
198 );
199 Self {
200 codes,
201 expiry,
202 database,
203 files,
204 identity,
205 person,
206 credentials,
207 state,
208 directory,
209 waits,
210 transfers,
211 keys: std::sync::Mutex::new(Some(keys)),
212 connector,
213 oauth,
214 clients,
215 authentication: tokio::sync::Mutex::new(authentication),
216 live: tokio::sync::Mutex::new(match storage {
217 Some(storage) => ConnectionState::Loaded(storage),
218 None => ConnectionState::Released,
219 }),
220 control: std::sync::Mutex::new(None),
221 configured: AtomicBool::new(configured),
222 epoch: AtomicU64::new(0),
223 stopped: tokio::sync::Notify::new(),
224 attempts: std::sync::Mutex::new(None),
225 device,
226 ids,
227 clock,
228 calls: tokio::sync::Mutex::new(()),
229 #[cfg(test)]
230 call_waits: tokio::sync::watch::channel(0).0,
231 #[cfg(test)]
232 close_failure: std::sync::Mutex::new(None),
233 #[cfg(test)]
234 deletion_gate: std::sync::Mutex::new(None),
235 keychain,
236 setup: std::sync::Mutex::new(match setup_finishing {
237 true => setup::SetupRun::Finishing,
238 false => setup::SetupRun::Idle,
239 }),
240 setup_cancelled: tokio::sync::Notify::new(),
241 #[cfg(test)]
242 setup_hook: std::sync::Mutex::new(None),
243 }
244 }
245
246 async fn ready(&self, live: &mut ConnectionState) -> Result<(), SyncError> {
248 if let ConnectionState::Loaded(storage) = &*live {
249 let storage = storage.clone();
250 self.attach(live, storage).await?;
251 }
252 Ok(())
253 }
254
255 fn store(&self) -> StoreId {
256 self.directory.id()
257 }
258
259 pub(crate) async fn show(&self, failure: LastPass) -> Result<(), SyncError> {
264 failure.record(&self.database).await.map_err(SyncError::from)
265 }
266
267 pub(crate) async fn record_location(
272 &self,
273 location: coven_database::RecordedLocation<CloudProvider>,
274 finished: Option<std::time::SystemTime>,
275 ) -> Result<(), SyncError> {
276 self.configured.store(location.is_connected(), Ordering::SeqCst);
277 coven_sync::record_location(&self.database, location, finished)
278 .await
279 .map_err(SyncError::from)
280 }
281
282 pub(crate) async fn record_custody(&self) -> Result<(), SyncError> {
285 let location = self.credentials.location()?;
286 self.record_location(location, None).await
287 }
288
289 pub(crate) async fn storage_config(&self) -> Result<Option<StorageConfig>, SyncError> {
290 let _call = self.lock_open().await?;
291 self.codes.location().await
292 }
293
294 fn with_keys<T>(
298 &self,
299 use_keys: impl FnOnce(&Arc<KeySession<StoreKeyring>>) -> Result<T, KeyError>,
300 ) -> Result<T, KeyError> {
301 let keys = lock_unpoisoned(&self.keys);
302 use_keys(keys.as_ref().ok_or(KeyError::StoreClosed)?)
303 }
304
305 pub(super) async fn lock_calls(&self) -> tokio::sync::MutexGuard<'_, ()> {
308 #[cfg(test)]
309 match self.calls.try_lock() {
310 Ok(call) => return call,
311 Err(_) => self.call_waits.send_modify(|waits| *waits += 1),
312 }
313 self.calls.lock().await
314 }
315
316 async fn lock_open(&self) -> Result<tokio::sync::MutexGuard<'_, ()>, KeyError> {
317 let call = self.lock_calls().await;
318 self.with_keys(|_| Ok(()))?;
319 Ok(call)
320 }
321
322 async fn call<T, E, F>(
325 self: &Arc<Self>,
326 run: impl FnOnce(Arc<Self>) -> F + Send + 'static,
327 ) -> Result<T, E>
328 where
329 T: Send + 'static,
330 E: From<KeyError>
331 + From<crate::task::TaskFailed>
332 + crate::task::CarriesBug
333 + Send
334 + 'static,
335 F: Future<Output = Result<T, E>> + Send,
336 {
337 let owner = self.clone();
338 let call = async move {
339 let _call = owner.lock_open().await?;
340 run(owner.clone()).await
341 };
342 crate::task::spawn_completing("storage call", self.database.bugs().clone(), call).await?
343 }
344
345 async fn attach(
348 &self,
349 live: &mut ConnectionState,
350 storage: Arc<StorageConnection>,
351 ) -> Result<(), SyncError> {
352 let sync = self.sequence(storage.clone(), live).await?;
353 self.files.set_storage(Some(storage), std::future::ready(Ok(()))).await?;
354 self.activate(live, sync, false).await
355 }
356
357 async fn sequence(
359 &self,
360 storage: Arc<StorageConnection>,
361 previous: &ConnectionState,
362 ) -> Result<Arc<SequenceSync>, SyncError> {
363 if let ConnectionState::Active(previous) = previous {
364 return Ok(Arc::new(previous.sync.reconnect(storage)));
365 }
366 let keys = self.with_keys(|keys| Ok(keys.clone()))?;
367 Ok(Arc::new(
368 SequenceSync::open(
369 self.database.clone(),
370 storage,
371 self.directory.clone(),
372 self.device,
373 keys,
374 self.identity.clone(),
375 self.person.clone(),
376 self.state.clone(),
377 self.clock.clone(),
378 self.ids.clone(),
379 self.waits.clone(),
380 self.files.clone(),
381 )
382 .await?,
383 ))
384 }
385
386 async fn activate(
388 &self,
389 live: &mut ConnectionState,
390 sync: Arc<SequenceSync>,
391 start: bool,
392 ) -> Result<(), SyncError> {
393 let sync_loop = SyncLoop::new(sync.clone(), self.codes.clone());
394 let started = if start { sync_loop.start().await } else { Ok(()) };
395 *lock_unpoisoned(&self.control) = Some(sync_loop.clone());
396 *live = ConnectionState::Active(Live { sync, sync_loop });
397 started
398 }
399
400 async fn with_passes_held<T>(
407 live: &ConnectionState,
408 work: impl std::future::Future<Output = Result<T, SyncError>>,
409 ) -> Result<T, SyncError> {
410 let ConnectionState::Active(previous) = live else {
411 return work.await;
412 };
413 previous.sync_loop.pause().await?;
414 let done = crate::task::caught("work with the passes held", work)
415 .await
416 .unwrap_or_else(|bug| Err(SyncError::Bug(bug)));
417 done.map_err(|error| match previous.sync_loop.resume() {
418 Ok(()) => error,
419 Err(cleanup) => SyncError::cleanup(error, cleanup),
420 })
421 }
422
423 async fn close_live(&self, live: &mut ConnectionState) -> Result<(), SyncError> {
427 match std::mem::replace(live, ConnectionState::Released) {
428 ConnectionState::Active(previous) => {
429 let result = previous.sync_loop.close().await;
430 self.codes.settled().await;
431 result
432 }
433 ConnectionState::Released | ConnectionState::Loaded(_) => Ok(()),
434 }
435 }
436
437 async fn end_loop(
442 &self,
443 live: &mut ConnectionState,
444 ) -> (Result<(), DbError>, Result<(), SyncError>) {
445 let stopped = self.waits.sync_stopped().await;
446 lock_unpoisoned(&self.control).take();
447 (stopped, self.close_live(live).await)
448 }
449
450 async fn release(&self, live: &mut ConnectionState) -> Result<(), SyncError> {
453 let (stopped, closed) = self.end_loop(live).await;
454 let released = self.files.set_storage(None, std::future::ready(Ok(()))).await;
455 closed.and(released).and(stopped.map_err(SyncError::from))
456 }
457
458 pub(crate) async fn connected(&self) -> Result<(), SyncError> {
461 let mut live = self.live.lock().await;
462 if let ConnectionState::Active(_) = &*live {
463 return Ok(());
464 }
465 self.connect(&mut live).await
466 }
467
468 async fn connect(&self, live: &mut ConnectionState) -> Result<(), SyncError> {
472 if self.setup_finishing() {
473 if let Some(journal) = coven_sync::SetupJournal::running(&self.database).await? {
474 return self.resume_setup(live, journal).await;
475 }
476 }
477 if let ConnectionState::Loaded(storage) = live {
478 let storage = storage.clone();
479 return self.attach(live, storage).await;
480 }
481 let data = self.codes.connection().await?.ok_or(SyncError::NoStorage)?;
482 let storage = self
483 .connector
484 .connect(
485 data.location.clone(),
486 self.codes.provider_auth(data.credentials),
487 self.device,
488 self.state.clone(),
489 self.waits.clone(),
490 self.transfers.clone(),
491 )
492 .await?;
493 storage.check_location_claim(self.store()).await?;
494 self.codes.record_labels(&storage.labelled_config().await?).await?;
497 s3::end_detection_runs(&self.database, self.store(), &data.location, &storage).await?;
498 self.attach(live, storage).await
499 }
500
501 pub(crate) fn key_state(&self) -> Result<StoreKeyState, KeyError> {
502 let held = self.with_keys(|keys| keys.read())?;
503 Ok(match held {
504 Some(_) => StoreKeyState::Available,
505 None => StoreKeyState::Locked,
506 })
507 }
508
509 pub(crate) async fn authenticate(&self, access: OAuthAccess) -> Result<(), StorageSetupError> {
510 let _call = self.lock_open().await?;
511 let flow = self.oauth.as_ref().ok_or(OAuthError::Unavailable(access.provider()))?;
512 let tokens = flow.authenticate(access).await?;
513 *self.authentication.lock().await = Some(SessionSignIn::new(tokens, self.clients.clone()));
514 Ok(())
515 }
516
517 pub(crate) async fn setup_oauth(
518 self: &Arc<Self>,
519 storage: StorageConfig,
520 device_name: &str,
521 progress: SetupProgress,
522 ) -> Result<StorageSetup, StorageSetupError> {
523 let device_name = device_name.to_owned();
524 let call = self.start_setup()?;
525 self.call(move |owner| async move {
526 let mut progress = ProgressReport::new(progress, owner.clock.clone());
527 let waits = SetupWaits::default();
528 let provider = storage.provider();
529 if !provider.signs_in_with_oauth() {
530 return Err(OAuthError::Unavailable(provider).into());
531 }
532 owner.check_location(&storage).await?;
533 let mut held = owner.authentication.lock().await;
534 let (auth, sign_in) = match held.as_ref() {
535 Some(sign_in) => {
536 let auth = crate::authentication::held_auth(sign_in, provider)?;
537 (auth, Some(sign_in.clone()))
538 }
539 None => {
540 let data = owner
541 .codes
542 .connection()
543 .await
544 .map_err(setup_error)?
545 .ok_or(OAuthError::Reauthorize(provider))?;
546 if data.location.provider() != provider {
547 return Err(OAuthError::Reauthorize(provider).into());
548 }
549 (owner.codes.provider_auth(data.credentials), None)
550 }
551 };
552 let journal = owner.begin_setup(call, &device_name, &auth.credentials()).await?;
553 let setup = owner.run_setup(journal, async |journal| {
554 owner
555 .check_new_location(journal, &storage, auth.clone(), &waits, &mut progress)
556 .await?;
557 let (connector, cooldown) = (owner.connector.as_ref(), &owner.waits);
558 let (location, signed_in) = (&storage, &auth);
559 let reading = waits.resend(owner.clock.as_ref(), move || {
560 crate::authentication::label_location(
561 connector,
562 location.clone(),
563 signed_in.clone(),
564 cooldown.clone(),
565 )
566 });
567 let storage =
568 setup_step::single(&mut progress, StorageSetupStep::ReadingAccount, reading)
569 .await?;
570 owner.setup_connection(journal, storage, auth, sign_in, waits, &mut progress).await
571 });
572 let result = setup.await?;
573 held.take();
574 Ok(result)
575 })
576 .await
577 }
578
579 pub(crate) async fn setup_cloudkit(
580 self: &Arc<Self>,
581 container: &str,
582 device_name: &str,
583 progress: SetupProgress,
584 ) -> Result<StorageSetup, StorageSetupError> {
585 let container = container.to_owned();
586 let device_name = device_name.to_owned();
587 let call = self.start_setup()?;
588 self.call(move |owner| async move {
589 let mut progress = ProgressReport::new(progress, owner.clock.clone());
590 let waits = SetupWaits::default();
591 let recorded = owner.codes.location().await.map_err(setup_error)?;
592 if recorded.as_ref().is_some_and(|recorded| {
593 !matches!(recorded, StorageConfig::CloudKit { container: kept, .. } if *kept == container)
594 }) {
595 return Err(StorageSetupError::LocationChangeUnsupported);
596 }
597 let journal =
598 owner.begin_setup(call, &device_name, &StorageCredentials::CloudKit).await?;
599 owner
600 .run_setup(journal, async |journal| {
601 let storage = match recorded {
602 Some(storage) => storage,
603 None => {
604 let (connector, container, store) =
608 (owner.connector.as_ref(), container.as_str(), owner.store());
609 let preparing = waits.resend(owner.clock.as_ref(), move || {
610 connector.prepare_cloudkit(container, store)
611 });
612 setup_step::single(
613 &mut progress,
614 StorageSetupStep::PreparingZone,
615 preparing,
616 )
617 .await?
618 }
619 };
620 owner
621 .setup_connection(
622 journal,
623 storage,
624 ProviderAuth::CloudKit,
625 None,
626 waits,
627 &mut progress,
628 )
629 .await
630 })
631 .await
632 })
633 .await
634 }
635
636 async fn check_new_location(
641 &self,
642 journal: &coven_sync::SetupJournal,
643 config: &StorageConfig,
644 auth: ProviderAuth,
645 waits: &SetupWaits,
646 progress: &mut ProgressReport<StorageSetupStep>,
647 ) -> Result<(), StorageSetupError> {
648 if !journal.creating() || !config.holds_one_store() {
649 return Ok(());
650 }
651 let checking = self.connector.check_new_store(config.clone(), auth, self.store(), waits);
652 setup_step::single(progress, StorageSetupStep::CheckingLocation, checking).await?;
653 Ok(())
654 }
655
656 async fn check_location(&self, config: &StorageConfig) -> Result<(), StorageSetupError> {
657 if self
658 .codes
659 .location()
660 .await
661 .map_err(setup_error)?
662 .is_some_and(|recorded| !recorded.same_location(config))
663 {
664 return Err(StorageSetupError::LocationChangeUnsupported);
665 }
666 Ok(())
667 }
668
669 fn persist_keys(&self, ring: &StoreKeyring) -> Result<(), KeyError> {
670 self.with_keys(|keys| keys.persist(ring))
671 }
672
673 fn forget_keys(&self) -> Result<(), KeyError> {
674 self.with_keys(|keys| keys.forget())
675 }
676
677 pub(crate) async fn unlock(self: &Arc<Self>) -> Result<ConnectedStorage, StoreKeyUnlockError> {
678 self.call(|owner| async move {
679 let data = owner
680 .codes
681 .connection()
682 .await
683 .map_err(unlock_error)?
684 .ok_or(StoreKeyUnlockError::NoStorage)?;
685 let config = data.location.clone();
686 let mut live = owner.live.lock().await;
687 owner.release(&mut live).await.map_err(unlock_error)?;
688 owner.connect(&mut live).await.map_err(unlock_error)?;
689 let unlocked = match &*live {
690 ConnectionState::Active(candidate) => candidate.sync.unlock().await,
691 _ => Err(SyncError::NoStorage),
692 };
693 if let Err(error) = unlocked {
694 let error = match owner.release(&mut live).await {
695 Ok(()) => error,
696 Err(cleanup) => SyncError::cleanup(error, cleanup),
697 };
698 let error = match owner.publish_released(Ok(())).await {
699 Ok(()) => error,
700 Err(cleanup) => SyncError::cleanup(error, cleanup),
701 };
702 return Err(unlock_error(error));
703 }
704 owner.publish_released(Ok(())).await.map_err(unlock_error)?;
706 Ok(ConnectedStorage { storage: config, key_state: StoreKeyState::Available })
707 })
708 .await
709 }
710
711 pub(crate) async fn load_joined(
718 self: &Arc<Self>,
719 progress: tokio::sync::watch::Sender<Option<Progress<coven_database::LoadStep>>>,
720 ) -> Result<(), SyncError> {
721 self.call(|owner| async move {
722 let mut live = owner.live.lock().await;
723 if !matches!(&*live, ConnectionState::Active(_)) {
724 owner.connect(&mut live).await?;
725 }
726 let ConnectionState::Active(candidate) = &*live else {
727 return Err(SyncError::NoStorage);
728 };
729 candidate.sync.load_joined(progress).await
730 })
731 .await
732 }
733
734 pub(crate) async fn forget_store_keys(self: &Arc<Self>) -> Result<(), KeyError> {
735 self.call(|owner| async move {
736 let mut live = owner.live.lock().await;
737 let result = async {
738 Self::with_passes_held(
739 &live,
740 owner
741 .files
742 .set_storage(None, async { owner.forget_keys().map_err(SyncError::from) }),
743 )
744 .await?;
745 let (stopped, released) = owner.end_loop(&mut live).await;
746 if let Err(error) = released {
751 owner.show(LastPass::failed(error)).await?;
752 }
753 Ok(stopped?)
754 }
755 .await;
756 result.map_err(|error| match error {
757 SyncError::SecureStorage(error) => error,
758 SyncError::Database(DbError::StoreClosed) => KeyError::StoreClosed,
759 error => match crate::task::CarriesBug::bug(&error) {
762 Some(bug) => KeyError::Bug(bug.clone()),
763 None => KeyError::Unavailable(Box::new(error)),
764 },
765 })
766 })
767 .await
768 }
769}
770
771#[path = "storage_control.rs"]
772mod control;
773
774#[path = "storage_folders.rs"]
775mod folders;
776#[cfg(test)]
777pub(crate) use folders::with_folder_label;
778
779#[path = "storage_s3.rs"]
780mod s3;
781
782#[path = "storage_setup_step.rs"]
783mod setup_step;
784pub use setup_step::StorageSetupStep;
785
786#[path = "storage_setup.rs"]
787mod setup;
788pub(crate) use setup::settle_opened;
789pub use setup::SetupCancellation;
790#[cfg(test)]
791pub(crate) use setup::SetupHook;
792
793#[path = "storage_admin.rs"]
794mod admin;
795pub use admin::GuidedAccessKey;
796
797#[path = "storage_operations.rs"]
798mod operations;
799
800pub(crate) async fn member_access(
803 credentials: &StorageCredentials,
804 storage: &StorageConnection,
805) -> Result<coven_format::MemberAccess, coven_storage::StorageError> {
806 Ok(match credentials {
807 StorageCredentials::S3(keys) => {
808 coven_format::MemberAccess::S3AccessKey { access_key_id: keys.access_key_id.clone() }
809 }
810 _ => coven_format::MemberAccess::ProviderAccount(storage.account().await?),
811 })
812}
813
814pub(crate) fn access_change(
818 state: &coven_database::StoreState,
819 member: MemberId,
820 access: coven_format::MemberAccess,
821) -> Result<Option<coven_format::admin::StoreChange>, SyncError> {
822 let recorded = state.active_member(&member).ok_or(SyncError::Removed)?;
823 Ok((!recorded.access.same_credential(&access))
824 .then_some(coven_format::admin::StoreChange::SetAccess { member, access }))
825}
826
827#[cfg(test)]
828#[path = "storage_tests.rs"]
829mod tests;