Skip to main content

systemprompt_runtime/
builder.rs

1//! Builder that assembles an [`AppContext`] from profile + config state.
2//!
3//! The builder owns the bootstrap order: profile -> paths -> files ->
4//! database -> logging -> extensions -> ancillary services. Failures at
5//! any step propagate as [`RuntimeError`](crate::error::RuntimeError).
6
7use std::sync::{Arc, OnceLock};
8
9use systemprompt_analytics::{AnalyticsService, FingerprintRepository};
10use systemprompt_config::ProfileBootstrap;
11use systemprompt_database::{Database, MigrationConfig, install_extension_schemas_full};
12use systemprompt_extension::ExtensionRegistry;
13use systemprompt_marketplace::{AllowAllFilter, MarketplaceFilter, discover_filters};
14use systemprompt_mcp::services::registry::RegistryService;
15use systemprompt_models::auth::UserRole;
16use systemprompt_models::services::{SystemAdmin, SystemAdminConfig};
17use systemprompt_models::{AppPaths, Config, ContentConfigRaw, ContentRouting};
18use systemprompt_security::authz::{AuthzDecisionHook, SharedAuthzHook};
19use systemprompt_users::UserService;
20
21use crate::context::{AppContext, ConfigPlane, DataPlane, Plugins, Subsystems};
22use crate::error::{RuntimeError, RuntimeResult};
23use crate::registry::ModuleApiRegistry;
24
25/// Assembles an [`AppContext`], owning the bootstrap order described on the
26/// module.
27///
28/// All fields default to a no-op build: extensions are discovered via
29/// inventory, schema installation is off, and the marketplace filter falls
30/// back to the inventory-registered implementation (or an allow-all filter).
31/// Override these with the `with_*` methods before calling
32/// [`build`](Self::build).
33#[derive(Default)]
34pub struct AppContextBuilder {
35    extension_registry: Option<ExtensionRegistry>,
36    show_startup_warnings: bool,
37    marketplace_filter: Option<Arc<dyn MarketplaceFilter>>,
38    authz_hook: Option<SharedAuthzHook>,
39    install_schemas: bool,
40    migration_config: MigrationConfig,
41}
42
43impl std::fmt::Debug for AppContextBuilder {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        f.debug_struct("AppContextBuilder")
46            .field("extension_registry", &self.extension_registry.is_some())
47            .field("show_startup_warnings", &self.show_startup_warnings)
48            .field("marketplace_filter", &self.marketplace_filter.is_some())
49            .field("authz_hook", &self.authz_hook.is_some())
50            .field("install_schemas", &self.install_schemas)
51            .field("migration_config", &self.migration_config)
52            .finish()
53    }
54}
55
56impl AppContextBuilder {
57    #[must_use]
58    pub fn new() -> Self {
59        Self::default()
60    }
61
62    /// Supplies an explicit extension registry. When unset, `build()`
63    /// discovers extensions via inventory ([`ExtensionRegistry::discover`]).
64    #[must_use]
65    pub fn with_extensions(mut self, registry: ExtensionRegistry) -> Self {
66        self.extension_registry = Some(registry);
67        self
68    }
69
70    #[must_use]
71    pub const fn with_startup_warnings(mut self, show: bool) -> Self {
72        self.show_startup_warnings = show;
73        self
74    }
75
76    /// Supplies an explicit marketplace filter. When unset, `build()` selects
77    /// the highest-priority inventory-registered filter, falling back to an
78    /// allow-all filter when none succeeds.
79    #[must_use]
80    pub fn with_marketplace_filter(mut self, filter: Arc<dyn MarketplaceFilter>) -> Self {
81        self.marketplace_filter = Some(filter);
82        self
83    }
84
85    /// Install / migrate extension schemas as part of `build()`. Off by
86    /// default so admin tools (`db doctor`, repair scripts) can open a
87    /// connection without mutating the schema. `serve` turns this on.
88    #[must_use]
89    pub const fn with_migrations(mut self, install: bool) -> Self {
90        self.install_schemas = install;
91        self
92    }
93
94    /// Supplies an extension-built authz decision hook. The hook is wired
95    /// only when `profile.governance.authz.hook.mode = extension`; pairing
96    /// this call with any other mode is a bootstrap error.
97    #[must_use]
98    pub fn with_authz_hook<H>(mut self, hook: H) -> Self
99    where
100        H: AuthzDecisionHook + 'static,
101    {
102        self.authz_hook = Some(Arc::new(hook));
103        self
104    }
105
106    /// Variant of [`Self::with_authz_hook`] for callers that already hold an
107    /// `Arc<dyn AuthzDecisionHook>` (e.g. a pre-built [`CompositeAuthzHook`]
108    /// shared across consumers).
109    ///
110    /// [`CompositeAuthzHook`]: systemprompt_security::authz::CompositeAuthzHook
111    #[must_use]
112    pub fn with_shared_authz_hook(mut self, hook: SharedAuthzHook) -> Self {
113        self.authz_hook = Some(hook);
114        self
115    }
116
117    #[must_use]
118    pub const fn with_migration_config(mut self, config: MigrationConfig) -> Self {
119        self.migration_config = config;
120        self
121    }
122
123    pub async fn build(self) -> RuntimeResult<AppContext> {
124        // The builder owns paths/files/config initialisation so it is
125        // self-sufficient: a non-CLI entry (API, tests) can build a context
126        // without a prior bootstrap step having installed the globals. All
127        // three inits are idempotent OnceLock guards, so a CLI that already
128        // ran them is a no-op here.
129        let profile = ProfileBootstrap::get()?;
130        let app_paths = Arc::new(AppPaths::from_profile(&profile.paths)?);
131        systemprompt_files::FilesConfig::init(&app_paths)?;
132        systemprompt_config::try_init_config()
133            .map_err(|err| RuntimeError::Internal(format!("config init: {err}")))?;
134        let config = Arc::new(Config::get()?.clone());
135
136        let database = Arc::new(
137            Database::from_config_with_write(
138                &config.database_type,
139                &config.database_url,
140                config.database_write_url.as_deref(),
141            )
142            .await?,
143        );
144
145        let authz_audit_pool = database.write_pool_arc().ok();
146        let authz_hook = systemprompt_security::authz::build_authz_hook(
147            profile.governance.as_ref(),
148            authz_audit_pool,
149            self.authz_hook,
150        )
151        .map_err(|err| RuntimeError::Internal(format!("authz bootstrap: {err}")))?;
152
153        systemprompt_logging::init_logging(Arc::clone(&database));
154
155        if config.database_write_url.is_some() {
156            tracing::debug!(
157                "Database read/write separation enabled: reads from replica, writes to primary"
158            );
159        }
160
161        let api_registry = Arc::new(ModuleApiRegistry::new());
162
163        let registry = match self.extension_registry {
164            Some(registry) => registry,
165            None => ExtensionRegistry::discover()?,
166        };
167        registry.validate()?;
168
169        if self.install_schemas {
170            install_extension_schemas_full(&registry, database.write(), &[], self.migration_config)
171                .await?;
172        }
173
174        let extension_registry = Arc::new(registry);
175
176        let geoip_reader = AppContext::load_geoip_database(&config, self.show_startup_warnings);
177        let content_config = AppContext::load_content_config(&config, &app_paths);
178        let content_routing = content_routing_from(content_config.as_ref());
179        let route_classifier = Arc::new(systemprompt_models::RouteClassifier::new(
180            content_routing.clone(),
181        ));
182        let analytics_service = Arc::new(AnalyticsService::new(
183            &database,
184            geoip_reader.clone(),
185            content_routing,
186        )?);
187
188        let fingerprint_repo = match FingerprintRepository::new(&database) {
189            Ok(repo) => Some(Arc::new(repo)),
190            Err(e) => {
191                tracing::warn!(error = %e, "Failed to initialize fingerprint repository");
192                None
193            },
194        };
195
196        // UserService is a mandatory dependency: the system admin cannot be
197        // resolved without it, so a construction failure is fatal here rather
198        // than a warning that re-surfaces as a less specific error downstream.
199        let user_service = Arc::new(UserService::new(&database)?);
200
201        let system_admin = resolve_and_install_system_admin(&config, &user_service).await?;
202        let mcp_registry = RegistryService::new(system_admin.id().clone());
203
204        let marketplace_filter = self
205            .marketplace_filter
206            .unwrap_or_else(|| build_marketplace_filter(&database));
207
208        let event_bridge = Arc::new(OnceLock::new());
209
210        Ok(AppContext::from_parts(
211            DataPlane {
212                database,
213                analytics_service,
214                fingerprint_repo,
215                user_service: Some(user_service),
216            },
217            ConfigPlane {
218                config,
219                app_paths,
220                content_config,
221                route_classifier,
222            },
223            Plugins {
224                extension_registry,
225                api_registry,
226                mcp_registry,
227                marketplace_filter,
228            },
229            Subsystems {
230                system_admin,
231                authz_hook,
232                event_bridge,
233                geoip_reader,
234            },
235        ))
236    }
237}
238
239async fn resolve_and_install_system_admin(
240    config: &Config,
241    users: &Arc<UserService>,
242) -> RuntimeResult<Arc<SystemAdmin>> {
243    let cfg = SystemAdminConfig {
244        username: config.system_admin_username.clone(),
245    };
246    let resolved = resolve_system_admin(&cfg, users.as_ref()).await?;
247    systemprompt_logging::install_log_attribution(resolved.clone());
248    Ok(Arc::new(resolved))
249}
250
251async fn resolve_system_admin(
252    cfg: &SystemAdminConfig,
253    users: &UserService,
254) -> RuntimeResult<SystemAdmin> {
255    let user = users.find_by_name(&cfg.username).await?.ok_or_else(|| {
256        RuntimeError::SystemAdminNotFound {
257            username: cfg.username.clone(),
258        }
259    })?;
260    if !user.is_active() {
261        return Err(RuntimeError::SystemAdminInactive {
262            username: cfg.username.clone(),
263        });
264    }
265    let admin_role = UserRole::Admin.as_str();
266    if !user.roles.iter().any(|r| r == admin_role) {
267        return Err(RuntimeError::SystemAdminMissingRole {
268            username: cfg.username.clone(),
269        });
270    }
271    Ok(SystemAdmin::new(user.id, user.name))
272}
273
274fn build_marketplace_filter(
275    database: &systemprompt_database::DbPool,
276) -> Arc<dyn MarketplaceFilter> {
277    for reg in discover_filters() {
278        match (reg.factory)(database) {
279            Ok(filter) => {
280                tracing::debug!(
281                    priority = reg.priority,
282                    "marketplace filter registered via inventory; using highest-priority impl",
283                );
284                return filter;
285            },
286            Err(err) => {
287                tracing::error!(
288                    priority = reg.priority,
289                    error = %err,
290                    "marketplace filter factory failed; trying next candidate",
291                );
292            },
293        }
294    }
295    let fallback: Arc<dyn MarketplaceFilter> = Arc::new(AllowAllFilter);
296    fallback
297}
298
299fn content_routing_from(
300    content_config: Option<&Arc<ContentConfigRaw>>,
301) -> Option<Arc<dyn ContentRouting>> {
302    let concrete = Arc::clone(content_config?);
303    let routing: Arc<dyn ContentRouting> = concrete;
304    Some(routing)
305}