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 = (
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        // Build a parameterised `IN (?, ?, ...)` clause for the candidate keys.
31        // SQLx doesn't support dynamic IN lists directly, so we iterate.
32        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        // Enforce purpose and scope to prevent cross-flow redemption (RFC-C).
47        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    // Build scope clause: when scope is provided, filter by it; when None, accept any scope.
160    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}