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