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    /// Expiry as Unix seconds (UTC).
43    pub expires_at: u64,
44}
45
46/// Parameters for a claim attempt.
47pub struct ClaimRequest<'a> {
48    /// The record to attempt to claim (from `find_redeemable`).
49    pub code_id: &'a CodeId,
50    /// The subject that is claiming this code. Stored on the record for audit.
51    pub subject: &'a SubjectId,
52    /// Current time as Unix seconds (UTC). Used as `used_at` and in the
53    /// expiry guard of the conditional UPDATE.
54    pub now: u64,
55    /// Optional purpose label checked against the stored purpose.
56    pub purpose: Option<&'a str>,
57    /// Optional scope checked against the stored scope.
58    pub scope: Option<&'a str>,
59}
60
61/// Atomic, single-winner code storage (RFC-005).
62///
63/// Implementors must guarantee:
64///
65/// - `find_redeemable` never returns expired, used, or revoked records;
66/// - `claim_code` uses a conditional UPDATE (not read-then-write); the
67///   affected-row count is exactly 1 for a winner and 0 for all others;
68/// - `changed > 1` is surfaced as [`StoreError::InvariantViolation`], not
69///   silently mapped to `Lost`.
70pub trait CodeStore {
71    /// Look up a redeemable code by its HMAC lookup key candidates.
72    ///
73    /// Returns the first record that matches any candidate key and is currently
74    /// redeemable (not used, revoked, or expired at `now`). Returns `Ok(None)`
75    /// if no such record exists.
76    fn find_redeemable(
77        &self,
78        candidates: &[LookupKey],
79        now: u64,
80        scope: Option<&str>,
81    ) -> impl Future<Output = Result<Option<RedeemableCode>, StoreError>>;
82
83    /// Attempt to atomically claim a code record.
84    ///
85    /// The adapter must execute a conditional UPDATE and classify via
86    /// [`crate::state::classify_claim`]. Returns [`ClaimOutcome::Won`] if and
87    /// only if exactly one row was updated.
88    fn claim_code(
89        &self,
90        req: &ClaimRequest<'_>,
91    ) -> impl Future<Output = Result<ClaimOutcome, StoreError>>;
92
93    /// Insert a new code record. Returns [`StoreError`] if the lookup key
94    /// already exists (unique constraint violation on the HMAC column).
95    fn insert_code(&self, record: CodeRecord) -> impl Future<Output = Result<(), StoreError>>;
96
97    /// Revoke a code by its record ID, scoped to `scope` when provided.
98    /// Only affects records that are not yet used or revoked.
99    fn revoke_code(
100        &self,
101        code_id: &CodeId,
102        scope: Option<&str>,
103        now: u64,
104    ) -> impl Future<Output = Result<(), StoreError>>;
105}
106
107use std::future::Future;
108
109/// Compute an `expires_at` Unix timestamp from a now value and a TTL.
110pub fn expires_at_from_ttl(now: u64, ttl: std::time::Duration) -> u64 {
111    now.saturating_add(ttl.as_secs())
112}
113
114/// Derive all candidate lookup keys for a normalized code value.
115///
116/// v0.1 produces only the active-key candidate. Key-rotation multi-candidate
117/// support is introduced in RFC-031.
118pub fn code_lookup_candidates<K: crate::hashing::KeyProvider>(
119    hasher: &crate::hashing::SecretHasher<K>,
120    normalized: &str,
121) -> Vec<(LookupKey, KeyVersion)> {
122    hasher
123        .lookup_key(crate::hashing::SecretDomain::Code, normalized)
124        .map(|(lk, kv)| vec![(lk, kv)])
125        .unwrap_or_default()
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use std::time::Duration;
132
133    #[test]
134    fn expires_at_from_ttl_adds_correctly() {
135        assert_eq!(expires_at_from_ttl(1_000, Duration::from_secs(3600)), 4_600);
136        assert_eq!(
137            expires_at_from_ttl(u64::MAX, Duration::from_secs(1)),
138            u64::MAX
139        );
140    }
141}