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}