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