Skip to main content

agentics_domain/models/
pioneer_codes.rs

1//! Typed pioneer-code values used to gate MVP agent registration.
2
3use std::borrow::Cow;
4use std::fmt;
5use std::str::FromStr;
6
7use rand::Rng;
8use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
9use secrecy::{ExposeSecret, SecretString};
10use serde::{Deserialize, Deserializer, Serialize, Serializer};
11
12/// Generic text returned for codes that cannot currently be consumed.
13pub const INVALID_OR_UNAVAILABLE_PIONEER_CODE: &str = "invalid or unavailable pioneer code";
14
15/// Error returned when a pioneer-code string violates the public grammar.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct PioneerCodeError;
18
19/// Lifecycle state for an admin-created pioneer code.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
21#[serde(rename_all = "snake_case")]
22pub enum PioneerCodeStatus {
23    Active,
24    Revoked,
25}
26
27impl PioneerCodeStatus {
28    /// Stable database string for a pioneer-code lifecycle state.
29    pub fn as_str(self) -> &'static str {
30        match self {
31            Self::Active => "active",
32            Self::Revoked => "revoked",
33        }
34    }
35
36    /// Parse a stable database string for a pioneer-code lifecycle state.
37    pub fn from_storage_value(value: &str) -> Option<Self> {
38        match value {
39            "active" => Some(Self::Active),
40            "revoked" => Some(Self::Revoked),
41            _ => None,
42        }
43    }
44}
45
46impl fmt::Display for PioneerCodeStatus {
47    /// Format the pioneer-code status as its stable persisted and wire value.
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        f.write_str(self.as_str())
50    }
51}
52
53/// Registration flow recorded for a consumed pioneer code.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
55#[serde(rename_all = "snake_case")]
56pub enum PioneerCodeUseKind {
57    HumanGithubSignIn,
58    AgentApi,
59}
60
61impl PioneerCodeUseKind {
62    /// Stable database string for a pioneer-code use.
63    pub fn as_str(self) -> &'static str {
64        match self {
65            Self::HumanGithubSignIn => "human_github_sign_in",
66            Self::AgentApi => "agent_api",
67        }
68    }
69
70    /// Parse the stable database string for a pioneer-code use.
71    pub fn from_storage_value(value: &str) -> Option<Self> {
72        match value {
73            "human_github_sign_in" => Some(Self::HumanGithubSignIn),
74            "agent_api" => Some(Self::AgentApi),
75            _ => None,
76        }
77    }
78}
79
80impl fmt::Display for PioneerCodeUseKind {
81    /// Format the use kind as its stable persisted and wire value.
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        f.write_str(self.as_str())
84    }
85}
86
87/// Subject kind recorded for a consumed pioneer code.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
89#[serde(rename_all = "snake_case")]
90pub enum PioneerCodeSubjectKind {
91    Human,
92    Agent,
93}
94
95impl PioneerCodeSubjectKind {
96    /// Stable database string for a pioneer-code subject.
97    pub fn as_str(self) -> &'static str {
98        match self {
99            Self::Human => "human",
100            Self::Agent => "agent",
101        }
102    }
103
104    /// Parse a stable database string for a pioneer-code subject.
105    pub fn from_storage_value(value: &str) -> Option<Self> {
106        match value {
107            "human" => Some(Self::Human),
108            "agent" => Some(Self::Agent),
109            _ => None,
110        }
111    }
112}
113
114impl fmt::Display for PioneerCodeSubjectKind {
115    /// Format the subject kind as its stable persisted and wire value.
116    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117        f.write_str(self.as_str())
118    }
119}
120
121impl fmt::Display for PioneerCodeError {
122    /// Writes the stable validation error without revealing code contents.
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        f.write_str(
125            "pioneer_code must be 8 lowercase hex chars or <label>-<8 lowercase hex chars>; label may use lowercase letters, digits, or _ and must be at most 6 chars",
126        )
127    }
128}
129
130impl std::error::Error for PioneerCodeError {}
131
132/// Secret registration code supplied by agents and creator GitHub sign-in users.
133#[derive(Clone)]
134pub struct PioneerCode(SecretString);
135
136impl PioneerCode {
137    /// Parse a code and retain it in a redacted secret wrapper.
138    pub fn try_new(value: impl Into<String>) -> Result<Self, PioneerCodeError> {
139        let value = value.into();
140        validate_pioneer_code(&value)?;
141        Ok(Self(SecretString::from(value)))
142    }
143
144    /// Generate a random code, optionally prefixed by a validated label.
145    pub fn generate(label: Option<&str>) -> Result<Self, PioneerCodeError> {
146        let mut bytes = [0u8; 4];
147        rand::rng().fill_bytes(&mut bytes);
148        let random_hex = hex::encode(bytes);
149        let code = match label {
150            Some(label) => {
151                validate_pioneer_label(label)?;
152                format!("{label}-{random_hex}")
153            }
154            None => random_hex,
155        };
156        Self::try_new(code)
157    }
158
159    /// Expose the code at the boundary that must hash or transmit it.
160    pub fn expose_secret(&self) -> &str {
161        self.0.expose_secret()
162    }
163
164    /// Return the optional label encoded in the code display text.
165    pub fn label(&self) -> Option<&str> {
166        self.expose_secret()
167            .split_once('-')
168            .map(|(label, _random)| label)
169    }
170}
171
172/// Redacted pioneer-code input accepted at public registration boundaries.
173#[derive(Clone)]
174pub struct PioneerCodeInput(SecretString);
175
176impl PioneerCodeInput {
177    /// Store a raw code candidate without validating its public grammar.
178    pub fn try_new(value: impl Into<String>) -> Result<Self, PioneerCodeError> {
179        Ok(Self(SecretString::from(value.into())))
180    }
181
182    /// Expose the raw code only where it must be validated, hashed, or sent.
183    pub fn expose_secret(&self) -> &str {
184        self.0.expose_secret()
185    }
186}
187
188impl fmt::Debug for PioneerCodeInput {
189    /// Redact the code from debug output.
190    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191        f.write_str("PioneerCodeInput([redacted])")
192    }
193}
194
195impl Serialize for PioneerCodeInput {
196    /// Serialize the secret at the outgoing HTTP request boundary.
197    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
198    where
199        S: Serializer,
200    {
201        serializer.serialize_str(self.expose_secret())
202    }
203}
204
205impl<'de> Deserialize<'de> for PioneerCodeInput {
206    /// Deserialize the raw secret without exposing grammar-specific failures.
207    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
208    where
209        D: Deserializer<'de>,
210    {
211        let value = String::deserialize(deserializer)?;
212        Self::try_new(value).map_err(serde::de::Error::custom)
213    }
214}
215
216impl JsonSchema for PioneerCodeInput {
217    /// Keep the boundary-input schema inline as a plain string.
218    fn inline_schema() -> bool {
219        true
220    }
221
222    /// Return the schema component name used by generated clients.
223    fn schema_name() -> Cow<'static, str> {
224        "PioneerCodeInput".into()
225    }
226
227    /// Describe only the wire type for public registration inputs.
228    fn json_schema(_: &mut SchemaGenerator) -> Schema {
229        json_schema!({ "type": "string" })
230    }
231}
232
233impl fmt::Debug for PioneerCode {
234    /// Redact the code from debug output.
235    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236        f.write_str("PioneerCode([redacted])")
237    }
238}
239
240impl fmt::Display for PioneerCode {
241    /// Redact the code from display output.
242    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
243        f.write_str("[redacted pioneer code]")
244    }
245}
246
247impl FromStr for PioneerCode {
248    type Err = PioneerCodeError;
249
250    /// Parse a pioneer code from its wire string.
251    fn from_str(value: &str) -> Result<Self, Self::Err> {
252        Self::try_new(value.to_string())
253    }
254}
255
256impl Serialize for PioneerCode {
257    /// Serialize the secret at the outgoing HTTP request boundary.
258    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
259    where
260        S: Serializer,
261    {
262        serializer.serialize_str(self.expose_secret())
263    }
264}
265
266impl<'de> Deserialize<'de> for PioneerCode {
267    /// Deserialize and validate the incoming secret code.
268    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
269    where
270        D: Deserializer<'de>,
271    {
272        let value = String::deserialize(deserializer)?;
273        Self::try_new(value).map_err(serde::de::Error::custom)
274    }
275}
276
277impl JsonSchema for PioneerCode {
278    /// Keep the code schema inline so request DTOs stay string-shaped.
279    fn inline_schema() -> bool {
280        true
281    }
282
283    /// Return the schema component name used by generated clients.
284    fn schema_name() -> Cow<'static, str> {
285        "PioneerCode".into()
286    }
287
288    /// Describe the public code grammar without exposing examples from storage.
289    fn json_schema(_: &mut SchemaGenerator) -> Schema {
290        json_schema!({
291            "type": "string",
292            "pattern": "^([a-z0-9_]{1,6}-)?[0-9a-f]{8}$"
293        })
294    }
295}
296
297/// Validate and normalize no part of a supplied code.
298fn validate_pioneer_code(value: &str) -> Result<(), PioneerCodeError> {
299    if let Some((label, random_hex)) = value.split_once('-') {
300        if random_hex.contains('-') {
301            return Err(PioneerCodeError);
302        }
303        validate_pioneer_label(label)?;
304        validate_random_hex(random_hex)?;
305    } else {
306        validate_random_hex(value)?;
307    }
308    Ok(())
309}
310
311/// Validate the optional human-selected prefix that is part of the code.
312fn validate_pioneer_label(label: &str) -> Result<(), PioneerCodeError> {
313    if label.is_empty() || label.len() > 6 {
314        return Err(PioneerCodeError);
315    }
316    if !label
317        .bytes()
318        .all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'_'))
319    {
320        return Err(PioneerCodeError);
321    }
322    Ok(())
323}
324
325/// Validate the random suffix carried by every pioneer code.
326fn validate_random_hex(random_hex: &str) -> Result<(), PioneerCodeError> {
327    if random_hex.len() != 8 {
328        return Err(PioneerCodeError);
329    }
330    if !random_hex
331        .bytes()
332        .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
333    {
334        return Err(PioneerCodeError);
335    }
336    Ok(())
337}
338
339#[cfg(test)]
340mod tests {
341    use super::PioneerCode;
342
343    /// Verifies accepted pioneer-code grammar variants.
344    #[test]
345    fn accepts_plain_and_labeled_codes() {
346        let plain = PioneerCode::try_new("deadbeef").expect("plain code should parse");
347        assert_eq!(plain.expose_secret(), "deadbeef");
348        assert_eq!(plain.label(), None);
349
350        let labeled = PioneerCode::try_new("jack_1-deadbeef").expect("labeled code should parse");
351        assert_eq!(labeled.expose_secret(), "jack_1-deadbeef");
352        assert_eq!(labeled.label(), Some("jack_1"));
353    }
354
355    /// Verifies invalid code forms are rejected before hashing or storage.
356    #[test]
357    fn rejects_invalid_codes() {
358        for value in [
359            "",
360            "DEADBEEF",
361            "deadbee",
362            "deadbeef00",
363            "labeltoolong-deadbeef",
364            "bad-label-deadbeef",
365            "bad!-deadbeef",
366            "-deadbeef",
367            "jack-DEADBEEF",
368            "jack-deadbee!",
369        ] {
370            assert!(PioneerCode::try_new(value).is_err(), "{value}");
371        }
372    }
373
374    /// Verifies generated labeled codes preserve the requested label.
375    #[test]
376    fn generated_labeled_code_keeps_label() {
377        let code = PioneerCode::generate(Some("jack")).expect("generated code should be valid");
378        assert_eq!(code.label(), Some("jack"));
379        assert!(code.expose_secret().starts_with("jack-"));
380    }
381
382    /// Verifies serde keeps the public wire shape as a JSON string.
383    #[test]
384    fn serde_uses_string_wire_shape() {
385        let code: PioneerCode =
386            serde_json::from_str("\"deadbeef\"").expect("valid code should deserialize");
387        assert_eq!(code.expose_secret(), "deadbeef");
388        assert_eq!(
389            serde_json::to_string(&code).expect("code should serialize"),
390            "\"deadbeef\""
391        );
392    }
393}