1//! The credentials Whiskers' outbound adapters need, by name.
2//!
3//! A name here is the one an operator sets it under on **every** backend: an environment variable
4//! natively, a Worker secret on Cloudflare. Hosting differs in where the value is kept and how
5//! protected it is (celld, for one, has no secret store and keeps `vars` in the fleet's bucket); that
6//! is an adapter's property to state, not a different name to learn. The set is closed, so a key that
7//! is not listed here cannot be asked for.
8//!
9//! "Not set" and "set to nothing" are different facts (an operator who put an empty value in has
10//! made a mistake worth saying so, where an absent one is just not configured), so a lookup says which.
11
12use std::fmt;
13
14use crate::error::StoreError;
15
16/// A credential Whiskers can be given.
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
18pub enum SecretName {
19    /// The key for Jev (the guard and the reranker).
20    JevKey,
21    /// The key for the natural voice.
22    VoiceKey,
23    /// Which voice speaks. Not a credential in itself, but it is handled like one: it is never logged.
24    VoiceId,
25}
26
27impl SecretName {
28    pub const ALL: [SecretName; 3] = [SecretName::JevKey, SecretName::VoiceKey, SecretName::VoiceId];
29
30    /// The name an operator sets it under.
31    pub const fn operator_name(self) -> &'static str {
32        match self {
33            SecretName::JevKey => "TYPESAFE_API_KEY",
34            SecretName::VoiceKey => "ELEVENLABS_API_KEY",
35            SecretName::VoiceId => "ELEVENLABS_VOICE_ID",
36        }
37    }
38}
39
40impl fmt::Display for SecretName {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        f.write_str(self.operator_name())
43    }
44}
45
46/// A secret's value. Its `Debug` and `Display` say nothing, so it cannot reach a log by accident;
47/// the only way to the text is [`expose`](Self::expose), which is greppable.
48#[derive(Clone, PartialEq, Eq)]
49pub struct SecretValue(String);
50
51impl SecretValue {
52    pub fn new(value: impl Into<String>) -> Self {
53        Self(value.into())
54    }
55
56    pub fn expose(&self) -> &str {
57        &self.0
58    }
59}
60
61impl fmt::Debug for SecretValue {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        write!(f, "SecretValue(..{} bytes..)", self.0.len())
64    }
65}
66
67/// What a lookup found.
68#[derive(Clone, Debug, PartialEq, Eq)]
69pub enum Lookup {
70    /// Nothing is set under that name.
71    Absent,
72    /// Something is set and it is empty (or only whitespace).
73    Empty,
74    /// A value, with surrounding whitespace removed (a pasted key often brings a newline).
75    Present(SecretValue),
76}
77
78impl Lookup {
79    /// The value, if there is one. Callers that treat "empty" like "absent" say so by calling this.
80    pub fn present(self) -> Option<SecretValue> {
81        match self {
82            Lookup::Present(v) => Some(v),
83            Lookup::Absent | Lookup::Empty => None,
84        }
85    }
86
87    /// Classifies raw text from a store: the one place the rule is written.
88    pub fn of(raw: Option<&str>) -> Self {
89        match raw.map(str::trim) {
90            None => Lookup::Absent,
91            Some("") => Lookup::Empty,
92            Some(v) => Lookup::Present(SecretValue::new(v)),
93        }
94    }
95}
96
97/// Where credentials come from.
98///
99/// Contract: a name that was never set is `Absent`, one set to nothing is `Empty`, anything else is
100/// `Present` and trimmed. A lookup changes nothing. `Err` means the store itself could not be asked,
101/// which is not the same as the secret being absent.
102#[expect(async_fn_in_trait, reason = "a secret store may be a binding that is awaited, and a Worker's futures cannot be Send")]
103pub trait Secrets {
104    async fn get(&self, name: SecretName) -> Result<Lookup, StoreError>;
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn absent_empty_and_present_are_three_different_things() {
113        assert_eq!(Lookup::of(None), Lookup::Absent);
114        assert_eq!(Lookup::of(Some("")), Lookup::Empty);
115        assert_eq!(Lookup::of(Some("  \n")), Lookup::Empty);
116        assert_eq!(Lookup::of(Some(" k\n")), Lookup::Present(SecretValue::new("k")));
117        assert!(Lookup::Empty.present().is_none() && Lookup::Absent.present().is_none());
118    }
119
120    #[test]
121    fn a_secret_never_prints_itself() {
122        let s = SecretValue::new("hunter2");
123        assert!(!format!("{s:?}").contains("hunter2"));
124    }
125
126    #[test]
127    fn the_operator_names_are_the_documented_ones() {
128        assert_eq!(SecretName::JevKey.to_string(), "TYPESAFE_API_KEY");
129        assert_eq!(SecretName::VoiceKey.to_string(), "ELEVENLABS_API_KEY");
130        assert_eq!(SecretName::VoiceId.to_string(), "ELEVENLABS_VOICE_ID");
131    }
132}