Skip to main content

apiplant_server/
lib.rs

1//! # apiplant-server
2//!
3//! Turns a loaded [`App`] into a running HTTP service on [`ntex`]:
4//!
5//! * generic CRUD routes for every resource (`<base>/<resource>[/<id>]`),
6//! * built-in auth routes (`<base>/auth/...`),
7//! * one route per loaded function (`<base>/functions/<name>`),
8//! * [lifecycle hooks](hooks) running functions around each CRUD operation,
9//! * the [admin](admin) dashboard, embedded in the binary and served at
10//!   `/admin/` for every app unless `[admin] enabled = false`,
11//! * the app's `public/` directory served at the site root, with a 404 page,
12//! * TLS inferred from the app's `https/` directory.
13
14/// Build the whole `ntex` application from an [`AppState`].
15///
16/// A macro rather than a function because the type of a fully-assembled `ntex`
17/// app is a tower of generics that can't reasonably be written down. Every
18/// route the server answers is registered here, and only here — `run` and the
19/// tests both go through it, so what the tests exercise is what ships.
20///
21/// Order matters: the dashboard and the public site are registered *before* the
22/// API scope, so their literal paths beat its generic `/{resource}` match. Only
23/// paths that name a real file get a route, which is why `/products` still
24/// reaches the API while `/about.html` reaches the static site.
25macro_rules! build_app {
26    ($state:expr) => {{
27        let state = $state.clone();
28        let config = &state.app.config;
29        let domain = config.server.domain.clone();
30        let statics = state.statics.clone();
31
32        // An empty `base_path` means "mount the API at the root" — but ntex's
33        // `scope("")` matches nothing at all, so it has to be spelled `/`.
34        let base_path = match config.server.base_path.as_str() {
35            "" => "/",
36            path => path,
37        };
38        let mut scope = $crate::ntex_web::scope(base_path);
39        if let Some(g) = $crate::host_guard(&domain) {
40            scope = scope.guard(g);
41        }
42        // Docs routes (literal segments) are registered before the generic
43        // `/{resource}` routes so they win.
44        if config.docs.enabled {
45            scope = scope
46                .route(
47                    "/openapi.json",
48                    $crate::ntex_web::get().to($crate::openapi_spec),
49                )
50                .route(
51                    config.docs.path.as_str(),
52                    $crate::ntex_web::get().to($crate::docs_page),
53                );
54        }
55        let mut scope = scope
56            .route("/_health", $crate::ntex_web::get().to($crate::health))
57            .route(
58                "/auth/register",
59                $crate::ntex_web::post().to($crate::auth_routes::register),
60            )
61            .route(
62                "/auth/login",
63                $crate::ntex_web::post().to($crate::auth_routes::login),
64            )
65            .route(
66                "/auth/me",
67                $crate::ntex_web::get().to($crate::auth_routes::me),
68            )
69            .route(
70                "/auth/apikeys",
71                $crate::ntex_web::post().to($crate::auth_routes::create_api_key),
72            );
73
74        // The flows that reach somebody through their mailbox exist only where
75        // this app can actually send mail. An unmounted route answers 404,
76        // which is the honest answer: there is no password reset here. The
77        // admin manifest carries the same three facts, so no interface offers a
78        // button that would land on one.
79        if state.invitations_enabled() {
80            scope = scope
81                .route(
82                    "/auth/invitations",
83                    $crate::ntex_web::post().to($crate::email_auth::create_invitation),
84                )
85                .route(
86                    "/auth/invitations/{token}",
87                    $crate::ntex_web::get().to($crate::email_auth::preview_invitation),
88                )
89                .route(
90                    "/auth/invitations/{token}/accept",
91                    $crate::ntex_web::post().to($crate::email_auth::accept_invitation),
92                );
93        }
94        if state.requires_email_verification() {
95            scope = scope
96                .route(
97                    "/auth/verify-email",
98                    $crate::ntex_web::post().to($crate::email_auth::verify_email),
99                )
100                .route(
101                    "/auth/verify-email/resend",
102                    $crate::ntex_web::post().to($crate::email_auth::resend_verification),
103                );
104        }
105        if state.password_reset_enabled() {
106            scope = scope
107                .route(
108                    "/auth/password/forgot",
109                    $crate::ntex_web::post().to($crate::email_auth::forgot_password),
110                )
111                .route(
112                    "/auth/password/reset",
113                    $crate::ntex_web::post().to($crate::email_auth::reset_password),
114                );
115        }
116
117        // Billing exists only where a provider does. The `billing_*`
118        // resources are absent in the same case, so an app that takes no
119        // money has neither the endpoints nor the tables.
120        if state.payments_enabled() {
121            scope = scope
122                .route(
123                    "/billing/config",
124                    $crate::ntex_web::get().to($crate::billing::config),
125                )
126                .route(
127                    "/billing/checkout",
128                    $crate::ntex_web::post().to($crate::billing::checkout),
129                )
130                .route(
131                    "/billing/portal",
132                    $crate::ntex_web::post().to($crate::billing::portal),
133                )
134                // Stripe's own deliveries. Not authenticated in the ordinary
135                // sense — the body carries a signature — and mounted even
136                // without a `webhook_secret`, where it refuses everything and
137                // says so, rather than 404ing and leaving an operator to
138                // wonder which half is misconfigured.
139                .route(
140                    "/billing/webhook",
141                    $crate::ntex_web::post().to($crate::billing::webhook),
142                );
143        }
144
145        // The assistant exists only where a provider does, like billing and
146        // like the mailbox flows: an app with no `[ai]` section has no
147        // endpoint to 404 on and no button offering one.
148        if state.ai_enabled() {
149            scope = scope
150                .route(
151                    "/ai/config",
152                    $crate::ntex_web::get().to($crate::ai_routes::config),
153                )
154                .route(
155                    "/ai/agents/{name}/chat",
156                    $crate::ntex_web::post().to($crate::agent_routes::chat),
157                )
158                .route(
159                    "/ai/chat",
160                    $crate::ntex_web::post().to($crate::ai_routes::chat),
161                );
162        }
163
164        let mut scope = scope
165            // Literal `functions` segment is registered before the generic
166            // resource routes so it wins over `/{resource}/{id}`.
167            // The streaming form is registered first: `/functions/{name}`
168            // would otherwise match `/functions/summarise/stream` with a name
169            // of `summarise` and lose the suffix.
170            .route(
171                "/functions/{name}/stream",
172                $crate::ntex_web::route().to($crate::function_routes::stream),
173            )
174            .route(
175                "/functions/{name}",
176                $crate::ntex_web::route().to($crate::function_routes::invoke),
177            )
178            .service(
179                $crate::ntex_web::resource("/{resource}")
180                    .route($crate::ntex_web::get().to($crate::crud::list))
181                    .route($crate::ntex_web::post().to($crate::crud::create)),
182            )
183            .service(
184                $crate::ntex_web::resource("/{resource}/{id}")
185                    .route($crate::ntex_web::get().to($crate::crud::get))
186                    .route($crate::ntex_web::patch().to($crate::crud::update))
187                    .route($crate::ntex_web::put().to($crate::crud::update))
188                    .route($crate::ntex_web::delete().to($crate::crud::delete)),
189            )
190            // Nested has_many: GET /parent/{id}/child
191            .route(
192                "/{parent}/{id}/{child}",
193                $crate::ntex_web::get().to($crate::crud::nested_list),
194            );
195
196        // With the API mounted at the root, its scope swallows every unmatched
197        // path, so the 404 page has to be its default too — not just the app's.
198        if statics.not_found_page.is_some() {
199            scope = scope.default_service($crate::ntex_web::to($crate::not_found_route));
200        }
201
202        let mut app = $crate::ntex_web::App::new().state(state.clone());
203
204        // Root-level routes answer for the configured domain only, exactly as
205        // the API scope does.
206        macro_rules! guarded {
207            ($path:expr) => {{
208                let resource = $crate::ntex_web::resource($path);
209                match $crate::host_guard(&domain) {
210                    Some(g) => resource.guard(g),
211                    None => resource,
212                }
213            }};
214        }
215
216        if let Some(admin_path) = &statics.admin_path {
217            app = app
218                .service(
219                    guarded!(format!("{admin_path}/"))
220                        .route($crate::ntex_web::get().to($crate::admin_index)),
221                )
222                .service(
223                    guarded!(format!("{admin_path}/{{path:.*}}"))
224                        .route($crate::ntex_web::get().to($crate::admin_asset)),
225                )
226                // `/admin` without the slash would otherwise 404; the page loads
227                // its assets relatively, so it has to resolve as a directory.
228                .service(
229                    guarded!(admin_path.as_str())
230                        .route($crate::ntex_web::get().to($crate::admin_redirect)),
231                );
232        }
233
234        for route in &statics.public_routes {
235            app = app.service(
236                guarded!(route.as_str()).route($crate::ntex_web::get().to($crate::public_asset)),
237            );
238        }
239
240        app = app.service(scope);
241        if statics.not_found_page.is_some() {
242            app = app.default_service($crate::ntex_web::to($crate::not_found_route));
243        }
244        app
245    }};
246}
247
248/// A `Host:` guard matching any of the configured domains, or `None` when no
249/// domains are configured and every host should be answered.
250pub(crate) fn host_guard(domains: &[String]) -> Option<ntex_guard::AnyGuard> {
251    if domains.is_empty() {
252        return None;
253    }
254    Some(ntex_guard::AnyGuard(
255        domains
256            .iter()
257            .map(|d| Box::new(ntex_guard::Host(d.clone())) as Box<dyn ntex_guard::Guard>)
258            .collect(),
259    ))
260}
261
262pub mod access;
263pub mod admin;
264mod agent_routes;
265mod ai_routes;
266mod auth_routes;
267mod banner;
268mod billing;
269pub mod builtins;
270pub mod cabi;
271mod crud;
272pub mod email_auth;
273mod emails;
274mod function_routes;
275pub mod functions;
276pub mod hooks;
277mod openapi;
278mod response;
279mod sse;
280mod state;
281#[cfg(test)]
282mod tests;
283
284use std::sync::Arc;
285use std::{fs, path::Component, path::Path, path::PathBuf};
286
287use apiplant_auth::Authenticator;
288use apiplant_core::{App, TlsPaths};
289use apiplant_db::Db;
290use ntex::web::{self, HttpRequest, HttpResponse, HttpServer};
291
292// Re-exported under crate-local names so `build_app!` can name them absolutely
293// and expand anywhere in the crate, tests included.
294pub(crate) use ntex::web as ntex_web;
295pub(crate) use ntex::web::guard as ntex_guard;
296use uuid::Uuid;
297
298use functions::FunctionRegistry;
299use state::{AppState, Statics};
300
301async fn health() -> HttpResponse {
302    HttpResponse::Ok().json(&serde_json::json!({ "status": "ok", "framework": "apiplant" }))
303}
304
305/// Serve the pre-rendered OpenAPI document.
306async fn openapi_spec(state: web::types::State<AppState>) -> HttpResponse {
307    HttpResponse::Ok()
308        .content_type("application/json")
309        .body(state.openapi_json.as_str().to_owned())
310}
311
312/// Serve the Swagger UI page.
313async fn docs_page(state: web::types::State<AppState>) -> HttpResponse {
314    HttpResponse::Ok()
315        .content_type("text/html; charset=utf-8")
316        .body(state.docs_html.as_str().to_owned())
317}
318
319async fn admin_index(state: web::types::State<AppState>) -> HttpResponse {
320    serve_admin(&state, "index.html")
321}
322
323async fn admin_asset(
324    state: web::types::State<AppState>,
325    path: web::types::Path<String>,
326) -> HttpResponse {
327    let path = path.into_inner();
328    serve_admin(&state, &path)
329}
330
331/// Serve one file of the dashboard.
332///
333/// Everything comes out of the binary: the files from the embedded build, the
334/// manifest from memory — it describes *this* app, and is built on boot. There
335/// is no directory to generate and none to go stale.
336fn serve_admin(state: &AppState, requested: &str) -> HttpResponse {
337    let requested = requested.trim_start_matches('/');
338
339    if requested == admin::MANIFEST_FILE {
340        return HttpResponse::Ok()
341            .content_type("application/json")
342            .body(state.admin_manifest.as_str().to_owned());
343    }
344
345    match admin::asset(requested) {
346        Some(bytes) => HttpResponse::Ok()
347            .content_type(apiplant_assets::content_type(requested))
348            .body(bytes.into_owned()),
349        None => HttpResponse::NotFound().finish(),
350    }
351}
352
353/// Serve a file from the app's `public/` directory.
354///
355/// Routes are registered per file at boot, so the path always names something
356/// that existed then; it is re-resolved here so edits are picked up without a
357/// restart, and a file deleted since boot answers with the 404 page.
358async fn public_asset(state: web::types::State<AppState>, req: HttpRequest) -> HttpResponse {
359    let Some(root) = state.statics.public_dir.as_deref() else {
360        return not_found(&state);
361    };
362    serve_file(root, req.path()).unwrap_or_else(|| not_found(&state))
363}
364
365/// Anything that matched no route at all: the app's 404 page, or a bare 404.
366async fn not_found_route(state: web::types::State<AppState>) -> HttpResponse {
367    not_found(&state)
368}
369
370fn not_found(state: &AppState) -> HttpResponse {
371    let Some(page) = state.statics.not_found_page.as_deref() else {
372        return HttpResponse::NotFound().finish();
373    };
374    match fs::read(page) {
375        Ok(bytes) => HttpResponse::NotFound()
376            .content_type(content_type_for(page))
377            .body(bytes),
378        Err(error) => {
379            tracing::error!(path = %page.display(), error = %error, "failed to read 404 page");
380            HttpResponse::NotFound().finish()
381        }
382    }
383}
384
385/// Read a file under `root`, or `None` when it isn't there.
386fn serve_file(root: &Path, requested: &str) -> Option<HttpResponse> {
387    let path = resolve_static_path(root, requested)?;
388    match fs::read(&path) {
389        Ok(bytes) => Some(
390            HttpResponse::Ok()
391                .content_type(content_type_for(&path))
392                .body(bytes),
393        ),
394        Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
395        Err(error) => {
396            tracing::error!(path = %path.display(), error = %error, "failed to read static file");
397            Some(HttpResponse::InternalServerError().finish())
398        }
399    }
400}
401
402fn resolve_static_path(root: &Path, requested: &str) -> Option<PathBuf> {
403    let mut path = root.to_path_buf();
404    let requested = requested.trim_matches('/');
405
406    if requested.is_empty() {
407        path.push("index.html");
408        return Some(path);
409    }
410
411    for component in Path::new(requested).components() {
412        match component {
413            Component::Normal(segment) => path.push(segment),
414            Component::CurDir => {}
415            _ => return None,
416        }
417    }
418
419    if path.is_dir() {
420        path.push("index.html");
421    }
422    Some(path)
423}
424
425fn content_type_for(path: &Path) -> &'static str {
426    apiplant_assets::content_type(&path.to_string_lossy())
427}
428
429/// `/admin` → `/admin/`, so relative asset URLs resolve.
430async fn admin_redirect(req: HttpRequest) -> HttpResponse {
431    HttpResponse::PermanentRedirect()
432        .header("location", format!("{}/", req.path()))
433        .finish()
434}
435
436/// The route patterns one public file answers on.
437///
438/// A file is served at its own path; an `index.html` additionally answers for
439/// the directory holding it, with and without the trailing slash. Returns
440/// nothing for names ntex would read as a path pattern (`{`, `}`) or that would
441/// escape the root — those are skipped rather than mis-registered.
442fn public_routes(relative: &str) -> Vec<String> {
443    if relative
444        .split('/')
445        .any(|segment| segment.is_empty() || segment.contains(['{', '}']) || segment == "..")
446    {
447        tracing::warn!(
448            file = relative,
449            "skipping public file: its name can't be a route"
450        );
451        return Vec::new();
452    }
453
454    let mut routes = vec![format!("/{relative}")];
455    if let Some(directory) = relative.strip_suffix("index.html") {
456        let directory = directory.trim_end_matches('/');
457        if directory.is_empty() {
458            routes.push("/".to_string());
459        } else {
460            routes.push(format!("/{directory}/"));
461            routes.push(format!("/{directory}"));
462        }
463    }
464    routes
465}
466
467/// Every file under `root`, as site-root-relative paths (`css/app.css`).
468///
469/// Used to register one route per public file, which is what lets a static site
470/// share the root with the API: an explicit `/about.html` route is matched
471/// before the generic `/{resource}` CRUD route, while `/products` still reaches
472/// the API because no such file exists.
473fn walk_public(root: &Path, prefix: &str, into: &mut Vec<String>) {
474    let entries = match fs::read_dir(root) {
475        Ok(entries) => entries,
476        Err(error) => {
477            tracing::error!(path = %root.display(), error = %error, "failed to read public directory");
478            return;
479        }
480    };
481    for entry in entries.flatten() {
482        let name = entry.file_name().to_string_lossy().into_owned();
483        let relative = if prefix.is_empty() {
484            name
485        } else {
486            format!("{prefix}/{name}")
487        };
488        if entry.path().is_dir() {
489            walk_public(&entry.path(), &relative, into);
490        } else {
491            into.push(relative);
492        }
493    }
494}
495
496/// Boot the server for a loaded app and serve until shut down.
497pub async fn run(app: App) -> anyhow::Result<()> {
498    run_with(app, Options::default()).await
499}
500
501/// How to boot.
502#[derive(Debug, Clone, Default)]
503pub struct Options {
504    /// Load the app's `seed/` directory after migrating. Off by default: a
505    /// fixture belongs to a fresh database and a development machine, not to
506    /// every restart of a production server.
507    pub seed: bool,
508}
509
510/// Boot the server for a loaded app, with boot-time options.
511pub async fn run_with(app: App, options: Options) -> anyhow::Result<()> {
512    // 1. Database + migrations.
513    let db_url = app.config.database.resolved_url();
514    tracing::info!("connecting to database");
515    let db = Db::connect(&db_url, app.config.database.max_connections).await?;
516    if app.config.database.auto_migrate {
517        tracing::info!("running migrations");
518        apiplant_db::migrate(db.connection(), &app).await?;
519    }
520    if options.seed {
521        // After the migrations, because the fixture needs its tables — and
522        // before anything is served, because a request that arrives mid-seed
523        // would see half a fixture.
524        let report = apiplant_db::seed::seed(db.connection(), &app).await?;
525        if report.is_empty() {
526            tracing::warn!("--seed was given but there is no seed/ directory to load");
527        } else {
528            tracing::info!(
529                inserted = report.inserted(),
530                already_present = report.skipped(),
531                "seeded"
532            );
533        }
534    }
535
536    // 2. Authenticator (ephemeral secret if none configured).
537    let secret = if app.config.auth.jwt_secret.is_empty() {
538        tracing::warn!(
539            "auth.jwt_secret is empty — using an ephemeral secret; sessions won't survive a restart"
540        );
541        format!("{}{}", Uuid::new_v4(), Uuid::new_v4()).into_bytes()
542    } else {
543        app.config.auth.jwt_secret.clone().into_bytes()
544    };
545    let authr = Authenticator::new(secret, app.config.auth.session_ttl_secs);
546
547    // 2b. Optional services a function can reach: the email provider and the
548    //     cache. Both are built here, once, and shared by every worker — and
549    //     both fail the boot when the app asked for one it can't have, rather
550    //     than at the first send or the first lookup.
551    let mailer = apiplant_email::Mailer::from_config(&app.config.email)?;
552    match &mailer {
553        Some(mailer) => tracing::info!(
554            "  email -> {} (from {})",
555            mailer.provider().as_str(),
556            app.config.email.from
557        ),
558        None => tracing::debug!("no email provider configured"),
559    }
560
561    let cache = apiplant_cache::Cache::connect(&app.config.cache).await?;
562    match &cache {
563        Some(_) => tracing::info!(
564            "  cache -> redis (prefix {:?})",
565            app.config.cache.prefix.as_str()
566        ),
567        None => tracing::debug!("no cache configured"),
568    }
569
570    let ai = apiplant_ai::Ai::from_config(&app.config.ai)?;
571    let agent_ais = app
572        .agents
573        .values()
574        .filter_map(|agent| {
575            agent.ai.as_ref().map(|_| {
576                apiplant_ai::Ai::from_config(&agent.merged_ai_config(&app.config.ai))
577                    .map(|ai| (agent.meta.name.clone(), ai))
578            })
579        })
580        .collect::<Result<Vec<_>, _>>()?
581        .into_iter()
582        .filter_map(|(name, ai)| ai.map(|ai| (name, ai)))
583        .collect();
584    match &ai {
585        Some(ai) => tracing::info!(
586            "  ai -> {} ({} at {})",
587            ai.provider().as_str(),
588            match ai.model() {
589                "" => "the server's own model",
590                model => model,
591            },
592            ai.url()
593        ),
594        None => tracing::debug!("no ai provider configured"),
595    }
596
597    // A buyer Stripe returns to needs somewhere to land, and this crate is
598    // the only thing that knows where that is: the dashboard's billing
599    // screen, or the app's own origin when the dashboard is switched off.
600    let billing_landing = match app.config.admin.enabled {
601        true => format!(
602            "{}{}/#/billing",
603            app.config.server.public_origin(),
604            app.config.admin.path.trim_end_matches('/')
605        ),
606        false => app.config.server.public_origin(),
607    };
608    let payments =
609        apiplant_payments::Payments::from_config(&app.config.payments, &billing_landing)?;
610    match &payments {
611        Some(payments) => tracing::info!(
612            "  payments -> {} ({}, automatic tax {})",
613            payments.provider().as_str(),
614            app.config.payments.default_currency(),
615            match app.config.payments.automatic_tax {
616                true => "on",
617                false => "off",
618            }
619        ),
620        None => tracing::debug!("no payment provider configured"),
621    }
622
623    // 3. Load dynamic functions.
624    let registry = FunctionRegistry::load(&app);
625    for f in registry.iter() {
626        // A `Private` function has no route — it exists to be called from a
627        // hook — so don't advertise one it would answer 404 on.
628        if f.manifest.visibility == apiplant_abi::Visibility::Private {
629            tracing::info!("  fn {} (private — no endpoint)", f.manifest.name);
630        } else {
631            tracing::info!(
632                "  fn {} -> {}/functions/{}",
633                f.manifest.name,
634                app.config.server.base_path,
635                f.manifest.name
636            );
637        }
638    }
639
640    // 4. Report the resource hooks, loudly flagging any that can't resolve —
641    //    a missing hook function fails its requests closed at runtime.
642    for resource in app.resources.values() {
643        for (event, function) in resource.hooks.iter() {
644            if registry.get(function).is_some() {
645                tracing::info!(
646                    "  hook {}.{} -> {}",
647                    resource.meta.name,
648                    event.as_str(),
649                    function
650                );
651            } else {
652                tracing::error!(
653                    resource = %resource.meta.name,
654                    hook = event.as_str(),
655                    function = function,
656                    "hook function is not loaded — this resource's {} requests will fail with 500",
657                    event.action()
658                );
659            }
660        }
661        for (event, function) in resource.hooks.auth_iter() {
662            if registry.get(function).is_some() {
663                tracing::info!("  hook auth.{} -> {}", event.as_str(), function);
664            } else {
665                tracing::error!(
666                    hook = event.as_str(),
667                    function = function,
668                    "auth hook function is not loaded — {} requests will fail with 500",
669                    event.action()
670                );
671            }
672        }
673    }
674
675    // 5. Generate the OpenAPI document + Swagger UI (once; static per boot).
676    let base_path = app.config.server.base_path.clone();
677    let spec_url = format!("{base_path}/openapi.json");
678    let spec = openapi::build(&app, &registry, mailer.is_some());
679    let openapi_json = serde_json::to_string(&spec).unwrap_or_else(|_| "{}".to_string());
680    let docs_html = openapi::swagger_ui_html(&spec_url, &app.docs_title());
681    if app.config.docs.enabled {
682        tracing::info!(
683            "  docs -> {base_path}{}  (spec: {spec_url})",
684            app.config.docs.path
685        );
686    }
687
688    // 6. Assemble shared state and pull out what the closure needs.
689    let host = app.config.server.host.clone();
690    let port = app.config.server.port;
691    let banner_docs_path = app
692        .config
693        .docs
694        .enabled
695        .then(|| app.config.docs.path.clone());
696    let banner_domains = app.config.server.domain.clone();
697    let banner_name = app.display_name();
698    let workers = app.config.server.workers;
699    let tls = app.tls.clone();
700
701    // 7. Work out what is served alongside the API — the dashboard, the public
702    //    site, the 404 page — and build the dashboard's manifest.
703    //
704    //    The dashboard ships inside the binary, so every app has one without
705    //    generating anything; an `admin/` directory in the app (from `apiplant
706    //    admin`) overrides the embedded build file for file. Either way the
707    //    manifest is derived here, from the app being served, and the dashboard
708    //    talks to its own origin — no CORS, and no rebuild after a model change.
709    let statics = Statics::resolve(&app);
710    let banner_admin_path = statics.admin_path.clone();
711    let banner_site = !statics.public_routes.is_empty();
712    let admin_manifest = match &statics.admin_path {
713        Some(path) => {
714            tracing::info!("  admin -> {path}/");
715            admin::manifest_json(&app, &registry, base_path.clone(), mailer.is_some()).unwrap_or_else(|error| {
716                tracing::error!(%error, "failed to build the admin manifest — the dashboard will not load");
717                "{}".to_string()
718            })
719        }
720        None => String::new(),
721    };
722    if let Some(dir) = &statics.public_dir {
723        tracing::info!(
724            routes = statics.public_routes.len(),
725            "  public -> /  (from {})",
726            dir.display()
727        );
728    }
729    if let Some(page) = &statics.not_found_page {
730        tracing::info!("  404 -> {}", page.display());
731    }
732
733    let state = AppState {
734        app: Arc::new(app),
735        db,
736        auth: authr,
737        functions: Arc::new(registry),
738        mailer,
739        cache,
740        payments,
741        ai,
742        agent_ais: Arc::new(agent_ais),
743        statics: Arc::new(statics),
744        admin_manifest: Arc::new(admin_manifest),
745        openapi_json: Arc::new(openapi_json),
746        docs_html: Arc::new(docs_html),
747    };
748
749    let base_path_log = base_path.clone();
750    let mut server = HttpServer::new(move || build_app!(state));
751
752    if let Some(w) = workers {
753        server = server.workers(w);
754    }
755
756    let addr = format!("{host}:{port}");
757    let scheme = if tls.is_some() { "https" } else { "http" };
758    let server = match tls {
759        Some(paths) => server.bind_rustls(&addr, load_tls(&paths)?)?,
760        None => server.bind(&addr)?,
761    };
762
763    tracing::info!("apiplant listening on {scheme}://{addr}{base_path_log}");
764    banner::Banner {
765        name: banner_name,
766        scheme,
767        addr: addr.clone(),
768        base_path: base_path_log.clone(),
769        docs_path: banner_docs_path,
770        admin_path: banner_admin_path,
771        site: banner_site,
772        domains: banner_domains,
773    }
774    .print();
775    server.run().await?;
776    Ok(())
777}
778
779/// Build a rustls server config from PEM cert + key files.
780fn load_tls(paths: &TlsPaths) -> anyhow::Result<rustls::ServerConfig> {
781    use std::io::BufReader;
782
783    // Install a default crypto provider once (ring); ignore "already set".
784    let _ = rustls::crypto::ring::default_provider().install_default();
785
786    let mut cert_reader = BufReader::new(std::fs::File::open(&paths.cert)?);
787    let certs = rustls_pemfile::certs(&mut cert_reader).collect::<Result<Vec<_>, _>>()?;
788
789    let mut key_reader = BufReader::new(std::fs::File::open(&paths.key)?);
790    let key = rustls_pemfile::private_key(&mut key_reader)?
791        .ok_or_else(|| anyhow::anyhow!("no private key in {}", paths.key.display()))?;
792
793    let config = rustls::ServerConfig::builder()
794        .with_no_client_auth()
795        .with_single_cert(certs, key)?;
796    Ok(config)
797}
798
799#[cfg(test)]
800mod route_tests {
801    use super::*;
802
803    #[test]
804    fn an_index_answers_for_its_directory_too() {
805        assert_eq!(public_routes("index.html"), ["/index.html", "/"]);
806        assert_eq!(
807            public_routes("guide/index.html"),
808            ["/guide/index.html", "/guide/", "/guide"]
809        );
810        assert_eq!(public_routes("css/app.css"), ["/css/app.css"]);
811    }
812
813    #[test]
814    fn names_that_cannot_be_routes_are_skipped() {
815        assert!(public_routes("weird{name}.html").is_empty());
816        assert!(public_routes("../escape.html").is_empty());
817    }
818
819    #[test]
820    fn static_paths_resolve_under_the_root_and_never_above_it() {
821        let root = Path::new("/srv/app/public");
822        assert_eq!(
823            resolve_static_path(root, "/css/app.css"),
824            Some(root.join("css/app.css"))
825        );
826        assert_eq!(
827            resolve_static_path(root, "/"),
828            Some(root.join("index.html"))
829        );
830        assert_eq!(resolve_static_path(root, "/../main.toml"), None);
831    }
832}