Skip to main content

codlet_core/
admin.rs

1//! Administrative code management API (RFC-030).
2//!
3//! This module provides metadata-only operations for host admin panels.
4//! **Authorization for calling these APIs is entirely host-owned.** codlet
5//! does not check who may issue or revoke codes; the host must enforce that.
6//!
7//! The [`CodeAdminStore`] trait is optional — adapters implement it in addition
8//! to [`crate::store::code::CodeStore`] when they want to expose admin listing.
9//! Metadata returned by this trait never includes plaintext codes or HMAC
10//! lookup keys (RFC-030 §returned metadata).
11
12use std::future::Future;
13
14use crate::hashing::KeyVersion;
15use crate::secret::{CodeId, ScopeKey, SubjectId};
16use crate::store::error::StoreError;
17
18/// Metadata record for a code — safe for admin display (RFC-030).
19///
20/// Contains no plaintext code value and no HMAC lookup key.
21#[derive(Debug, Clone)]
22#[cfg_attr(feature = "serde", derive(serde::Serialize))]
23pub struct CodeMeta {
24    /// Opaque record identifier.
25    pub id: CodeId,
26    /// The key version under which this code was stored.
27    pub key_version: KeyVersion,
28    /// Optional host-owned purpose label.
29    pub purpose: Option<String>,
30    /// Optional scope label.
31    pub scope: Option<String>,
32    /// Opaque host-owned grant payload (safe to return to admins).
33    pub grant: Option<String>,
34    /// Creation time as Unix seconds (UTC), if available.
35    pub created_at: Option<u64>,
36    /// Expiry as Unix seconds (UTC).
37    pub expires_at: u64,
38    /// When the code was claimed, if it was.
39    pub used_at: Option<u64>,
40    /// Which subject claimed it, if it was claimed.
41    pub used_by: Option<SubjectId>,
42    /// When the code was revoked, if it was.
43    pub revoked_at: Option<u64>,
44}
45
46impl CodeMeta {
47    /// Whether the code is currently redeemable at `now`.
48    #[must_use]
49    pub fn is_redeemable_at(&self, now: u64) -> bool {
50        self.used_at.is_none() && self.revoked_at.is_none() && self.expires_at > now
51    }
52}
53
54/// Filter for code listing queries (RFC-030).
55#[derive(Debug, Default, Clone)]
56pub struct CodeListFilter {
57    /// Restrict to codes with this scope key.
58    pub scope: Option<ScopeKey>,
59    /// Include only active (unused, unrevoked, unexpired at `now`) codes.
60    pub active_only: bool,
61    /// Maximum number of records to return.
62    pub limit: Option<usize>,
63}
64
65impl CodeListFilter {
66    /// An empty filter that matches all codes.
67    #[must_use]
68    pub fn all() -> Self {
69        Self::default()
70    }
71
72    /// Filter to active codes only within a scope.
73    #[must_use]
74    pub fn active_in_scope(scope: ScopeKey) -> Self {
75        Self {
76            scope: Some(scope),
77            active_only: true,
78            limit: None,
79        }
80    }
81}
82
83/// Optional admin extension trait for code stores (RFC-030).
84///
85/// Adapters that support admin listing implement this in addition to
86/// [`crate::store::code::CodeStore`]. Authorization remains host-owned.
87pub trait CodeAdminStore {
88    /// List code metadata matching `filter`, ordered by `expires_at` descending.
89    ///
90    /// Never returns plaintext codes or HMAC lookup keys.
91    ///
92    /// # Errors
93    /// [`StoreError::Backend`] on storage failure.
94    fn list_codes(
95        &self,
96        filter: &CodeListFilter,
97        now: u64,
98    ) -> impl Future<Output = Result<Vec<CodeMeta>, StoreError>>;
99
100    /// Retrieve a single code's metadata by its record ID.
101    ///
102    /// Returns `Ok(None)` if no record with that ID exists.
103    ///
104    /// # Errors
105    /// [`StoreError::Backend`] on storage failure.
106    fn get_code_meta(
107        &self,
108        code_id: &CodeId,
109    ) -> impl Future<Output = Result<Option<CodeMeta>, StoreError>>;
110}
111
112/// Admin statistics snapshot (RFC-030).
113#[derive(Debug, Default, Clone)]
114#[cfg_attr(feature = "serde", derive(serde::Serialize))]
115pub struct CodeStats {
116    /// Codes that are currently redeemable.
117    pub active: usize,
118    /// Codes that have been successfully claimed.
119    pub used: usize,
120    /// Codes that were revoked before use.
121    pub revoked: usize,
122    /// Codes that expired without being claimed or revoked.
123    pub expired: usize,
124}
125
126impl CodeStats {
127    /// Total number of records across all states.
128    #[must_use]
129    pub fn total(&self) -> usize {
130        self.active + self.used + self.revoked + self.expired
131    }
132}
133
134/// In-memory implementation of `CodeAdminStore` for tests.
135/// Available under the `test-utils` feature.
136#[cfg(any(test, feature = "test-utils"))]
137pub mod mem_admin {
138    use super::*;
139    use crate::mem::MemCodeStore;
140
141    impl CodeAdminStore for MemCodeStore {
142        async fn list_codes(
143            &self,
144            filter: &CodeListFilter,
145            now: u64,
146        ) -> Result<Vec<CodeMeta>, StoreError> {
147            // Reflect on internal rows via the store's public query surface.
148            // The in-memory implementation has no index, so we synthesise from
149            // what find_redeemable exposes. For the test implementation we
150            // return a best-effort view without touching private fields.
151            //
152            // Real adapters (SQLx, D1) implement this with a direct SELECT.
153            // This stub always returns empty — tests that need listing should
154            // use the SQLite adapter.
155            let _ = (filter, now);
156            Ok(Vec::new())
157        }
158
159        async fn get_code_meta(&self, _code_id: &CodeId) -> Result<Option<CodeMeta>, StoreError> {
160            // Stub — see above.
161            Ok(None)
162        }
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn code_meta_is_redeemable_logic() {
172        let now = 1_000;
173        let base = CodeMeta {
174            id: CodeId::new("c1".into()),
175            key_version: crate::hashing::KeyVersion::new("v1"),
176            purpose: None,
177            scope: None,
178            grant: None,
179            created_at: Some(now - 10),
180            expires_at: now + 100,
181            used_at: None,
182            used_by: None,
183            revoked_at: None,
184        };
185        assert!(base.is_redeemable_at(now));
186        assert!(
187            !CodeMeta {
188                used_at: Some(now),
189                ..base.clone()
190            }
191            .is_redeemable_at(now)
192        );
193        assert!(
194            !CodeMeta {
195                revoked_at: Some(now),
196                ..base.clone()
197            }
198            .is_redeemable_at(now)
199        );
200        assert!(
201            !CodeMeta {
202                expires_at: now - 1,
203                ..base
204            }
205            .is_redeemable_at(now)
206        );
207    }
208
209    #[test]
210    fn code_list_filter_helpers() {
211        let all = CodeListFilter::all();
212        assert!(all.scope.is_none() && !all.active_only);
213        let scoped = CodeListFilter::active_in_scope(ScopeKey::new("community-1"));
214        assert!(scoped.active_only);
215        assert_eq!(scoped.scope.unwrap().as_str(), "community-1");
216    }
217
218    #[test]
219    fn code_stats_total() {
220        let s = CodeStats {
221            active: 3,
222            used: 10,
223            revoked: 2,
224            expired: 5,
225        };
226        assert_eq!(s.total(), 20);
227    }
228
229    #[test]
230    fn code_meta_contains_no_secrets() {
231        // Verify the type has no field named plaintext / lookup_key / hmac.
232        // This is enforced by the type definition but we assert via Debug.
233        let m = CodeMeta {
234            id: CodeId::new("c1".into()),
235            key_version: crate::hashing::KeyVersion::new("v1"),
236            purpose: Some("invite".into()),
237            scope: Some("community-1".into()),
238            grant: Some("role:member".into()),
239            created_at: None,
240            expires_at: 9_999_999,
241            used_at: None,
242            used_by: None,
243            revoked_at: None,
244        };
245        let dbg = format!("{m:?}");
246        let forbidden = ["lookup_key", "hmac", "plain_code", "secret", "pepper"];
247        for word in forbidden {
248            assert!(
249                !dbg.to_lowercase().contains(word),
250                "CodeMeta debug contains {word:?}: {dbg}"
251            );
252        }
253    }
254}