Skip to main content

better_auth_core/plugin_runtime/
fields.rs

1mod api_key;
2mod device;
3mod jwk;
4mod models;
5mod passkey;
6mod two_factor;
7mod wallet;
8
9use crate::id::AdapterIdInput;
10use crate::store::schema::{EntityRole, resolve_field_name};
11use crate::user_fields::{AdapterRecord, UserConfig, UserFieldType};
12use crate::{AuthConfig, AuthError, AuthResult, FieldMap, FieldValue as Value, SchemaValue};
13use indexmap::{IndexMap, IndexSet};
14use std::sync::{Arc, LazyLock, Mutex};
15
16/// Plugin field policies consumed by the selected adapter during auth initialization.
17#[derive(Clone, Default)]
18pub struct ModelFields {
19    models: IndexMap<EntityRole, UserConfig>,
20    schema_models: Option<Vec<(EntityRole, &'static str)>>,
21    custom_models: IndexMap<String, (String, UserConfig)>,
22    user_plugin_fields: Vec<&'static str>,
23    session_plugin_fields: Vec<&'static str>,
24    native_fields: IndexMap<EntityRole, IndexSet<String>>,
25    organization_output_order: IndexMap<EntityRole, Vec<String>>,
26    organization: Option<crate::organization_fields::OrganizationFields>,
27    id_history: Arc<Mutex<IdHistory>>,
28}
29
30#[derive(Default)]
31struct IdHistory {
32    canonical: IndexSet<EntityRole>,
33    input: IndexMap<EntityRole, AdapterIdInput>,
34}
35
36fn optional_string(fields: &mut FieldMap, name: &str) -> Option<SchemaValue<Option<String>>> {
37    fields.shift_remove(name).map(SchemaValue::from_field)
38}
39
40impl ModelFields {
41    /// Start an adapter runtime with the same policies and no operation history.
42    #[doc(hidden)]
43    pub fn fresh_runtime(&self) -> Self {
44        Self {
45            id_history: Arc::default(),
46            ..self.clone()
47        }
48    }
49
50    /// Retain the upstream primary-key replacement across calls on this adapter runtime.
51    #[doc(hidden)]
52    pub fn canonicalize_id(&self, role: EntityRole) -> AuthResult<()> {
53        let _ = self
54            .id_history
55            .lock()
56            .map_err(|_| AuthError::internal("Adapter schema history lock poisoned"))?
57            .canonical
58            .insert(role);
59        Ok(())
60    }
61
62    /// Install the ID input policy before create or update field conversion.
63    #[doc(hidden)]
64    pub fn begin_id_input(&self, role: EntityRole, policy: AdapterIdInput) -> AuthResult<()> {
65        let mut history = self
66            .id_history
67            .lock()
68            .map_err(|_| AuthError::internal("Adapter schema history lock poisoned"))?;
69        let _ = history.canonical.insert(role);
70        let _ = history.input.insert(role, policy);
71        Ok(())
72    }
73
74    /// Field-attribute lookup installs an unforced ID policy without native UUID support.
75    #[doc(hidden)]
76    pub fn begin_id_query(&self, role: EntityRole) -> AuthResult<()> {
77        self.begin_id_input(role, AdapterIdInput::default())
78    }
79
80    /// Replace the ID input policy when a nonempty output conversion starts.
81    #[doc(hidden)]
82    pub fn begin_id_output(&self, role: EntityRole) -> AuthResult<()> {
83        let mut history = self
84            .id_history
85            .lock()
86            .map_err(|_| AuthError::internal("Adapter schema history lock poisoned"))?;
87        let _ = history.canonical.insert(role);
88        let _ = history.input.shift_remove(&role);
89        Ok(())
90    }
91
92    /// Read the current ID policy after preceding field callbacks have completed.
93    #[doc(hidden)]
94    pub fn id_input_active(&self, role: EntityRole) -> AuthResult<bool> {
95        Ok(self.id_input_policy(role)?.is_some())
96    }
97
98    /// Read the complete input policy after preceding field callbacks have completed.
99    #[doc(hidden)]
100    pub fn id_input_policy(&self, role: EntityRole) -> AuthResult<Option<AdapterIdInput>> {
101        Ok(self
102            .id_history
103            .lock()
104            .map_err(|_| AuthError::internal("Adapter schema history lock poisoned"))?
105            .input
106            .get(&role)
107            .copied())
108    }
109
110    pub(crate) fn runtime_fields(
111        &self,
112        role: EntityRole,
113        configured: &UserConfig,
114    ) -> AuthResult<UserConfig> {
115        let mut fields = configured.clone();
116        if self
117            .id_history
118            .lock()
119            .map_err(|_| AuthError::internal("Adapter schema history lock poisoned"))?
120            .canonical
121            .contains(&role)
122        {
123            let _ = fields.fields_mut().insert("id".into(), Default::default());
124        }
125        Ok(fields)
126    }
127
128    /// Retain the same active logical models used by runtime schema validation.
129    pub fn set_schema_configuration(&mut self, config: &crate::store::schema::SchemaConfiguration) {
130        self.schema_models = Some(config.models());
131        self.user_plugin_fields =
132            crate::wire::UserView::active_plugin_fields(&config.metadata).collect();
133        self.session_plugin_fields =
134            crate::wire::SessionView::active_plugin_fields(&config.metadata).collect();
135    }
136
137    /// Read native User fields owned by active plugins before adapter output projection.
138    #[doc(hidden)]
139    pub fn user_plugin_fields(&self) -> &[&'static str] {
140        &self.user_plugin_fields
141    }
142
143    /// Read native Session fields owned by active plugins before adapter output projection.
144    #[doc(hidden)]
145    pub fn session_plugin_fields(&self) -> &[&'static str] {
146        &self.session_plugin_fields
147    }
148
149    pub(crate) fn organization_join_schema(&self, config: &AuthConfig) -> Self {
150        let mut schema = self.clone();
151        if schema.schema_models.is_none() {
152            // Direct OrganizationStore calls support every organization model before plugin initialization.
153            schema.set_schema_configuration(&crate::store::schema::SchemaConfiguration {
154                config: Arc::new(config.clone()),
155                plugins: vec!["organization"],
156                metadata: [
157                    (
158                        "organization.teams_enabled".into(),
159                        serde_json::Value::Bool(true),
160                    ),
161                    (
162                        "organization.dynamic_access_control".into(),
163                        serde_json::Value::Bool(true),
164                    ),
165                ]
166                .into_iter()
167                .collect(),
168                secondary_storage: false,
169                database_rate_limit: false,
170            });
171        }
172        schema
173    }
174
175    pub(crate) fn register(&mut self, role: EntityRole, fields: UserConfig) -> AuthResult<()> {
176        match role {
177            EntityRole::ApiKey => {
178                let mut combined = self.fields(role).clone();
179                combined.fields_mut().extend(fields.fields().clone());
180                api_key::validate_fields(&combined)?;
181            }
182            EntityRole::DeviceCode => device::validate_fields(&fields)?,
183            EntityRole::Jwk => jwk::validate_fields(&fields)?,
184            EntityRole::Passkey => {
185                let mut combined = self.fields(role).clone();
186                combined.fields_mut().extend(fields.fields().clone());
187                passkey::validate_fields(&combined)?;
188            }
189            EntityRole::TwoFactor => two_factor::validate_fields(&fields)?,
190            EntityRole::WalletAddress => wallet::validate_fields(&fields)?,
191            EntityRole::User
192            | EntityRole::Session
193            | EntityRole::Account
194            | EntityRole::Verification
195            | EntityRole::Organization
196            | EntityRole::Team => {}
197            _ => {
198                return Err(AuthError::config(format!(
199                    "Plugin field registration does not support {role:?}"
200                )));
201            }
202        }
203        self.extend(role, fields);
204        if role == EntityRole::Passkey
205            && let Some(fields) = self.models.get_mut(&role)
206        {
207            // Replacing an upstream field policy retains the field's schema position.
208            fields.fields_mut().sort_by(|left, _, right, _| {
209                passkey::field_order(left).cmp(&passkey::field_order(right))
210            });
211        }
212        if role == EntityRole::ApiKey
213            && let Some(fields) = self.models.get_mut(&role)
214        {
215            fields
216                .fields_mut()
217                .sort_by(|left, _, right, _| (left != "name").cmp(&(right != "name")));
218        }
219        Ok(())
220    }
221
222    pub(crate) fn extend(&mut self, role: EntityRole, fields: UserConfig) {
223        if let Some(fields) = fields.additional_fields {
224            if let Some(native) = self.native_fields.get_mut(&role) {
225                native.retain(|name| !fields.contains_key(name));
226            }
227            self.models
228                .entry(role)
229                .or_default()
230                .fields_mut()
231                .extend(fields);
232        }
233    }
234
235    pub(crate) fn register_organization_schema(
236        &mut self,
237        fields: &crate::organization_fields::OrganizationFields,
238        teams_enabled: bool,
239    ) {
240        self.organization = Some(fields.clone());
241        self.declare_native_fields(
242            EntityRole::Organization,
243            &["name", "slug", "logo", "createdAt", "metadata"],
244            fields.organization.clone(),
245        );
246        if teams_enabled {
247            self.declare_native_fields(
248                EntityRole::Team,
249                &[
250                    "name",
251                    "memberCount",
252                    "organizationId",
253                    "createdAt",
254                    "updatedAt",
255                ],
256                fields.team.clone(),
257            );
258        }
259        self.declare_native_fields(
260            EntityRole::Member,
261            &["organizationId", "userId", "role", "createdAt"],
262            fields.member.clone(),
263        );
264        self.declare_native_fields(
265            EntityRole::Invitation,
266            &[
267                "organizationId",
268                "email",
269                "role",
270                "teamId",
271                "status",
272                "expiresAt",
273                "createdAt",
274                "inviterId",
275            ],
276            fields.invitation.clone(),
277        );
278        self.declare_native_fields(
279            EntityRole::OrganizationRole,
280            &[
281                "organizationId",
282                "role",
283                "permission",
284                "createdAt",
285                "updatedAt",
286            ],
287            fields.organization_role.clone(),
288        );
289    }
290
291    fn declare_native_fields(&mut self, role: EntityRole, names: &[&str], fields: UserConfig) {
292        let registered = self.models.entry(role).or_default().fields_mut();
293        let native = self.native_fields.entry(role).or_default();
294        for name in names {
295            // Reserve the schema position; the adapter already implements native field defaults.
296            let _ = registered.insert((*name).into(), Default::default());
297            let _ = native.insert((*name).into());
298        }
299        self.extend(role, fields);
300    }
301
302    /// Resolve Organization model policies without replacing adapter-owned native defaults.
303    pub fn organization_fields(
304        &self,
305        fields: crate::organization_fields::OrganizationFields,
306    ) -> crate::organization_fields::OrganizationFields {
307        let mut fields = self.organization.clone().unwrap_or(fields);
308        for (role, schema) in [
309            (EntityRole::Organization, &mut fields.organization),
310            (EntityRole::Team, &mut fields.team),
311            (EntityRole::Member, &mut fields.member),
312            (EntityRole::Invitation, &mut fields.invitation),
313            (EntityRole::OrganizationRole, &mut fields.organization_role),
314        ] {
315            let Some(registered) = self.models.get(&role) else {
316                continue;
317            };
318            let mut resolved = registered.clone();
319            for (name, field) in schema.fields() {
320                let _ = resolved
321                    .fields_mut()
322                    .entry(name.clone())
323                    .or_insert_with(|| field.clone());
324            }
325            if let Some(native) = self.native_fields.get(&role) {
326                resolved
327                    .fields_mut()
328                    .retain(|name, _| !native.contains(name));
329            }
330            *schema = resolved;
331        }
332        // Upstream initializes the dynamic-role update schema eagerly as partial.
333        for field in fields.organization_role.fields_mut().values_mut() {
334            field.required = Some(false);
335        }
336        fields
337    }
338
339    /// Read the adapter policies for one supported model.
340    pub fn fields(&self, role: EntityRole) -> &UserConfig {
341        static EMPTY: LazyLock<UserConfig> = LazyLock::new(UserConfig::default);
342        self.models.get(&role).unwrap_or(&EMPTY)
343    }
344
345    pub(crate) fn iter(&self) -> impl Iterator<Item = (EntityRole, &UserConfig)> {
346        self.models.iter().map(|(role, fields)| (*role, fields))
347    }
348
349    pub(crate) fn organization_output_field_names(
350        &self,
351        role: EntityRole,
352        policies: &UserConfig,
353    ) -> Vec<String> {
354        let mut names = self
355            .organization_output_order
356            .get(&role)
357            .cloned()
358            .unwrap_or_else(|| {
359                let mut fields = Self::default();
360                fields.register_organization_schema(&Default::default(), true);
361                fields.fields(role).fields().keys().cloned().collect()
362            });
363        for name in policies.fields().keys() {
364            if !names.contains(name) {
365                names.push(name.clone());
366            }
367        }
368        names.retain(|name| name != "id");
369        names.push("id".into());
370        names
371    }
372
373    /// Merge core policies and retain plugin-table policies for `RuntimeStore::with_runtime`.
374    /// Application fields win at the adapter; plugin fields win at existing endpoint parsers.
375    pub fn resolve(mut self, application: &AuthConfig) -> (AuthConfig, AuthConfig, Self) {
376        let mut adapter = application.clone();
377        let mut endpoint = application.clone();
378        let mut resolve = |role, configured| {
379            let fields = self.models.shift_remove(&role).unwrap_or_default();
380            super::resolve_user_fields(&configured, fields)
381        };
382        (adapter.user, endpoint.user) = resolve(EntityRole::User, application.user.clone());
383        let (stored, public) = resolve(EntityRole::Session, application.session.field_schema());
384        // Initial defaults use input precedence; adapter policies retain application precedence.
385        adapter.session_input_fields = Some(public.clone());
386        adapter.session.additional_fields = stored.additional_fields;
387        endpoint.session.additional_fields = public.additional_fields;
388        let (stored, public) = resolve(
389            EntityRole::Account,
390            UserConfig {
391                additional_fields: Some(application.account.additional_fields.clone()),
392            },
393        );
394        adapter.account.additional_fields = stored.additional_fields.unwrap_or_default();
395        endpoint.account.additional_fields = public.additional_fields.unwrap_or_default();
396        let (stored, public) = resolve(
397            EntityRole::Verification,
398            UserConfig {
399                additional_fields: Some(application.verification.additional_fields.clone()),
400            },
401        );
402        adapter.verification.additional_fields = stored.additional_fields.unwrap_or_default();
403        endpoint.verification.additional_fields = public.additional_fields.unwrap_or_default();
404        for (role, native) in &self.native_fields {
405            if let Some(fields) = self.models.get_mut(role) {
406                // Native slots determine read timing but are not configured adapter policies.
407                let _ = self
408                    .organization_output_order
409                    .insert(*role, fields.fields().keys().cloned().collect());
410                fields.fields_mut().retain(|name, _| !native.contains(name));
411            }
412        }
413        (adapter, endpoint, self)
414    }
415}