Skip to main content

laterite_admin/
bootstrap.rs

1//! Application bootstrap: one call to load config, connect, migrate, build the
2//! admin router, and serve it.
3//!
4//! [`Bootstrap`] keeps an application's `main` small and stable: framework
5//! internals (config fields, the migration runner, router assembly) change here,
6//! not in every app. An app registers its own modules; each module supplies its
7//! migrations and admin surfaces (resources, permissions, settings) from its
8//! `register`, and the app adds extra routes via [`Bootstrap::extend`].
9
10use std::collections::{HashMap, HashSet};
11use std::panic::{catch_unwind, resume_unwind, AssertUnwindSafe};
12use std::path::PathBuf;
13
14use axum::Router;
15use laterite_auth::{AuthConfig, AuthService};
16use laterite_core::config::{self, AppMeta, BackendConfig, DatabaseConfig, ServerConfig};
17use laterite_core::{CapabilitySet, Db, Module, ModuleRegistry, Registry, Translator};
18use serde::Deserialize;
19
20use crate::settings::SettingsItem;
21use crate::{builtin_modules, normalize_path, router, AdminConfig, Permission, Resource};
22
23/// Default env-var prefix for config overrides (`LAT__SECTION__KEY`). An app
24/// declares its own as `app.env_prefix` in its config, which `Bootstrap` and the
25/// `lat` command both read; [`Bootstrap::env_prefix`] overrides even that.
26pub const DEFAULT_ENV_PREFIX: &str = "LAT";
27
28/// The env-var prefix to load config under: a code override, else the config's
29/// own `app.env_prefix`, else [`DEFAULT_ENV_PREFIX`].
30fn resolve_env_prefix(code: Option<String>, declared: Option<String>) -> String {
31    code.or(declared)
32        .unwrap_or_else(|| DEFAULT_ENV_PREFIX.to_string())
33}
34
35#[cfg(test)]
36mod env_prefix_tests {
37    use super::resolve_env_prefix;
38
39    #[test]
40    fn code_override_then_declaration_then_default() {
41        assert_eq!(resolve_env_prefix(None, None), "LAT");
42        assert_eq!(resolve_env_prefix(None, Some("ACME".into())), "ACME");
43        assert_eq!(
44            resolve_env_prefix(Some("CODE".into()), Some("ACME".into())),
45            "CODE"
46        );
47    }
48}
49
50/// The configuration [`Bootstrap`] loads. An app needing more loads its own with
51/// [`laterite_core::config::load`].
52#[derive(Debug, Clone, Deserialize)]
53pub struct AppConfig {
54    #[serde(default)]
55    pub app: AppMeta,
56    pub server: ServerConfig,
57    pub database: DatabaseConfig,
58    #[serde(default)]
59    pub auth: AuthConfig,
60    #[serde(default)]
61    pub backend: BackendConfig,
62    #[serde(default)]
63    pub plugins: PluginsConfig,
64    #[serde(default)]
65    pub cache: laterite_core::config::CacheConfig,
66}
67
68/// Deployment control over which plugins load. A disabled plugin's migrations do
69/// not run and it contributes nothing; any plugin that `requires` a disabled one
70/// is skipped in turn. Built-in modules cannot be disabled.
71#[derive(Debug, Clone, Deserialize, Default)]
72pub struct PluginsConfig {
73    /// Module ids to disable at boot, e.g. `disabled = ["rainmill.location"]`.
74    #[serde(default)]
75    pub disabled: Vec<String>,
76}
77
78/// Passed to [`Bootstrap::extend`] so an app's own routes can share the database
79/// and the detected database capabilities.
80pub struct BootstrapCtx {
81    db: Db,
82    capabilities: CapabilitySet,
83    translator: Translator,
84    events: laterite_core::Events,
85    cache: laterite_core::Cache,
86}
87
88impl BootstrapCtx {
89    /// The connected database, for an app's routes to build their state on.
90    pub fn db(&self) -> Db {
91        self.db.clone()
92    }
93
94    /// The database capabilities available on this deployment, for gating
95    /// optional features.
96    pub fn capabilities(&self) -> &CapabilitySet {
97        &self.capabilities
98    }
99
100    /// The UI-string translator for the configured locale, for an app's routes
101    /// to resolve their own strings.
102    pub fn translator(&self) -> &Translator {
103        &self.translator
104    }
105
106    /// The application's event bus, with every module's listeners on it, for an
107    /// app's routes to announce on.
108    pub fn events(&self) -> &laterite_core::Events {
109        &self.events
110    }
111
112    /// The application's cache, for an app's routes to read through and
113    /// bump. See [`laterite_core::cache`].
114    pub fn cache(&self) -> &laterite_core::Cache {
115        &self.cache
116    }
117}
118
119type ExtendFn = Box<dyn FnOnce(Router, &BootstrapCtx) -> Router>;
120
121/// Builds and serves a Laterite application.
122pub struct Bootstrap {
123    config_dir: PathBuf,
124    env_prefix: Option<String>,
125    app_modules: Vec<Box<dyn Module>>,
126    plugin_modules: Vec<Box<dyn Module>>,
127    extend: Option<ExtendFn>,
128}
129
130impl Bootstrap {
131    /// Loads config from `config_dir` (`default.toml`, the `APP_ENV` overlay, and
132    /// `local.toml`). Env vars override under the prefix the config declares as
133    /// `app.env_prefix`, else [`DEFAULT_ENV_PREFIX`]; [`Bootstrap::env_prefix`]
134    /// overrides both.
135    pub fn new(config_dir: impl Into<PathBuf>) -> Self {
136        Self {
137            config_dir: config_dir.into(),
138            env_prefix: None,
139            app_modules: Vec::new(),
140            plugin_modules: Vec::new(),
141            extend: None,
142        }
143    }
144
145    /// Overrides the config env-var prefix from code, above the config's own
146    /// `app.env_prefix` declaration and [`DEFAULT_ENV_PREFIX`].
147    pub fn env_prefix(mut self, prefix: impl Into<String>) -> Self {
148        self.env_prefix = Some(prefix.into());
149        self
150    }
151
152    /// Registers one of the application's own modules, after the built-in ones. Its
153    /// migrations and contributions run after those of the modules it names in
154    /// `requires`. App modules are load-critical: a failure at boot aborts, rather
155    /// than being quarantined like a plugin registered with [`Bootstrap::modules`].
156    pub fn module(mut self, module: impl Module) -> Self {
157        self.app_modules.push(Box::new(module));
158        self
159    }
160
161    /// Registers a set of plugins, typically the generated `plugins-manifest`.
162    /// Plugins are isolated at boot: one that fails to migrate or register is
163    /// quarantined (logged and skipped, taking its dependents with it) rather than
164    /// aborting the whole application.
165    pub fn modules(mut self, modules: Vec<Box<dyn Module>>) -> Self {
166        self.plugin_modules.extend(modules);
167        self
168    }
169
170    /// Merges the app's own routes onto the admin router before serving. The
171    /// closure gets the assembled router and a [`BootstrapCtx`].
172    pub fn extend(mut self, f: impl FnOnce(Router, &BootstrapCtx) -> Router + 'static) -> Self {
173        self.extend = Some(Box::new(f));
174        self
175    }
176
177    /// Loads config, connects, migrates, builds the router, and serves. Uses a
178    /// `systemfd`-inherited socket in development, else binds the configured
179    /// address.
180    pub async fn serve(self) -> anyhow::Result<()> {
181        // Log subscriber (RUST_LOG, default below); try_init yields to an app's own.
182        let _ = tracing_subscriber::fmt()
183            .with_env_filter(
184                tracing_subscriber::EnvFilter::try_from_default_env()
185                    .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,sqlx=warn")),
186            )
187            .try_init();
188
189        let env_prefix = resolve_env_prefix(
190            self.env_prefix.clone(),
191            config::declared_env_prefix(&self.config_dir)?,
192        );
193        let config: AppConfig = config::load(&self.config_dir, &env_prefix)?;
194        // Debug gates the cause on error pages; the log records it regardless.
195        laterite_core::config::set_debug(config.app.debug);
196
197        let db = laterite_core::db::connect(&config.database).await?;
198
199        // Plugins (registered via `modules`) are quarantine-eligible; built-ins and
200        // the app's own modules are load-critical. Capture the plugin ids before the
201        // modules move into the registry.
202        let plugin_ids: HashSet<String> = self
203            .plugin_modules
204            .iter()
205            .map(|m| m.id().to_string())
206            .collect();
207
208        // Create the plugin-registry table, record the compiled plugins in it, and
209        // read the operator's disabled set, all before deciding what loads. The
210        // built-in PluginsModule carries this same migration, applied idempotently in
211        // the load loop below.
212        laterite_core::migration::run(
213            &db.pool,
214            db.backend,
215            &[crate::plugins::PluginsModule.migrations()],
216        )
217        .await?;
218        crate::plugins::record_roster(&db, plugin_ids.iter().map(String::as_str)).await?;
219
220        // Crash-loop journal: reconcile the previous boot's load markers against
221        // this binary's fingerprint. A plugin whose init took the whole process
222        // down under this same binary is auto-quarantined so it is not retried
223        // into the same crash; a quarantine recorded under a different binary is
224        // lifted, giving a fresh build another chance.
225        let fingerprint = crate::plugins::binary_fingerprint();
226        let reconcile = crate::plugins::reconcile_boot(&db, &fingerprint).await?;
227        for id in &reconcile.crashed {
228            eprintln!(
229                "Plugin '{id}' quarantined: {}.",
230                crate::plugins::CRASH_REASON
231            );
232        }
233        for id in &reconcile.cleared {
234            println!("Plugin '{id}' quarantine lifted: the binary changed since it failed.");
235        }
236
237        let db_disabled = crate::plugins::disabled_ids(&db).await?;
238        let quarantined = crate::plugins::quarantined(&db).await?;
239
240        let mut registry = ModuleRegistry::new();
241        for module in builtin_modules() {
242            registry.register_boxed(module);
243        }
244        for module in self.app_modules {
245            registry.register_boxed(module);
246        }
247        for module in self.plugin_modules {
248            registry.register_boxed(module);
249        }
250        let capabilities =
251            laterite_core::capabilities::check(&db.pool, db.backend, &registry).await?;
252
253        // The modules in dependency order, driving both migrations and
254        // registration so a module's tables and contributions follow its
255        // requirements'.
256        let ordered = registry.ordered()?;
257
258        // Narrow to the modules that will actually load. Config can disable a
259        // plugin, and any plugin that requires a disabled one is skipped in turn.
260        // The registry itself is untouched, so ordered()'s dependency checks still
261        // hold; this only decides what migrates and registers.
262        let builtins: HashSet<String> = builtin_modules()
263            .iter()
264            .map(|m| m.id().to_string())
265            .collect();
266        let mut seed: HashMap<String, String> = HashMap::new();
267        for id in config.plugins.disabled.iter().chain(db_disabled.iter()) {
268            if builtins.contains(id) {
269                eprintln!("Ignoring request to disable built-in module '{id}'.");
270            } else {
271                seed.insert(id.clone(), "disabled".to_string());
272            }
273        }
274        // Quarantined plugins are skipped like disabled ones, but keep the
275        // system's reason. A built-in never carries a quarantine, but guard anyway.
276        for (id, reason) in &quarantined {
277            if !builtins.contains(id) {
278                seed.insert(id.clone(), format!("quarantined: {reason}"));
279            }
280        }
281        let skipped = skip_plan(&ordered, &seed);
282        report_plugins(&ordered, &skipped);
283        let active: Vec<&dyn Module> = ordered
284            .iter()
285            .copied()
286            .filter(|m| !skipped.contains_key(m.id().as_str()))
287            .collect();
288
289        // Load each active module in dependency order: migrate its tables, then
290        // register its contributions. A plugin that fails either step is quarantined
291        // (its partial contributions rolled back) and its dependents skipped; a
292        // built-in or app module failing is fatal.
293        let mut contributions = Registry::new();
294        let mut failed: HashMap<String, String> = HashMap::new();
295        for module in &active {
296            let mid = module.id();
297            let id = mid.as_str();
298            let eligible = plugin_ids.contains(id);
299
300            if let Some(dep) = module
301                .requires()
302                .iter()
303                .find(|r| failed.contains_key(r.as_str()))
304            {
305                let reason = format!("requires {}, which failed to load", dep.as_str());
306                println!("Plugin '{id}' off: {reason}");
307                failed.insert(id.to_string(), reason);
308                continue;
309            }
310
311            // Journal the attempt before any of the plugin's own code runs, so an
312            // uncatchable crash during migrate or register leaves a marker the next
313            // boot will find. Only plugins are journaled: a built-in or app module
314            // crashing is a fatal bug to fix, not something to quarantine.
315            if eligible {
316                crate::plugins::mark_loading(&db, id, &fingerprint).await?;
317            }
318
319            match laterite_core::migration::run(&db.pool, db.backend, &[module.migrations()]).await
320            {
321                Ok(()) => {}
322                Err(e) if !eligible => return Err(e.into()),
323                Err(e) => {
324                    let reason = format!("migration failed: {e}");
325                    eprintln!("Plugin '{id}' quarantined: {reason}");
326                    crate::plugins::quarantine(&db, id, &reason, &fingerprint).await?;
327                    failed.insert(id.to_string(), reason);
328                    continue;
329                }
330            }
331
332            contributions.set_owner(mid);
333            let registered = catch_unwind(AssertUnwindSafe(|| module.register(&mut contributions)));
334            match registered {
335                // Init succeeded: clear the marker so this plugin is not blamed for
336                // a crash later in the boot.
337                Ok(()) => {
338                    if eligible {
339                        crate::plugins::clear_loading(&db, id).await?;
340                    }
341                }
342                Err(panic) => {
343                    contributions.purge_owner(mid);
344                    if !eligible {
345                        resume_unwind(panic);
346                    }
347                    let reason = "register panicked".to_string();
348                    eprintln!("Plugin '{id}' quarantined: {reason}");
349                    crate::plugins::quarantine(&db, id, &reason, &fingerprint).await?;
350                    failed.insert(id.to_string(), reason);
351                }
352            }
353        }
354        // Resolve where each module's screens mount before any route is built,
355        // so a namespace, a declared base and a deployment override all land in
356        // one place and a clash is caught below.
357        // Every module has migrated and registered: now each boots, in the same
358        // order, seeing the whole registry. A plugin whose boot fails is
359        // quarantined like one whose migration failed; anything else aborts.
360        for module in &active {
361            let mid = module.id();
362            let id = mid.as_str();
363            if failed.contains_key(id) {
364                continue;
365            }
366            let cx = laterite_core::BootCx {
367                db: &db,
368                registry: &contributions,
369            };
370            match module.boot(&cx).await {
371                Ok(()) => {}
372                Err(e) if !plugin_ids.contains(id) => return Err(e.into()),
373                Err(e) => {
374                    let reason = format!("boot failed: {e}");
375                    eprintln!("Plugin '{id}' quarantined: {reason}");
376                    crate::plugins::quarantine(&db, id, &reason, &fingerprint).await?;
377                    contributions.purge_owner(mid);
378                    failed.insert(id.to_string(), reason);
379                }
380            }
381        }
382        let active: Vec<&dyn Module> = active
383            .into_iter()
384            .filter(|m| !failed.contains_key(m.id().as_str()))
385            .collect();
386        let bases: HashMap<String, Option<&'static str>> = active
387            .iter()
388            .map(|m| (m.id().to_string(), m.admin_base()))
389            .collect();
390        let mut resources = Vec::new();
391        for (owner, mut resource) in contributions.take_owned::<Resource>() {
392            let base = bases.get(owner.as_str()).copied().flatten();
393            crate::rebase_resource(
394                &crate::routemap::resolve(owner, &resource.base_path, base, &config.backend.paths),
395                &mut resource,
396            );
397            resources.push((owner, resource));
398        }
399        let mut screens = Vec::new();
400        for (owner, mut reg) in contributions.take_owned::<crate::routes::ScreenReg>() {
401            let base = bases.get(owner.as_str()).copied().flatten();
402            reg.base_path =
403                crate::routemap::resolve(owner, &reg.base_path, base, &config.backend.paths);
404            screens.push((owner, reg));
405        }
406
407        let public = contributions.take_owned::<crate::routes::PublicRouteReg>();
408
409        // Every admin path in one list, the framework's own included, so a module
410        // shadowing a built-in screen is caught as loudly as two modules clashing.
411        let mut claims: Vec<(String, String)> = crate::builtin_resources()
412            .iter()
413            .map(|r| (r.base_path.clone(), "the framework".to_string()))
414            .collect();
415        claims.extend(
416            resources
417                .iter()
418                .map(|(owner, r)| (r.base_path.clone(), owner.to_string())),
419        );
420        claims.extend(
421            screens
422                .iter()
423                .map(|(owner, s)| (s.base_path.clone(), owner.to_string())),
424        );
425        crate::routemap::check_segments(&claims);
426        crate::routemap::check_collisions(&claims);
427        let resources: Vec<Resource> = resources.into_iter().map(|(_, r)| r).collect();
428        // Every sealed column is on record, so `lat crypt rotate` finds it.
429        for resource in &resources {
430            if let Some(form) = &resource.form {
431                for field in form.fields.iter().filter(|f| f.field_type == "sensitive") {
432                    laterite_core::crypt::register_column(
433                        &db,
434                        &form.entity,
435                        &field.name,
436                        laterite_core::crypt::FIELD_PURPOSE,
437                    )
438                    .await?;
439                }
440            }
441        }
442        let screens: Vec<crate::routes::ScreenReg> = screens.into_iter().map(|(_, s)| s).collect();
443        // Public paths are literal, so they get their own check: two claims on one
444        // path, or anything reaching inside the panel.
445        crate::routemap::check_public(
446            &public
447                .iter()
448                .map(|(owner, r)| (r.path.clone(), owner.to_string()))
449                .collect::<Vec<_>>(),
450            &crate::normalize_path(&config.backend.path),
451        );
452        let public: Vec<crate::routes::PublicRouteReg> =
453            public.into_iter().map(|(_, r)| r).collect();
454        // Settings screens resolve like every other contributed screen: through
455        // the owning module's namespace. Before this they used their storage key
456        // as a URL, which is both a dotted path and a screen the deployment
457        // could not move with [backend.paths].
458        let mut settings = Vec::new();
459        for (owner, mut item) in contributions.take_owned::<SettingsItem>() {
460            // The screen's own name within its module: the storage key with the
461            // module's prefix removed, since the namespace supplies that part.
462            let declared = item
463                .code
464                .strip_prefix(&format!("{}.", owner.as_str()))
465                .unwrap_or(&item.code)
466                .replace('.', "/");
467            let base = bases.get(owner.as_str()).copied().flatten();
468            item.mount_at(crate::routemap::resolve(
469                owner,
470                &format!("/{declared}"),
471                base,
472                &config.backend.paths,
473            ));
474            settings.push((owner, item));
475        }
476        // A settings screen claims a path like any other, so one shadowing a
477        // resource is caught at boot rather than silently winning.
478        let settings_claims: Vec<(String, String)> = settings
479            .iter()
480            .filter(|(_, i)| i.link.is_none())
481            .map(|(owner, i)| (i.route(), owner.to_string()))
482            .collect();
483        crate::routemap::check_segments(&settings_claims);
484        let settings: Vec<SettingsItem> = settings.into_iter().map(|(_, i)| i).collect();
485        // The cache, over the generations table every module has migrated,
486        // knowing every namespace a module registered.
487        let cache = laterite_core::cache::from_config(&config.cache, &db, config.app.debug).await?;
488        for (owner, namespace) in contributions.take_owned::<laterite_core::cache::CacheNamespace>()
489        {
490            cache.allow(namespace.qualified(owner));
491        }
492        if config.cache.poll_seconds > 0 {
493            cache.start_polling(std::time::Duration::from_secs(config.cache.poll_seconds));
494        }
495        tracing::info!(store = cache.store(), "cache ready");
496        let permissions = contributions.take::<Permission>();
497        let picker_sources = contributions.take::<crate::picker::PickerSourceReg>();
498        let persisters = contributions.take::<crate::persist::PersisterReg>();
499        let listeners = contributions.take::<laterite_core::ModelListenerReg>();
500        let field_types = contributions.take::<crate::field::FieldTypeReg>();
501        let column_types = contributions.take::<crate::list::ColumnTypeReg>();
502        let nav = contributions.take::<crate::nav::NavItem>();
503        let resource_hooks = contributions.take::<crate::ResourceHook>();
504        let widgets = contributions.take::<crate::widget::WidgetReg>();
505        let skins = contributions.take::<crate::skin::Skin>();
506        let assets = contributions.take::<crate::AssetReg>();
507        // Owned, so each icon is namespaced to the module that contributed it.
508        let icons = contributions.take_owned::<laterite_core::icons::IconReg>();
509
510        // One bus for the application, carrying every listener a module
511        // contributed, in dependency order.
512        let mut bus = laterite_core::Events::builder(db.clone());
513        for (owner, listener) in contributions.take_owned::<laterite_core::EventListenerReg>() {
514            bus = bus.registered(owner, listener);
515        }
516        let events = bus.build()?;
517
518        let auth = AuthService::new(db.clone(), config.auth.clone()).with_events(events.clone());
519        // Expired sessions and stay-signed-in credentials are swept hourly (the
520        // auth module's boot did the first sweep), so the tables hold only what
521        // a request could still use.
522        tokio::spawn(purge_hourly(auth.clone()));
523        let origin = config::base_url(config.app.url.as_deref(), &config.server.listen);
524        let secret = signing_key(&db, config.backend.secret.as_deref()).await?;
525        let crypt = crypt_from_config(&config, &secret)?;
526        let admin_config = AdminConfig {
527            secure_cookie: config.backend.secure_cookie,
528            trusted_proxies: config.backend.trusted_proxies.clone(),
529            timezone: config.backend.timezone.clone(),
530            locale: config.app.locale.clone(),
531            app_name: config.app.name.clone(),
532            path: config.backend.path.clone(),
533            origin: origin.clone(),
534            storage_dir: config.backend.storage.clone(),
535            skin: config.backend.skin.clone(),
536            upload_extensions: config.backend.upload_extensions.clone(),
537            order: config.backend.order.clone(),
538            menu: config.backend.menu.clone(),
539            secret,
540            crypt: Some(std::sync::Arc::new(crypt)),
541            cache: Some(cache.clone()),
542        };
543        // Merge every loaded module's PO catalogs into the shared store the request
544        // translator looks up against. A malformed catalog fails boot, like a bad
545        // descriptor key. Skipped and failed modules do not contribute.
546        let mut catalogs = laterite_core::CatalogStore::builder();
547        for module in active
548            .iter()
549            .filter(|m| !failed.contains_key(m.id().as_str()))
550        {
551            for (locale, po) in module.catalogs() {
552                catalogs = catalogs.po(locale, po).map_err(|e| {
553                    laterite_core::CoreError::Config(format!(
554                        "module {}: {locale} catalog: {e}",
555                        module.id().as_str()
556                    ))
557                })?;
558            }
559        }
560
561        // A renamed permission is repaired wherever an operator's own choices
562        // still hold the old code, before the roles below are rebuilt.
563        let renamed =
564            laterite_auth::store::rename_permissions(&db, &crate::permission_renames(&permissions))
565                .await?;
566        if renamed > 0 {
567            tracing::info!(rows = renamed, "rewrote renamed permission codes");
568        }
569
570        // The framework's own roles, rewritten from the registry now that every
571        // module has contributed: a new permission lands in its role at the boot
572        // that introduces it.
573        laterite_auth::store::sync_system_roles(&db, &crate::system_roles(&permissions)).await?;
574
575        let mut app = router(
576            auth,
577            db.clone(),
578            crate::Contributions {
579                nav,
580                resource_hooks,
581                widgets,
582                skins,
583                assets,
584                resources,
585                settings,
586                permissions,
587                picker_sources,
588                persisters,
589                listeners,
590                field_types,
591                column_types,
592                icons,
593                screens,
594                public_routes: public,
595                // Whatever is left is plugin-defined: types the framework knows
596                // nothing about, kept so one module's route can read another's
597                // contributions.
598                plugin_defined: std::sync::Arc::new(contributions),
599            },
600            admin_config,
601            std::sync::Arc::new(catalogs.build()),
602        );
603
604        if let Some(extend) = self.extend {
605            let ctx = BootstrapCtx {
606                db,
607                capabilities,
608                translator: Translator::new(&config.app.locale),
609                events,
610                cache,
611            };
612            app = extend(app, &ctx);
613        }
614
615        let listener = bind(&config.server.listen).await?;
616        let admin_path = normalize_path(&config.backend.path);
617        println!("{} on {origin}{admin_path}", config.app.name);
618        axum::serve(
619            listener,
620            app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
621        )
622        .await?;
623        Ok(())
624    }
625}
626
627/// Binds the listener, preferring a `systemfd`-inherited socket (so the port
628/// survives reloads) and falling back to binding `listen`.
629/// One sweep of expired sessions and credentials, logged when it removed any.
630async fn purge_expired(auth: &AuthService) {
631    match auth.purge_expired().await {
632        Ok(purged) if purged.sessions + purged.remember_tokens > 0 => tracing::info!(
633            sessions = purged.sessions,
634            remember_tokens = purged.remember_tokens,
635            "purged expired sessions and credentials"
636        ),
637        Ok(_) => {}
638        Err(e) => tracing::error!(error = %e, "purging expired sessions failed"),
639    }
640}
641
642/// The hourly sweep, for as long as the process runs.
643async fn purge_hourly(auth: AuthService) {
644    let mut ticks = tokio::time::interval(std::time::Duration::from_secs(60 * 60));
645    // The first tick fires at once; the boot sweep has just run.
646    ticks.tick().await;
647    loop {
648        ticks.tick().await;
649        purge_expired(&auth).await;
650    }
651}
652
653async fn bind(listen: &str) -> anyhow::Result<tokio::net::TcpListener> {
654    use listenfd::ListenFd;
655    match ListenFd::from_env().take_tcp_listener(0)? {
656        Some(std_listener) => {
657            std_listener.set_nonblocking(true)?;
658            Ok(tokio::net::TcpListener::from_std(std_listener)?)
659        }
660        None => Ok(tokio::net::TcpListener::bind(listen).await?),
661    }
662}
663
664/// Given modules in dependency order (dependencies first) and a `seed` of ids
665/// already known to be off (disabled or quarantined) each with its reason,
666/// returns the full set to skip, each with a reason. One forward pass suffices
667/// because `ordered` places a module after everything it requires: a seeded
668/// module is skipped, and so is anything that requires an already-skipped one.
669fn skip_plan(ordered: &[&dyn Module], seed: &HashMap<String, String>) -> HashMap<String, String> {
670    let mut skipped: HashMap<String, String> = HashMap::new();
671    for module in ordered {
672        let id = module.id().as_str();
673        if let Some(reason) = seed.get(id) {
674            skipped.insert(id.to_string(), reason.clone());
675            continue;
676        }
677        if let Some(dep) = module
678            .requires()
679            .iter()
680            .find(|r| skipped.contains_key(r.as_str()))
681        {
682            skipped.insert(
683                id.to_string(),
684                format!("requires {}, which is off", dep.as_str()),
685            );
686        }
687    }
688    skipped
689}
690
691/// Prints one line per skipped plugin, in dependency order. Silent when every
692/// module loads, so a normal boot stays quiet.
693fn report_plugins(ordered: &[&dyn Module], skipped: &HashMap<String, String>) {
694    if skipped.is_empty() {
695        return;
696    }
697    for module in ordered {
698        if let Some(reason) = skipped.get(module.id().as_str()) {
699            println!("Plugin '{}' off: {reason}", module.id());
700        }
701    }
702}
703
704#[cfg(test)]
705mod tests {
706    use super::*;
707    use laterite_core::ModuleId;
708
709    #[test]
710    fn env_prefix_is_unset_until_overridden_in_code() {
711        assert_eq!(Bootstrap::new("config").env_prefix, None);
712        assert_eq!(
713            Bootstrap::new("config")
714                .env_prefix("ACME")
715                .env_prefix
716                .as_deref(),
717            Some("ACME")
718        );
719    }
720
721    #[test]
722    fn app_config_loads_named_sections_and_falls_back_to_defaults() {
723        let dir = tempfile::tempdir().unwrap();
724        std::fs::write(
725            dir.path().join("default.toml"),
726            "[app]\nname = \"Acme\"\n\n[server]\nlisten = \"0.0.0.0:9000\"\n\n\
727             [database]\nurl = \"postgres://x\"\n",
728        )
729        .unwrap();
730        let cfg: AppConfig = config::load(dir.path(), "LATERITE_BOOTSTRAP_TEST").unwrap();
731        assert_eq!(cfg.app.name, "Acme");
732        assert_eq!(cfg.server.listen, "0.0.0.0:9000");
733        assert_eq!(cfg.database.url, "postgres://x");
734        assert_eq!(cfg.backend.path, "/admin");
735        assert!(!cfg.backend.secure_cookie);
736        assert!(cfg.app.url.is_none());
737    }
738
739    struct M(&'static str, &'static [ModuleId]);
740    impl Module for M {
741        fn id(&self) -> ModuleId {
742            ModuleId::new(self.0)
743        }
744        fn requires(&self) -> &'static [ModuleId] {
745            self.1
746        }
747    }
748
749    #[test]
750    fn skip_plan_disables_config_ids_and_cascades_to_dependents() {
751        const NONE: [ModuleId; 0] = [];
752        const REQ_A: [ModuleId; 1] = [ModuleId::new("acme.a")];
753        const REQ_B: [ModuleId; 1] = [ModuleId::new("acme.b")];
754        // Dependency order (deps first): a, b (requires a), c (requires b), and an
755        // independent d.
756        let (a, b, c, d) = (
757            M("acme.a", &NONE),
758            M("acme.b", &REQ_A),
759            M("acme.c", &REQ_B),
760            M("acme.d", &NONE),
761        );
762        let ordered: Vec<&dyn Module> = vec![&a, &b, &c, &d];
763
764        let seed: HashMap<String, String> = [("acme.a".to_string(), "disabled".to_string())]
765            .into_iter()
766            .collect();
767        let skipped = skip_plan(&ordered, &seed);
768
769        // a is off by the seed; b then c cascade off; d is unaffected.
770        assert_eq!(skipped.get("acme.a").map(String::as_str), Some("disabled"));
771        assert!(skipped["acme.b"].contains("requires acme.a"));
772        assert!(skipped["acme.c"].contains("requires acme.b"));
773        assert!(!skipped.contains_key("acme.d"));
774    }
775}
776
777/// The settings code the generated signing key is kept under.
778const SIGNING_KEY: &str = "laterite.signing_key";
779
780/// The application's crypt: `app.key` and `app.previous_keys` when set, else a
781/// key derived from `backend.secret` or the key stored at the first boot (id
782/// 0), so a deployment that configured nothing keeps a stable key. A key that
783/// does not parse aborts the boot; a production deployment without `app.key`
784/// is warned to run `lat key generate`.
785pub fn crypt_from_config(
786    config: &AppConfig,
787    legacy: &[u8],
788) -> anyhow::Result<laterite_core::Crypt> {
789    match config
790        .app
791        .key
792        .as_deref()
793        .map(str::trim)
794        .filter(|k| !k.is_empty())
795    {
796        Some(key) => laterite_core::Crypt::from_config(key, &config.app.previous_keys)
797            .map_err(|e| anyhow::anyhow!("app.key: {e}")),
798        None => {
799            if !config.app.debug {
800                tracing::warn!(
801                    "app.key is not set; the key derives from backend.secret or the stored key. Run `lat key generate`."
802                );
803            }
804            Ok(laterite_core::Crypt::new(
805                laterite_core::MasterKey::derived(0, legacy),
806                Vec::new(),
807            ))
808        }
809    }
810}
811
812/// The signing key: `backend.secret` when set, else the one generated at the
813/// first boot and kept in the settings table, so tokens survive a restart.
814pub async fn legacy_key_material(
815    db: &laterite_core::Db,
816    configured: Option<&str>,
817) -> anyhow::Result<Vec<u8>> {
818    signing_key(db, configured).await
819}
820
821async fn signing_key(db: &laterite_core::Db, configured: Option<&str>) -> anyhow::Result<Vec<u8>> {
822    if let Some(secret) = configured.map(str::trim).filter(|s| !s.is_empty()) {
823        return Ok(secret.as_bytes().to_vec());
824    }
825    if let Some(serde_json::Value::String(hex)) =
826        crate::settings::store::get(db, SIGNING_KEY).await?
827    {
828        if let Some(key) = crate::routes::unhex(&hex).filter(|k| k.len() >= 16) {
829            return Ok(key);
830        }
831    }
832    let key = crate::random_key();
833    let hex: String = key.iter().map(|b| format!("{b:02x}")).collect();
834    crate::settings::store::set(db, SIGNING_KEY, &serde_json::Value::String(hex)).await?;
835    Ok(key)
836}