act_credentials/store.rs
1use crate::record::{SecretInfo, SecretRecord};
2
3#[derive(Debug, thiserror::Error)]
4pub enum StoreError {
5 #[error("credential store io: {0}")]
6 Io(#[from] std::io::Error),
7 #[error("credential store encoding: {0}")]
8 Encoding(String),
9 #[error("credential store is read-only; {0}")]
10 ReadOnly(String),
11 #[error("credential store unavailable: {0}")]
12 Unavailable(String),
13}
14
15/// Keys are `(component, key)`. `component` is the resolved reference the
16/// operator used, which is what makes the profile a per-component namespace.
17pub trait CredentialStore: Send + Sync {
18 fn get(&self, component: &str, key: &str) -> Result<Option<SecretRecord>, StoreError>;
19 fn put(&self, component: &str, key: &str, rec: &SecretRecord) -> Result<(), StoreError>;
20 fn erase(&self, component: &str, key: &str) -> Result<(), StoreError>;
21 /// Metadata only, never values. `None` lists every component.
22 fn list(&self, component: Option<&str>) -> Result<Vec<SecretInfo>, StoreError>;
23 /// The components holding at least one credential.
24 ///
25 /// `list(None)` flattens the profile away, which is the one thing a
26 /// listing across components must not lose: profile keys are normalised
27 /// (`act-cli`'s `resolve::profile_key`), so this is also how an operator
28 /// sees which key a `set` actually landed under. Component names, like
29 /// `SecretInfo`, cannot carry a value.
30 fn components(&self) -> Result<Vec<String>, StoreError>;
31
32 /// Read, mutate and write one record **atomically against other processes**.
33 ///
34 /// `Ok(None)` when the key does not exist; the mutation is not run.
35 ///
36 /// This exists because `get` then `put` is not the same thing. Two `act`
37 /// processes sharing the default store โ two MCP servers under one client
38 /// is the ordinary case โ can interleave there, and the loser's write wins
39 /// with data read before the winner's. For an OAuth refresh that is not a
40 /// lost update but a dead credential: with refresh-token rotation the
41 /// overwritten token is the only one the server still honours, and the user
42 /// is sent back to `act login` with nothing to explain it.
43 ///
44 /// An implementation MUST hold an exclusive advisory lock across the whole
45 /// read-modify-write, and the caller MUST re-check inside the closure what
46 /// it decided outside it: by the time the lock is held, the work may already
47 /// have been done by whoever held it first.
48 fn update(
49 &self,
50 component: &str,
51 key: &str,
52 mutate: &mut dyn FnMut(&mut SecretRecord),
53 ) -> Result<Option<SecretRecord>, StoreError>;
54}
55
56// There is deliberately no `writable()`. It existed for the read-only reference
57// backends of design ยง7.1, which are an open question rather than a feature, and
58// nothing consulted it: `put` and `erase` never asked, so a backend answering
59// `false` would have been written to anyway. A trait method that every
60// implementor must write, that no caller reads, and that reads as a promise the
61// crate does not keep is worse than its absence. It comes back with the first
62// backend that can answer `false` โ and with the check that honours it.