Skip to main content

assay_engine/
engine_api.rs

1//! Engine-core HTTP admin API.
2//!
3//! Mounted at `/api/v1/engine/core/*` by the engine binary so the engine
4//! console (`/engine/console`) has structured JSON to render.
5//!
6//! Endpoints (all return JSON):
7//!
8//! - `GET    /api/v1/engine/core/info`              public, no auth
9//! - `GET    /api/v1/engine/core/health`            public, no auth
10//! - `GET    /api/v1/engine/core/active-modules`    public, no auth
11//! - `GET    /api/v1/engine/core/modules`           admin
12//! - `POST   /api/v1/engine/core/modules/{name}/toggle`  admin
13//! - `GET    /api/v1/engine/core/instances`         admin
14//! - `GET    /api/v1/engine/core/audit`             admin
15//! - `GET    /api/v1/engine/core/config`            admin (secrets redacted)
16//!
17//! Admin-gated endpoints reuse the same `Authorization: Bearer ...`
18//! check the auth admin router uses (compared in constant-ish time
19//! against `EngineState.admin_api_keys`). When `admin_api_keys` is
20//! empty every admin endpoint returns 401 — locking the surface
21//! entirely. `info`, `health`, and `active-modules` are always public
22//! so dashboards can render the header bar + cross-nav before an
23//! operator has supplied credentials.
24//!
25//! Backend-agnostic: handlers branch on the `BackendConfig` variant
26//! to call into either `PgEngineSchema` or `SqliteEngineSchema`. SQLite
27//! engines are single-instance so the `instances` endpoint typically
28//! returns one row; the shape stays identical so the UI doesn't care.
29
30use axum::Router;
31use axum::extract::{Path, Query, State};
32use axum::http::{HeaderMap, StatusCode, header};
33use axum::response::{IntoResponse, Json, Response};
34use axum::routing::{get, post};
35use serde::{Deserialize, Serialize};
36use serde_json::{Value, json};
37
38use assay_workflow::WorkflowStore;
39
40use crate::config::{BackendConfig, EngineConfig};
41use crate::state::EngineState;
42
43/// Build the engine-core admin router. Bound to `EngineState<S>` so
44/// handlers can pluck the workflow store, the parsed config, and the
45/// admin keys list off the parent state.
46pub fn router<S>() -> Router<EngineState<S>>
47where
48    S: WorkflowStore + Clone + 'static,
49{
50    Router::new()
51        .route("/api/v1/engine/core/info", get(engine_info::<S>))
52        .route("/api/v1/engine/core/health", get(engine_health::<S>))
53        .route(
54            "/api/v1/engine/core/active-modules",
55            get(active_modules::<S>),
56        )
57        .route("/api/v1/engine/core/modules", get(list_modules::<S>))
58        .route(
59            "/api/v1/engine/core/modules/{name}/toggle",
60            post(toggle_module::<S>),
61        )
62        .route("/api/v1/engine/core/instances", get(list_instances::<S>))
63        .route("/api/v1/engine/core/audit", get(list_audit::<S>))
64        .route("/api/v1/engine/core/config", get(get_config::<S>))
65}
66
67// =====================================================================
68//   /api/v1/engine/core/health
69// =====================================================================
70
71/// Engine-core health probe. Returns the same envelope previously
72/// served by the legacy `/healthz` endpoint (status + version +
73/// instance_id + active modules + leader flag) so existing operator
74/// scripts that scrape the JSON keep working after the URL move.
75async fn engine_health<S: WorkflowStore + Clone + 'static>(
76    State(s): State<EngineState<S>>,
77) -> Json<Value> {
78    Json(json!({
79        "status": "ok",
80        "engine_version": s.engine_version,
81        "instance_id": s.instance_id.to_string(),
82        "modules": &*s.modules,
83        // SQLite is single-instance and PG uses session-scoped
84        // pg_try_advisory_lock; both make leadership a runtime
85        // property. Surface it as `leader = true` for SQLite (no
86        // election) so dashboards keep the field stable.
87        "leader": true,
88    }))
89}
90
91// =====================================================================
92//   /api/v1/engine/core/active-modules
93// =====================================================================
94
95/// Active-modules listing — public, no auth. Read by the cross-console
96/// nav strip JS so disabled modules' pills don't render. Replaces the
97/// legacy top-level `/api/v1/modules` endpoint.
98async fn active_modules<S: WorkflowStore + Clone + 'static>(
99    State(s): State<EngineState<S>>,
100) -> Json<Value> {
101    Json(json!({
102        "modules": &*s.modules,
103    }))
104}
105
106// =====================================================================
107//   /api/v1/engine/core/info
108// =====================================================================
109
110#[derive(Debug, Clone, Serialize)]
111pub struct EngineInfo {
112    pub version: &'static str,
113    pub instance_id: String,
114    pub started_at: f64,
115    pub leader: bool,
116    pub modules: Vec<String>,
117    pub backend_kind: &'static str,
118    /// SQLite data directory when the backend is SQLite. `None` for PG.
119    pub backend_data_dir: Option<String>,
120    /// Postgres connection URL with the userinfo + password redacted.
121    /// `None` for SQLite.
122    pub backend_url_redacted: Option<String>,
123    pub bind_addr: String,
124    pub public_url: String,
125}
126
127async fn engine_info<S: WorkflowStore + Clone + 'static>(
128    State(s): State<EngineState<S>>,
129) -> Json<EngineInfo> {
130    let cfg: &EngineConfig = &s.engine_config;
131    let (kind, data_dir, url_redacted) = match &cfg.backend {
132        BackendConfig::Sqlite { data_dir, .. } => ("sqlite", Some(data_dir.clone()), None),
133        BackendConfig::Postgres { url } => ("postgres", None, Some(redact_pg_url(url))),
134    };
135    Json(EngineInfo {
136        version: s.engine_version,
137        instance_id: s.instance_id.to_string(),
138        started_at: s.started_at,
139        leader: true,
140        modules: (*s.modules).clone(),
141        backend_kind: kind,
142        backend_data_dir: data_dir,
143        backend_url_redacted: url_redacted,
144        bind_addr: cfg.server.bind_addr.clone(),
145        public_url: cfg.server.public_url.clone(),
146    })
147}
148
149// =====================================================================
150//   /api/v1/engine/core/modules
151// =====================================================================
152
153#[derive(Debug, Clone, Serialize)]
154pub struct ModuleEntry {
155    pub name: String,
156    pub enabled: bool,
157    pub enabled_at: Option<f64>,
158    pub enabled_by: Option<String>,
159    pub version: Option<String>,
160    pub config: Value,
161}
162
163#[derive(Debug, Clone, Serialize)]
164pub struct ListModulesResponse {
165    pub items: Vec<ModuleEntry>,
166}
167
168async fn list_modules<S: WorkflowStore + Clone + 'static>(
169    State(s): State<EngineState<S>>,
170    headers: HeaderMap,
171) -> Response {
172    if let Err(r) = require_admin(&headers, &s).await {
173        return *r;
174    }
175    let items = match list_module_records(&s.engine_config).await {
176        Ok(v) => v,
177        Err(e) => return server_error(&format!("list modules: {e}")),
178    };
179    let entries = items
180        .into_iter()
181        .map(|m| ModuleEntry {
182            name: m.name,
183            enabled: m.enabled,
184            enabled_at: m.enabled_at,
185            enabled_by: m.enabled_by,
186            version: m.version,
187            config: m.config,
188        })
189        .collect();
190    (StatusCode::OK, Json(ListModulesResponse { items: entries })).into_response()
191}
192
193#[derive(Debug, Clone, Deserialize)]
194pub struct ToggleBody {
195    /// Optional explicit enabled flag. When omitted the handler flips
196    /// the current value.
197    pub enabled: Option<bool>,
198}
199
200#[derive(Debug, Clone, Serialize)]
201pub struct ToggleResponse {
202    pub enabled: bool,
203    pub restart_required: bool,
204    pub message: String,
205}
206
207async fn toggle_module<S: WorkflowStore + Clone + 'static>(
208    State(s): State<EngineState<S>>,
209    headers: HeaderMap,
210    Path(name): Path<String>,
211    body: Option<Json<ToggleBody>>,
212) -> Response {
213    if let Err(r) = require_admin(&headers, &s).await {
214        return *r;
215    }
216    // Look up the current module row so we know what to flip to.
217    let modules = match list_module_records(&s.engine_config).await {
218        Ok(v) => v,
219        Err(e) => return server_error(&format!("list modules: {e}")),
220    };
221    let Some(current) = modules.iter().find(|m| m.name == name) else {
222        return (
223            StatusCode::NOT_FOUND,
224            Json(json!({"error": "unknown module name"})),
225        )
226            .into_response();
227    };
228    let target = body
229        .and_then(|Json(b)| b.enabled)
230        .unwrap_or(!current.enabled);
231    let actor = bearer_token(&headers).map(short_actor);
232    if let Err(e) = set_module_enabled(&s.engine_config, &name, target, actor.as_deref()).await {
233        return server_error(&format!("set enabled: {e}"));
234    }
235    if let Err(e) = audit_module_toggle(&s.engine_config, &name, target, actor.as_deref()).await {
236        // Failure to audit doesn't undo the flip; surface as a warning.
237        tracing::warn!(?e, "engine.audit insert failed for module toggle");
238    }
239    let msg = if target {
240        format!("module {name} marked enabled — restart engine to load")
241    } else {
242        format!("module {name} marked disabled — restart engine to unload")
243    };
244    (
245        StatusCode::OK,
246        Json(ToggleResponse {
247            enabled: target,
248            restart_required: true,
249            message: msg,
250        }),
251    )
252        .into_response()
253}
254
255// =====================================================================
256//   /api/v1/engine/core/instances
257// =====================================================================
258
259#[derive(Debug, Clone, Serialize)]
260pub struct InstanceEntry {
261    pub id: String,
262    pub started_at: f64,
263    pub last_heartbeat: f64,
264    pub namespaces: Vec<String>,
265    pub version: Option<String>,
266}
267
268#[derive(Debug, Clone, Serialize)]
269pub struct ListInstancesResponse {
270    pub items: Vec<InstanceEntry>,
271}
272
273#[derive(Debug, Clone, Default, Deserialize)]
274pub struct PageQuery {
275    #[serde(default)]
276    pub limit: Option<i64>,
277    #[serde(default)]
278    pub offset: Option<i64>,
279}
280
281async fn list_instances<S: WorkflowStore + Clone + 'static>(
282    State(s): State<EngineState<S>>,
283    headers: HeaderMap,
284    Query(_q): Query<PageQuery>,
285) -> Response {
286    if let Err(r) = require_admin(&headers, &s).await {
287        return *r;
288    }
289    let items = match list_instance_records(&s.engine_config).await {
290        Ok(v) => v,
291        Err(e) => return server_error(&format!("list instances: {e}")),
292    };
293    let entries = items
294        .into_iter()
295        .map(|i| InstanceEntry {
296            id: i.id,
297            started_at: i.started_at,
298            last_heartbeat: i.last_heartbeat,
299            namespaces: i.namespaces,
300            version: i.version,
301        })
302        .collect();
303    (
304        StatusCode::OK,
305        Json(ListInstancesResponse { items: entries }),
306    )
307        .into_response()
308}
309
310// =====================================================================
311//   /api/v1/engine/core/audit
312// =====================================================================
313
314#[derive(Debug, Clone, Default, Deserialize)]
315pub struct AuditQuery {
316    #[serde(default)]
317    pub limit: Option<i64>,
318    #[serde(default)]
319    pub offset: Option<i64>,
320    #[serde(default)]
321    pub actor: Option<String>,
322    #[serde(default)]
323    pub action: Option<String>,
324    #[serde(default)]
325    pub since: Option<f64>,
326    #[serde(default)]
327    pub until: Option<f64>,
328}
329
330#[derive(Debug, Clone, Serialize)]
331pub struct AuditEntry {
332    pub id: String,
333    pub ts: f64,
334    pub actor: Option<String>,
335    pub action: String,
336    pub details: Value,
337}
338
339#[derive(Debug, Clone, Serialize)]
340pub struct ListAuditResponse {
341    pub items: Vec<AuditEntry>,
342    pub total: i64,
343    pub limit: i64,
344    pub offset: i64,
345}
346
347async fn list_audit<S: WorkflowStore + Clone + 'static>(
348    State(s): State<EngineState<S>>,
349    headers: HeaderMap,
350    Query(q): Query<AuditQuery>,
351) -> Response {
352    if let Err(r) = require_admin(&headers, &s).await {
353        return *r;
354    }
355    let limit = q.limit.unwrap_or(50).clamp(1, 500);
356    let offset = q.offset.unwrap_or(0).max(0);
357    let (rows, total) = match list_audit_records(
358        &s.engine_config,
359        limit,
360        offset,
361        q.actor.as_deref(),
362        q.action.as_deref(),
363        q.since,
364        q.until,
365    )
366    .await
367    {
368        Ok(v) => v,
369        Err(e) => return server_error(&format!("list audit: {e}")),
370    };
371    let items = rows
372        .into_iter()
373        .map(|a| AuditEntry {
374            id: a.id,
375            ts: a.ts,
376            actor: a.actor,
377            action: a.action,
378            details: a.details,
379        })
380        .collect();
381    (
382        StatusCode::OK,
383        Json(ListAuditResponse {
384            items,
385            total,
386            limit,
387            offset,
388        }),
389    )
390        .into_response()
391}
392
393// =====================================================================
394//   /api/v1/engine/core/config
395// =====================================================================
396
397async fn get_config<S: WorkflowStore + Clone + 'static>(
398    State(s): State<EngineState<S>>,
399    headers: HeaderMap,
400) -> Response {
401    if let Err(r) = require_admin(&headers, &s).await {
402        return *r;
403    }
404    let mut value = match serde_json::to_value(&*s.engine_config) {
405        Ok(v) => v,
406        Err(e) => return server_error(&format!("serialise config: {e}")),
407    };
408    redact_secrets(&mut value);
409    (StatusCode::OK, Json(value)).into_response()
410}
411
412/// Replace secrets in the serialised config with `[REDACTED]`. Targets
413/// `admin_api_keys` and any key containing `password`, `secret`,
414/// `token`, or `key` (case-insensitive), excluding the structural
415/// `kid` / `keys`. Conservative on purpose — the engine console
416/// renders this for operators; over-redaction beats credential leaks.
417fn redact_secrets(v: &mut Value) {
418    let placeholder = Value::String("[REDACTED]".to_string());
419    match v {
420        Value::Object(map) => {
421            // Snapshot the keys upfront — modifying values while iterating
422            // owned-mut keys is fine, but we want a clear walk.
423            let keys: Vec<String> = map.keys().cloned().collect();
424            for k in keys {
425                let lk = k.to_lowercase();
426                if k == "admin_api_keys" {
427                    if let Some(Value::Array(arr)) = map.get_mut(&k) {
428                        for entry in arr {
429                            *entry = placeholder.clone();
430                        }
431                    }
432                    continue;
433                }
434                if (lk.contains("password")
435                    || lk.contains("secret")
436                    || lk.contains("api_key")
437                    || lk.contains("api-key"))
438                    && let Some(slot) = map.get_mut(&k)
439                {
440                    match slot {
441                        Value::String(_) => *slot = placeholder.clone(),
442                        Value::Array(arr) => {
443                            for entry in arr {
444                                if let Value::String(_) = entry {
445                                    *entry = placeholder.clone();
446                                } else {
447                                    redact_secrets(entry);
448                                }
449                            }
450                        }
451                        other => redact_secrets(other),
452                    }
453                    continue;
454                }
455                if let Some(slot) = map.get_mut(&k) {
456                    redact_secrets(slot);
457                }
458            }
459        }
460        Value::Array(arr) => {
461            for entry in arr {
462                redact_secrets(entry);
463            }
464        }
465        _ => {}
466    }
467}
468
469// =====================================================================
470//   helpers — admin auth
471// =====================================================================
472
473/// Engine-core admin gate.
474///
475/// Auth is mandatory so the engine always has an
476/// [`AuthCtx`]. Dispatch to [`assay_auth::gate::require_role_for`]
477/// for `engine#core#admin`. Admin api-key callers bypass as
478/// break-glass; session/JWT callers go through Zanzibar.
479async fn require_admin<S: WorkflowStore + Clone + 'static>(
480    headers: &HeaderMap,
481    state: &EngineState<S>,
482) -> Result<(), Box<Response>> {
483    let auth = state
484        .auth
485        .as_ref()
486        .ok_or_else(|| svc_unavailable_box("auth not configured on this engine instance"))?;
487    let keys = crate::state::AdminApiKeys(std::sync::Arc::clone(&state.admin_api_keys));
488    assay_auth::gate::require_role_for(headers, auth, &keys, "engine", "core", "admin")
489        .await
490        .map(|_| ())
491}
492
493fn svc_unavailable_box(msg: &str) -> Box<Response> {
494    Box::new(
495        (
496            StatusCode::SERVICE_UNAVAILABLE,
497            Json(json!({"error": "service_unavailable", "error_description": msg})),
498        )
499            .into_response(),
500    )
501}
502
503fn bearer_token(headers: &HeaderMap) -> Option<String> {
504    let raw = headers
505        .get(header::AUTHORIZATION)
506        .and_then(|v| v.to_str().ok())?;
507    raw.strip_prefix("Bearer ")
508        .or_else(|| raw.strip_prefix("bearer "))
509        .map(|s| s.trim().to_string())
510}
511
512/// Reduce an admin token to a short, non-reversible identifier so the
513/// audit log doesn't store the token itself. Truncated last 6 chars
514/// labelled `admin:****abcdef` — same shape used elsewhere for keyed
515/// admin actions (see auth admin.rs `audit` calls).
516fn short_actor(token: String) -> String {
517    let t = token.trim();
518    if t.len() <= 6 {
519        return format!("admin:****{t}");
520    }
521    let tail = &t[t.len() - 6..];
522    format!("admin:****{tail}")
523}
524
525fn server_error(msg: &str) -> Response {
526    (
527        StatusCode::INTERNAL_SERVER_ERROR,
528        Json(json!({"error": "server_error", "error_description": msg})),
529    )
530        .into_response()
531}
532
533// =====================================================================
534//   helpers — backend-routed schema reads
535// =====================================================================
536
537/// Open a fresh schema handle on demand. Cheap — the underlying pool
538/// is owned by the workflow context that already initialised at boot.
539/// We re-resolve the connection string from the parsed config so this
540/// helper is callable from handlers that only carry the cloned state.
541async fn list_module_records(
542    cfg: &EngineConfig,
543) -> anyhow::Result<Vec<assay_domain::engine::ModuleRecord>> {
544    match &cfg.backend {
545        #[cfg(feature = "backend-postgres")]
546        BackendConfig::Postgres { url } => {
547            let pool = sqlx::PgPool::connect(url)
548                .await
549                .map_err(|e| anyhow::anyhow!("connect pg: {e}"))?;
550            let schema = assay_domain::engine::PgEngineSchema::new(pool);
551            let rows = schema
552                .list_modules()
553                .await
554                .map_err(|e| anyhow::anyhow!("list modules (pg): {e}"))?;
555            Ok(rows)
556        }
557        #[cfg(feature = "backend-sqlite")]
558        BackendConfig::Sqlite { .. } => {
559            let pool = open_sqlite_engine_pool(cfg).await?;
560            let schema = assay_domain::engine::SqliteEngineSchema::new(pool);
561            let rows = schema
562                .list_modules()
563                .await
564                .map_err(|e| anyhow::anyhow!("list modules (sqlite): {e}"))?;
565            Ok(rows)
566        }
567        #[allow(unreachable_patterns)]
568        _ => anyhow::bail!("backend not enabled at compile time"),
569    }
570}
571
572async fn set_module_enabled(
573    cfg: &EngineConfig,
574    name: &str,
575    enabled: bool,
576    actor: Option<&str>,
577) -> anyhow::Result<bool> {
578    match &cfg.backend {
579        #[cfg(feature = "backend-postgres")]
580        BackendConfig::Postgres { url } => {
581            let pool = sqlx::PgPool::connect(url).await?;
582            let schema = assay_domain::engine::PgEngineSchema::new(pool);
583            schema.set_module_enabled(name, enabled, actor).await
584        }
585        #[cfg(feature = "backend-sqlite")]
586        BackendConfig::Sqlite { .. } => {
587            let pool = open_sqlite_engine_pool(cfg).await?;
588            let schema = assay_domain::engine::SqliteEngineSchema::new(pool);
589            schema.set_module_enabled(name, enabled, actor).await
590        }
591        #[allow(unreachable_patterns)]
592        _ => anyhow::bail!("backend not enabled at compile time"),
593    }
594}
595
596async fn audit_module_toggle(
597    cfg: &EngineConfig,
598    name: &str,
599    enabled: bool,
600    actor: Option<&str>,
601) -> anyhow::Result<()> {
602    let details = json!({"module": name, "enabled": enabled});
603    match &cfg.backend {
604        #[cfg(feature = "backend-postgres")]
605        BackendConfig::Postgres { url } => {
606            let pool = sqlx::PgPool::connect(url).await?;
607            let schema = assay_domain::engine::PgEngineSchema::new(pool);
608            schema.audit(actor, "engine.module.toggle", &details).await
609        }
610        #[cfg(feature = "backend-sqlite")]
611        BackendConfig::Sqlite { .. } => {
612            let pool = open_sqlite_engine_pool(cfg).await?;
613            let schema = assay_domain::engine::SqliteEngineSchema::new(pool);
614            schema.audit(actor, "engine.module.toggle", &details).await
615        }
616        #[allow(unreachable_patterns)]
617        _ => anyhow::bail!("backend not enabled at compile time"),
618    }
619}
620
621async fn list_instance_records(
622    cfg: &EngineConfig,
623) -> anyhow::Result<Vec<assay_domain::engine::InstanceRecord>> {
624    match &cfg.backend {
625        #[cfg(feature = "backend-postgres")]
626        BackendConfig::Postgres { url } => {
627            let pool = sqlx::PgPool::connect(url).await?;
628            let schema = assay_domain::engine::PgEngineSchema::new(pool);
629            schema.list_instances().await
630        }
631        #[cfg(feature = "backend-sqlite")]
632        BackendConfig::Sqlite { .. } => {
633            let pool = open_sqlite_engine_pool(cfg).await?;
634            let schema = assay_domain::engine::SqliteEngineSchema::new(pool);
635            schema.list_instances().await
636        }
637        #[allow(unreachable_patterns)]
638        _ => anyhow::bail!("backend not enabled at compile time"),
639    }
640}
641
642async fn list_audit_records(
643    cfg: &EngineConfig,
644    limit: i64,
645    offset: i64,
646    actor: Option<&str>,
647    action: Option<&str>,
648    since: Option<f64>,
649    until: Option<f64>,
650) -> anyhow::Result<(Vec<assay_domain::engine::AuditRecord>, i64)> {
651    match &cfg.backend {
652        #[cfg(feature = "backend-postgres")]
653        BackendConfig::Postgres { url } => {
654            let pool = sqlx::PgPool::connect(url).await?;
655            let schema = assay_domain::engine::PgEngineSchema::new(pool);
656            schema
657                .list_audit(limit, offset, actor, action, since, until)
658                .await
659        }
660        #[cfg(feature = "backend-sqlite")]
661        BackendConfig::Sqlite { .. } => {
662            let pool = open_sqlite_engine_pool(cfg).await?;
663            let schema = assay_domain::engine::SqliteEngineSchema::new(pool);
664            schema
665                .list_audit(limit, offset, actor, action, since, until)
666                .await
667        }
668        #[allow(unreachable_patterns)]
669        _ => anyhow::bail!("backend not enabled at compile time"),
670    }
671}
672
673/// Open a fresh `engine.db`-only sqlite pool, mirroring the
674/// `init.rs::sqlite_boot` ATTACH layout. Used by handlers that need a
675/// schema-qualified `engine.modules` query without sharing the boot
676/// pool's connection. Lighter than re-running boot — skips workflow
677/// + auth ATTACH.
678#[cfg(feature = "backend-sqlite")]
679async fn open_sqlite_engine_pool(cfg: &EngineConfig) -> anyhow::Result<sqlx::SqlitePool> {
680    use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
681    use std::str::FromStr;
682
683    let data_dir = cfg
684        .backend
685        .sqlite_data_dir()
686        .ok_or_else(|| anyhow::anyhow!("sqlite_data_dir missing on non-sqlite backend"))?;
687    let path = format!("file:{data_dir}/engine.db?mode=rw");
688    let opts = SqliteConnectOptions::from_str("sqlite::memory:")?.create_if_missing(true);
689    let pool = SqlitePoolOptions::new()
690        .max_connections(1)
691        .after_connect(move |conn, _meta| {
692            let path = path.clone();
693            Box::pin(async move {
694                use sqlx::Executor;
695                conn.execute(format!("ATTACH DATABASE '{path}' AS engine").as_str())
696                    .await?;
697                Ok(())
698            })
699        })
700        .connect_with(opts)
701        .await
702        .map_err(|e| anyhow::anyhow!("connect engine.db: {e}"))?;
703    Ok(pool)
704}
705
706/// Strip userinfo (user + password) from a Postgres URL for safe
707/// display — keeps host / port / db / params. `postgres://u:p@h:5/db`
708/// → `postgres://[REDACTED]@h:5/db`. We rebuild the URL string by
709/// hand because `url::Url::set_username` percent-encodes `[`/`]`,
710/// which would garble the placeholder; the string surface only ever
711/// flows out to the dashboard so a plain rebuild is safe.
712fn redact_pg_url(raw: &str) -> String {
713    let Ok(u) = url::Url::parse(raw) else {
714        return "[REDACTED]".to_string();
715    };
716    let scheme = u.scheme();
717    let host = u.host_str().unwrap_or("");
718    let port = u.port().map(|p| format!(":{p}")).unwrap_or_default();
719    let path = u.path();
720    let query = match u.query() {
721        Some(q) => format!("?{q}"),
722        None => String::new(),
723    };
724    let userinfo = if !u.username().is_empty() || u.password().is_some() {
725        "[REDACTED]@"
726    } else {
727        ""
728    };
729    format!("{scheme}://{userinfo}{host}{port}{path}{query}")
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735
736    #[test]
737    fn redact_secrets_replaces_admin_api_keys_array() {
738        let mut v = json!({
739            "auth": {
740                "admin_api_keys": ["secret1", "secret2"],
741                "issuer": "https://issuer.example",
742            }
743        });
744        redact_secrets(&mut v);
745        let arr = v["auth"]["admin_api_keys"].as_array().unwrap();
746        assert!(arr.iter().all(|x| x.as_str() == Some("[REDACTED]")));
747        // Non-secret strings stay intact.
748        assert_eq!(v["auth"]["issuer"], json!("https://issuer.example"));
749    }
750
751    #[test]
752    fn redact_secrets_replaces_password_fields() {
753        let mut v = json!({"backend": {"password": "hunter2"}});
754        redact_secrets(&mut v);
755        assert_eq!(v["backend"]["password"], json!("[REDACTED]"));
756    }
757
758    #[test]
759    fn redact_pg_url_strips_userinfo() {
760        let raw = "postgres://alice:hunter2@db.example.com:5432/assay";
761        let red = redact_pg_url(raw);
762        assert!(!red.contains("hunter2"), "password leak: {red}");
763        assert!(red.contains("[REDACTED]"));
764        assert!(red.contains("db.example.com:5432"));
765        assert!(red.contains("/assay"));
766    }
767
768    // Admin-gate behaviour now lives in `assay_auth::gate` —
769    // engine_api's `require_admin` is a one-line wrapper. Tests for
770    // the gate live in that crate's gate.rs.
771
772    #[test]
773    fn short_actor_safely_truncates() {
774        let s = short_actor("abcdef0123456789".to_string());
775        assert_eq!(s, "admin:****456789");
776        // Short token guard.
777        let s = short_actor("xyz".to_string());
778        assert!(s.starts_with("admin:****"));
779    }
780}