fraiseql-server 2.8.0

HTTP server for FraiseQL v2 GraphQL engine
Documentation
//! Observer management route mounting.

use axum::{Router, middleware};
use fraiseql_core::db::traits::DatabaseAdapter;
use tracing::info;

use super::super::Server;
use crate::middleware::admin_auth_middleware;

impl<A: DatabaseAdapter + Clone + Send + Sync + 'static> Server<A> {
    /// Add observer-related routes to the router.
    ///
    /// # PostgreSQL requirement
    ///
    /// The `observers` feature requires a PostgreSQL connection pool (`db_pool`).
    /// When this feature is enabled, `Server::new()` must receive a `Some(PgPool)` as the
    /// `db_pool` argument. If no pool is provided, observer management routes are skipped
    /// and an error is logged rather than panicking, so the server can still serve other
    /// requests. Callers should treat a missing pool as a configuration error.
    ///
    /// # Authentication requirement (since v2.4.0)
    ///
    /// The observer admin API — create / update / delete observers, reload runtime,
    /// inspect DLQ, read the changelog — exposes write-side cluster-state mutations
    /// and read-side endpoints that return bearer-token secrets stored in observer
    /// `actions[].headers`.  All four routers are gated behind `admin_auth_middleware`,
    /// which requires a valid token **and** the `fraiseql:admin` scope (Phase 03 C3):
    /// this closes H5 (the routers were previously un-authed whenever the data plane
    /// ran with optional auth, since `oidc_auth_middleware` defers to the global
    /// `required` flag) and H6 (any authenticated end-user token could read the webhook
    /// secrets or drive DLQ retry/delete). If no OIDC validator is configured (`[auth]`
    /// absent in `fraiseql.toml`), the routes are *not* mounted and a `WARN` is logged
    /// at startup, rather than mounting them open.  This closes the FW-21 class
    /// anonymous-write primitive (issue #348).
    #[cfg(feature = "observers")]
    pub(super) fn add_observer_routes(&self, app: Router) -> Router {
        use std::sync::Arc;

        use crate::observers::{
            ChangelogState, DlqState, ObserverRepository, ObserverState, RuntimeHealthState,
            observer_changelog_routes, observer_dlq_routes, observer_routes,
            observer_runtime_routes,
        };

        let Some(db_pool) = self.db_pool.clone() else {
            tracing::error!(
                "Observer management routes not mounted: \
                 the `observers` feature requires a PostgreSQL pool (`db_pool`). \
                 Pass `Some(sqlx::PgPool)` to Server::new() to enable observer endpoints."
            );
            return app;
        };

        let Some(ref validator) = self.oidc_validator else {
            tracing::warn!(
                "Observer admin API not mounted: \
                 the observer routes expose cluster-state mutations and bearer-token \
                 secrets (in actions[].headers) and so require an OIDC validator. \
                 Configure [auth] in fraiseql.toml to enable observer endpoints. \
                 The observer runtime itself (in-process triggers and dispatch) is \
                 unaffected — only the HTTP admin API is skipped."
            );
            return app;
        };

        let observer_state = ObserverState {
            repository: ObserverRepository::new(db_pool.clone()),
        };

        let changelog_state = ChangelogState { pool: db_pool };

        let auth_layer = || {
            let auth_state = self.oidc_auth_state(Arc::clone(validator));
            middleware::from_fn_with_state(auth_state, admin_auth_middleware)
        };

        let app = app
            .nest("/api/observers", observer_routes(observer_state).route_layer(auth_layer()))
            .nest(
                "/api/observers",
                observer_changelog_routes(changelog_state).route_layer(auth_layer()),
            );

        if let Some(ref runtime) = self.observer_runtime {
            info!(
                path = "/api/observers",
                "Observer management, runtime health, and DLQ delivery status endpoints \
                 enabled (auth-gated)"
            );

            let runtime_state = RuntimeHealthState {
                runtime: runtime.clone(),
            };

            let dlq_state = DlqState {
                runtime: runtime.clone(),
            };

            mount_observer_runtime_routes(
                app,
                observer_runtime_routes(runtime_state).route_layer(auth_layer()),
                observer_dlq_routes(dlq_state).route_layer(auth_layer()),
            )
        } else {
            app
        }
    }
}

/// Mount the optional observer runtime-health and DLQ routers under the shared
/// `/api/observers` prefix.
///
/// Both routers declare *inner* paths (`/runtime/health`, `/runtime/reload`,
/// `/dlq`, …) and so must be `nest`ed under `/api/observers`, consistent with
/// the always-on observer-management and changelog routers. Mounting the runtime
/// router at the router root (the pre-#340 behaviour) made
/// `/api/observers/runtime/health` return 404 while shadowing any user routes at
/// `/runtime/*`.
///
/// Extracted as a free function so the mount placement is unit-testable without
/// constructing a full [`Server`] (issue #340).
#[cfg(feature = "observers")]
pub(in crate::server) fn mount_observer_runtime_routes(
    app: Router,
    runtime_routes: Router,
    dlq_routes: Router,
) -> Router {
    app.nest("/api/observers", runtime_routes).nest("/api/observers", dlq_routes)
}