Skip to main content

kovra_webui/
lib.rs

1//! `kovra-webui` — the on-demand, loopback administration Web UI (L10, KOV-22;
2//! spec §9.3, §12; invariants I1/I2/I10).
3//!
4//! A richer admin surface than the CLI, brought up on demand by `kovra ui`: an
5//! [`axum`] server bound to `127.0.0.1` only (I10), behind an ephemeral
6//! per-launch session token and an `Origin`/`Host` check (anti
7//! DNS-rebinding/CSRF even on loopback). It does CRUD + generate plus
8//! **sensitivity-governed visualization**:
9//!
10//! - `low`/`medium` → the value is revealed **on demand** (fetched per click,
11//!   never preloaded into the listing, §9.3).
12//! - `high` → masked + truncated fingerprint; the UI defers an actual reveal to
13//!   the CLI (the trusted, biometric channel). The browser never sees it (I1).
14//! - `inject-only` → existence/metadata only (I2).
15//! - `reference` → the pointer URI is shown/edited, **never** a value (it has
16//!   none); at most a resolution status. Keypair private halves and TOTP seeds
17//!   are likewise never rendered.
18//!
19//! The reveal gate is **not re-derived here** — every reveal runs through
20//! [`kovra_core::decide`] with [`Surface::WebUi`], so the I1/I2 boundary lives in
21//! the core and the UI is a thin adapter (spec §2/§15). Nothing in this crate is
22//! `[host]`: the router is exercised by `[mock]` endpoint tests; only the real
23//! TCP bind + browser-open + Docker packaging (L11) are validated on hardware.
24
25use std::net::SocketAddr;
26use std::path::PathBuf;
27use std::sync::Arc;
28use std::sync::Mutex;
29use std::sync::atomic::{AtomicBool, Ordering};
30use std::time::{Duration, Instant};
31
32use axum::{
33    Json, Router,
34    extract::{Query, Request, State},
35    http::{HeaderValue, Method, StatusCode, header},
36    middleware::{self, Next},
37    response::{Html, IntoResponse, Response},
38    routing::{get, post},
39};
40use kovra_core::{
41    AccessRequest, AgentScope, AuditAction, AuditEvent, AuditSink, Clock, ConfirmOutcome,
42    ConfirmRequest, Confirmer, Coordinate, Decision, FileAuditSink, IntakeBroker, MasterKey,
43    Operation, Origin, Registry, Resolution, SecretRecord, SecretValue, Sensitivity, Surface,
44    SystemClock, birth_sensitivity, decide, delete_requires_confirmation,
45    downgrade_requires_confirmation, fingerprint, is_downgrade, store,
46};
47use rand::RngCore;
48use serde::Deserialize;
49use serde_json::{Value, json};
50use std::str::FromStr;
51
52mod assets;
53
54/// HTTP header carrying the ephemeral per-launch session token.
55pub const SESSION_HEADER: &str = "x-kovra-session";
56
57/// Default loopback port for `kovra ui`.
58pub const DEFAULT_PORT: u16 = 8731;
59
60/// Shared application state. Cheap to clone (an `Arc`); holds the registry root,
61/// the resolved master key (zeroized on drop via [`MasterKey`]), the ephemeral
62/// session token, and the last-activity instant for the idle watchdog.
63#[derive(Clone)]
64pub struct AppState {
65    inner: Arc<Inner>,
66}
67
68struct Inner {
69    root: PathBuf,
70    master: MasterKey,
71    session_token: String,
72    last_activity: Mutex<Instant>,
73    /// Broker for the **per-action** attended confirmation of destructive UI
74    /// operations (sensitivity downgrade, delete — KOV-30). Supplied by the
75    /// launcher: Touch ID on `[host]` macOS, the file broker (`kovra approve`)
76    /// otherwise / in the container. The same authoritative `Confirmer` the CLI
77    /// uses (I3/I5/I16), never re-derived here.
78    confirmer: Arc<dyn Confirmer + Send + Sync>,
79    /// Lock latch (KOV-73). When set, the secret-rendering API returns `423
80    /// Locked` and reveals nothing until a fresh attended re-auth (`POST
81    /// /api/unlock`). The persistent-mode auto-lock-on-screen-lock/sleep ([host])
82    /// flips this via [`AppState::lock`]; `kovra ui` (non-persistent) never sets it.
83    locked: AtomicBool,
84}
85
86impl AppState {
87    /// Build state for a registry `root`, a resolved `master` key, and the
88    /// attended-confirmation `confirmer`, minting a fresh random session token
89    /// (128 bits of hex). The token dies with the process — it is never
90    /// persisted.
91    pub fn new(
92        root: PathBuf,
93        master: MasterKey,
94        confirmer: Arc<dyn Confirmer + Send + Sync>,
95    ) -> Self {
96        let mut buf = [0u8; 16];
97        rand::rngs::OsRng.fill_bytes(&mut buf);
98        let session_token = buf.iter().map(|b| format!("{b:02x}")).collect();
99        Self::new_with_session(root, master, session_token, confirmer)
100    }
101
102    /// Like [`AppState::new`] but with a caller-supplied session token. Used by
103    /// the L11 container entrypoint so the host orchestrator (`kovra ui
104    /// --docker`) — which generated the token and built the browser URL — and
105    /// the in-container server agree on it.
106    pub fn new_with_session(
107        root: PathBuf,
108        master: MasterKey,
109        session_token: String,
110        confirmer: Arc<dyn Confirmer + Send + Sync>,
111    ) -> Self {
112        Self {
113            inner: Arc::new(Inner {
114                root,
115                master,
116                session_token,
117                last_activity: Mutex::new(Instant::now()),
118                confirmer,
119                locked: AtomicBool::new(false),
120            }),
121        }
122    }
123
124    /// The ephemeral session token (embedded into the page URL by `kovra ui`).
125    pub fn session_token(&self) -> &str {
126        &self.inner.session_token
127    }
128
129    /// A clone of the per-action confirmation broker (cheap — an `Arc`).
130    fn confirmer(&self) -> Arc<dyn Confirmer + Send + Sync> {
131        Arc::clone(&self.inner.confirmer)
132    }
133
134    fn registry(&self) -> Result<Registry, AppError> {
135        Registry::open(&self.inner.root).map_err(|e| AppError::internal(e.to_string()))
136    }
137
138    /// The registry root — also where the intake broker queues live.
139    fn root(&self) -> &std::path::Path {
140        &self.inner.root
141    }
142
143    fn key(&self) -> &[u8; kovra_core::KEY_LEN] {
144        self.inner.master.expose()
145    }
146
147    fn audit(&self, action: AuditAction, result: &str, canonical: &str, env: &str) {
148        let clock = SystemClock;
149        let _ = FileAuditSink::under_root(&self.inner.root).record(
150            &AuditEvent::new(&clock, action, result)
151                .at(canonical, env)
152                .by(Origin::Human),
153        );
154    }
155
156    fn touch(&self) {
157        if let Ok(mut t) = self.inner.last_activity.lock() {
158            *t = Instant::now();
159        }
160    }
161
162    fn idle_for(&self) -> Duration {
163        self.inner
164            .last_activity
165            .lock()
166            .map(|t| t.elapsed())
167            .unwrap_or_default()
168    }
169
170    /// Lock the UI (KOV-73): the secret-rendering API then returns `423 Locked`
171    /// and reveals nothing until [`AppState::unlock`]. Public so a `[host]` native
172    /// bridge (NSWorkspace screen-lock/sleep notifications) can flip it on the
173    /// persistent UI; idempotent and lock-free.
174    pub fn lock(&self) {
175        self.inner.locked.store(true, Ordering::SeqCst);
176    }
177
178    /// Clear the lock latch after a successful attended re-auth.
179    pub fn unlock(&self) {
180        self.inner.locked.store(false, Ordering::SeqCst);
181    }
182
183    /// Whether the UI is currently locked.
184    pub fn is_locked(&self) -> bool {
185        self.inner.locked.load(Ordering::SeqCst)
186    }
187}
188
189/// A handler error rendered as an HTTP status + JSON body (never a value).
190#[derive(Debug)]
191struct AppError {
192    status: StatusCode,
193    message: String,
194}
195
196impl AppError {
197    fn new(status: StatusCode, message: impl Into<String>) -> Self {
198        Self {
199            status,
200            message: message.into(),
201        }
202    }
203    fn internal(message: impl Into<String>) -> Self {
204        Self::new(StatusCode::INTERNAL_SERVER_ERROR, message)
205    }
206    fn bad(message: impl Into<String>) -> Self {
207        Self::new(StatusCode::BAD_REQUEST, message)
208    }
209    fn not_found(message: impl Into<String>) -> Self {
210        Self::new(StatusCode::NOT_FOUND, message)
211    }
212}
213
214impl IntoResponse for AppError {
215    fn into_response(self) -> Response {
216        (self.status, Json(json!({ "error": self.message }))).into_response()
217    }
218}
219
220/// Build the router for `state`. The `/api/*` routes sit behind the ephemeral
221/// session-token check; every route (incl. `/`) is behind the `Origin`/`Host`
222/// loopback guard. This is the unit exercised by the endpoint tests.
223pub fn build_app(state: AppState) -> Router {
224    // Secret-rendering routes sit behind the lock latch (KOV-73): when locked
225    // they return 423 and reveal nothing until /api/unlock.
226    let guarded = Router::new()
227        .route("/secrets", get(list_secrets))
228        .route("/reveal", get(reveal_secret))
229        .route(
230            "/secret",
231            post(create_secret)
232                .put(update_value)
233                .patch(edit_metadata)
234                .delete(delete_secret),
235        )
236        .route("/generate", post(generate_secret))
237        // Agent-initiated intakes (KOV-69): list pending, fulfil (human enters the
238        // value over loopback — never the agent), or dismiss.
239        .route("/intakes", get(list_intakes).delete(dismiss_intake))
240        .route("/intakes/fulfill", post(fulfill_intake))
241        .route_layer(middleware::from_fn_with_state(state.clone(), lock_guard));
242
243    // lock/unlock stay reachable WHILE locked (unlock is the way back in); the
244    // whole /api surface still requires the ephemeral session token.
245    let api = guarded
246        .route("/lock", post(lock_ui))
247        .route("/unlock", post(unlock_ui))
248        .route_layer(middleware::from_fn_with_state(
249            state.clone(),
250            require_session,
251        ));
252
253    Router::new()
254        .route("/", get(index))
255        // Static front-end assets (vendored Tabulator + first-party app shell).
256        // Carry no secrets, so they sit outside the `/api` session layer but
257        // inside the loopback guard below (KOV-29).
258        .merge(assets::routes())
259        .nest("/api", api)
260        .layer(middleware::from_fn_with_state(
261            state.clone(),
262            loopback_guard,
263        ))
264        // Outermost: stamp security headers on **every** response (incl. the
265        // loopback-guard rejections and static assets) — see [`security_headers`].
266        .layer(middleware::from_fn(security_headers))
267        .with_state(state)
268}
269
270/// The Content-Security-Policy for the admin shell. The page loads only
271/// first-party, same-origin scripts/styles/fonts/images and talks only to its
272/// own origin, so the policy is deliberately tight: no inline *scripts*, no
273/// framing, no base/form hijack. `style-src` keeps `'unsafe-inline'` solely for
274/// the static `style="…"` attributes in the markup (a presentational, not a
275/// script-execution, vector); `img-src` allows `data:` for any inline SVG/data
276/// thumbnails the grid renders.
277const CSP: &str = "default-src 'none'; \
278script-src 'self'; \
279style-src 'self' 'unsafe-inline'; \
280img-src 'self' data:; \
281font-src 'self'; \
282connect-src 'self'; \
283base-uri 'none'; \
284form-action 'none'; \
285frame-ancestors 'none'";
286
287/// Defense-in-depth response headers (KOV — security-audit hardening). A loopback
288/// admin tool that reveals secrets must not be framable (clickjacking over a
289/// reveal/delete), must not leak its URL — which carries the session token — in a
290/// `Referer`, and must never be cached to disk. Applied to every response.
291async fn security_headers(req: Request, next: Next) -> Response {
292    let mut res = next.run(req).await;
293    let h = res.headers_mut();
294    h.insert(
295        header::CONTENT_SECURITY_POLICY,
296        HeaderValue::from_static(CSP),
297    );
298    h.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
299    h.insert(
300        header::REFERRER_POLICY,
301        HeaderValue::from_static("no-referrer"),
302    );
303    h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
304    res
305}
306
307// ───────────────────────────── middleware ─────────────────────────────
308
309/// I10 / anti-DNS-rebinding: accept only loopback `Host` and same-origin
310/// `Origin`. Runs for every route (including `/`). Also refreshes the
311/// idle-watchdog clock.
312async fn loopback_guard(State(state): State<AppState>, req: Request, next: Next) -> Response {
313    if let Some(host) = req
314        .headers()
315        .get(header::HOST)
316        .and_then(|h| h.to_str().ok())
317        && !is_loopback_host(host)
318    {
319        return AppError::new(StatusCode::FORBIDDEN, "non-loopback Host rejected (I10)")
320            .into_response();
321    }
322    // If an Origin is present (a browser fetch), it must itself be loopback.
323    if let Some(origin) = req
324        .headers()
325        .get(header::ORIGIN)
326        .and_then(|h| h.to_str().ok())
327        && !is_loopback_origin(origin)
328    {
329        return AppError::new(StatusCode::FORBIDDEN, "cross-origin request rejected")
330            .into_response();
331    }
332    // CSRF / DNS-rebinding: a state-changing request must carry a *present*,
333    // loopback `Origin`. A real same-origin browser fetch always sends one on
334    // non-GET; its absence means the request is not a same-origin fetch (a
335    // form-POST, a non-browser client, or a cross-origin attempt that stripped
336    // it), so it is refused. The custom `x-kovra-session` header (not a cookie)
337    // remains the primary CSRF defense — a cross-site page cannot set it — and
338    // this closes the no-`Origin` gap left by the present-only check above.
339    if is_state_changing(req.method()) {
340        let origin_ok = req
341            .headers()
342            .get(header::ORIGIN)
343            .and_then(|h| h.to_str().ok())
344            .is_some_and(is_loopback_origin);
345        if !origin_ok {
346            return AppError::new(
347                StatusCode::FORBIDDEN,
348                "state-changing request requires a loopback Origin",
349            )
350            .into_response();
351        }
352    }
353    state.touch();
354    next.run(req).await
355}
356
357/// Whether `method` mutates server state (and therefore needs the stricter
358/// `Origin` check above). Safe, idempotent reads (`GET`/`HEAD`/`OPTIONS`) do not.
359fn is_state_changing(method: &Method) -> bool {
360    matches!(
361        *method,
362        Method::POST | Method::PUT | Method::PATCH | Method::DELETE
363    )
364}
365
366/// Require the ephemeral session token on `/api/*`. The browser shell receives
367/// it from the launch URL and echoes it in [`SESSION_HEADER`].
368async fn require_session(State(state): State<AppState>, req: Request, next: Next) -> Response {
369    let presented = req
370        .headers()
371        .get(SESSION_HEADER)
372        .and_then(|h| h.to_str().ok())
373        .unwrap_or_default();
374    // Constant-ish comparison is unnecessary here (loopback, ephemeral token),
375    // but we still avoid leaking which half mismatched.
376    if presented.is_empty() || presented != state.session_token() {
377        return AppError::new(StatusCode::UNAUTHORIZED, "missing or invalid session token")
378            .into_response();
379    }
380    next.run(req).await
381}
382
383/// Lock-latch guard (KOV-73): while the UI is locked, every secret-rendering route
384/// returns `423 Locked` and reveals nothing. `/api/lock` and `/api/unlock` sit
385/// outside this layer so the human can always re-auth their way back in.
386async fn lock_guard(State(state): State<AppState>, req: Request, next: Next) -> Response {
387    if state.is_locked() {
388        return AppError::new(
389            StatusCode::LOCKED,
390            "the UI is locked — POST /api/unlock to re-authenticate",
391        )
392        .into_response();
393    }
394    next.run(req).await
395}
396
397/// `POST /api/lock` — lock the UI immediately. Locking only ever *reduces*
398/// exposure, so it needs no confirmation: the menu-bar app's "Lock" and the
399/// `[host]` auto-lock-on-screen-lock/sleep bridge both call this on the persistent
400/// UI (the bridge via [`AppState::lock`] directly).
401async fn lock_ui(State(state): State<AppState>) -> Response {
402    state.lock();
403    (StatusCode::OK, Json(json!({ "locked": true }))).into_response()
404}
405
406/// `POST /api/unlock` — clear the lock latch after a fresh attended confirmation
407/// (the same authoritative Touch ID / file-broker `Confirmer`, I16). Denied or
408/// timed-out leaves the UI locked (fail safe).
409async fn unlock_ui(State(state): State<AppState>) -> Response {
410    let req = ConfirmRequest::for_action("Unlock the kovra Web UI", Origin::Human)
411        .with_requesting_process("kovra ui (web admin)");
412    match confirm_action(state.confirmer(), req).await {
413        ConfirmOutcome::Approved => {
414            state.unlock();
415            (StatusCode::OK, Json(json!({ "locked": false }))).into_response()
416        }
417        ConfirmOutcome::Denied => {
418            AppError::new(StatusCode::FORBIDDEN, "unlock denied").into_response()
419        }
420        ConfirmOutcome::TimedOut => {
421            AppError::new(StatusCode::REQUEST_TIMEOUT, "unlock timed out").into_response()
422        }
423    }
424}
425
426fn is_loopback_host(host: &str) -> bool {
427    // Strip the optional port; accept only the numeric loopback literals. We
428    // deliberately do NOT accept the name `localhost`: the launcher always opens
429    // the numeric `127.0.0.1` URL, and a name can be steered by a hosts/resolver
430    // entry — the exact DNS-rebinding vector this guard exists to close (I10).
431    let h = host.rsplit_once(':').map(|(h, _)| h).unwrap_or(host);
432    h == "127.0.0.1" || h == "[::1]" || h == "::1"
433}
434
435fn is_loopback_origin(origin: &str) -> bool {
436    let rest = match origin.strip_prefix("http://") {
437        Some(r) => r,
438        None => match origin.strip_prefix("https://") {
439            Some(r) => r,
440            None => return false,
441        },
442    };
443    is_loopback_host(rest)
444}
445
446// ───────────────────────────── handlers ─────────────────────────────
447
448#[derive(Deserialize, Default)]
449struct ScopeQuery {
450    project: Option<String>,
451}
452
453#[derive(Deserialize)]
454struct CoordQuery {
455    coord: String,
456    project: Option<String>,
457}
458
459/// `GET /` — the minimal admin shell. Serves no secret; the listing and any
460/// reveal are fetched over `/api/*` on demand with the session token.
461async fn index() -> Html<&'static str> {
462    Html(INDEX_HTML)
463}
464
465/// `GET /api/secrets` — metadata-only inventory (never a value, §9.3). Lists the
466/// global vault plus every project (or one project), marking shadowing and
467/// reference pointers.
468async fn list_secrets(
469    State(state): State<AppState>,
470    Query(q): Query<ScopeQuery>,
471) -> Result<Json<Value>, AppError> {
472    let registry = state.registry()?;
473    let mut rows: Vec<Value> = Vec::new();
474    let mut global_coords: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
475
476    let mut collect = |dir: PathBuf, origin: String| -> Result<(), AppError> {
477        let outcome =
478            store::load_all(&dir, state.key()).map_err(|e| AppError::internal(e.to_string()))?;
479        for (_, record) in outcome.records {
480            if origin == "global" {
481                global_coords.insert(record.canonical_path());
482            }
483            rows.push(row_for(&record, &origin));
484        }
485        Ok(())
486    };
487
488    match q.project.as_deref() {
489        Some(p) => collect(registry.project_dir(p), format!("project:{p}"))?,
490        None => {
491            collect(registry.global_dir(), "global".to_string())?;
492            for name in registry
493                .list_projects()
494                .map_err(|e| AppError::internal(e.to_string()))?
495            {
496                collect(registry.project_dir(&name), format!("project:{name}"))?;
497            }
498        }
499    }
500
501    // Mark project rows that shadow a homonymous global coordinate (§9.3).
502    for row in &mut rows {
503        let is_project = row
504            .get("origin")
505            .and_then(|o| o.as_str())
506            .is_some_and(|o| o.starts_with("project:"));
507        let coord = row.get("coordinate").and_then(|c| c.as_str()).unwrap_or("");
508        if is_project && global_coords.contains(coord) {
509            row["shadows_global"] = json!(true);
510        }
511    }
512
513    Ok(Json(json!({ "secrets": rows })))
514}
515
516/// One inventory row — metadata only (I1/I12). Literals carry a truncated
517/// fingerprint; references carry the pointer; keypair/totp carry their
518/// non-secret descriptors. **No value, private key, or seed is ever included.**
519fn row_for(record: &SecretRecord, origin: &str) -> Value {
520    let base = json!({
521        "origin": origin,
522        "coordinate": record.canonical_path(),
523        "environment": record.environment(),
524        "component": record.component(),
525        "key": record.key(),
526        "sensitivity": sensitivity_str(record.sensitivity()),
527        "revealable": record.revealable(),
528        "shadows_global": false,
529        "created": record.created(),
530        "updated": record.updated(),
531    });
532    let mut v = base;
533    match record {
534        SecretRecord::Literal { value, .. } => {
535            v["mode"] = json!("literal");
536            v["fingerprint"] = json!(fingerprint(value.expose()));
537        }
538        SecretRecord::Reference { reference, .. } => {
539            v["mode"] = json!("reference");
540            v["pointer"] = json!(reference);
541        }
542        SecretRecord::Keypair {
543            algorithm,
544            private,
545            public,
546            ..
547        } => {
548            v["mode"] = json!(if private.is_some() {
549                "keypair"
550            } else {
551                "public-only"
552            });
553            v["algorithm"] = json!(algorithm.as_str());
554            v["public"] = json!(public); // public key is not a secret
555            v["fingerprint"] = json!(fingerprint(public.as_bytes()));
556        }
557        SecretRecord::Totp {
558            algorithm,
559            digits,
560            period,
561            ..
562        } => {
563            v["mode"] = json!("totp");
564            v["algorithm"] = json!(algorithm.as_str());
565            v["digits"] = json!(digits);
566            v["period"] = json!(period);
567        }
568    }
569    v
570}
571
572/// `GET /api/reveal?coord=&project=` — reveal a value **on demand**, governed by
573/// sensitivity through [`decide`] (I1/I2). Only `low`/`medium` literals return a
574/// value; `high` returns masked + fingerprint; `inject-only` returns metadata
575/// only; references/keypairs/totp never return their secret material.
576async fn reveal_secret(
577    State(state): State<AppState>,
578    Query(q): Query<CoordQuery>,
579) -> Result<Json<Value>, AppError> {
580    let coord = parse_coord(&q.coord)?;
581    let registry = state.registry()?;
582    let record = match registry
583        .resolve_with_key(&coord, q.project.as_deref(), state.key())
584        .map_err(|e| AppError::internal(e.to_string()))?
585    {
586        Resolution::Found { record, origin } => {
587            let _ = origin; // origin is surfaced by the listing, not the reveal
588            record
589        }
590        Resolution::NotFound => {
591            return Err(AppError::not_found(format!("no secret at `{}`", q.coord)));
592        }
593    };
594    let canonical = record.canonical_path();
595    let env = record.environment().to_string();
596    let sensitivity = record.sensitivity();
597
598    // Non-literal modalities never expose their secret material in the browser.
599    match &record {
600        SecretRecord::Reference { reference, .. } => {
601            return Ok(Json(json!({
602                "coordinate": canonical,
603                "kind": "reference",
604                "pointer": reference,
605                "status": "unverified",
606                "note": "value not stored; materialized at run time by the provider (I8)"
607            })));
608        }
609        SecretRecord::Keypair {
610            algorithm,
611            private,
612            public,
613            ..
614        } => {
615            return Ok(Json(json!({
616                "coordinate": canonical,
617                "kind": if private.is_some() { "keypair" } else { "public-only" },
618                "algorithm": algorithm.as_str(),
619                "public": public,
620                "note": "private half is custodied; use the CLI (sign/decrypt/ssh-add)"
621            })));
622        }
623        SecretRecord::Totp {
624            algorithm,
625            digits,
626            period,
627            ..
628        } => {
629            return Ok(Json(json!({
630                "coordinate": canonical,
631                "kind": "totp",
632                "algorithm": algorithm.as_str(),
633                "digits": digits,
634                "period": period,
635                "note": "seed is custodied; derive a code with the CLI (`kovra code`)"
636            })));
637        }
638        SecretRecord::Literal { .. } => {}
639    }
640
641    let SecretRecord::Literal {
642        value, revealable, ..
643    } = &record
644    else {
645        unreachable!("non-literal handled above");
646    };
647
648    let request = AccessRequest {
649        coordinate: &coord,
650        project: q.project.as_deref(),
651        sensitivity,
652        revealable: *revealable,
653        operation: Operation::Reveal,
654        surface: Surface::WebUi,
655        origin: Origin::Human,
656    };
657    match decide(&request, &AgentScope::full()) {
658        Decision::Allow => {
659            // low/medium: the only path that returns a literal value, and only on
660            // this explicit per-coordinate fetch (never in the listing).
661            let value_str = String::from_utf8_lossy(value.expose()).into_owned();
662            state.audit(AuditAction::Reveal, "revealed", &canonical, &env);
663            Ok(Json(json!({
664                "coordinate": canonical,
665                "kind": "literal",
666                "sensitivity": sensitivity_str(sensitivity),
667                "value": value_str
668            })))
669        }
670        Decision::Deny(reason) => {
671            // high → masked + fingerprint (defer to CLI); inject-only → metadata
672            // only. The value never leaves the core (I1/I2).
673            use kovra_core::DenyReason;
674            let body = match reason {
675                DenyReason::WebUiCriticalMasked => json!({
676                    "coordinate": canonical,
677                    "kind": "literal",
678                    "sensitivity": sensitivity_str(sensitivity),
679                    "masked": true,
680                    "fingerprint": fingerprint(value.expose()),
681                    "note": "high — masked in the browser (I1); reveal via the CLI's biometric channel"
682                }),
683                DenyReason::InjectOnlyNeverRevealed => json!({
684                    "coordinate": canonical,
685                    "kind": "literal",
686                    "sensitivity": sensitivity_str(sensitivity),
687                    "inject_only": true,
688                    "note": "inject-only — never revealed on any surface (I2)"
689                }),
690                other => json!({
691                    "coordinate": canonical,
692                    "kind": "literal",
693                    "masked": true,
694                    "note": format!("not revealable here: {other:?}")
695                }),
696            };
697            state.audit(AuditAction::Reveal, "masked", &canonical, &env);
698            Ok(Json(body))
699        }
700        Decision::Unaddressable => Err(AppError::not_found("not addressable")),
701        Decision::RequireConfirmation => {
702            // The Web UI never prompts for confirmation; it masks instead (the
703            // CLI is the confirmation channel). Treat as masked.
704            Ok(Json(json!({
705                "coordinate": canonical,
706                "kind": "literal",
707                "masked": true,
708                "fingerprint": fingerprint(value.expose()),
709                "note": "requires confirmation — reveal via the CLI"
710            })))
711        }
712    }
713}
714
715#[derive(Deserialize)]
716struct CreateBody {
717    coord: String,
718    project: Option<String>,
719    value: Option<String>,
720    reference: Option<String>,
721    sensitivity: Option<String>,
722    description: Option<String>,
723    #[serde(default)]
724    revealable: bool,
725}
726
727/// `POST /api/secret` — create a literal or reference secret. Values arrive in
728/// the request body over loopback (never argv); prod is born `high` (I5).
729async fn create_secret(
730    State(state): State<AppState>,
731    Json(body): Json<CreateBody>,
732) -> Result<Json<Value>, AppError> {
733    let coord = parse_coord(&body.coord)?;
734    let (env, component, key) = segments(&coord);
735    let registry = state.registry()?;
736    let dir = vault_dir(&registry, body.project.as_deref());
737
738    if store::read_record(&dir, &coord, state.key())
739        .map_err(|e| AppError::internal(e.to_string()))?
740        .is_some()
741    {
742        return Err(AppError::bad(format!("`{}` already exists", body.coord)));
743    }
744    let chosen = parse_sensitivity(body.sensitivity.as_deref())?.unwrap_or(Sensitivity::Medium);
745    let born = birth_sensitivity(&env, chosen);
746    let now = SystemClock.now_rfc3339();
747    let record = match (&body.reference, &body.value) {
748        (Some(reference), _) => SecretRecord::Reference {
749            reference: reference.clone(),
750            sensitivity: born,
751            revealable: body.revealable,
752            environment: env.clone(),
753            component,
754            key,
755            description: body.description.clone(),
756            created: now.clone(),
757            updated: now,
758        },
759        (None, Some(value)) => SecretRecord::Literal {
760            value: SecretValue::from(value.as_str()),
761            sensitivity: born,
762            revealable: body.revealable,
763            environment: env.clone(),
764            component,
765            key,
766            description: body.description.clone(),
767            created: now.clone(),
768            updated: now,
769        },
770        (None, None) => return Err(AppError::bad("provide `value` or `reference`")),
771    };
772    write(&dir, &coord, &record, state.key())?;
773    state.audit(
774        AuditAction::Create,
775        "created",
776        &record.canonical_path(),
777        &env,
778    );
779    Ok(Json(
780        json!({ "created": record.canonical_path(), "sensitivity": sensitivity_str(born) }),
781    ))
782}
783
784#[derive(Deserialize)]
785struct UpdateBody {
786    coord: String,
787    project: Option<String>,
788    value: String,
789}
790
791/// `PUT /api/secret` — replace a literal's value (metadata preserved). Refuses
792/// to overwrite a keypair/totp/reference (those are not plain values).
793async fn update_value(
794    State(state): State<AppState>,
795    Json(body): Json<UpdateBody>,
796) -> Result<Json<Value>, AppError> {
797    let coord = parse_coord(&body.coord)?;
798    let registry = state.registry()?;
799    let dir = vault_dir(&registry, body.project.as_deref());
800    let existing = store::read_record(&dir, &coord, state.key())
801        .map_err(|e| AppError::internal(e.to_string()))?
802        .ok_or_else(|| AppError::not_found(format!("`{}` not found", body.coord)))?;
803    let now = SystemClock.now_rfc3339();
804    let record = match existing {
805        SecretRecord::Literal {
806            sensitivity,
807            revealable,
808            environment,
809            component,
810            key,
811            description,
812            created,
813            ..
814        } => SecretRecord::Literal {
815            value: SecretValue::from(body.value.as_str()),
816            sensitivity,
817            revealable,
818            environment,
819            component,
820            key,
821            description,
822            created,
823            updated: now,
824        },
825        _ => return Err(AppError::bad("only a literal's value can be updated here")),
826    };
827    write(&dir, &coord, &record, state.key())?;
828    state.audit(
829        AuditAction::Edit,
830        "value-updated",
831        &record.canonical_path(),
832        record.environment(),
833    );
834    Ok(Json(json!({ "updated": record.canonical_path() })))
835}
836
837#[derive(Deserialize)]
838struct EditBody {
839    coord: String,
840    project: Option<String>,
841    sensitivity: Option<String>,
842    description: Option<String>,
843    reference: Option<String>,
844    revealable: Option<bool>,
845}
846
847/// `PATCH /api/secret` — edit metadata (sensitivity / description / reference
848/// pointer / revealable). Lowering sensitivity is an audited downgrade (I5).
849async fn edit_metadata(
850    State(state): State<AppState>,
851    Json(body): Json<EditBody>,
852) -> Result<Json<Value>, AppError> {
853    let coord = parse_coord(&body.coord)?;
854    let registry = state.registry()?;
855    let dir = vault_dir(&registry, body.project.as_deref());
856    let existing = store::read_record(&dir, &coord, state.key())
857        .map_err(|e| AppError::internal(e.to_string()))?
858        .ok_or_else(|| AppError::not_found(format!("`{}` not found", body.coord)))?;
859    let new_sensitivity = parse_sensitivity(body.sensitivity.as_deref())?;
860    let env = existing.environment().to_string();
861    let lowered = matches!(new_sensitivity, Some(s) if is_downgrade(existing.sensitivity(), s));
862
863    // KOV-30 — lowering a CRITICAL secret's sensitivity from the UI is an
864    // attended action (I5 + I16), gated through the same broker the CLI uses
865    // (commands.rs::edit). The downgrade is applied only on an approved
866    // confirmation; deny/timeout leave the record untouched.
867    if let Some(new) = new_sensitivity
868        && downgrade_requires_confirmation(existing.sensitivity(), new)
869    {
870        let canonical = existing.canonical_path();
871        let req = ui_action_request(
872            &existing,
873            format!(
874                "edit {canonical} --sensitivity {} (downgrade, web ui)",
875                sensitivity_str(new)
876            ),
877        );
878        match confirm_action(state.confirmer(), req).await {
879            ConfirmOutcome::Approved => {
880                state.audit(AuditAction::Approve, "approved-downgrade", &canonical, &env);
881            }
882            ConfirmOutcome::Denied => {
883                state.audit(AuditAction::Deny, "denied-downgrade", &canonical, &env);
884                return Err(AppError::new(
885                    StatusCode::FORBIDDEN,
886                    "denied — sensitivity not lowered",
887                ));
888            }
889            ConfirmOutcome::TimedOut => {
890                state.audit(AuditAction::Timeout, "timeout-downgrade", &canonical, &env);
891                return Err(AppError::new(
892                    StatusCode::REQUEST_TIMEOUT,
893                    "timed out — sensitivity not lowered",
894                ));
895            }
896        }
897    }
898
899    let now = SystemClock.now_rfc3339();
900    let updated = apply_edit(
901        existing,
902        new_sensitivity,
903        body.description.clone(),
904        body.reference.clone(),
905        body.revealable,
906        now,
907    )?;
908    write(&dir, &coord, &updated, state.key())?;
909    if lowered {
910        state.audit(
911            AuditAction::SensitivityDowngrade,
912            "downgraded",
913            &updated.canonical_path(),
914            &env,
915        );
916    }
917    state.audit(
918        AuditAction::Edit,
919        "metadata-updated",
920        &updated.canonical_path(),
921        &env,
922    );
923    Ok(Json(json!({ "edited": updated.canonical_path() })))
924}
925
926/// `DELETE /api/secret?coord=&project=`.
927async fn delete_secret(
928    State(state): State<AppState>,
929    Query(q): Query<CoordQuery>,
930) -> Result<Json<Value>, AppError> {
931    let coord = parse_coord(&q.coord)?;
932    let registry = state.registry()?;
933    let dir = vault_dir(&registry, q.project.as_deref());
934    let existing = store::read_record(&dir, &coord, state.key())
935        .map_err(|e| AppError::internal(e.to_string()))?
936        .ok_or_else(|| AppError::not_found(format!("`{}` not found", q.coord)))?;
937    let canonical = existing.canonical_path();
938    let env = existing.environment().to_string();
939
940    // KOV-30 — deleting a CRITICAL secret (high / inject-only) from the UI is an
941    // attended action, gated through the same broker the rest of kovra uses
942    // (Touch ID / `kovra approve`, I16). Non-critical secrets (low / medium) are
943    // viewable on demand without biometrics, so their deletion is NOT broker-
944    // gated here — the browser guards it with a type-the-name confirmation modal
945    // (client-side friction against accidents, matching the reveal tier). The
946    // record is removed only on an approved confirmation when gating applies.
947    if delete_requires_confirmation(existing.sensitivity()) {
948        let req = ui_action_request(&existing, format!("delete {canonical} (web ui)"));
949        match confirm_action(state.confirmer(), req).await {
950            ConfirmOutcome::Approved => {
951                state.audit(AuditAction::Approve, "approved-delete", &canonical, &env);
952            }
953            ConfirmOutcome::Denied => {
954                state.audit(AuditAction::Deny, "denied-delete", &canonical, &env);
955                return Err(AppError::new(StatusCode::FORBIDDEN, "denied — not deleted"));
956            }
957            ConfirmOutcome::TimedOut => {
958                state.audit(AuditAction::Timeout, "timeout-delete", &canonical, &env);
959                return Err(AppError::new(
960                    StatusCode::REQUEST_TIMEOUT,
961                    "timed out — not deleted",
962                ));
963            }
964        }
965    }
966
967    store::delete_record(&dir, &coord).map_err(|e| AppError::internal(e.to_string()))?;
968    state.audit(AuditAction::Delete, "deleted", &canonical, &env);
969    Ok(Json(json!({ "deleted": canonical })))
970}
971
972#[derive(Deserialize)]
973struct GenerateBody {
974    coord: String,
975    project: Option<String>,
976    length: Option<usize>,
977    sensitivity: Option<String>,
978    description: Option<String>,
979}
980
981/// `POST /api/generate` — generate a random value server-side, store it, and
982/// **never return it** (the value is born in the core, §9.2).
983async fn generate_secret(
984    State(state): State<AppState>,
985    Json(body): Json<GenerateBody>,
986) -> Result<Json<Value>, AppError> {
987    let coord = parse_coord(&body.coord)?;
988    let (env, component, key) = segments(&coord);
989    let registry = state.registry()?;
990    let dir = vault_dir(&registry, body.project.as_deref());
991    if store::read_record(&dir, &coord, state.key())
992        .map_err(|e| AppError::internal(e.to_string()))?
993        .is_some()
994    {
995        return Err(AppError::bad(format!("`{}` already exists", body.coord)));
996    }
997    let length = body.length.unwrap_or(32);
998    if length == 0 {
999        return Err(AppError::bad("length must be at least 1"));
1000    }
1001    use rand::Rng;
1002    use rand::distributions::Alphanumeric;
1003    let generated: String = rand::rngs::OsRng
1004        .sample_iter(&Alphanumeric)
1005        .take(length)
1006        .map(char::from)
1007        .collect();
1008    let chosen = parse_sensitivity(body.sensitivity.as_deref())?.unwrap_or(Sensitivity::Medium);
1009    let born = birth_sensitivity(&env, chosen);
1010    let now = SystemClock.now_rfc3339();
1011    let record = SecretRecord::Literal {
1012        value: SecretValue::from(generated),
1013        sensitivity: born,
1014        revealable: false,
1015        environment: env.clone(),
1016        component,
1017        key,
1018        description: body.description.clone(),
1019        created: now.clone(),
1020        updated: now,
1021    };
1022    write(&dir, &coord, &record, state.key())?;
1023    state.audit(
1024        AuditAction::Create,
1025        "generated",
1026        &record.canonical_path(),
1027        &env,
1028    );
1029    Ok(Json(json!({
1030        "generated": record.canonical_path(),
1031        "length": length,
1032        "sensitivity": sensitivity_str(born),
1033        "note": "value stored, never returned"
1034    })))
1035}
1036
1037/// `GET /api/intakes` — the pending agent-initiated secret-creation requests
1038/// (KOV-69), metadata only: an intake carries an address + the requester's
1039/// untrusted note, never a value. Backed by the same file queue the CLI uses.
1040async fn list_intakes(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
1041    let broker = IntakeBroker::under_root(state.root());
1042    let pending = broker
1043        .list_pending()
1044        .map_err(|e| AppError::internal(e.to_string()))?;
1045    let rows: Vec<Value> = pending
1046        .iter()
1047        .map(|i| {
1048            json!({
1049                "id": i.id,
1050                "coordinate": i.coordinate,
1051                "sensitivity": sensitivity_str(i.sensitivity),
1052                "environment": i.environment,
1053                "origin": format!("{:?}", i.origin).to_lowercase(),
1054                "requesting_process": i.requesting_process,
1055                // Untrusted requester free-text (I16): rendered fenced/escaped by
1056                // the client, never as an authoritative line.
1057                "description": i.description.as_ref().map(|d| d.0.clone()),
1058                "created_unix": i.created_unix,
1059            })
1060        })
1061        .collect();
1062    Ok(Json(json!({ "intakes": rows })))
1063}
1064
1065#[derive(Deserialize)]
1066struct FulfillBody {
1067    id: String,
1068    value: String,
1069    project: Option<String>,
1070}
1071
1072/// `POST /api/intakes/fulfill` — fulfil a pending intake: the **human** types the
1073/// value here over loopback (it never enters the agent / model context — I11/I14,
1074/// the same path as `create_secret`), it is sealed under the intake's coordinate,
1075/// and the intake is cleared. Sensitivity is the intake's, with `prod` born `high`
1076/// (I5) — the request cannot lower it. Fulfilling a `high` secret is an attended
1077/// action gated by the same broker the CLI uses (Touch ID / `kovra approve`, I16).
1078async fn fulfill_intake(
1079    State(state): State<AppState>,
1080    Json(body): Json<FulfillBody>,
1081) -> Result<Json<Value>, AppError> {
1082    let broker = IntakeBroker::under_root(state.root());
1083    let intake = broker
1084        .get(&body.id)
1085        .map_err(|e| AppError::internal(e.to_string()))?
1086        .ok_or_else(|| AppError::not_found(format!("no pending intake `{}`", body.id)))?;
1087    if body.value.is_empty() {
1088        return Err(AppError::bad("value must not be empty"));
1089    }
1090    let coord = parse_coord(&intake.coordinate)?;
1091    let (env, component, key) = segments(&coord);
1092    let registry = state.registry()?;
1093    let dir = vault_dir(&registry, body.project.as_deref());
1094    if store::read_record(&dir, &coord, state.key())
1095        .map_err(|e| AppError::internal(e.to_string()))?
1096        .is_some()
1097    {
1098        return Err(AppError::bad(format!(
1099            "`{}` already exists",
1100            intake.coordinate
1101        )));
1102    }
1103    let born = birth_sensitivity(&env, intake.sensitivity); // prod ⇒ high (I5)
1104
1105    // I16 — fulfilling a `high` secret is an attended action, gated by the same
1106    // broker the CLI uses (commands.rs::intake_fulfill). Biometrics-only here
1107    // (secret birth), so no device-password fallback (unlike the KOV-30 admin
1108    // actions). The request body never enters the prompt — only stored fields.
1109    if born == Sensitivity::High {
1110        let mut req =
1111            ConfirmRequest::new(intake.coordinate.clone(), born, env.clone(), Origin::Human)
1112                .with_command(format!("fulfil {} (web ui)", intake.coordinate))
1113                .with_requesting_process("kovra ui (web admin)");
1114        if let Some(d) = &intake.description {
1115            req = req.with_requester_description(d.0.clone());
1116        }
1117        match confirm_action(state.confirmer(), req).await {
1118            ConfirmOutcome::Approved => {
1119                state.audit(
1120                    AuditAction::Approve,
1121                    "approved-intake",
1122                    &intake.coordinate,
1123                    &env,
1124                );
1125            }
1126            ConfirmOutcome::Denied => {
1127                state.audit(AuditAction::Deny, "denied-intake", &intake.coordinate, &env);
1128                return Err(AppError::new(StatusCode::FORBIDDEN, "denied — not created"));
1129            }
1130            ConfirmOutcome::TimedOut => {
1131                state.audit(
1132                    AuditAction::Timeout,
1133                    "timeout-intake",
1134                    &intake.coordinate,
1135                    &env,
1136                );
1137                return Err(AppError::new(
1138                    StatusCode::REQUEST_TIMEOUT,
1139                    "timed out — not created",
1140                ));
1141            }
1142        }
1143    }
1144
1145    let now = SystemClock.now_rfc3339();
1146    let record = SecretRecord::Literal {
1147        value: SecretValue::from(body.value.as_str()),
1148        sensitivity: born,
1149        revealable: false,
1150        environment: env.clone(),
1151        component,
1152        key,
1153        description: None,
1154        created: now.clone(),
1155        updated: now,
1156    };
1157    write(&dir, &coord, &record, state.key())?;
1158    broker
1159        .cancel(&body.id)
1160        .map_err(|e| AppError::internal(e.to_string()))?;
1161    state.audit(
1162        AuditAction::Create,
1163        "fulfilled-intake",
1164        &record.canonical_path(),
1165        &env,
1166    );
1167    Ok(Json(json!({
1168        "fulfilled": record.canonical_path(),
1169        "sensitivity": sensitivity_str(born),
1170    })))
1171}
1172
1173#[derive(Deserialize)]
1174struct IdQuery {
1175    id: String,
1176}
1177
1178/// `DELETE /api/intakes?id=` — dismiss a pending intake without fulfilling it
1179/// (drops the request; reveals nothing, so it is unguarded like `kovra intake
1180/// cancel`).
1181async fn dismiss_intake(
1182    State(state): State<AppState>,
1183    Query(q): Query<IdQuery>,
1184) -> Result<Json<Value>, AppError> {
1185    let broker = IntakeBroker::under_root(state.root());
1186    broker
1187        .cancel(&q.id)
1188        .map_err(|e| AppError::internal(e.to_string()))?;
1189    Ok(Json(json!({ "dismissed": q.id })))
1190}
1191
1192// ───────────────────────────── helpers ─────────────────────────────
1193
1194/// How long a destructive-action confirmation waits for an attended decision
1195/// before failing safe to denial — mirrors the CLI's `CONFIRM_TIMEOUT` (§8).
1196const CONFIRM_TIMEOUT: Duration = Duration::from_secs(120);
1197
1198/// Run a (blocking) broker confirmation off the async reactor. `Confirmer::confirm`
1199/// polls a file / blocks on a condvar, so it must not run on a Tokio worker
1200/// thread. A join error fails safe to denial (§8). The `ConfirmRequest` is built
1201/// by the core from the **stored record** (I16), never from the request body.
1202async fn confirm_action(
1203    confirmer: Arc<dyn Confirmer + Send + Sync>,
1204    req: ConfirmRequest,
1205) -> ConfirmOutcome {
1206    tokio::task::spawn_blocking(move || confirmer.confirm(&req, CONFIRM_TIMEOUT))
1207        .await
1208        .unwrap_or(ConfirmOutcome::Denied)
1209}
1210
1211/// Build the authoritative `ConfirmRequest` for a destructive UI action against a
1212/// stored `record`. All fields are core-observed facts (coordinate / sensitivity
1213/// / environment from the record; the surface identity is server-authored), so
1214/// the prompt can never be steered by untrusted request input (I16).
1215fn ui_action_request(record: &SecretRecord, command: String) -> ConfirmRequest {
1216    ConfirmRequest::new(
1217        record.canonical_path(),
1218        record.sensitivity(),
1219        record.environment().to_string(),
1220        Origin::Human,
1221    )
1222    .with_command(command)
1223    // Trusted, server-authored surface identity (never the browser/requester).
1224    .with_requesting_process("kovra ui (web admin)")
1225    // KOV-30 — these are administrative *actions* (delete / downgrade), not
1226    // delivery of the secret value, so the native Touch ID prompt always offers
1227    // the device-password fallback ("Use Password"). The secret broker (high
1228    // reveal/inject) stays biometrics-only via `ConfirmRequest::new` (§8/I3).
1229    .with_allow_password(true)
1230}
1231
1232fn parse_coord(s: &str) -> Result<Coordinate, AppError> {
1233    let with_scheme = if s.starts_with("secret:") {
1234        s.to_string()
1235    } else {
1236        format!("secret:{s}")
1237    };
1238    let coord = Coordinate::from_str(&with_scheme).map_err(|e| AppError::bad(e.to_string()))?;
1239    // A web coordinate must be concrete (no `${ENV}` placeholder).
1240    coord
1241        .canonical_path()
1242        .map_err(|e| AppError::bad(format!("{e} (coordinate must be concrete)")))?;
1243    Ok(coord)
1244}
1245
1246fn segments(coord: &Coordinate) -> (String, String, String) {
1247    use kovra_core::EnvSegment;
1248    let env = match &coord.environment {
1249        EnvSegment::Literal(e) => e.clone(),
1250        EnvSegment::Placeholder => unreachable!("parse_coord rejects placeholders"),
1251    };
1252    (env, coord.component.clone(), coord.key.clone())
1253}
1254
1255fn vault_dir(registry: &Registry, project: Option<&str>) -> PathBuf {
1256    match project {
1257        Some(p) => registry.project_dir(p),
1258        None => registry.global_dir(),
1259    }
1260}
1261
1262fn write(
1263    dir: &std::path::Path,
1264    coord: &Coordinate,
1265    record: &SecretRecord,
1266    key: &[u8; kovra_core::KEY_LEN],
1267) -> Result<(), AppError> {
1268    let sealed = kovra_core::seal(record, key).map_err(|e| AppError::internal(e.to_string()))?;
1269    store::write_record(dir, coord, &sealed).map_err(|e| AppError::internal(e.to_string()))
1270}
1271
1272fn sensitivity_str(s: Sensitivity) -> &'static str {
1273    match s {
1274        Sensitivity::Low => "low",
1275        Sensitivity::Medium => "medium",
1276        Sensitivity::High => "high",
1277        Sensitivity::InjectOnly => "inject-only",
1278    }
1279}
1280
1281fn parse_sensitivity(s: Option<&str>) -> Result<Option<Sensitivity>, AppError> {
1282    match s {
1283        None => Ok(None),
1284        Some(v) => match v.to_ascii_lowercase().replace('_', "-").as_str() {
1285            "low" => Ok(Some(Sensitivity::Low)),
1286            "medium" => Ok(Some(Sensitivity::Medium)),
1287            "high" => Ok(Some(Sensitivity::High)),
1288            "inject-only" => Ok(Some(Sensitivity::InjectOnly)),
1289            other => Err(AppError::bad(format!("unknown sensitivity `{other}`"))),
1290        },
1291    }
1292}
1293
1294fn apply_edit(
1295    existing: SecretRecord,
1296    new_sensitivity: Option<Sensitivity>,
1297    new_description: Option<String>,
1298    new_reference: Option<String>,
1299    new_revealable: Option<bool>,
1300    now: String,
1301) -> Result<SecretRecord, AppError> {
1302    match existing {
1303        SecretRecord::Literal {
1304            value,
1305            sensitivity,
1306            revealable,
1307            environment,
1308            component,
1309            key,
1310            description,
1311            created,
1312            ..
1313        } => {
1314            if new_reference.is_some() {
1315                return Err(AppError::bad(
1316                    "`reference` edits a reference secret; this is a literal",
1317                ));
1318            }
1319            Ok(SecretRecord::Literal {
1320                value,
1321                sensitivity: new_sensitivity.unwrap_or(sensitivity),
1322                revealable: new_revealable.unwrap_or(revealable),
1323                environment,
1324                component,
1325                key,
1326                description: new_description.or(description),
1327                created,
1328                updated: now,
1329            })
1330        }
1331        SecretRecord::Reference {
1332            reference,
1333            sensitivity,
1334            revealable,
1335            environment,
1336            component,
1337            key,
1338            description,
1339            created,
1340            ..
1341        } => Ok(SecretRecord::Reference {
1342            reference: new_reference.unwrap_or(reference),
1343            sensitivity: new_sensitivity.unwrap_or(sensitivity),
1344            revealable: new_revealable.unwrap_or(revealable),
1345            environment,
1346            component,
1347            key,
1348            description: new_description.or(description),
1349            created,
1350            updated: now,
1351        }),
1352        SecretRecord::Keypair {
1353            algorithm,
1354            private,
1355            public,
1356            sensitivity,
1357            revealable,
1358            environment,
1359            component,
1360            key,
1361            description,
1362            created,
1363            ..
1364        } => {
1365            if new_reference.is_some() {
1366                return Err(AppError::bad(
1367                    "`reference` edits a reference secret; this is a keypair",
1368                ));
1369            }
1370            Ok(SecretRecord::Keypair {
1371                algorithm,
1372                private,
1373                public,
1374                sensitivity: new_sensitivity.unwrap_or(sensitivity),
1375                revealable: new_revealable.unwrap_or(revealable),
1376                environment,
1377                component,
1378                key,
1379                description: new_description.or(description),
1380                created,
1381                updated: now,
1382            })
1383        }
1384        SecretRecord::Totp {
1385            seed,
1386            algorithm,
1387            digits,
1388            period,
1389            sensitivity,
1390            revealable,
1391            environment,
1392            component,
1393            key,
1394            description,
1395            created,
1396            ..
1397        } => {
1398            if new_reference.is_some() {
1399                return Err(AppError::bad(
1400                    "`reference` edits a reference secret; this is a TOTP enrollment",
1401                ));
1402            }
1403            Ok(SecretRecord::Totp {
1404                seed,
1405                algorithm,
1406                digits,
1407                period,
1408                sensitivity: new_sensitivity.unwrap_or(sensitivity),
1409                revealable: new_revealable.unwrap_or(revealable),
1410                environment,
1411                component,
1412                key,
1413                description: new_description.or(description),
1414                created,
1415                updated: now,
1416            })
1417        }
1418    }
1419}
1420
1421// ───────────────────────────── serve (host) ─────────────────────────────
1422
1423/// Run the server on an already-bound loopback `listener` until Ctrl-C or
1424/// `idle` of inactivity. `[host]`: the real bind + browser-open are validated on
1425/// hardware; the router itself is covered by the `[mock]` endpoint tests.
1426pub async fn serve(
1427    listener: tokio::net::TcpListener,
1428    state: AppState,
1429    idle: Duration,
1430    persistent: bool,
1431) -> std::io::Result<()> {
1432    let app = build_app(state.clone());
1433    if persistent {
1434        // Persistent mode (KOV-73): never idle-*shut-down*. Instead, if `idle` > 0,
1435        // an idle watchdog LOCKS the UI after that inactivity (the secret surface
1436        // then reveals nothing until an attended `/api/unlock`); the server stays
1437        // up until Ctrl-C. A `[host]` NSWorkspace bridge can also lock on screen
1438        // lock/sleep via [`AppState::lock`]. `--idle 0` opts out of the idle lock
1439        // (rely on the sleep/lock bridge only).
1440        if !idle.is_zero() {
1441            tokio::spawn(idle_lock_watchdog(state.clone(), idle));
1442        }
1443        // A second lock trigger ([host], macOS): lock the UI when the screen locks
1444        // or the Mac sleeps. The guard owns a native observer thread and must live
1445        // for the server's lifetime, so it is bound (not `let _ = …`) and held
1446        // across the serve `.await` below. No-op (absent) off macOS — behaviour
1447        // then matches the idle-only watchdog above.
1448        #[cfg(target_os = "macos")]
1449        let _screenlock = {
1450            let st = state.clone();
1451            kovra_native_macos::watch_screen_lock(Box::new(move || st.lock()))
1452        };
1453        axum::serve(listener, app)
1454            .with_graceful_shutdown(async {
1455                let _ = tokio::signal::ctrl_c().await;
1456            })
1457            .await
1458    } else {
1459        axum::serve(listener, app)
1460            .with_graceful_shutdown(shutdown_signal(state, idle))
1461            .await
1462    }
1463}
1464
1465/// Persistent-mode idle watchdog (KOV-73): **lock** the UI after `idle` of
1466/// inactivity rather than shutting it down, so it stays up for a quick attended
1467/// re-auth. No-op once already locked.
1468async fn idle_lock_watchdog(state: AppState, idle: Duration) {
1469    let tick = Duration::from_secs(5).min(idle).max(Duration::from_secs(1));
1470    loop {
1471        tokio::time::sleep(tick).await;
1472        if !state.is_locked() && state.idle_for() >= idle {
1473            state.lock();
1474        }
1475    }
1476}
1477
1478/// Resolve when either Ctrl-C arrives or the server has been idle for `idle`.
1479async fn shutdown_signal(state: AppState, idle: Duration) {
1480    let ctrl_c = async {
1481        let _ = tokio::signal::ctrl_c().await;
1482    };
1483    let idle_watchdog = async {
1484        let tick = Duration::from_secs(5).min(idle);
1485        loop {
1486            tokio::time::sleep(tick).await;
1487            if state.idle_for() >= idle {
1488                break;
1489            }
1490        }
1491    };
1492    tokio::select! {
1493        _ = ctrl_c => {}
1494        _ = idle_watchdog => {}
1495    }
1496}
1497
1498/// The default loopback bind address for `kovra ui`.
1499pub fn default_addr(port: u16) -> SocketAddr {
1500    SocketAddr::from(([127, 0, 0, 1], port))
1501}
1502
1503/// Parse a master key supplied as a file's bytes (L11 Docker entrypoint, I9).
1504///
1505/// Accepts either exactly [`kovra_core::KEY_LEN`] raw bytes, or a hex string of
1506/// `2 * KEY_LEN` characters (with optional surrounding whitespace/newline — the
1507/// common shape of a Docker secret file). Never logs the bytes. The key arrives
1508/// from a Docker secret in `tmpfs` at runtime, never from an image layer (I9).
1509pub fn parse_master_key(raw: &[u8]) -> Result<MasterKey, String> {
1510    // Raw binary key: exact length.
1511    if raw.len() == kovra_core::KEY_LEN {
1512        let mut key = [0u8; kovra_core::KEY_LEN];
1513        key.copy_from_slice(raw);
1514        return Ok(MasterKey::new(key));
1515    }
1516    // Otherwise treat it as hex text (trimmed).
1517    let text = std::str::from_utf8(raw)
1518        .map_err(|_| "master key file is neither raw bytes nor UTF-8 hex".to_string())?
1519        .trim();
1520    if text.len() != kovra_core::KEY_LEN * 2 {
1521        return Err(format!(
1522            "master key must be {} raw bytes or {} hex chars (got {} chars)",
1523            kovra_core::KEY_LEN,
1524            kovra_core::KEY_LEN * 2,
1525            text.len()
1526        ));
1527    }
1528    let mut key = [0u8; kovra_core::KEY_LEN];
1529    for (i, pair) in text.as_bytes().chunks(2).enumerate() {
1530        let hi = (pair[0] as char)
1531            .to_digit(16)
1532            .ok_or_else(|| "master key hex is invalid".to_string())?;
1533        let lo = (pair[1] as char)
1534            .to_digit(16)
1535            .ok_or_else(|| "master key hex is invalid".to_string())?;
1536        key[i] = (hi * 16 + lo) as u8;
1537    }
1538    Ok(MasterKey::new(key))
1539}
1540
1541/// The admin shell. Carries no secret and no inline script — it loads the
1542/// vendored Tabulator grid and the first-party `app.js`/`app.css` from the
1543/// embedded `/assets/*` routes, which then drive the governed `/api` (KOV-29).
1544/// The ephemeral session token rides in the page URL (`?session=`) and is read
1545/// by `app.js`; `high`/`inject-only` values are never delivered here (I1/I2).
1546const INDEX_HTML: &str = r##"<!doctype html>
1547<html lang="en" data-theme="dark"><head>
1548<meta charset="utf-8"><title>kovra — local admin</title>
1549<meta name="viewport" content="width=device-width, initial-scale=1">
1550<link rel="icon" type="image/svg+xml" href="/assets/kovra-iconmark.svg">
1551<link rel="stylesheet" href="/assets/tabulator/tabulator.min.css">
1552<link rel="stylesheet" href="/assets/app.css">
1553</head><body>
1554<div class="app">
1555  <aside class="side">
1556    <div class="brand">
1557      <div class="logo"><img src="/assets/kovra-mark-color.png" alt="kovra"></div>
1558      <div><div class="name">ko<span class="v">v</span>ra</div><div class="tag">local secrets</div></div>
1559    </div>
1560    <nav class="nav">
1561      <a id="nav-home" class="on" href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 11l9-8 9 8M5 9.5V21h14V9.5"/></svg>Home</a>
1562      <div class="navgroup">
1563        <button id="proj-toggle" class="navgroup-h" aria-expanded="false">
1564          <svg class="gi" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/></svg>
1565          <span class="gl">Projects</span>
1566          <svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
1567        </button>
1568        <div id="proj-list" class="navgroup-items" hidden></div>
1569      </div>
1570    </nav>
1571    <div class="spacer"></div>
1572    <div class="vault"><span class="dot"></span><div><div class="who">local vault</div><div class="sub">loopback only</div></div></div>
1573  </aside>
1574  <div class="main">
1575    <div class="top">
1576      <div class="search">
1577        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/></svg>
1578        <input id="search" type="search" placeholder="Search secrets, coordinates, projects…" autocomplete="off" spellcheck="false">
1579      </div>
1580      <span class="looppill"><span class="d"></span>loopback</span>
1581      <button class="iconbtn" id="refresh" title="Refresh">
1582        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6M3 12a9 9 0 0 1 15-6.7L21 8M3 22v-6h6M21 12a9 9 0 0 1-15 6.7L3 16"/></svg>
1583      </button>
1584      <button class="iconbtn" id="theme" title="Toggle theme">
1585        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"/></svg>
1586      </button>
1587    </div>
1588    <div class="content">
1589      <!-- HOME: overview — metrics, pending intakes, recent secrets -->
1590      <section id="page-home">
1591        <div class="head">
1592          <div><h1>Home</h1><div class="sub"><span id="home-sub">overview</span> · governed by sensitivity · loopback only</div></div>
1593          <div class="right">
1594            <button class="btn primary" id="home-new"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>New secret</button>
1595          </div>
1596        </div>
1597        <div class="stats">
1598          <div class="stat"><div class="n" id="stat-total">—</div><div class="l"><span class="d" style="background:var(--accent)"></span>total secrets</div></div>
1599          <div class="stat"><div class="n" id="stat-high">—</div><div class="l"><span class="d" style="background:var(--high)"></span>high / critical</div></div>
1600          <div class="stat"><div class="n" id="stat-inject">—</div><div class="l"><span class="d" style="background:var(--inj)"></span>inject-only</div></div>
1601          <div class="stat"><div class="n" id="stat-ref">—</div><div class="l"><span class="d" style="background:var(--med)"></span>references</div></div>
1602        </div>
1603        <div class="home-grid">
1604          <div class="card pad">
1605            <div class="card-h"><h2>Pending intakes</h2><span class="pill" id="intake-count">0</span></div>
1606            <div id="intake-list" class="intake-list"></div>
1607          </div>
1608          <div class="card pad">
1609            <div class="card-h"><h2>Recent secrets</h2></div>
1610            <div id="recent" class="recent"></div>
1611          </div>
1612        </div>
1613      </section>
1614      <!-- SECRETS: the full inventory (table / tree), scoped by the sidebar -->
1615      <section id="page-secrets" hidden>
1616        <div class="head">
1617          <div><h1 id="secrets-title">Secrets</h1><div class="sub"><span id="status">loading…</span> · governed by sensitivity · loopback only</div></div>
1618          <div class="right">
1619            <div class="seg">
1620              <button id="view-table" class="on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 5h18M3 12h18M3 19h18"/></svg>Table</button>
1621              <button id="view-tree"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 4v16M5 8h6M11 8v8M11 12h6"/></svg>Tree</button>
1622            </div>
1623            <button class="btn primary" id="new"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>New secret</button>
1624          </div>
1625        </div>
1626        <div class="card"><div id="grid"></div></div>
1627      </section>
1628    </div>
1629  </div>
1630</div>
1631
1632<div class="scrim" id="scrim"></div>
1633<aside class="drawer" id="drawer">
1634  <div class="dh"><h3 id="reveal-title">…</h3><button class="iconbtn" id="reveal-close" title="Close"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6 6 18"/></svg></button></div>
1635  <div class="db" id="reveal-body"></div>
1636</aside>
1637
1638<dialog id="form">
1639  <form id="form-el">
1640    <div class="mh"><h3 id="form-title">…</h3><button type="button" id="form-cancel" class="iconbtn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6 6 18"/></svg></button></div>
1641    <div class="mb" id="form-body"></div>
1642    <div class="mf">
1643      <button type="button" id="form-cancel-2" class="btn">Cancel</button>
1644      <button type="submit" id="form-submit" class="btn primary">Save</button>
1645    </div>
1646  </form>
1647</dialog>
1648<div id="toasts" aria-live="polite"></div>
1649<script src="/assets/tabulator/tabulator.min.js"></script>
1650<script src="/assets/app.js"></script>
1651</body></html>"##;
1652
1653#[cfg(test)]
1654mod tests {
1655    use super::*;
1656    use axum::body::Body;
1657    use axum::http::Request;
1658    use kovra_core::MockConfirmer;
1659    use tower::ServiceExt; // oneshot
1660
1661    const KEY: [u8; kovra_core::KEY_LEN] = [0x33; kovra_core::KEY_LEN];
1662
1663    /// State whose per-action broker (KOV-30) always returns `outcome` — lets a
1664    /// test assert both the gated (denied/timeout) and the ungated (approved)
1665    /// paths deterministically, without touching biometrics.
1666    fn state_with_confirmer(outcome: ConfirmOutcome) -> (AppState, tempfile::TempDir) {
1667        let dir = tempfile::tempdir().unwrap();
1668        // The registry layout is created on open.
1669        Registry::open(dir.path()).unwrap();
1670        let state = AppState::new(
1671            dir.path().to_path_buf(),
1672            MasterKey::new(KEY),
1673            Arc::new(MockConfirmer::always(outcome)),
1674        );
1675        (state, dir)
1676    }
1677
1678    /// Default test state: confirmations auto-approve, so the pre-existing
1679    /// non-gating tests (reveal/list/create/generate/crud) behave as before.
1680    fn temp_state() -> (AppState, tempfile::TempDir) {
1681        state_with_confirmer(ConfirmOutcome::Approved)
1682    }
1683
1684    fn put_record(state: &AppState, record: &SecretRecord) {
1685        let registry = state.registry().unwrap();
1686        let coord = Coordinate::from_str(&format!("secret:{}", record.canonical_path())).unwrap();
1687        write(&registry.global_dir(), &coord, record, state.key()).unwrap();
1688    }
1689
1690    fn read_back(state: &AppState, coord: &str) -> Option<SecretRecord> {
1691        let c = Coordinate::from_str(&format!("secret:{coord}")).unwrap();
1692        store::read_record(&state.registry().unwrap().global_dir(), &c, state.key()).unwrap()
1693    }
1694
1695    fn api_patch(body: &str, session: &str) -> Request<Body> {
1696        Request::builder()
1697            .method("PATCH")
1698            .uri("/api/secret")
1699            .header(header::HOST, "127.0.0.1:8731")
1700            .header(header::ORIGIN, "http://127.0.0.1:8731")
1701            .header(SESSION_HEADER, session)
1702            .header(header::CONTENT_TYPE, "application/json")
1703            .body(Body::from(body.to_string()))
1704            .unwrap()
1705    }
1706
1707    fn api_delete(coord: &str, session: &str) -> Request<Body> {
1708        Request::builder()
1709            .method("DELETE")
1710            .uri(format!("/api/secret?coord={coord}"))
1711            .header(header::HOST, "127.0.0.1:8731")
1712            .header(header::ORIGIN, "http://127.0.0.1:8731")
1713            .header(SESSION_HEADER, session)
1714            .body(Body::empty())
1715            .unwrap()
1716    }
1717
1718    fn literal(env: &str, key: &str, value: &str, sens: Sensitivity) -> SecretRecord {
1719        SecretRecord::Literal {
1720            value: SecretValue::from(value),
1721            sensitivity: sens,
1722            revealable: false,
1723            environment: env.to_string(),
1724            component: "app".to_string(),
1725            key: key.to_string(),
1726            description: None,
1727            created: "2026-06-01T00:00:00Z".to_string(),
1728            updated: "2026-06-01T00:00:00Z".to_string(),
1729        }
1730    }
1731
1732    async fn body_json(resp: Response) -> Value {
1733        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1734            .await
1735            .unwrap();
1736        serde_json::from_slice(&bytes).unwrap_or(Value::Null)
1737    }
1738
1739    fn api_get(uri: &str, session: &str) -> Request<Body> {
1740        Request::builder()
1741            .method("GET")
1742            .uri(uri)
1743            .header(header::HOST, "127.0.0.1:8731")
1744            .header(SESSION_HEADER, session)
1745            .body(Body::empty())
1746            .unwrap()
1747    }
1748
1749    fn api_post(uri: &str, session: &str) -> Request<Body> {
1750        Request::builder()
1751            .method("POST")
1752            .uri(uri)
1753            .header(header::HOST, "127.0.0.1:8731")
1754            .header(header::ORIGIN, "http://127.0.0.1:8731")
1755            .header(SESSION_HEADER, session)
1756            .body(Body::empty())
1757            .unwrap()
1758    }
1759
1760    // A low/medium literal value is revealed on the explicit fetch.
1761    #[tokio::test]
1762    async fn medium_literal_reveals_value() {
1763        let (state, _d) = temp_state();
1764        put_record(
1765            &state,
1766            &literal("dev", "url", "postgres://x", Sensitivity::Medium),
1767        );
1768        let app = build_app(state.clone());
1769        let resp = app
1770            .oneshot(api_get(
1771                "/api/reveal?coord=dev/app/url",
1772                state.session_token(),
1773            ))
1774            .await
1775            .unwrap();
1776        assert_eq!(resp.status(), StatusCode::OK);
1777        let j = body_json(resp).await;
1778        assert_eq!(j["value"], "postgres://x");
1779    }
1780
1781    // KOV-73 — the lock latch: a locked UI returns 423 for the secret routes and
1782    // reveals nothing; /api/lock locks; /api/unlock (attended) is the way back.
1783    #[tokio::test]
1784    async fn lock_latch_blocks_secret_routes_until_unlock() {
1785        let (state, _d) = state_with_confirmer(ConfirmOutcome::Approved);
1786        put_record(
1787            &state,
1788            &literal("dev", "url", "postgres://x", Sensitivity::Medium),
1789        );
1790
1791        // Unlocked: reveal works.
1792        let resp = build_app(state.clone())
1793            .oneshot(api_get(
1794                "/api/reveal?coord=dev/app/url",
1795                state.session_token(),
1796            ))
1797            .await
1798            .unwrap();
1799        assert_eq!(resp.status(), StatusCode::OK);
1800
1801        // Lock via the endpoint.
1802        let resp = build_app(state.clone())
1803            .oneshot(api_post("/api/lock", state.session_token()))
1804            .await
1805            .unwrap();
1806        assert_eq!(resp.status(), StatusCode::OK);
1807        assert!(state.is_locked());
1808
1809        // Locked: reveal is refused 423 and returns no value.
1810        let resp = build_app(state.clone())
1811            .oneshot(api_get(
1812                "/api/reveal?coord=dev/app/url",
1813                state.session_token(),
1814            ))
1815            .await
1816            .unwrap();
1817        assert_eq!(resp.status(), StatusCode::LOCKED);
1818        let j = body_json(resp).await;
1819        assert!(j.get("value").is_none(), "a locked UI reveals no value");
1820
1821        // Unlock (attended approve) → serving again.
1822        let resp = build_app(state.clone())
1823            .oneshot(api_post("/api/unlock", state.session_token()))
1824            .await
1825            .unwrap();
1826        assert_eq!(resp.status(), StatusCode::OK);
1827        assert!(!state.is_locked());
1828        let resp = build_app(state.clone())
1829            .oneshot(api_get(
1830                "/api/reveal?coord=dev/app/url",
1831                state.session_token(),
1832            ))
1833            .await
1834            .unwrap();
1835        assert_eq!(resp.status(), StatusCode::OK);
1836    }
1837
1838    // Unlock fails safe: a denied confirmation leaves the UI locked.
1839    #[tokio::test]
1840    async fn unlock_denied_stays_locked() {
1841        let (state, _d) = state_with_confirmer(ConfirmOutcome::Denied);
1842        state.lock();
1843        let resp = build_app(state.clone())
1844            .oneshot(api_post("/api/unlock", state.session_token()))
1845            .await
1846            .unwrap();
1847        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1848        assert!(state.is_locked(), "a denied unlock must leave it locked");
1849    }
1850
1851    // I1 — a high literal is never returned as a value; masked + fingerprint only.
1852    #[tokio::test]
1853    async fn high_literal_is_masked_never_value() {
1854        let (state, _d) = temp_state();
1855        put_record(
1856            &state,
1857            &literal("dev", "key", "TOP-SECRET-HIGH", Sensitivity::High),
1858        );
1859        let app = build_app(state.clone());
1860        let resp = app
1861            .oneshot(api_get(
1862                "/api/reveal?coord=dev/app/key",
1863                state.session_token(),
1864            ))
1865            .await
1866            .unwrap();
1867        let j = body_json(resp).await;
1868        assert_eq!(j["masked"], json!(true));
1869        assert!(j.get("value").is_none(), "high must not return a value");
1870        assert!(j["fingerprint"].is_string());
1871        // Defensive: the plaintext appears nowhere in the response.
1872        assert!(
1873            !serde_json::to_string(&j)
1874                .unwrap()
1875                .contains("TOP-SECRET-HIGH")
1876        );
1877    }
1878
1879    // I2 — an inject-only literal returns metadata only, never the value.
1880    #[tokio::test]
1881    async fn inject_only_returns_metadata_only() {
1882        let (state, _d) = temp_state();
1883        put_record(
1884            &state,
1885            &literal("dev", "tok", "INJECT-ONLY-VAL", Sensitivity::InjectOnly),
1886        );
1887        let app = build_app(state.clone());
1888        let resp = app
1889            .oneshot(api_get(
1890                "/api/reveal?coord=dev/app/tok",
1891                state.session_token(),
1892            ))
1893            .await
1894            .unwrap();
1895        let j = body_json(resp).await;
1896        assert_eq!(j["inject_only"], json!(true));
1897        assert!(j.get("value").is_none());
1898        assert!(
1899            !serde_json::to_string(&j)
1900                .unwrap()
1901                .contains("INJECT-ONLY-VAL")
1902        );
1903    }
1904
1905    // A reference reveals only the pointer, never a value (I8 at the surface).
1906    #[tokio::test]
1907    async fn reference_reveals_pointer_only() {
1908        let (state, _d) = temp_state();
1909        put_record(
1910            &state,
1911            &SecretRecord::Reference {
1912                reference: "azure-kv://corp-kv/api".to_string(),
1913                sensitivity: Sensitivity::High,
1914                revealable: false,
1915                environment: "dev".to_string(),
1916                component: "app".to_string(),
1917                key: "api".to_string(),
1918                description: None,
1919                created: "2026-06-01T00:00:00Z".to_string(),
1920                updated: "2026-06-01T00:00:00Z".to_string(),
1921            },
1922        );
1923        let app = build_app(state.clone());
1924        let resp = app
1925            .oneshot(api_get(
1926                "/api/reveal?coord=dev/app/api",
1927                state.session_token(),
1928            ))
1929            .await
1930            .unwrap();
1931        let j = body_json(resp).await;
1932        assert_eq!(j["kind"], "reference");
1933        assert_eq!(j["pointer"], "azure-kv://corp-kv/api");
1934        assert!(j.get("value").is_none());
1935    }
1936
1937    // The inventory lists metadata and never a value.
1938    #[tokio::test]
1939    async fn listing_is_metadata_only() {
1940        let (state, _d) = temp_state();
1941        put_record(
1942            &state,
1943            &literal("dev", "url", "secret-listing-value", Sensitivity::Medium),
1944        );
1945        let app = build_app(state.clone());
1946        let resp = app
1947            .oneshot(api_get("/api/secrets", state.session_token()))
1948            .await
1949            .unwrap();
1950        let j = body_json(resp).await;
1951        let txt = serde_json::to_string(&j).unwrap();
1952        assert!(txt.contains("dev/app/url"));
1953        assert!(
1954            !txt.contains("secret-listing-value"),
1955            "listing must not carry values"
1956        );
1957    }
1958
1959    // The session token is required on /api.
1960    #[tokio::test]
1961    async fn api_requires_session_token() {
1962        let (state, _d) = temp_state();
1963        let app = build_app(state.clone());
1964        let resp = app
1965            .oneshot(api_get("/api/secrets", "wrong-token"))
1966            .await
1967            .unwrap();
1968        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1969    }
1970
1971    // I10 — a non-loopback Host is rejected (anti DNS-rebinding).
1972    #[tokio::test]
1973    async fn non_loopback_host_is_rejected() {
1974        let (state, _d) = temp_state();
1975        let app = build_app(state.clone());
1976        let req = Request::builder()
1977            .method("GET")
1978            .uri("/api/secrets")
1979            .header(header::HOST, "evil.example.com")
1980            .header(SESSION_HEADER, state.session_token())
1981            .body(Body::empty())
1982            .unwrap();
1983        let resp = app.oneshot(req).await.unwrap();
1984        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1985    }
1986
1987    // I10 hardening — the NAME `localhost` is rejected (only the numeric loopback
1988    // literal is accepted). The launcher opens the 127.0.0.1 URL, and a name can
1989    // be steered by a resolver/hosts entry (the DNS-rebinding sliver).
1990    #[tokio::test]
1991    async fn localhost_name_host_is_rejected() {
1992        let (state, _d) = temp_state();
1993        let req = Request::builder()
1994            .method("GET")
1995            .uri("/api/secrets")
1996            .header(header::HOST, "localhost:8731")
1997            .header(SESSION_HEADER, state.session_token())
1998            .body(Body::empty())
1999            .unwrap();
2000        let resp = build_app(state.clone()).oneshot(req).await.unwrap();
2001        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2002    }
2003
2004    // A cross-origin request is rejected even with a valid session + loopback Host.
2005    #[tokio::test]
2006    async fn cross_origin_is_rejected() {
2007        let (state, _d) = temp_state();
2008        let app = build_app(state.clone());
2009        let req = Request::builder()
2010            .method("GET")
2011            .uri("/api/secrets")
2012            .header(header::HOST, "127.0.0.1:8731")
2013            .header(header::ORIGIN, "http://evil.example.com")
2014            .header(SESSION_HEADER, state.session_token())
2015            .body(Body::empty())
2016            .unwrap();
2017        let resp = app.oneshot(req).await.unwrap();
2018        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2019    }
2020
2021    // M6 (CSRF / DNS-rebinding) — a state-changing request with NO `Origin`
2022    // header is refused, even with a valid session token and loopback Host. A
2023    // cross-site form-POST (which cannot set the `x-kovra-session` header anyway)
2024    // also omits a same-origin Origin, so requiring one closes the gap.
2025    #[tokio::test]
2026    async fn state_change_without_origin_is_rejected() {
2027        let (state, _d) = temp_state();
2028        put_record(&state, &literal("dev", "url", "v", Sensitivity::Medium));
2029        let body = json!({"coord":"dev/app/url","sensitivity":"low"}).to_string();
2030        let req = Request::builder()
2031            .method("PATCH")
2032            .uri("/api/secret")
2033            .header(header::HOST, "127.0.0.1:8731")
2034            // no Origin header
2035            .header(SESSION_HEADER, state.session_token())
2036            .header(header::CONTENT_TYPE, "application/json")
2037            .body(Body::from(body))
2038            .unwrap();
2039        let resp = build_app(state.clone()).oneshot(req).await.unwrap();
2040        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2041        // The mutation did not apply.
2042        assert_eq!(
2043            read_back(&state, "dev/app/url").unwrap().sensitivity(),
2044            Sensitivity::Medium
2045        );
2046    }
2047
2048    // M6 — a GET read is *not* subject to the Origin requirement (the session
2049    // header + loopback Host already gate it), so reads still work without Origin.
2050    #[tokio::test]
2051    async fn get_without_origin_still_allowed() {
2052        let (state, _d) = temp_state();
2053        put_record(&state, &literal("dev", "url", "v", Sensitivity::Medium));
2054        let resp = build_app(state.clone())
2055            .oneshot(api_get("/api/secrets", state.session_token()))
2056            .await
2057            .unwrap();
2058        assert_eq!(resp.status(), StatusCode::OK);
2059    }
2060
2061    // M8 — every response carries the defense-in-depth security headers (CSP,
2062    // anti-framing, no-referrer, no-store), including the index shell.
2063    #[tokio::test]
2064    async fn responses_carry_security_headers() {
2065        let (state, _d) = temp_state();
2066        let resp = build_app(state.clone())
2067            .oneshot(get_loopback("/", "127.0.0.1:8731"))
2068            .await
2069            .unwrap();
2070        let h = resp.headers();
2071        let csp = h
2072            .get(header::CONTENT_SECURITY_POLICY)
2073            .and_then(|v| v.to_str().ok())
2074            .unwrap_or("");
2075        assert!(
2076            csp.contains("frame-ancestors 'none'"),
2077            "CSP frame-ancestors"
2078        );
2079        assert!(csp.contains("script-src 'self'"), "CSP script-src self");
2080        assert_eq!(
2081            h.get(header::X_FRAME_OPTIONS).and_then(|v| v.to_str().ok()),
2082            Some("DENY")
2083        );
2084        assert_eq!(
2085            h.get(header::REFERRER_POLICY).and_then(|v| v.to_str().ok()),
2086            Some("no-referrer")
2087        );
2088        assert_eq!(
2089            h.get(header::CACHE_CONTROL).and_then(|v| v.to_str().ok()),
2090            Some("no-store")
2091        );
2092    }
2093
2094    // CRUD round-trip: create → reveal → delete.
2095    #[tokio::test]
2096    async fn crud_round_trip() {
2097        let (state, _d) = temp_state();
2098        let app = build_app(state.clone());
2099        // create
2100        let body = json!({"coord":"dev/app/new","value":"v1","sensitivity":"medium"}).to_string();
2101        let req = Request::builder()
2102            .method("POST")
2103            .uri("/api/secret")
2104            .header(header::HOST, "127.0.0.1:8731")
2105            .header(header::ORIGIN, "http://127.0.0.1:8731")
2106            .header(SESSION_HEADER, state.session_token())
2107            .header(header::CONTENT_TYPE, "application/json")
2108            .body(Body::from(body))
2109            .unwrap();
2110        let resp = app.clone().oneshot(req).await.unwrap();
2111        assert_eq!(resp.status(), StatusCode::OK, "create failed");
2112        // reveal
2113        let resp = build_app(state.clone())
2114            .oneshot(api_get(
2115                "/api/reveal?coord=dev/app/new",
2116                state.session_token(),
2117            ))
2118            .await
2119            .unwrap();
2120        assert_eq!(body_json(resp).await["value"], "v1");
2121        // delete
2122        let req = Request::builder()
2123            .method("DELETE")
2124            .uri("/api/secret?coord=dev/app/new")
2125            .header(header::HOST, "127.0.0.1:8731")
2126            .header(header::ORIGIN, "http://127.0.0.1:8731")
2127            .header(SESSION_HEADER, state.session_token())
2128            .body(Body::empty())
2129            .unwrap();
2130        let resp = build_app(state.clone()).oneshot(req).await.unwrap();
2131        assert_eq!(resp.status(), StatusCode::OK);
2132    }
2133
2134    // KOV-30 (I5/I16) — lowering a CRITICAL secret from the UI is gated: a denied
2135    // confirmation is refused (403) and the record keeps its sensitivity.
2136    #[tokio::test]
2137    async fn downgrade_of_high_denied_leaves_record_unchanged() {
2138        let (state, _d) = state_with_confirmer(ConfirmOutcome::Denied);
2139        put_record(&state, &literal("dev", "key", "v", Sensitivity::High));
2140        let body = json!({"coord":"dev/app/key","sensitivity":"low"}).to_string();
2141        let resp = build_app(state.clone())
2142            .oneshot(api_patch(&body, state.session_token()))
2143            .await
2144            .unwrap();
2145        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2146        assert_eq!(
2147            read_back(&state, "dev/app/key").unwrap().sensitivity(),
2148            Sensitivity::High,
2149            "denied downgrade must not lower sensitivity"
2150        );
2151    }
2152
2153    // KOV-30 — an approved confirmation applies the critical downgrade.
2154    #[tokio::test]
2155    async fn downgrade_of_high_approved_lowers_sensitivity() {
2156        let (state, _d) = state_with_confirmer(ConfirmOutcome::Approved);
2157        put_record(&state, &literal("dev", "key", "v", Sensitivity::High));
2158        let body = json!({"coord":"dev/app/key","sensitivity":"low"}).to_string();
2159        let resp = build_app(state.clone())
2160            .oneshot(api_patch(&body, state.session_token()))
2161            .await
2162            .unwrap();
2163        assert_eq!(resp.status(), StatusCode::OK);
2164        assert_eq!(
2165            read_back(&state, "dev/app/key").unwrap().sensitivity(),
2166            Sensitivity::Low
2167        );
2168    }
2169
2170    // KOV-30 — a NON-critical downgrade (medium→low) is not gated; it applies
2171    // even with a denying broker (downgrade_requires_confirmation = high|inject).
2172    #[tokio::test]
2173    async fn noncritical_downgrade_is_not_gated() {
2174        let (state, _d) = state_with_confirmer(ConfirmOutcome::Denied);
2175        put_record(&state, &literal("dev", "url", "v", Sensitivity::Medium));
2176        let body = json!({"coord":"dev/app/url","sensitivity":"low"}).to_string();
2177        let resp = build_app(state.clone())
2178            .oneshot(api_patch(&body, state.session_token()))
2179            .await
2180            .unwrap();
2181        assert_eq!(resp.status(), StatusCode::OK);
2182        assert_eq!(
2183            read_back(&state, "dev/app/url").unwrap().sensitivity(),
2184            Sensitivity::Low
2185        );
2186    }
2187
2188    // KOV-30 — deleting a CRITICAL secret is broker-gated: a denied confirmation
2189    // keeps the record (403).
2190    #[tokio::test]
2191    async fn delete_of_high_denied_keeps_record() {
2192        let (state, _d) = state_with_confirmer(ConfirmOutcome::Denied);
2193        put_record(&state, &literal("dev", "key", "v", Sensitivity::High));
2194        let resp = build_app(state.clone())
2195            .oneshot(api_delete("dev/app/key", state.session_token()))
2196            .await
2197            .unwrap();
2198        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2199        assert!(
2200            read_back(&state, "dev/app/key").is_some(),
2201            "denied delete of a critical secret must keep the record"
2202        );
2203    }
2204
2205    // KOV-30 — deleting a NON-critical secret is NOT broker-gated: it succeeds
2206    // even with a denying broker (the browser guards it with a type-the-name
2207    // modal instead, not the broker). The reveal tier and the delete tier match.
2208    #[tokio::test]
2209    async fn delete_of_low_is_not_broker_gated() {
2210        let (state, _d) = state_with_confirmer(ConfirmOutcome::Denied);
2211        put_record(&state, &literal("dev", "url", "v", Sensitivity::Low));
2212        let resp = build_app(state.clone())
2213            .oneshot(api_delete("dev/app/url", state.session_token()))
2214            .await
2215            .unwrap();
2216        assert_eq!(resp.status(), StatusCode::OK);
2217        assert!(
2218            read_back(&state, "dev/app/url").is_none(),
2219            "non-critical delete must not consult the broker"
2220        );
2221    }
2222
2223    // L11 (I9): the master key parses from a Docker-secret file as raw bytes or
2224    // hex; a wrong length is rejected. (The container reads this from tmpfs.)
2225    #[test]
2226    fn master_key_parses_raw_and_hex() {
2227        let raw = [0x33u8; kovra_core::KEY_LEN];
2228        let from_raw = parse_master_key(&raw).unwrap();
2229        assert_eq!(from_raw.expose(), &raw);
2230
2231        let hex: String = raw.iter().map(|b| format!("{b:02x}")).collect();
2232        let from_hex = parse_master_key(hex.as_bytes()).unwrap();
2233        assert_eq!(from_hex.expose(), &raw);
2234
2235        // Trailing newline (typical secret file) is tolerated.
2236        let from_hex_nl = parse_master_key(format!("{hex}\n").as_bytes()).unwrap();
2237        assert_eq!(from_hex_nl.expose(), &raw);
2238
2239        // Wrong length and non-hex are rejected.
2240        assert!(parse_master_key(b"too-short").is_err());
2241        assert!(parse_master_key(&[0u8; kovra_core::KEY_LEN - 1]).is_err());
2242        let bad_hex = "z".repeat(kovra_core::KEY_LEN * 2);
2243        assert!(parse_master_key(bad_hex.as_bytes()).is_err());
2244    }
2245
2246    // generate stores a value and never returns it; prod is born high (I5).
2247    #[tokio::test]
2248    async fn generate_never_returns_value_and_prod_is_high() {
2249        let (state, _d) = temp_state();
2250        let body = json!({"coord":"prod/app/gen","length":24}).to_string();
2251        let req = Request::builder()
2252            .method("POST")
2253            .uri("/api/generate")
2254            .header(header::HOST, "127.0.0.1:8731")
2255            .header(header::ORIGIN, "http://127.0.0.1:8731")
2256            .header(SESSION_HEADER, state.session_token())
2257            .header(header::CONTENT_TYPE, "application/json")
2258            .body(Body::from(body))
2259            .unwrap();
2260        let resp = build_app(state.clone()).oneshot(req).await.unwrap();
2261        let j = body_json(resp).await;
2262        assert_eq!(j["sensitivity"], "high", "prod born high (I5)");
2263        assert!(j.get("value").is_none(), "generate never returns the value");
2264        // And the stored prod value is masked on reveal (I1).
2265        let resp = build_app(state.clone())
2266            .oneshot(api_get(
2267                "/api/reveal?coord=prod/app/gen",
2268                state.session_token(),
2269            ))
2270            .await
2271            .unwrap();
2272        assert_eq!(body_json(resp).await["masked"], json!(true));
2273    }
2274
2275    // ── KOV-29: embedded asset routes + new shell ──────────────────────────
2276
2277    async fn body_text(resp: Response) -> (StatusCode, String, String) {
2278        let status = resp.status();
2279        let ctype = resp
2280            .headers()
2281            .get(header::CONTENT_TYPE)
2282            .and_then(|v| v.to_str().ok())
2283            .unwrap_or_default()
2284            .to_string();
2285        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2286            .await
2287            .unwrap();
2288        (status, ctype, String::from_utf8_lossy(&bytes).into_owned())
2289    }
2290
2291    fn get_loopback(uri: &str, host: &str) -> Request<Body> {
2292        Request::builder()
2293            .method("GET")
2294            .uri(uri)
2295            .header(header::HOST, host)
2296            .body(Body::empty())
2297            .unwrap()
2298    }
2299
2300    // The shell loads the vendored grid + first-party app from `/assets/*` and
2301    // carries no inline application logic (the old inline reveal script is gone).
2302    #[tokio::test]
2303    async fn index_shell_references_assets_and_has_no_inline_logic() {
2304        let (state, _d) = temp_state();
2305        let resp = build_app(state.clone())
2306            .oneshot(get_loopback("/", "127.0.0.1:8731"))
2307            .await
2308            .unwrap();
2309        let (status, _ct, html) = body_text(resp).await;
2310        assert_eq!(status, StatusCode::OK);
2311        assert!(html.contains(r#"src="/assets/tabulator/tabulator.min.js""#));
2312        assert!(html.contains(r#"src="/assets/app.js""#));
2313        assert!(html.contains(r#"<div id="grid">"#));
2314        // No inline app logic in the shell — it must live in the embedded app.js.
2315        assert!(
2316            !html.contains("fetch('/api/secrets'") && !html.contains("/api/reveal?"),
2317            "shell must not embed inline API logic"
2318        );
2319    }
2320
2321    // Each embedded asset is served with the right content type and real content.
2322    #[tokio::test]
2323    async fn embedded_assets_are_served_with_types() {
2324        let (state, _d) = temp_state();
2325        let cases = [
2326            (
2327                "/assets/tabulator/tabulator.min.js",
2328                "javascript",
2329                "Tabulator",
2330            ),
2331            (
2332                "/assets/tabulator/tabulator.min.css",
2333                "text/css",
2334                ".tabulator",
2335            ),
2336            ("/assets/app.js", "javascript", "kovra Web UI v2"),
2337            ("/assets/app.css", "text/css", "kovra Web UI v2"),
2338        ];
2339        for (uri, want_ct, want_body) in cases {
2340            let resp = build_app(state.clone())
2341                .oneshot(get_loopback(uri, "127.0.0.1:8731"))
2342                .await
2343                .unwrap();
2344            let (status, ct, body) = body_text(resp).await;
2345            assert_eq!(status, StatusCode::OK, "{uri}");
2346            assert!(ct.contains(want_ct), "{uri} content-type was `{ct}`");
2347            assert!(body.contains(want_body), "{uri} body missing `{want_body}`");
2348        }
2349    }
2350
2351    // The brand icon + vendored fonts are served as binary assets with the
2352    // right content type and a non-empty body (KOV-29).
2353    #[tokio::test]
2354    async fn embedded_brand_binary_assets_are_served() {
2355        let (state, _d) = temp_state();
2356        let cases = [
2357            ("/assets/kovra-appicon.svg", "image/svg+xml; charset=utf-8"),
2358            ("/assets/kovra-iconmark.svg", "image/svg+xml; charset=utf-8"),
2359            ("/assets/fonts/sora-latin-600-normal.woff2", "font/woff2"),
2360            ("/assets/fonts/inter-latin-400-normal.woff2", "font/woff2"),
2361            ("/assets/fonts/inter-latin-500-normal.woff2", "font/woff2"),
2362            ("/assets/fonts/inter-latin-600-normal.woff2", "font/woff2"),
2363        ];
2364        for (uri, want_ct) in cases {
2365            let resp = build_app(state.clone())
2366                .oneshot(get_loopback(uri, "127.0.0.1:8731"))
2367                .await
2368                .unwrap();
2369            let status = resp.status();
2370            let ct = resp
2371                .headers()
2372                .get(header::CONTENT_TYPE)
2373                .and_then(|v| v.to_str().ok())
2374                .unwrap_or_default()
2375                .to_string();
2376            let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2377                .await
2378                .unwrap();
2379            assert_eq!(status, StatusCode::OK, "{uri}");
2380            assert_eq!(ct, want_ct, "{uri} content-type");
2381            assert!(!bytes.is_empty(), "{uri} body is empty");
2382        }
2383    }
2384
2385    // The shell wires the brand chrome the client depends on: the icon (logo +
2386    // favicon), the theme toggle, the reveal drawer, and the stats strip.
2387    #[tokio::test]
2388    async fn index_shell_has_brand_chrome() {
2389        let (state, _d) = temp_state();
2390        let resp = build_app(state.clone())
2391            .oneshot(get_loopback("/", "127.0.0.1:8731"))
2392            .await
2393            .unwrap();
2394        let (status, _ct, html) = body_text(resp).await;
2395        assert_eq!(status, StatusCode::OK);
2396        assert!(html.contains(r#"href="/assets/kovra-iconmark.svg""#));
2397        assert!(html.contains(r#"src="/assets/kovra-mark-color.png""#));
2398        assert!(html.contains(r#"id="theme""#));
2399        assert!(html.contains(r#"id="drawer""#));
2400        // Sidebar: Home + the collapsible Projects group (scopes reach the table).
2401        assert!(html.contains(r#"id="nav-home""#));
2402        assert!(html.contains(r#"id="proj-toggle""#));
2403        // Home overview: metrics + the pending-intakes panel.
2404        assert!(html.contains(r#"id="page-home""#));
2405        assert!(html.contains(r#"id="stat-total""#));
2406        assert!(html.contains(r#"id="intake-list""#));
2407        // The two Secrets grid views (table / tree).
2408        assert!(html.contains(r#"id="view-table""#));
2409        assert!(html.contains(r#"id="view-tree""#));
2410    }
2411
2412    // Assets carry no secrets, so they need no session token (a `<script src>`
2413    // load cannot attach one) — but they are still loopback-guarded (I10).
2414    #[tokio::test]
2415    async fn assets_need_no_session_but_are_loopback_guarded() {
2416        let (state, _d) = temp_state();
2417        // No session header → still served.
2418        let resp = build_app(state.clone())
2419            .oneshot(get_loopback("/assets/app.js", "127.0.0.1:8731"))
2420            .await
2421            .unwrap();
2422        assert_eq!(resp.status(), StatusCode::OK);
2423        // Non-loopback Host → rejected, like every route.
2424        let resp = build_app(state.clone())
2425            .oneshot(get_loopback("/assets/app.js", "evil.example.com"))
2426            .await
2427            .unwrap();
2428        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2429    }
2430
2431    // Contract the new client depends on: the inventory is metadata-only — it
2432    // carries the coordinate/sensitivity/mode/fingerprint but never a value.
2433    #[tokio::test]
2434    async fn api_secrets_contract_is_metadata_only() {
2435        let (state, _d) = temp_state();
2436        put_record(
2437            &state,
2438            &literal(
2439                "dev",
2440                "url",
2441                "should-not-appear-in-listing",
2442                Sensitivity::Medium,
2443            ),
2444        );
2445        let resp = build_app(state.clone())
2446            .oneshot(api_get("/api/secrets", state.session_token()))
2447            .await
2448            .unwrap();
2449        let j = body_json(resp).await;
2450        let row = &j["secrets"][0];
2451        for k in ["coordinate", "sensitivity", "mode", "fingerprint"] {
2452            assert!(row.get(k).is_some(), "row missing `{k}`");
2453        }
2454        assert!(
2455            row.get("value").is_none(),
2456            "listing must never carry a value"
2457        );
2458        let txt = serde_json::to_string(&j).unwrap();
2459        assert!(!txt.contains("should-not-appear-in-listing"));
2460    }
2461}