Skip to main content

codlet_core/store/
token.rs

1//! Form-token storage trait (RFC-007).
2
3use std::future::Future;
4
5use crate::hashing::{KeyVersion, LookupKey};
6use crate::state::TokenConsumeOutcome;
7
8use super::error::StoreError;
9
10/// The subject binding for a form token (RFC-007 §13.3).
11///
12/// Explicit variants prevent the "empty string for anonymous" anti-pattern
13/// identified in RFC-007 §13.3. Bindings are persisted as part of the token
14/// record and checked on consume.
15#[derive(Debug, Clone, PartialEq, Eq)]
16#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
17pub enum TokenSubject {
18    /// Token issued before authentication (e.g. a join form).
19    Anonymous,
20    /// Token issued for an authenticated subject.
21    Authenticated(crate::secret::SubjectId),
22    /// Token bound to a transient flow ID (e.g. a join ticket).
23    Flow(crate::secret::CodeId),
24}
25
26impl TokenSubject {
27    /// A stable string representation persisted in the store. This is not a
28    /// security boundary on its own; the store's consume WHERE clause enforces
29    /// the binding.
30    #[must_use]
31    pub fn as_binding_str(&self) -> String {
32        match self {
33            TokenSubject::Anonymous => "anon".to_string(),
34            TokenSubject::Authenticated(s) => format!("auth:{}", s.as_str()),
35            TokenSubject::Flow(f) => format!("flow:{}", f.as_str()),
36        }
37    }
38}
39
40/// A consumed token record with an optional replay reference.
41#[derive(Debug, Clone)]
42pub struct ConsumedTokenRecord {
43    /// Whether the token has been consumed.
44    pub consumed: bool,
45    /// Optional result reference for idempotency replay (RFC-007 §4,
46    /// `set_result`). `None` if the result was not yet stored.
47    pub result_ref: Option<String>,
48    /// Whether the binding checked in the consume WHERE clause matched.
49    pub binding_ok: bool,
50}
51
52/// Parameters for inserting a new form token.
53pub struct FormTokenRecord {
54    /// Domain-separated HMAC of the token secret.
55    pub lookup_key: LookupKey,
56    /// Key version that produced `lookup_key`.
57    pub key_version: KeyVersion,
58    /// Subject binding (never an empty string).
59    pub subject: TokenSubject,
60    /// Purpose label, stable across the token's lifetime.
61    pub purpose: String,
62    /// Optional bound resource (HMAC of a domain object, not plaintext).
63    pub bound_resource: Option<String>,
64    /// Issuance time as Unix seconds (UTC).
65    pub issued_at: u64,
66    /// Expiry as Unix seconds (UTC).
67    pub expires_at: u64,
68}
69
70/// Form-token storage (RFC-007).
71///
72/// The consume operation must be atomic: a conditional UPDATE sets `consumed_at`
73/// only when the token is unconsumed, unexpired, and bindings match. The
74/// affected-row count drives [`TokenConsumeOutcome`] via
75/// [`crate::state::classify_token_consume`].
76pub trait FormTokenStore {
77    /// Insert a new form token record.
78    fn insert_form_token(
79        &self,
80        record: FormTokenRecord,
81    ) -> impl Future<Output = Result<(), StoreError>>;
82
83    /// Attempt to atomically consume a form token.
84    ///
85    /// The adapter must:
86    /// 1. Run the conditional UPDATE (sets `consumed_at`).
87    /// 2. If `changed == 0`, run a follow-up SELECT to classify why.
88    /// 3. Call [`crate::state::classify_token_consume`] with the results.
89    /// 4. Return the outcome plus any stored `result_ref` for replays.
90    fn consume_form_token(
91        &self,
92        lookup_key: &LookupKey,
93        subject: &TokenSubject,
94        purpose: &str,
95        bound_resource: Option<&str>,
96        now: u64,
97    ) -> impl Future<Output = Result<(TokenConsumeOutcome, Option<String>), StoreError>>;
98
99    /// Store a result reference on a consumed token for idempotency replay.
100    fn set_token_result(
101        &self,
102        lookup_key: &LookupKey,
103        result_ref: &str,
104    ) -> impl Future<Output = Result<(), StoreError>>;
105}