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