Skip to main content

better_auth_core/
wire.rs

1//! Concrete auth types for API responses and framework callbacks.
2//!
3//! These types decouple JSON response shapes from app-owned SeaORM entities.
4//! Account and verification views retain adapter replacement types and field omission.
5//! Persistence traits describe application models; runtime readers consume projected views.
6
7use crate::SchemaValue;
8#[cfg(test)]
9use chrono::Utc;
10use serde::{Deserialize, Serialize, Serializer};
11use std::borrow::Cow;
12
13use crate::entity::{
14    AuthApiKey, AuthInvitation, AuthOrganization, AuthPasskey, AuthSession, AuthUser,
15};
16use crate::types::InvitationStatus;
17
18mod account_view;
19mod session_projection;
20mod verification_view;
21pub use account_view::AccountView;
22pub use verification_view::VerificationView;
23
24/// Public user response shape.
25#[derive(Debug, Clone, PartialEq)]
26pub struct UserView {
27    /// Configured application fields after output transforms.
28    pub additional_fields: crate::FieldMap,
29    /// Present optional core fields and enabled plugin fields. `None` preserves an unconfigured view.
30    pub visible_fields: Option<std::collections::BTreeSet<String>>,
31    pub id: SchemaValue<String>,
32    pub name: SchemaValue<Option<String>>,
33    pub email: Option<String>,
34    pub email_verified: bool,
35    pub image: SchemaValue<Option<String>>,
36    pub created_at: crate::FieldDate,
37    pub updated_at: crate::FieldDate,
38    pub is_anonymous: Option<bool>,
39    pub phone_number: Option<String>,
40    pub phone_number_verified: Option<bool>,
41    pub username: Option<String>,
42    pub display_username: Option<String>,
43    pub two_factor_enabled: Option<bool>,
44    pub role: Option<String>,
45    pub banned: bool,
46    pub ban_reason: Option<String>,
47    pub ban_expires: Option<crate::FieldDate>,
48    pub metadata: crate::FieldValue,
49}
50
51/// Public session response shape.
52#[derive(Debug, Clone, PartialEq)]
53pub struct SessionView {
54    /// Plugin field presence in the database projection or signed cache.
55    pub visible_fields: Option<std::collections::BTreeSet<String>>,
56    pub id: SchemaValue<String>,
57    pub expires_at: crate::FieldDate,
58    pub token: String,
59    pub created_at: crate::FieldDate,
60    pub updated_at: crate::FieldDate,
61    pub ip_address: Option<String>,
62    pub user_agent: Option<String>,
63    pub user_id: SchemaValue<String>,
64    pub impersonated_by: Option<String>,
65    pub active_organization_id: Option<String>,
66    pub active_team_id: Option<String>,
67    pub active: bool,
68    pub additional_fields: crate::FieldMap,
69}
70
71impl From<crate::session::SessionData> for crate::FieldMap {
72    fn from(data: crate::session::SessionData) -> Self {
73        Self::from([
74            ("session".into(), Self::from(data.session).into()),
75            ("user".into(), Self::from(data.user).into()),
76        ])
77    }
78}
79
80impl crate::FromFieldMap for crate::session::SessionData {
81    fn from_field_values(fields: crate::FieldMap) -> crate::AuthResult<Self> {
82        fn record<T: crate::FromFieldMap>(
83            fields: &crate::FieldMap,
84            name: &str,
85        ) -> crate::AuthResult<T> {
86            let fields = fields
87                .get(name)
88                .and_then(crate::FieldValue::as_object)
89                .ok_or_else(|| {
90                    crate::AuthError::internal(format!(
91                        "Session response must contain a `{name}` object"
92                    ))
93                })?;
94            T::from_field_values(fields.clone())
95        }
96        Ok(Self {
97            session: record(&fields, "session")?,
98            user: record(&fields, "user")?,
99        })
100    }
101}
102
103impl UserView {
104    /// Read an application model without applying adapter output transforms.
105    pub fn from_model<T: AuthUser>(user: &T) -> crate::AuthResult<Self> {
106        let model = user.field_values()?;
107        Ok(Self {
108            additional_fields: user.projected_fields().cloned().unwrap_or_default(),
109            visible_fields: user.field_presence().cloned(),
110            id: user.id().into_owned(),
111            name: SchemaValue::from_field(
112                model
113                    .get(T::serialized_field_name("name"))
114                    .cloned()
115                    .unwrap_or_default(),
116            ),
117            email: user.email().map(str::to_owned),
118            email_verified: user.email_verified(),
119            image: SchemaValue::from_field(
120                model
121                    .get(T::serialized_field_name("image"))
122                    .cloned()
123                    .unwrap_or_default(),
124            ),
125            created_at: user.created_at(),
126            updated_at: user.updated_at(),
127            is_anonymous: user.is_anonymous(),
128            phone_number: user.phone_number().map(str::to_owned),
129            phone_number_verified: user.phone_number_verified(),
130            username: user.username().map(str::to_owned),
131            display_username: user.display_username().map(str::to_owned),
132            two_factor_enabled: match model.get(T::serialized_field_name("twoFactorEnabled")) {
133                Some(value) => value.decode()?,
134                None => Some(user.two_factor_enabled()),
135            },
136            role: user.role().map(str::to_owned),
137            banned: user.banned(),
138            ban_reason: user.ban_reason().map(str::to_owned),
139            ban_expires: user.ban_expires(),
140            metadata: model
141                .get(T::serialized_field_name("metadata"))
142                .cloned()
143                .unwrap_or(crate::FieldValue::Null),
144        })
145    }
146}
147
148impl From<&UserView> for UserView {
149    fn from(user: &UserView) -> Self {
150        user.clone()
151    }
152}
153
154impl<T: AuthSession> From<&T> for SessionView {
155    fn from(session: &T) -> Self {
156        Self {
157            visible_fields: Some(session.field_presence().cloned().unwrap_or_else(|| {
158                [
159                    ("impersonated_by", "impersonatedBy"),
160                    ("active_organization_id", "activeOrganizationId"),
161                    ("active_team_id", "activeTeamId"),
162                ]
163                .into_iter()
164                .filter(|(field, _)| T::PLUGIN_FIELDS.contains(field))
165                .map(|(_, name)| name.to_owned())
166                .collect()
167            })),
168            id: session.id().into_owned(),
169            expires_at: session.expires_at(),
170            token: session.token().to_owned(),
171            created_at: session.created_at(),
172            updated_at: session.updated_at(),
173            ip_address: session.ip_address().map(str::to_owned),
174            user_agent: session.user_agent().map(str::to_owned),
175            user_id: session.user_id().into_owned(),
176            impersonated_by: session.impersonated_by().map(str::to_owned),
177            active_organization_id: session.active_organization_id().map(str::to_owned),
178            active_team_id: session.active_team_id().map(str::to_owned),
179            active: session.active(),
180            additional_fields: Default::default(),
181        }
182    }
183}
184
185impl SessionView {
186    pub(crate) fn active_plugin_fields(
187        metadata: &crate::plugin::MetadataMap,
188    ) -> impl Iterator<Item = &'static str> + '_ {
189        [
190            ("admin.enabled", "impersonatedBy"),
191            ("organization.enabled", "activeOrganizationId"),
192            ("organization.teams_enabled", "activeTeamId"),
193        ]
194        .into_iter()
195        .filter(move |(plugin, _)| {
196            metadata.get(*plugin).and_then(serde_json::Value::as_bool) == Some(true)
197        })
198        .map(|(_, field)| field)
199    }
200
201    /// Apply current public visibility without materializing absent cached fields.
202    pub fn filter_returned_fields(&mut self, config: &crate::config::SessionConfig) {
203        self.additional_fields.retain(|name, _| {
204            config
205                .fields()
206                .get(name)
207                .is_none_or(|field| field.returned())
208        });
209    }
210
211    /// Apply configured field visibility to a serialized application session model.
212    pub async fn with_fields<T: AuthSession>(
213        session: &T,
214        config: &crate::config::SessionConfig,
215    ) -> crate::AuthResult<Self> {
216        Self::with_fields_for_adapter(session, config, true).await
217    }
218
219    pub async fn with_fields_for_adapter<T: AuthSession>(
220        session: &T,
221        config: &crate::config::SessionConfig,
222        supports_native_json: bool,
223    ) -> crate::AuthResult<Self> {
224        let mut view =
225            Self::with_internal_fields_for_adapter(session, config, supports_native_json).await?;
226        view.filter_returned_fields(config);
227        Ok(view)
228    }
229
230    /// Include hidden application fields for trusted callbacks, never for public responses.
231    pub async fn with_internal_fields_for_adapter<T: AuthSession>(
232        session: &T,
233        config: &crate::config::SessionConfig,
234        supports_native_json: bool,
235    ) -> crate::AuthResult<Self> {
236        // Projection preserves the one input row.
237        Ok(Self::with_internal_fields_many_for_adapter(
238            std::slice::from_ref(session),
239            config,
240            supports_native_json,
241        )
242        .await?
243        .remove(0))
244    }
245
246    /// Project a database result in row order, interleaving synchronous callbacks by field.
247    pub async fn with_internal_fields_many_for_adapter<T: AuthSession>(
248        sessions: &[T],
249        config: &crate::config::SessionConfig,
250        supports_native_json: bool,
251    ) -> crate::AuthResult<Vec<Self>> {
252        Self::with_internal_fields_many_for_adapter_then(
253            sessions,
254            config,
255            supports_native_json,
256            |_, session| std::future::ready(Ok(session)),
257        )
258        .await
259    }
260
261    /// Continue each projected session while other rows retain their pending field callbacks.
262    /// Preserve the original row index for adapter-owned association data.
263    pub async fn with_internal_fields_many_for_adapter_then<T: AuthSession, R: Send, F>(
264        sessions: &[T],
265        config: &crate::config::SessionConfig,
266        supports_native_json: bool,
267        complete: impl Fn(usize, Self) -> F + Sync,
268    ) -> crate::AuthResult<Vec<R>>
269    where
270        F: std::future::Future<Output = crate::AuthResult<R>> + Send,
271    {
272        let mut rows = session_projection::rows(sessions, config)?;
273        let schema = config.adapter_schema();
274        crate::user_fields::project_fields_then(
275            &mut rows,
276            schema.fields(),
277            |row, name, field| Box::pin(row.project(name, field, supports_native_json)),
278            |index, row| complete(index, row.view.clone().into_projected_fields()),
279        )
280        .await
281    }
282
283    /// Continue ready session projections together, retaining their original row indices.
284    pub async fn with_internal_fields_many_for_adapter_batches_then<T: AuthSession, R: Send, F>(
285        sessions: &[T],
286        config: &crate::config::SessionConfig,
287        supports_native_json: bool,
288        complete: impl Fn(Vec<(usize, Self)>) -> F + Sync,
289    ) -> crate::AuthResult<Vec<R>>
290    where
291        F: std::future::Future<Output = crate::AuthResult<Vec<(usize, R)>>> + Send,
292    {
293        let mut rows = session_projection::rows(sessions, config)?;
294        let schema = config.adapter_schema();
295        crate::user_fields::project_fields_batches_then(
296            &mut rows,
297            schema.fields(),
298            |row, name, field| Box::pin(row.project(name, field, supports_native_json)),
299            |_, row| Ok(row.view.clone().into_projected_fields()),
300            complete,
301        )
302        .await
303    }
304}
305
306impl AuthUser for UserView {
307    fn field_presence(&self) -> Option<&std::collections::BTreeSet<String>> {
308        self.visible_fields.as_ref()
309    }
310    fn projected_fields(&self) -> Option<&crate::FieldMap> {
311        Some(&self.additional_fields)
312    }
313    const PLUGIN_FIELDS: &'static [&'static str] = &[
314        "is_anonymous",
315        "phone_number",
316        "phone_number_verified",
317        "username",
318        "display_username",
319        "two_factor_enabled",
320        "role",
321        "banned",
322        "ban_reason",
323        "ban_expires",
324        "metadata",
325    ];
326    fn id(&self) -> SchemaValue<Cow<'_, str>> {
327        self.id.as_ref().map(|id| Cow::Borrowed(id.as_str()))
328    }
329    fn email(&self) -> Option<&str> {
330        self.email.as_deref()
331    }
332    fn email_verified(&self) -> bool {
333        self.email_verified
334    }
335    fn created_at(&self) -> crate::FieldDate {
336        self.created_at.clone()
337    }
338    fn updated_at(&self) -> crate::FieldDate {
339        self.updated_at.clone()
340    }
341    fn is_anonymous(&self) -> Option<bool> {
342        self.is_anonymous
343    }
344    fn phone_number(&self) -> Option<&str> {
345        self.phone_number.as_deref()
346    }
347    fn phone_number_verified(&self) -> Option<bool> {
348        self.phone_number_verified
349    }
350    fn username(&self) -> Option<&str> {
351        self.username.as_deref()
352    }
353    fn display_username(&self) -> Option<&str> {
354        self.display_username.as_deref()
355    }
356    fn two_factor_enabled(&self) -> bool {
357        self.two_factor_enabled == Some(true)
358    }
359    fn role(&self) -> Option<&str> {
360        self.role.as_deref()
361    }
362    fn banned(&self) -> bool {
363        self.banned
364    }
365    fn ban_reason(&self) -> Option<&str> {
366        self.ban_reason.as_deref()
367    }
368    fn ban_expires(&self) -> Option<crate::FieldDate> {
369        self.ban_expires.clone()
370    }
371}
372
373impl AuthSession for SessionView {
374    fn field_presence(&self) -> Option<&std::collections::BTreeSet<String>> {
375        self.visible_fields.as_ref()
376    }
377    fn projected_fields(&self) -> Option<&crate::FieldMap> {
378        Some(&self.additional_fields)
379    }
380
381    const PLUGIN_FIELDS: &'static [&'static str] = &[
382        "impersonated_by",
383        "active_organization_id",
384        "active_team_id",
385    ];
386    fn id(&self) -> SchemaValue<Cow<'_, str>> {
387        self.id.as_ref().map(|id| Cow::Borrowed(id.as_str()))
388    }
389    fn expires_at(&self) -> crate::FieldDate {
390        self.expires_at.clone()
391    }
392    fn token(&self) -> &str {
393        &self.token
394    }
395    fn created_at(&self) -> crate::FieldDate {
396        self.created_at.clone()
397    }
398    fn updated_at(&self) -> crate::FieldDate {
399        self.updated_at.clone()
400    }
401    fn ip_address(&self) -> Option<&str> {
402        self.ip_address.as_deref()
403    }
404    fn user_agent(&self) -> Option<&str> {
405        self.user_agent.as_deref()
406    }
407    fn user_id(&self) -> SchemaValue<Cow<'_, str>> {
408        self.user_id.as_ref().map(|id| Cow::Borrowed(id.as_str()))
409    }
410    fn impersonated_by(&self) -> Option<&str> {
411        self.impersonated_by.as_deref()
412    }
413    fn active_team_id(&self) -> Option<&str> {
414        self.active_team_id.as_deref()
415    }
416    fn active_organization_id(&self) -> Option<&str> {
417        self.active_organization_id.as_deref()
418    }
419    fn active(&self) -> bool {
420        self.active
421    }
422}
423
424// ---------------------------------------------------------------------------
425// Plugin entity views
426// ---------------------------------------------------------------------------
427
428/// Public organization response shape.
429#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
430pub struct OrganizationView {
431    #[serde(with = "crate::field_value::serde::map", flatten)]
432    pub additional_fields: crate::FieldMap,
433
434    #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
435    pub id: SchemaValue<String>,
436    #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
437    pub name: SchemaValue<String>,
438    #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
439    pub slug: SchemaValue<String>,
440    #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
441    pub logo: SchemaValue<Option<String>>,
442    #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
443    pub metadata: SchemaValue<Option<crate::FieldValue>>,
444    #[serde(rename = "createdAt")]
445    #[serde(with = "crate::field_value::serde::schema_date")]
446    #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
447    pub created_at: SchemaValue<crate::FieldDate>,
448}
449
450impl<T: AuthOrganization> From<&T> for OrganizationView {
451    fn from(org: &T) -> Self {
452        Self {
453            additional_fields: org.projected_fields().cloned().unwrap_or_default(),
454            id: org.id().into_owned(),
455            name: org.name().clone(),
456            slug: org.slug().clone(),
457            logo: org.logo().clone(),
458            metadata: org.metadata().clone(),
459            created_at: org.created_at().clone(),
460        }
461    }
462}
463
464/// Public invitation response shape.
465#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
466pub struct InvitationView {
467    #[serde(with = "crate::field_value::serde::map", flatten)]
468    pub additional_fields: crate::FieldMap,
469
470    #[serde(rename = "teamId")]
471    #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
472    pub team_id: SchemaValue<Option<String>>,
473    #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
474    pub id: SchemaValue<String>,
475    #[serde(rename = "organizationId")]
476    #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
477    pub organization_id: SchemaValue<String>,
478    #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
479    pub email: SchemaValue<String>,
480    #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
481    pub role: SchemaValue<String>,
482    #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
483    pub status: SchemaValue<InvitationStatus>,
484    #[serde(rename = "inviterId")]
485    #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
486    pub inviter_id: SchemaValue<String>,
487    #[serde(rename = "expiresAt")]
488    #[serde(with = "crate::field_value::serde::schema_date")]
489    #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
490    pub expires_at: SchemaValue<crate::FieldDate>,
491    #[serde(rename = "createdAt")]
492    #[serde(with = "crate::field_value::serde::schema_date")]
493    #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
494    pub created_at: SchemaValue<crate::FieldDate>,
495}
496
497impl<T: AuthInvitation> From<&T> for InvitationView {
498    fn from(inv: &T) -> Self {
499        Self {
500            additional_fields: inv.projected_fields().cloned().unwrap_or_default(),
501            id: inv.id().into_owned(),
502            organization_id: inv.organization_id().clone(),
503            email: inv.email().clone(),
504            role: inv.role().clone(),
505            status: inv.status().clone(),
506            inviter_id: inv.inviter_id().clone(),
507            team_id: inv.team_id().clone(),
508            expires_at: inv.expires_at().clone(),
509            created_at: inv.created_at().clone(),
510        }
511    }
512}
513
514/// Public passkey response shape.
515#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
516pub struct PasskeyView {
517    /// Declared application fields after adapter output projection.
518    #[serde(with = "crate::field_value::serde::map", flatten, default)]
519    pub additional_fields: crate::FieldMap,
520    #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
521    pub id: SchemaValue<String>,
522    #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
523    pub name: SchemaValue<Option<String>>,
524    #[serde(rename = "credentialID")]
525    pub credential_id: String,
526    #[serde(rename = "userId")]
527    #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
528    pub user_id: SchemaValue<String>,
529    #[serde(rename = "publicKey")]
530    pub public_key: String,
531    pub counter: u64,
532    #[serde(rename = "deviceType")]
533    pub device_type: String,
534    #[serde(rename = "backedUp")]
535    pub backed_up: bool,
536    pub transports: Option<String>,
537    #[serde(rename = "createdAt")]
538    #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
539    #[serde(with = "crate::field_value::serde::optional_schema_date")]
540    pub created_at: SchemaValue<Option<crate::FieldDate>>,
541    #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
542    pub aaguid: SchemaValue<Option<String>>,
543}
544
545impl<T: AuthPasskey> From<&T> for PasskeyView {
546    fn from(pk: &T) -> Self {
547        Self {
548            additional_fields: pk.additional_fields().cloned().unwrap_or_default(),
549            id: pk.id().into_owned(),
550            name: pk.name().clone(),
551            credential_id: pk.credential_id().to_owned(),
552            user_id: pk.user_id().into_owned(),
553            public_key: pk.public_key().to_owned(),
554            counter: pk.counter(),
555            device_type: pk.device_type().to_owned(),
556            backed_up: pk.backed_up(),
557            transports: pk.transports().map(str::to_owned),
558            created_at: pk.created_at().clone(),
559            aaguid: pk.aaguid().clone(),
560        }
561    }
562}
563
564/// Public API key response shape.
565///
566/// Intentionally omits `key_hash` — the hashed key value is never returned
567/// over the wire (matches upstream TS behavior).
568#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
569pub struct ApiKeyView {
570    /// Declared application fields after adapter output projection.
571    #[serde(with = "crate::field_value::serde::map", flatten, default)]
572    pub additional_fields: crate::FieldMap,
573    #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
574    pub id: SchemaValue<String>,
575    #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
576    pub name: SchemaValue<Option<String>>,
577    pub start: Option<crate::ApiKeyStart>,
578    pub prefix: Option<String>,
579    #[serde(rename = "referenceId")]
580    pub reference_id: String,
581    #[serde(rename = "configId")]
582    pub config_id: String,
583    #[serde(rename = "refillInterval")]
584    #[serde(serialize_with = "serialize_optional_number")]
585    pub refill_interval: Option<f64>,
586    #[serde(rename = "refillAmount")]
587    #[serde(serialize_with = "serialize_optional_number")]
588    pub refill_amount: Option<f64>,
589    #[serde(rename = "lastRefillAt")]
590    #[serde(default, with = "crate::field_value::serde::optional_date")]
591    pub last_refill_at: Option<crate::FieldDate>,
592    #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
593    pub enabled: SchemaValue<bool>,
594    #[serde(rename = "rateLimitEnabled")]
595    #[serde(default, skip_serializing_if = "SchemaValue::is_undefined")]
596    pub rate_limit_enabled: SchemaValue<bool>,
597    #[serde(rename = "rateLimitTimeWindow")]
598    #[serde(serialize_with = "serialize_optional_number")]
599    pub rate_limit_time_window: Option<f64>,
600    #[serde(rename = "rateLimitMax")]
601    #[serde(serialize_with = "serialize_optional_number")]
602    pub rate_limit_max: Option<f64>,
603    #[serde(rename = "requestCount")]
604    #[serde(serialize_with = "serialize_optional_number")]
605    pub request_count: Option<f64>,
606    #[serde(serialize_with = "serialize_optional_number")]
607    pub remaining: Option<f64>,
608    #[serde(rename = "lastRequest")]
609    #[serde(default, with = "crate::field_value::serde::optional_date")]
610    pub last_request: Option<crate::FieldDate>,
611    #[serde(rename = "expiresAt")]
612    #[serde(default, with = "crate::field_value::serde::optional_date")]
613    pub expires_at: Option<crate::FieldDate>,
614    #[serde(rename = "createdAt")]
615    #[serde(with = "crate::field_value::serde::date")]
616    pub created_at: crate::FieldDate,
617    #[serde(rename = "updatedAt")]
618    #[serde(with = "crate::field_value::serde::date")]
619    pub updated_at: crate::FieldDate,
620    pub permissions: Option<serde_json::Value>,
621    pub metadata: Option<serde_json::Value>,
622}
623
624/// Serialize optional JavaScript numbers, preserving whole-number JSON representation.
625pub fn serialize_optional_number<S: Serializer>(
626    value: &Option<f64>,
627    serializer: S,
628) -> Result<S::Ok, S::Error> {
629    match value {
630        Some(value) if value.fract() == 0.0 && value.abs() <= 9_007_199_254_740_991.0 => {
631            serializer.serialize_i64(*value as i64)
632        }
633        value => value.serialize(serializer),
634    }
635}
636
637impl<T: AuthApiKey> From<&T> for ApiKeyView {
638    fn from(ak: &T) -> Self {
639        Self {
640            additional_fields: ak.additional_fields().cloned().unwrap_or_default(),
641            id: ak.id().into_owned(),
642            name: ak.name().clone(),
643            start: ak.start().map(std::borrow::Cow::into_owned),
644            prefix: ak.prefix().map(str::to_owned),
645            reference_id: ak.reference_id().into_owned(),
646            config_id: ak.config_id().into_owned(),
647            refill_interval: ak.refill_interval(),
648            refill_amount: ak.refill_amount(),
649            last_refill_at: ak.last_refill_at(),
650            enabled: ak.enabled().clone(),
651            rate_limit_enabled: ak.rate_limit_enabled().clone(),
652            rate_limit_time_window: ak.rate_limit_time_window(),
653            rate_limit_max: ak.rate_limit_max(),
654            request_count: ak.request_count(),
655            remaining: ak.remaining(),
656            last_request: ak.last_request(),
657            expires_at: ak.expires_at(),
658            created_at: ak.created_at().to_owned(),
659            updated_at: ak.updated_at().to_owned(),
660            permissions: ak.permissions().and_then(|s| serde_json::from_str(s).ok()),
661            metadata: ak.metadata().and_then(|s| serde_json::from_str(s).ok()),
662        }
663    }
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669
670    #[test]
671    fn user_view_serializes_camel_case() {
672        let user = UserView {
673            visible_fields: None,
674            additional_fields: Default::default(),
675            id: "user-1".to_string().into(),
676            name: Some("Ada".to_string()).into(),
677            email: Some("ada@example.com".to_string()),
678            email_verified: true,
679            image: Default::default(),
680            created_at: Utc::now().into(),
681            updated_at: Utc::now().into(),
682            is_anonymous: None,
683            phone_number: None,
684            phone_number_verified: None,
685            username: Some("ada".to_string()),
686            display_username: Some("Ada".to_string()),
687            two_factor_enabled: Some(true),
688            role: Some("admin".to_string()),
689            banned: false,
690            ban_reason: None,
691            ban_expires: None,
692            metadata: crate::FieldMap::new().into(),
693        };
694
695        let json = serde_json::to_value(UserView::from(&user)).expect("serialize user view");
696        assert_eq!(json["emailVerified"], true);
697        assert_eq!(json["displayUsername"], "Ada");
698        assert_eq!(json["twoFactorEnabled"], true);
699    }
700
701    #[tokio::test]
702    async fn two_factor_enabled_preserves_null_through_projection_and_cache() {
703        let metadata = crate::plugin::MetadataMap::from_iter([(
704            "two_factor.enabled".to_owned(),
705            serde_json::json!(true),
706        )]);
707        for value in [None, Some(false), Some(true)] {
708            let user: UserView = serde_json::from_value(serde_json::json!({
709                "id": "owner",
710                "name": "Owner",
711                "email": "owner@example.com",
712                "emailVerified": true,
713                "createdAt": "2026-10-07T00:00:00.000Z",
714                "updatedAt": "2026-10-07T00:00:00.000Z",
715                "twoFactorEnabled": value,
716            }))
717            .expect("user response");
718            let projected = UserView::with_fields(&user, &Default::default(), &metadata)
719                .await
720                .expect("project nullable plugin field");
721            let cached: UserView = serde_json::from_value(
722                serde_json::to_value(&projected).expect("serialize signed cache user"),
723            )
724            .expect("deserialize signed cache user");
725            let projected = UserView::with_fields(&cached, &Default::default(), &metadata)
726                .await
727                .expect("reproject cached user");
728            assert_eq!(projected.two_factor_enabled, value);
729            assert_eq!(projected.two_factor_enabled(), value == Some(true));
730            assert_eq!(
731                serde_json::to_value(projected)
732                    .expect("serialize public user")
733                    .get("twoFactorEnabled"),
734                Some(&serde_json::json!(value)),
735            );
736        }
737    }
738
739    #[test]
740    fn session_view_serializes_camel_case() {
741        let session = SessionView {
742            visible_fields: None,
743            id: "session-1".to_string().into(),
744            expires_at: Utc::now().into(),
745            token: "token".to_string(),
746            created_at: Utc::now().into(),
747            updated_at: Utc::now().into(),
748            ip_address: Some("127.0.0.1".to_string()),
749            user_agent: Some("agent".to_string()),
750            user_id: "user-1".to_string().into(),
751            impersonated_by: Some("admin-1".to_string()),
752            active_organization_id: Some("org-1".to_string()),
753            active_team_id: None,
754            active: true,
755            additional_fields: Default::default(),
756        };
757
758        let json =
759            serde_json::to_value(SessionView::from(&session)).expect("serialize session view");
760        assert_eq!(json["expiresAt"].is_string(), true);
761        assert_eq!(json["ipAddress"], "127.0.0.1");
762        assert_eq!(json["activeOrganizationId"], "org-1");
763    }
764
765    #[test]
766    fn account_view_omits_password_on_serialize() {
767        let account = AccountView {
768            additional_fields: Default::default(),
769            id: "acc-1".to_string().into(),
770            account_id: "account-id".to_string().into(),
771            provider_id: "credential".to_string().into(),
772            user_id: "user-1".to_string().into(),
773            access_token: None.into(),
774            refresh_token: None.into(),
775            id_token: None.into(),
776            access_token_expires_at: None.into(),
777            refresh_token_expires_at: None.into(),
778            scope: None.into(),
779            password: Some("$2a$hash".to_string()).into(),
780            created_at: Utc::now().into(),
781            updated_at: Utc::now().into(),
782        };
783
784        let json = serde_json::to_value(&account).expect("serialize account view");
785        assert!(
786            json.get("password").is_none(),
787            "password field must not appear in serialized output"
788        );
789    }
790}