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}