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