Skip to main content

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.