Skip to main content

better_auth_api/plugins/api_key/
storage.rs

1use std::collections::{BTreeMap, HashMap, HashSet};
2use std::sync::atomic::{AtomicBool, Ordering};
3use std::sync::{Arc, OnceLock, Weak};
4
5use better_auth_core::store::SecondaryStorage;
6use better_auth_core::wire::ApiKeyView;
7use better_auth_core::{
8    ApiKey, AuthContext, AuthError, AuthResult, CreateApiKey, FieldValue, UpdateApiKey,
9};
10use chrono::Utc;
11use futures_util::{StreamExt, TryFutureExt, future, stream};
12use serde_json::Value;
13use tokio::sync::Mutex;
14
15use super::ApiKeyConfig;
16
17const STORAGE_CONCURRENCY: usize = 10;
18
19mod usage;
20pub(super) use usage::consume;
21#[cfg(test)]
22mod batch_tests;
23
24/// Persistence used by an API Key configuration.
25#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
26pub enum ApiKeyStorage {
27    #[default]
28    Database,
29    SecondaryStorage,
30}
31
32pub(super) fn backend<'a>(
33    config: &'a ApiKeyConfig,
34    ctx: &'a AuthContext<impl better_auth_core::AuthSchema>,
35) -> Option<&'a Arc<dyn SecondaryStorage>> {
36    config
37        .custom_storage
38        .as_ref()
39        .or(ctx.secondary_storage.as_ref())
40}
41
42pub(super) fn group(config: &ApiKeyConfig) -> String {
43    if config.storage == ApiKeyStorage::Database {
44        "database".into()
45    } else if config.custom_storage.is_some() {
46        format!("custom:{}", config.config_id)
47    } else if config.fallback_to_database {
48        "secondary-storage-with-fallback".into()
49    } else {
50        "secondary-storage".into()
51    }
52}
53
54fn required_backend<'a>(
55    config: &'a ApiKeyConfig,
56    ctx: &'a AuthContext<impl better_auth_core::AuthSchema>,
57) -> AuthResult<&'a Arc<dyn SecondaryStorage>> {
58    backend(config, ctx).ok_or_else(|| {
59        AuthError::internal(
60            "Secondary storage is required when storage mode is 'secondary-storage'",
61        )
62    })
63}
64
65pub(super) fn now() -> better_auth_core::FieldDate {
66    Utc::now().into()
67}
68
69fn ttl(key: &ApiKey) -> Option<u64> {
70    key.expires_at.as_ref().and_then(|expires| {
71        let seconds =
72            ((expires.milliseconds() - Utc::now().timestamp_millis() as f64) / 1000.0).floor();
73        (seconds > 0.0).then_some(seconds as u64)
74    })
75}
76
77fn serialize(key: &ApiKey) -> AuthResult<String> {
78    let mut value: BTreeMap<String, Box<serde_json::value::RawValue>> =
79        serde_json::from_str(&serde_json::to_string(key)?)?;
80    // Upstream stores metadata as JSON, but the typed database boundary uses JSON text.
81    let metadata = key
82        .metadata
83        .as_deref()
84        .map(serde_json::from_str)
85        .transpose()?
86        .unwrap_or(Value::Null);
87    let _ = value.insert(
88        "metadata".into(),
89        serde_json::value::to_raw_value(&metadata)?,
90    );
91    Ok(serde_json::to_string(&value)?)
92}
93
94fn deserialize(value: Option<Value>) -> Option<ApiKey> {
95    let Value::String(value) = value? else {
96        return None;
97    };
98    // Upstream treats malformed serialized cache entries as a cache miss.
99    let mut object: BTreeMap<String, Box<serde_json::value::RawValue>> =
100        serde_json::from_str(&value).ok()?;
101    if let Some(metadata) = object.get_mut("metadata") {
102        let decoded: Value = serde_json::from_str(metadata.get()).ok()?;
103        if !decoded.is_null() {
104            *metadata = serde_json::value::to_raw_value(&decoded.to_string()).ok()?;
105        }
106    }
107    if object
108        .get("configId")
109        .is_none_or(|value| value.get() == "null")
110    {
111        let _ = object.insert("configId".into(), serde_json::value::to_raw_value("").ok()?);
112    }
113    serde_json::from_str(&serde_json::to_string(&object).ok()?).ok()
114}
115
116async fn cached(storage: &dyn SecondaryStorage, key: &str) -> AuthResult<Option<ApiKey>> {
117    Ok(deserialize(storage.get(key).await?))
118}
119
120fn reference_ids(value: Option<Value>) -> Vec<better_auth_core::SchemaValue<String>> {
121    let value = match value {
122        Some(Value::String(value)) => serde_json::from_str(&value).ok(),
123        value => value,
124    };
125    value
126        .and_then(|value| serde_json::from_value(value).ok())
127        .unwrap_or_default()
128}
129
130async fn modify_reference(
131    storage: &dyn SecondaryStorage,
132    key: &ApiKey,
133    insert: bool,
134) -> AuthResult<()> {
135    // The upstream reference-list lock only coordinates writers in this process.
136    // Cache-only quota remains non-atomic; database fallback provides guarded quota.
137    let index = format!("api-key:by-ref:{}", key.reference_id);
138    static LOCKS: OnceLock<std::sync::Mutex<HashMap<String, Weak<Mutex<()>>>>> = OnceLock::new();
139    let lock = {
140        let mut locks = LOCKS
141            .get_or_init(Default::default)
142            .lock()
143            .map_err(|_| AuthError::internal("API key reference-list lock poisoned"))?;
144        locks.retain(|_, lock| lock.strong_count() > 0);
145        match locks.get(&index).and_then(Weak::upgrade) {
146            Some(lock) => lock,
147            None => {
148                let lock = Arc::new(Mutex::new(()));
149                let _ = locks.insert(index.clone(), Arc::downgrade(&lock));
150                lock
151            }
152        }
153    };
154    let _guard = lock.lock().await;
155    let mut ids = reference_ids(storage.get(&index).await?);
156    if insert {
157        if !ids.contains(&key.id) {
158            ids.push(key.id.clone());
159        }
160    } else {
161        ids.retain(|id| id != &key.id);
162    }
163    if ids.is_empty() {
164        storage.delete(&index).await
165    } else {
166        storage
167            .set(&index, &serde_json::to_string(&ids)?, None)
168            .await
169    }
170}
171
172pub(super) async fn put(
173    storage: &dyn SecondaryStorage,
174    key: &ApiKey,
175    fallback: bool,
176) -> AuthResult<()> {
177    put_with_failure_flag(storage, key, fallback, None).await
178}
179
180async fn put_with_failure_flag(
181    storage: &dyn SecondaryStorage,
182    key: &ApiKey,
183    fallback: bool,
184    failed: Option<&AtomicBool>,
185) -> AuthResult<()> {
186    let value = serialize(key)?;
187    let ttl = ttl(key);
188    let hashed = format!("api-key:{}", key.key_hash);
189    let id = format!("api-key:by-id:{}", key.id.display_string()?);
190    let reference = format!("api-key:by-ref:{}", key.reference_id);
191    if fallback {
192        // Stop a list refill when an IO fails, even while another write for this key is pending.
193        let stop_batch = |_: &AuthError| {
194            if let Some(failed) = failed {
195                failed.store(true, Ordering::Relaxed);
196            }
197        };
198        let (hashed, id, reference) = tokio::join!(
199            storage.set(&hashed, &value, ttl).inspect_err(stop_batch),
200            storage.set(&id, &value, ttl).inspect_err(stop_batch),
201            storage.delete(&reference).inspect_err(stop_batch)
202        );
203        hashed?;
204        id?;
205        reference
206    } else {
207        let (hashed, id) = tokio::join!(
208            storage.set(&hashed, &value, ttl),
209            storage.set(&id, &value, ttl)
210        );
211        hashed?;
212        id?;
213        modify_reference(storage, key, true).await
214    }
215}
216
217async fn remove_cached(
218    storage: &dyn SecondaryStorage,
219    key: &ApiKey,
220    fallback: bool,
221) -> AuthResult<()> {
222    let hashed = format!("api-key:{}", key.key_hash);
223    let id = format!("api-key:by-id:{}", key.id.display_string()?);
224    let reference = format!("api-key:by-ref:{}", key.reference_id);
225    if fallback {
226        let (hashed, id, reference) = tokio::join!(
227            storage.delete(&hashed),
228            storage.delete(&id),
229            storage.delete(&reference)
230        );
231        hashed?;
232        id?;
233        reference
234    } else {
235        let (hashed, id, reference) = tokio::join!(
236            storage.delete(&hashed),
237            storage.delete(&id),
238            modify_reference(storage, key, false)
239        );
240        hashed?;
241        id?;
242        reference
243    }
244}
245
246pub(crate) async fn get_by_id(
247    config: &ApiKeyConfig,
248    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
249    id: &str,
250) -> AuthResult<Option<ApiKey>> {
251    get(config, ctx, id, false).await
252}
253
254pub(super) async fn get_by_hash(
255    config: &ApiKeyConfig,
256    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
257    hash: &str,
258) -> AuthResult<Option<ApiKey>> {
259    get(config, ctx, hash, true).await
260}
261
262async fn get(
263    config: &ApiKeyConfig,
264    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
265    value: &str,
266    hash: bool,
267) -> AuthResult<Option<ApiKey>> {
268    let storage = backend(config, ctx);
269    if config.storage == ApiKeyStorage::SecondaryStorage {
270        if let Some(storage) = storage {
271            let key = if hash {
272                format!("api-key:{value}")
273            } else {
274                format!("api-key:by-id:{value}")
275            };
276            if let Some(key) = cached(storage.as_ref(), &key).await? {
277                return Ok(Some(key));
278            }
279        }
280        if !config.fallback_to_database {
281            return Ok(None);
282        }
283    }
284    let key = if hash {
285        ctx.database.get_api_key_by_hash(value).await?
286    } else {
287        ctx.database.get_api_key_by_id(value).await?
288    };
289    if config.storage == ApiKeyStorage::SecondaryStorage
290        && let (Some(storage), Some(key)) = (storage, key.as_ref())
291    {
292        put(storage.as_ref(), key, config.fallback_to_database).await?;
293    }
294    Ok(key)
295}
296
297pub(super) async fn create(
298    config: &ApiKeyConfig,
299    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
300    input: CreateApiKey,
301) -> AuthResult<ApiKey> {
302    let key = if config.storage == ApiKeyStorage::Database || config.fallback_to_database {
303        ctx.database.create_api_key(input).await?
304    } else {
305        let created_at = now();
306        ApiKey {
307            additional_fields: Default::default(),
308            id: ctx
309                .config
310                .advanced
311                .generate_id("apikey", None)?
312                .filter(|id| !id.is_empty())
313                .unwrap_or_else(|| better_auth_core::id::random_id(None))
314                .into(),
315            name: input.name,
316            start: input.start,
317            prefix: input.prefix,
318            key_hash: input.key_hash,
319            reference_id: input.reference_id,
320            config_id: input.config_id,
321            refill_interval: input.refill_interval,
322            refill_amount: input.refill_amount,
323            last_refill_at: None,
324            enabled: input.enabled.into(),
325            rate_limit_enabled: input.rate_limit_enabled.into(),
326            rate_limit_time_window: input.rate_limit_time_window,
327            rate_limit_max: input.rate_limit_max,
328            request_count: Some(0.0),
329            remaining: input.remaining,
330            last_request: None,
331            expires_at: input.expires_at,
332            created_at: created_at.clone(),
333            updated_at: created_at,
334            permissions: input.permissions,
335            metadata: input.metadata,
336        }
337    };
338    if config.storage == ApiKeyStorage::SecondaryStorage {
339        put(
340            required_backend(config, ctx)?.as_ref(),
341            &key,
342            config.fallback_to_database,
343        )
344        .await?;
345    }
346    Ok(key)
347}
348
349pub(super) fn apply_update(key: &mut ApiKey, update: UpdateApiKey) {
350    if let Some(name) = update.name {
351        key.name = name;
352    }
353    macro_rules! optional { ($($field:ident),* $(,)?) => { $(if let Some(value) = update.$field { key.$field = Some(value); })* }; }
354    optional!(
355        remaining,
356        rate_limit_time_window,
357        rate_limit_max,
358        refill_interval,
359        refill_amount,
360        permissions,
361        metadata,
362        request_count
363    );
364    if let Some(value) = update.enabled {
365        key.enabled = value.into();
366    }
367    if let Some(value) = update.rate_limit_enabled {
368        key.rate_limit_enabled = value.into();
369    }
370    if let Some(value) = update.expires_at {
371        key.expires_at = value;
372    }
373    if let Some(value) = update.last_request {
374        key.last_request = value;
375    }
376    if let Some(value) = update.last_refill_at {
377        key.last_refill_at = value;
378    }
379    key.updated_at = now();
380}
381
382pub(super) async fn update(
383    config: &ApiKeyConfig,
384    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
385    mut key: ApiKey,
386    update: UpdateApiKey,
387) -> AuthResult<ApiKey> {
388    if config.storage == ApiKeyStorage::Database || config.fallback_to_database {
389        key = match ctx.database.update_api_key(&key.id, update).await {
390            Ok(updated) => updated,
391            // Upstream adapter.update returns null when a row disappears after the ownership lookup.
392            Err(AuthError::NotFound(_)) => return Ok(key),
393            Err(error) => return Err(error),
394        };
395    } else {
396        apply_update(&mut key, update);
397    }
398    if config.storage == ApiKeyStorage::SecondaryStorage {
399        put(
400            required_backend(config, ctx)?.as_ref(),
401            &key,
402            config.fallback_to_database,
403        )
404        .await?;
405    }
406    Ok(key)
407}
408
409pub(super) async fn delete(
410    config: &ApiKeyConfig,
411    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
412    key: &ApiKey,
413) -> AuthResult<()> {
414    if config.storage == ApiKeyStorage::SecondaryStorage {
415        remove_cached(
416            required_backend(config, ctx)?.as_ref(),
417            key,
418            config.fallback_to_database,
419        )
420        .await?;
421    }
422    if config.storage == ApiKeyStorage::Database || config.fallback_to_database {
423        ctx.database.delete_api_key(&key.id).await?;
424    }
425    Ok(())
426}
427
428pub(super) async fn delete_for_verification(
429    config: &ApiKeyConfig,
430    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
431    key: &ApiKey,
432) -> AuthResult<()> {
433    if !config.defer_updates {
434        return delete(config, ctx, key).await;
435    }
436    let storage = backend(config, ctx).cloned();
437    let database = ctx.database.clone();
438    let config = config.clone();
439    let key = key.clone();
440    let _task = tokio::spawn(async move {
441        let result: AuthResult<()> = async {
442            if config.storage == ApiKeyStorage::SecondaryStorage {
443                let storage = storage.ok_or_else(|| {
444                    AuthError::internal(
445                        "Secondary storage is required when storage mode is 'secondary-storage'",
446                    )
447                })?;
448                remove_cached(storage.as_ref(), &key, config.fallback_to_database).await?;
449            }
450            if config.storage == ApiKeyStorage::Database || config.fallback_to_database {
451                database.delete_api_key(&key.id).await?;
452            }
453            Ok(())
454        }
455        .await;
456        if let Err(error) = result {
457            better_auth_core::observability::logger::current().error(
458                "Deferred API key deletion failed",
459                &[better_auth_core::observability::LogArgument::Error(&error)],
460            );
461        }
462    });
463    Ok(())
464}
465
466pub(super) async fn list(
467    config: &ApiKeyConfig,
468    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
469    reference: &str,
470    sort: Option<(&str, &str)>,
471) -> AuthResult<Vec<ApiKey>> {
472    let storage = backend(config, ctx);
473    if config.storage == ApiKeyStorage::SecondaryStorage {
474        if let Some(storage) = storage {
475            let ids = reference_ids(storage.get(&format!("api-key:by-ref:{reference}")).await?);
476            if !ids.is_empty() || !config.fallback_to_database {
477                let failed = AtomicBool::new(false);
478                let mut results = stream::iter(ids.into_iter().enumerate())
479                    .take_while(|_| future::ready(!failed.load(Ordering::Relaxed)))
480                    .map(|(index, id)| {
481                        let failed = &failed;
482                        async move {
483                            let result = async {
484                                cached(
485                                    storage.as_ref(),
486                                    &format!("api-key:by-id:{}", id.display_string()?),
487                                )
488                                .await
489                            }
490                            .await;
491                            if result.is_err() {
492                                failed.store(true, Ordering::Relaxed);
493                            }
494                            result.map(|key| (index, key))
495                        }
496                    })
497                    .buffer_unordered(STORAGE_CONCURRENCY)
498                    // Keep started callbacks in this request's scope. Unlike a JS promise,
499                    // the Rust result waits for these peers before returning the first error.
500                    .collect::<Vec<_>>()
501                    .await
502                    .into_iter()
503                    .collect::<AuthResult<Vec<_>>>()?;
504                results.sort_unstable_by_key(|(index, _)| *index);
505                let mut keys: Vec<_> = results.into_iter().filter_map(|(_, key)| key).collect();
506                if let Some((field, direction)) = sort {
507                    let mut views: Vec<_> = keys
508                        .iter()
509                        .map(better_auth_core::wire::ApiKeyView::from)
510                        .collect();
511                    sort_views(&mut views, field, Some(direction))?;
512                    let mut by_id: std::collections::HashMap<_, _> = keys
513                        .into_iter()
514                        .map(|key| (key.id.as_str().map(str::to_owned), key))
515                        .collect();
516                    keys = views
517                        .into_iter()
518                        .filter_map(|view| by_id.remove(&view.id.as_str().map(str::to_owned)))
519                        .collect();
520                }
521                return Ok(keys);
522            }
523        }
524        if !config.fallback_to_database {
525            return Ok(Vec::new());
526        }
527    }
528    let (keys, total) = tokio::join!(
529        ctx.database.find_api_keys_by_reference(reference, sort),
530        ctx.database.count_api_keys_by_reference(reference),
531    );
532    let mut keys = keys?;
533    // The public endpoint recomputes total from these rows, but upstream still
534    // performs the adapter count and propagates a failure from that operation.
535    let _ = total?;
536    if config.storage == ApiKeyStorage::SecondaryStorage
537        && !keys.is_empty()
538        && let Some(storage) = storage
539    {
540        let failed = AtomicBool::new(false);
541        let mut results = stream::iter(keys.into_iter().enumerate())
542            .take_while(|_| future::ready(!failed.load(Ordering::Relaxed)))
543            .map(|(index, key)| {
544                let failed = &failed;
545                async move {
546                    let result =
547                        put_with_failure_flag(storage.as_ref(), &key, true, Some(failed)).await;
548                    if result.is_err() {
549                        failed.store(true, Ordering::Relaxed);
550                    }
551                    result.map(|()| (index, key))
552                }
553            })
554            .buffer_unordered(STORAGE_CONCURRENCY)
555            .collect::<Vec<_>>()
556            .await
557            .into_iter()
558            .collect::<AuthResult<Vec<_>>>()?;
559        results.sort_unstable_by_key(|(index, _)| *index);
560        keys = results.into_iter().map(|(_, key)| key).collect();
561        let ids: Vec<_> = keys.iter().map(|key| &key.id).collect();
562        storage
563            .set(
564                &format!("api-key:by-ref:{reference}"),
565                &serde_json::to_string(&ids)?,
566                None,
567            )
568            .await?;
569    }
570    Ok(keys)
571}
572
573fn compare_numbers(left: Option<f64>, right: Option<f64>) -> std::cmp::Ordering {
574    match (left, right) {
575        (Some(left), Some(right)) => left
576            .partial_cmp(&right)
577            .unwrap_or(std::cmp::Ordering::Equal),
578        (None, None) => std::cmp::Ordering::Equal,
579        (None, Some(_)) => std::cmp::Ordering::Less,
580        (Some(_), None) => std::cmp::Ordering::Greater,
581    }
582}
583
584fn compare_strings(left: Option<&str>, right: Option<&str>) -> std::cmp::Ordering {
585    match (left, right) {
586        (Some(left), Some(right)) => left.encode_utf16().cmp(right.encode_utf16()),
587        _ => left.cmp(&right),
588    }
589}
590
591fn sort_views(views: &mut [ApiKeyView], sort_by: &str, direction: Option<&str>) -> AuthResult<()> {
592    use std::cmp::Ordering::{Equal, Greater, Less};
593
594    let compare = |a: &ApiKeyView, b: &ApiKeyView| -> AuthResult<std::cmp::Ordering> {
595        let ordering = match sort_by {
596            "name" | "enabled" | "rateLimitEnabled" => {
597                let (left, right) = match sort_by {
598                    "enabled" => (a.enabled.field_value(), b.enabled.field_value()),
599                    "rateLimitEnabled" => (
600                        a.rate_limit_enabled.field_value(),
601                        b.rate_limit_enabled.field_value(),
602                    ),
603                    _ => (a.name.field_value(), b.name.field_value()),
604                };
605                match (&left, &right) {
606                    (
607                        FieldValue::Null | FieldValue::Undefined,
608                        FieldValue::Null | FieldValue::Undefined,
609                    ) => Equal,
610                    (FieldValue::Null | FieldValue::Undefined, _) => Less,
611                    (_, FieldValue::Null | FieldValue::Undefined) => Greater,
612                    _ => better_auth_core::query::field_compare(&left, &right)?.unwrap_or(Equal),
613                }
614            }
615            "id" => compare_strings(a.id.as_str(), b.id.as_str()),
616            "start" => a.start.cmp(&b.start),
617            "prefix" => compare_strings(a.prefix.as_deref(), b.prefix.as_deref()),
618            "referenceId" => compare_strings(Some(&a.reference_id), Some(&b.reference_id)),
619            "configId" => compare_strings(Some(&a.config_id), Some(&b.config_id)),
620            "createdAt" => compare_numbers(
621                Some(a.created_at.milliseconds()),
622                Some(b.created_at.milliseconds()),
623            ),
624            "updatedAt" => compare_numbers(
625                Some(a.updated_at.milliseconds()),
626                Some(b.updated_at.milliseconds()),
627            ),
628            "expiresAt" => compare_numbers(
629                a.expires_at
630                    .as_ref()
631                    .map(better_auth_core::FieldDate::milliseconds),
632                b.expires_at
633                    .as_ref()
634                    .map(better_auth_core::FieldDate::milliseconds),
635            ),
636            "lastRequest" => compare_numbers(
637                a.last_request
638                    .as_ref()
639                    .map(better_auth_core::FieldDate::milliseconds),
640                b.last_request
641                    .as_ref()
642                    .map(better_auth_core::FieldDate::milliseconds),
643            ),
644            "lastRefillAt" => compare_numbers(
645                a.last_refill_at
646                    .as_ref()
647                    .map(better_auth_core::FieldDate::milliseconds),
648                b.last_refill_at
649                    .as_ref()
650                    .map(better_auth_core::FieldDate::milliseconds),
651            ),
652            "remaining" => compare_numbers(a.remaining, b.remaining),
653            "requestCount" => compare_numbers(a.request_count, b.request_count),
654            "rateLimitMax" => compare_numbers(a.rate_limit_max, b.rate_limit_max),
655            "rateLimitTimeWindow" => {
656                compare_numbers(a.rate_limit_time_window, b.rate_limit_time_window)
657            }
658            "refillAmount" => compare_numbers(a.refill_amount, b.refill_amount),
659            "refillInterval" => compare_numbers(a.refill_interval, b.refill_interval),
660            _ => Equal,
661        };
662        Ok(if direction == Some("desc") {
663            ordering.reverse()
664        } else {
665            ordering
666        })
667    };
668    // Mixed values and invalid dates can violate sort_by's total-order requirement.
669    // ponytail: O(n²); use a subquadratic sort that accepts unordered comparisons if large cache lists become costly.
670    #[expect(
671        clippy::indexing_slicing,
672        reason = "The outer range bounds current; current only decreases while positive"
673    )]
674    for index in 1..views.len() {
675        let mut current = index;
676        while current > 0 && compare(&views[current], &views[current - 1])? == Less {
677            views.swap(current - 1, current);
678            current -= 1;
679        }
680    }
681    Ok(())
682}
683
684pub(super) fn deduplicate(keys: &mut Vec<ApiKey>) {
685    let mut ids = HashSet::new();
686    keys.retain(|key| ids.insert(key.id.as_str().map(str::to_owned)));
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692
693    #[test]
694    fn cache_codec_preserves_nullable_flags_in_public_views() -> AuthResult<()> {
695        for (flag, expected, truthy) in [
696            (None, FieldValue::Undefined, false),
697            (Some(Value::Null), FieldValue::Null, false),
698            (Some(Value::Bool(false)), FieldValue::Bool(false), false),
699            (Some(Value::Bool(true)), FieldValue::Bool(true), true),
700        ] {
701            let mut cached = serde_json::json!({
702                "id": "nullable-flags", "key": "hash", "referenceId": "owner", "configId": "default",
703                "createdAt": "2030-01-02T03:04:05.000Z", "updatedAt": "2030-01-02T03:04:05.000Z",
704                "metadata": { "purpose": "device" },
705            });
706            if let Some(flag) = &flag {
707                cached["enabled"] = flag.clone();
708                cached["rateLimitEnabled"] = flag.clone();
709            }
710            let key = deserialize(Some(Value::String(cached.to_string())))
711                .ok_or_else(|| AuthError::internal("The nullable cache entry must decode"))?;
712            assert_eq!(key.enabled.field_value(), expected);
713            assert_eq!(key.rate_limit_enabled.field_value(), expected);
714            assert_eq!(key.enabled.is_truthy()?, truthy);
715            assert_eq!(key.rate_limit_enabled.is_truthy()?, truthy);
716            let serialized = serialize(&key)?;
717            let stored: Value = serde_json::from_str(&serialized)?;
718            let restored = deserialize(Some(Value::String(serialized)))
719                .ok_or_else(|| AuthError::internal("The serialized cache entry must decode"))?;
720            assert_eq!(restored.enabled.field_value(), expected);
721            assert_eq!(restored.rate_limit_enabled.field_value(), expected);
722            let view = serde_json::to_value(ApiKeyView::from(&restored))?;
723            for value in [stored, view] {
724                assert_eq!(value.get("enabled"), flag.as_ref());
725                assert_eq!(value.get("rateLimitEnabled"), flag.as_ref());
726                assert_eq!(value["metadata"], cached["metadata"]);
727            }
728        }
729        Ok(())
730    }
731
732    #[test]
733    fn cache_codec_preserves_unpaired_start_and_structured_metadata() {
734        let cached = r#"{"id":"key-id","key":"hash","referenceId":"owner","configId":null,"start":"\ud83d","enabled":true,"rateLimitEnabled":false,"createdAt":"2026-10-01T00:00:00.000Z","updatedAt":"2026-10-01T00:00:00.000Z","metadata":{"purpose":"device"}}"#;
735        let key = deserialize(Some(Value::String(cached.into()))).unwrap();
736        assert_eq!(key.start.as_ref().unwrap().as_utf16(), &[0xd83d]);
737        assert_eq!(key.config_id, "");
738        assert_eq!(key.metadata.as_deref(), Some(r#"{"purpose":"device"}"#));
739        let serialized = serialize(&key).unwrap();
740        let fields: BTreeMap<String, Box<serde_json::value::RawValue>> =
741            serde_json::from_str(&serialized).unwrap();
742        assert_eq!(fields.get("start").unwrap().get(), r#""\ud83d""#);
743        assert_eq!(
744            fields.get("metadata").unwrap().get(),
745            r#"{"purpose":"device"}"#
746        );
747        let restored = deserialize(Some(Value::String(serialized))).unwrap();
748        assert_eq!(restored.start, key.start);
749        assert_eq!(restored.metadata, key.metadata);
750        assert!(deserialize(Some(Value::String("malformed".into()))).is_none());
751    }
752}