1use codlet_core::hashing::{KeyVersion, LookupKey};
4use codlet_core::secret::CodeId;
5use codlet_core::state::{ClaimOutcome, classify_claim};
6use codlet_core::store::code::{ClaimRequest, CodeRecord, CodeStore, RedeemableCode};
7use codlet_core::store::error::StoreError;
8
9use crate::SqliteStore;
10
11type CodeRow = (
14 String,
15 String,
16 String,
17 Option<String>,
18 Option<String>,
19 Option<String>,
20 i64,
21);
22
23impl CodeStore for SqliteStore {
24 async fn find_redeemable(
25 &self,
26 candidates: &[LookupKey],
27 now: u64,
28 scope: Option<&str>,
29 ) -> Result<Option<RedeemableCode>, StoreError> {
30 for candidate in candidates {
33 let row = find_one(&self.pool, candidate.as_str(), now, scope).await?;
34 if row.is_some() {
35 return Ok(row);
36 }
37 }
38 Ok(None)
39 }
40
41 async fn claim_code(&self, req: &ClaimRequest<'_>) -> Result<ClaimOutcome, StoreError> {
42 let now = req.now as i64;
43 let id = req.code_id.as_str();
44 let subject = req.subject.as_str();
45
46 let sql = match (req.purpose, req.scope) {
48 (Some(p), Some(s)) => format!(
49 "UPDATE codlet_codes SET used_at = ?, used_by_subject = ?
50 WHERE id = ? AND used_at IS NULL AND revoked_at IS NULL
51 AND expires_at > ? AND purpose = {p:?} AND scope = {s:?}"
52 ),
53 (Some(p), None) => format!(
54 "UPDATE codlet_codes SET used_at = ?, used_by_subject = ?
55 WHERE id = ? AND used_at IS NULL AND revoked_at IS NULL
56 AND expires_at > ? AND purpose = {p:?}"
57 ),
58 (None, Some(s)) => format!(
59 "UPDATE codlet_codes SET used_at = ?, used_by_subject = ?
60 WHERE id = ? AND used_at IS NULL AND revoked_at IS NULL
61 AND expires_at > ? AND scope = {s:?}"
62 ),
63 (None, None) => "UPDATE codlet_codes SET used_at = ?, used_by_subject = ?
64 WHERE id = ? AND used_at IS NULL AND revoked_at IS NULL
65 AND expires_at > ?"
66 .to_string(),
67 };
68 let result = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
69 .bind(now)
70 .bind(subject)
71 .bind(id)
72 .bind(now)
73 .execute(&self.pool)
74 .await
75 .map_err(|e| StoreError::Backend(e.to_string()))?;
76
77 let changed = result.rows_affected() as usize;
78 if changed > 1 {
79 return Err(StoreError::InvariantViolation(format!(
80 "claim_code changed {changed} rows for id={id}"
81 )));
82 }
83 Ok(classify_claim(changed))
84 }
85
86 async fn insert_code(&self, record: CodeRecord) -> Result<(), StoreError> {
87 sqlx::query(
88 "INSERT INTO codlet_codes
89 (id, lookup_key, key_version, purpose, scope, grant_payload, created_at, expires_at)
90 VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
91 )
92 .bind(record.id.as_str())
93 .bind(record.lookup_key.as_str())
94 .bind(record.key_version.as_str())
95 .bind(record.purpose.as_deref())
96 .bind(record.scope.as_deref())
97 .bind(record.grant.as_deref())
98 .bind(record.created_at as i64)
99 .bind(record.expires_at as i64)
100 .execute(&self.pool)
101 .await
102 .map_err(|e| {
103 if e.to_string().contains("UNIQUE") {
104 StoreError::Backend("duplicate lookup key (unique constraint)".into())
105 } else {
106 StoreError::Backend(e.to_string())
107 }
108 })?;
109 Ok(())
110 }
111
112 async fn revoke_code(
113 &self,
114 code_id: &CodeId,
115 scope: Option<&str>,
116 now: u64,
117 ) -> Result<(), StoreError> {
118 let now_i = now as i64;
119 let id = code_id.as_str();
120
121 if let Some(scope_val) = scope {
122 sqlx::query(
123 "UPDATE codlet_codes
124 SET revoked_at = ?
125 WHERE id = ? AND scope = ?
126 AND used_at IS NULL AND revoked_at IS NULL",
127 )
128 .bind(now_i)
129 .bind(id)
130 .bind(scope_val)
131 .execute(&self.pool)
132 .await
133 .map_err(|e| StoreError::Backend(e.to_string()))?;
134 } else {
135 sqlx::query(
136 "UPDATE codlet_codes
137 SET revoked_at = ?
138 WHERE id = ?
139 AND used_at IS NULL AND revoked_at IS NULL",
140 )
141 .bind(now_i)
142 .bind(id)
143 .execute(&self.pool)
144 .await
145 .map_err(|e| StoreError::Backend(e.to_string()))?;
146 }
147 Ok(())
148 }
149}
150
151async fn find_one(
152 pool: &sqlx::SqlitePool,
153 lookup_key: &str,
154 now: u64,
155 scope: Option<&str>,
156) -> Result<Option<RedeemableCode>, StoreError> {
157 let now_i = now as i64;
158
159 let row: Option<CodeRow> = if let Some(s) = scope {
161 sqlx::query_as(
162 "SELECT id, lookup_key, key_version, purpose, grant_payload, scope, expires_at
163 FROM codlet_codes
164 WHERE lookup_key = ?
165 AND scope = ?
166 AND used_at IS NULL
167 AND revoked_at IS NULL
168 AND expires_at > ?
169 LIMIT 1",
170 )
171 .bind(lookup_key)
172 .bind(s)
173 .bind(now_i)
174 .fetch_optional(pool)
175 .await
176 .map_err(|e| StoreError::Backend(e.to_string()))?
177 } else {
178 sqlx::query_as(
179 "SELECT id, lookup_key, key_version, purpose, grant_payload, scope, expires_at
180 FROM codlet_codes
181 WHERE lookup_key = ?
182 AND used_at IS NULL
183 AND revoked_at IS NULL
184 AND expires_at > ?
185 LIMIT 1",
186 )
187 .bind(lookup_key)
188 .bind(now_i)
189 .fetch_optional(pool)
190 .await
191 .map_err(|e| StoreError::Backend(e.to_string()))?
192 };
193
194 Ok(row.map(
195 |(id, _lk, kv, purpose_val, grant, scope_val, exp)| RedeemableCode {
196 id: CodeId::new(id),
197 key_version: KeyVersion::new(kv),
198 grant,
199 purpose: purpose_val,
200 scope: scope_val,
201 expires_at: exp as u64,
202 },
203 ))
204}