Skip to main content

codlet_core/store/
code.rs

1//! Code storage trait (RFC-005).
2//!
3//! Adapters must implement [`CodeStore`] and prove atomic single-winner claim
4//! behaviour by running the conformance suite (RFC-023).
5
6use crate::hashing::{KeyVersion, LookupKey};
7use crate::secret::{CodeId, SubjectId};
8use crate::state::ClaimOutcome;
9
10use super::error::StoreError;
11
12/// Record returned by a successful `find_redeemable` call.
13#[derive(Debug, Clone)]
14pub struct RedeemableCode {
15    /// Opaque record identifier (not a secret, safe for logs and audit).
16    pub id: CodeId,
17    /// The lookup key version under which this code was stored. Needed to
18    /// re-derive the comparison candidate during claim.
19    pub key_version: KeyVersion,
20    /// Opaque host-owned grant payload, returned after a won claim.
21    pub grant: Option<String>,
22    /// Optional scope label set at issuance; restricts claim to matching scope.
23    pub scope: Option<String>,
24    /// Expiry as Unix seconds (UTC).
25    pub expires_at: u64,
26}
27
28/// Parameters for inserting a new code record.
29pub struct CodeRecord {
30    /// Storage identifier (caller-assigned; UUID recommended).
31    pub id: CodeId,
32    /// Domain-separated HMAC of the normalized code (never the plaintext).
33    pub lookup_key: LookupKey,
34    /// Key version that produced `lookup_key`.
35    pub key_version: KeyVersion,
36    /// Optional host-owned purpose label (e.g. `"redeem_invite"`).
37    pub purpose: Option<String>,
38    /// Optional scope key (e.g. a community ID).
39    pub scope: Option<String>,
40    /// Optional opaque grant returned to the host after a won claim.
41    pub grant: Option<String>,
42    /// Record creation time as Unix seconds (UTC).
43    pub created_at: u64,
44    /// Expiry as Unix seconds (UTC).
45    pub expires_at: u64,
46}
47
48/// Parameters for a claim attempt.
49pub struct ClaimRequest<'a> {
50    /// The record to attempt to claim (from `find_redeemable`).
51    pub code_id: &'a CodeId,
52    /// The subject that is claiming this code. Stored on the record for audit.
53    pub subject: &'a SubjectId,
54    /// Current time as Unix seconds (UTC). Used as `used_at` and in the
55    /// expiry guard of the conditional UPDATE.
56    pub now: u64,
57    /// Optional purpose label checked against the stored purpose.
58    pub purpose: Option<&'a str>,
59    /// Optional scope checked against the stored scope.
60    pub scope: Option<&'a str>,
61}
62
63/// Atomic, single-winner code storage (RFC-005).
64///
65/// Implementors must guarantee:
66///
67/// - `find_redeemable` never returns expired, used, or revoked records;
68/// - `claim_code` uses a conditional UPDATE (not read-then-write); the
69///   affected-row count is exactly 1 for a winner and 0 for all others;
70/// - `changed > 1` is surfaced as [`StoreError::InvariantViolation`], not
71///   silently mapped to `Lost`.
72pub trait CodeStore {
73    /// Look up a redeemable code by its HMAC lookup key candidates.
74    ///
75    /// Returns the first record that matches any candidate key and is currently
76    /// redeemable (not used, revoked, or expired at `now`). Returns `Ok(None)`
77    /// if no such record exists.
78    fn find_redeemable(
79        &self,
80        candidates: &[LookupKey],
81        now: u64,
82        scope: Option<&str>,
83    ) -> impl Future<Output = Result<Option<RedeemableCode>, StoreError>>;
84
85    /// Attempt to atomically claim a code record.
86    ///
87    /// The adapter must execute a conditional UPDATE and classify via
88    /// [`crate::state::classify_claim`]. Returns [`ClaimOutcome::Won`] if and
89    /// only if exactly one row was updated.
90    fn claim_code(
91        &self,
92        req: &ClaimRequest<'_>,
93    ) -> impl Future<Output = Result<ClaimOutcome, StoreError>>;
94
95    /// Insert a new code record. Returns [`StoreError`] if the lookup key
96    /// already exists (unique constraint violation on the HMAC column).
97    fn insert_code(&self, record: CodeRecord) -> impl Future<Output = Result<(), StoreError>>;
98
99    /// Revoke a code by its record ID, scoped to `scope` when provided.
100    /// Only affects records that are not yet used or revoked.
101    fn revoke_code(
102        &self,
103        code_id: &CodeId,
104        scope: Option<&str>,
105        now: u64,
106    ) -> impl Future<Output = Result<(), StoreError>>;
107}
108
109use std::future::Future;
110
111/// Compute an `expires_at` Unix timestamp from a now value and a TTL.
112pub fn expires_at_from_ttl(now: u64, ttl: std::time::Duration) -> u64 {
113    now.saturating_add(ttl.as_secs())
114}
115
116/// Derive all candidate lookup keys for a normalized code value.
117///
118/// v0.1 produces only the active-key candidate. Key-rotation multi-candidate
119/// support is introduced in RFC-031.
120pub fn code_lookup_candidates<K: crate::hashing::KeyProvider>(
121    hasher: &crate::hashing::SecretHasher<K>,
122    normalized: &str,
123) -> Vec<(LookupKey, KeyVersion)> {
124    hasher
125        .lookup_key(crate::hashing::SecretDomain::Code, normalized)
126        .map(|(lk, kv)| vec![(lk, kv)])
127        .unwrap_or_default()
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use std::time::Duration;
134
135    #[test]
136    fn expires_at_from_ttl_adds_correctly() {
137        assert_eq!(expires_at_from_ttl(1_000, Duration::from_secs(3600)), 4_600);
138        assert_eq!(
139            expires_at_from_ttl(u64::MAX, Duration::from_secs(1)),
140            u64::MAX
141        );
142    }
143}