Skip to main content

codlet_sqlx/
code.rs

1//! SQLite implementation of [`codlet_core::store::code::CodeStore`].
2
3use 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
11/// Columns returned by the `find_one` SELECT:
12/// (id, lookup_key, key_version, grant_payload, scope, expires_at)
13type CodeRow = (String, String, String, Option<String>, Option<String>, i64);
14
15impl CodeStore for SqliteStore {
16    async fn find_redeemable(
17        &self,
18        candidates: &[LookupKey],
19        now: u64,
20        scope: Option<&str>,
21    ) -> Result<Option<RedeemableCode>, StoreError> {
22        // Build a parameterised `IN (?, ?, ...)` clause for the candidate keys.
23        // SQLx doesn't support dynamic IN lists directly, so we iterate.
24        for candidate in candidates {
25            let row = find_one(&self.pool, candidate.as_str(), now, scope).await?;
26            if row.is_some() {
27                return Ok(row);
28            }
29        }
30        Ok(None)
31    }
32
33    async fn claim_code(&self, req: &ClaimRequest<'_>) -> Result<ClaimOutcome, StoreError> {
34        let now = req.now as i64;
35        let id = req.code_id.as_str();
36        let subject = req.subject.as_str();
37
38        let result = sqlx::query(
39            "UPDATE codlet_codes
40             SET used_at = ?, used_by_subject = ?
41             WHERE id = ?
42               AND used_at   IS NULL
43               AND revoked_at IS NULL
44               AND expires_at  > ?",
45        )
46        .bind(now)
47        .bind(subject)
48        .bind(id)
49        .bind(now)
50        .execute(&self.pool)
51        .await
52        .map_err(|e| StoreError::Backend(e.to_string()))?;
53
54        let changed = result.rows_affected() as usize;
55        if changed > 1 {
56            return Err(StoreError::InvariantViolation(format!(
57                "claim_code changed {changed} rows for id={id}"
58            )));
59        }
60        Ok(classify_claim(changed))
61    }
62
63    async fn insert_code(&self, record: CodeRecord) -> Result<(), StoreError> {
64        sqlx::query(
65            "INSERT INTO codlet_codes
66             (id, lookup_key, key_version, purpose, scope, grant_payload, created_at, expires_at)
67             VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
68        )
69        .bind(record.id.as_str())
70        .bind(record.lookup_key.as_str())
71        .bind(record.key_version.as_str())
72        .bind(record.purpose.as_deref())
73        .bind(record.scope.as_deref())
74        .bind(record.grant.as_deref())
75        .bind(record.created_at as i64)
76        .bind(record.expires_at as i64)
77        .execute(&self.pool)
78        .await
79        .map_err(|e| {
80            if e.to_string().contains("UNIQUE") {
81                StoreError::Backend("duplicate lookup key (unique constraint)".into())
82            } else {
83                StoreError::Backend(e.to_string())
84            }
85        })?;
86        Ok(())
87    }
88
89    async fn revoke_code(
90        &self,
91        code_id: &CodeId,
92        scope: Option<&str>,
93        now: u64,
94    ) -> Result<(), StoreError> {
95        let now_i = now as i64;
96        let id = code_id.as_str();
97
98        if let Some(scope_val) = scope {
99            sqlx::query(
100                "UPDATE codlet_codes
101                 SET revoked_at = ?
102                 WHERE id = ? AND scope = ?
103                   AND used_at IS NULL AND revoked_at IS NULL",
104            )
105            .bind(now_i)
106            .bind(id)
107            .bind(scope_val)
108            .execute(&self.pool)
109            .await
110            .map_err(|e| StoreError::Backend(e.to_string()))?;
111        } else {
112            sqlx::query(
113                "UPDATE codlet_codes
114                 SET revoked_at = ?
115                 WHERE id = ?
116                   AND used_at IS NULL AND revoked_at IS NULL",
117            )
118            .bind(now_i)
119            .bind(id)
120            .execute(&self.pool)
121            .await
122            .map_err(|e| StoreError::Backend(e.to_string()))?;
123        }
124        Ok(())
125    }
126}
127
128async fn find_one(
129    pool: &sqlx::SqlitePool,
130    lookup_key: &str,
131    now: u64,
132    scope: Option<&str>,
133) -> Result<Option<RedeemableCode>, StoreError> {
134    let now_i = now as i64;
135
136    // Build scope clause: when scope is provided, filter by it; when None, accept any scope.
137    let row: Option<CodeRow> = if let Some(s) = scope {
138        sqlx::query_as(
139            "SELECT id, lookup_key, key_version, grant_payload, scope, expires_at
140             FROM codlet_codes
141             WHERE lookup_key = ?
142               AND scope       = ?
143               AND used_at     IS NULL
144               AND revoked_at  IS NULL
145               AND expires_at  > ?
146             LIMIT 1",
147        )
148        .bind(lookup_key)
149        .bind(s)
150        .bind(now_i)
151        .fetch_optional(pool)
152        .await
153        .map_err(|e| StoreError::Backend(e.to_string()))?
154    } else {
155        sqlx::query_as(
156            "SELECT id, lookup_key, key_version, grant_payload, scope, expires_at
157             FROM codlet_codes
158             WHERE lookup_key = ?
159               AND used_at    IS NULL
160               AND revoked_at IS NULL
161               AND expires_at > ?
162             LIMIT 1",
163        )
164        .bind(lookup_key)
165        .bind(now_i)
166        .fetch_optional(pool)
167        .await
168        .map_err(|e| StoreError::Backend(e.to_string()))?
169    };
170
171    Ok(
172        row.map(|(id, _lk, kv, grant, scope_val, exp)| RedeemableCode {
173            id: CodeId::new(id),
174            key_version: KeyVersion::new(kv),
175            grant,
176            scope: scope_val,
177            expires_at: exp as u64,
178        }),
179    )
180}