Skip to main content

kernel/event/record/
credential.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! How a credential reached this city: the line a vault write leaves,
7//! and the app a toolkit link was asked for.
8//!
9//! **No line here carries a credential.** A capture names the vault
10//! place and never the value; a toolkit link records which app was
11//! asked for and not the consent URL, which is a capability and would be
12//! handed to every reader of a replayable ledger.
13//!
14//! Older builds also wrote `login_started` for a subscription login and
15//! an `expires_at` key on a capture. This build signs in to no
16//! subscription (`crates/gateway/Spec.lean` §8-5): the kind stays in the vocabulary
17//! so such a ledger still reads, nothing reads its payload, and a
18//! capture's `expires_at` is ignored on the way in.
19
20use serde::{Deserialize, Serialize};
21
22use crate::secret::SecretRef;
23
24/// `secret_captured`: a credential entered the vault at this place.
25///
26/// The place is a [`SecretRef`], so a line whose `ref` is outside the
27/// grammar does not read as a capture at all: every reader meets that
28/// as one refusal from [`Payload::read`](super::super::Payload::read)
29/// rather than each deciding for itself what such a line names.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
32pub struct SecretCaptured {
33    /// The vault place the credential is stored under.
34    #[serde(rename = "ref")]
35    #[cfg_attr(feature = "schema", schemars(with = "String"))]
36    pub reference: SecretRef,
37    /// How it arrived, in the writer's words: `enrolment` or `pasted`.
38    /// An older build also wrote `<provider>-subscription` and
39    /// `<provider>-renewal`. Free text, because those spellings carry a
40    /// provider name.
41    #[serde(default)]
42    pub origin: String,
43}
44
45/// `toolkit_link_opened`: the person asked to connect this app.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
48pub struct ToolkitLinkOpened {
49    /// The toolkit's slug.
50    pub toolkit: String,
51}
52
53#[cfg(test)]
54#[allow(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
55mod tests {
56    use super::*;
57    use crate::event::Payload;
58
59    /// The golden bytes: each line as the hand-written writers spelled
60    /// it before these structs existed, so a ledger already on disk
61    /// reads back and a new line is byte-identical to an old one.
62    #[test]
63    fn each_credential_line_keeps_the_bytes_its_ledger_already_holds() {
64        let place = SecretRef::parse("secret:anthropic/api-key").unwrap();
65        let cases: [(Payload, &str); 2] = [
66            (
67                Payload::of(&SecretCaptured {
68                    reference: place,
69                    origin: "enrolment".to_owned(),
70                })
71                .unwrap(),
72                "{\"origin\":\"enrolment\",\"ref\":\"secret:anthropic/api-key\"}",
73            ),
74            (
75                Payload::of(&ToolkitLinkOpened {
76                    toolkit: "gmail".to_owned(),
77                })
78                .unwrap(),
79                "{\"toolkit\":\"gmail\"}",
80            ),
81        ];
82        for (payload, wire) in cases {
83            assert_eq!(serde_json::to_string(&payload).unwrap(), wire);
84            let back: Payload = serde_json::from_str(wire).unwrap();
85            assert_eq!(back, payload);
86        }
87    }
88
89    /// A capture an older build wrote for a subscription token, with its
90    /// `expires_at`, still reads: the key is ignored rather than refused,
91    /// so a ledger that holds one replays (`crates/gateway/Spec.lean` §8-5).
92    #[test]
93    fn a_capture_with_an_expiry_an_older_build_wrote_still_reads() {
94        let payload: Payload = serde_json::from_str(
95            "{\"expires_at\":1700000000000,\"origin\":\"anthropic-subscription\",\"ref\":\"secret:anthropic/oauth\"}",
96        )
97        .unwrap();
98        assert_eq!(
99            payload.read::<SecretCaptured>().unwrap(),
100            SecretCaptured {
101                reference: SecretRef::parse("secret:anthropic/oauth").unwrap(),
102                origin: "anthropic-subscription".to_owned(),
103            }
104        );
105    }
106
107    /// A reference outside the grammar is not a capture, so no reader
108    /// can fold it as one.
109    #[test]
110    fn a_capture_outside_the_reference_grammar_does_not_read() {
111        let payload: Payload =
112            serde_json::from_str("{\"origin\":\"pasted\",\"ref\":\"anthropic/oauth\"}").unwrap();
113        assert!(payload.read::<SecretCaptured>().is_err());
114    }
115}