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::time::{Duration, Instant};
30
31use axum::{
32    Json, Router,
33    extract::{Query, Request, State},
34    http::{StatusCode, header},
35    middleware::{self, Next},
36    response::{Html, IntoResponse, Response},
37    routing::{get, post},
38};
39use kovra_core::{
40    AccessRequest, AgentScope, AuditAction, AuditEvent, AuditSink, Clock, ConfirmOutcome,
41    ConfirmRequest, Confirmer, Coordinate, Decision, FileAuditSink, MasterKey, Operation, Origin,
42    Registry, Resolution, SecretRecord, SecretValue, Sensitivity, Surface, SystemClock,
43    birth_sensitivity, decide, delete_requires_confirmation, downgrade_requires_confirmation,
44    fingerprint, is_downgrade, store,
45};
46use rand::RngCore;
47use serde::Deserialize;
48use serde_json::{Value, json};
49use std::str::FromStr;
50
51mod assets;
52
53/// HTTP header carrying the ephemeral per-launch session token.
54pub const SESSION_HEADER: &str = "x-kovra-session";
55
56/// Default loopback port for `kovra ui`.
57pub const DEFAULT_PORT: u16 = 8731;
58
59/// Shared application state. Cheap to clone (an `Arc`); holds the registry root,
60/// the resolved master key (zeroized on drop via [`MasterKey`]), the ephemeral
61/// session token, and the last-activity instant for the idle watchdog.
62#[derive(Clone)]
63pub struct AppState {
64    inner: Arc<Inner>,
65}
66
67struct Inner {
68    root: PathBuf,
69    master: MasterKey,
70    session_token: String,
71    last_activity: Mutex<Instant>,
72    /// Broker for the **per-action** attended confirmation of destructive UI
73    /// operations (sensitivity downgrade, delete — KOV-30). Supplied by the
74    /// launcher: Touch ID on `[host]` macOS, the file broker (`kovra approve`)
75    /// otherwise / in the container. The same authoritative `Confirmer` the CLI
76    /// uses (I3/I5/I16), never re-derived here.
77    confirmer: Arc<dyn Confirmer + Send + Sync>,
78}
79
80impl AppState {
81    /// Build state for a registry `root`, a resolved `master` key, and the
82    /// attended-confirmation `confirmer`, minting a fresh random session token
83    /// (128 bits of hex). The token dies with the process — it is never
84    /// persisted.
85    pub fn new(
86        root: PathBuf,
87        master: MasterKey,
88        confirmer: Arc<dyn Confirmer + Send + Sync>,
89    ) -> Self {
90        let mut buf = [0u8; 16];
91        rand::rngs::OsRng.fill_bytes(&mut buf);
92        let session_token = buf.iter().map(|b| format!("{b:02x}")).collect();
93        Self::new_with_session(root, master, session_token, confirmer)
94    }
95
96    /// Like [`AppState::new`] but with a caller-supplied session token. Used by
97    /// the L11 container entrypoint so the host orchestrator (`kovra ui
98    /// --docker`) — which generated the token and built the browser URL — and
99    /// the in-container server agree on it.
100    pub fn new_with_session(
101        root: PathBuf,
102        master: MasterKey,
103        session_token: String,
104        confirmer: Arc<dyn Confirmer + Send + Sync>,
105    ) -> Self {
106        Self {
107            inner: Arc::new(Inner {
108                root,
109                master,
110                session_token,
111                last_activity: Mutex::new(Instant::now()),
112                confirmer,
113            }),
114        }
115    }
116
117    /// The ephemeral session token (embedded into the page URL by `kovra ui`).
118    pub fn session_token(&self) -> &str {
119        &self.inner.session_token
120    }
121
122    /// A clone of the per-action confirmation broker (cheap — an `Arc`).
123    fn confirmer(&self) -> Arc<dyn Confirmer + Send + Sync> {
124        Arc::clone(&self.inner.confirmer)
125    }
126
127    fn registry(&self) -> Result<Registry, AppError> {
128        Registry::open(&self.inner.root).map_err(|e| AppError::internal(e.to_string()))
129    }
130
131    fn key(&self) -> &[u8; kovra_core::KEY_LEN] {
132        self.inner.master.expose()
133    }
134
135    fn audit(&self, action: AuditAction, result: &str, canonical: &str, env: &str) {
136        let clock = SystemClock;
137        let _ = FileAuditSink::under_root(&self.inner.root).record(
138            &AuditEvent::new(&clock, action, result)
139                .at(canonical, env)
140                .by(Origin::Human),
141        );
142    }
143
144    fn touch(&self) {
145        if let Ok(mut t) = self.inner.last_activity.lock() {
146            *t = Instant::now();
147        }
148    }
149
150    fn idle_for(&self) -> Duration {
151        self.inner
152            .last_activity
153            .lock()
154            .map(|t| t.elapsed())
155            .unwrap_or_default()
156    }
157}
158
159/// A handler error rendered as an HTTP status + JSON body (never a value).
160#[derive(Debug)]
161struct AppError {
162    status: StatusCode,
163    message: String,
164}
165
166impl AppError {
167    fn new(status: StatusCode, message: impl Into<String>) -> Self {
168        Self {
169            status,
170            message: message.into(),
171        }
172    }
173    fn internal(message: impl Into<String>) -> Self {
174        Self::new(StatusCode::INTERNAL_SERVER_ERROR, message)
175    }
176    fn bad(message: impl Into<String>) -> Self {
177        Self::new(StatusCode::BAD_REQUEST, message)
178    }
179    fn not_found(message: impl Into<String>) -> Self {
180        Self::new(StatusCode::NOT_FOUND, message)
181    }
182}
183
184impl IntoResponse for AppError {
185    fn into_response(self) -> Response {
186        (self.status, Json(json!({ "error": self.message }))).into_response()
187    }
188}
189
190/// Build the router for `state`. The `/api/*` routes sit behind the ephemeral
191/// session-token check; every route (incl. `/`) is behind the `Origin`/`Host`
192/// loopback guard. This is the unit exercised by the endpoint tests.
193pub fn build_app(state: AppState) -> Router {
194    let api = Router::new()
195        .route("/secrets", get(list_secrets))
196        .route("/reveal", get(reveal_secret))
197        .route(
198            "/secret",
199            post(create_secret)
200                .put(update_value)
201                .patch(edit_metadata)
202                .delete(delete_secret),
203        )
204        .route("/generate", post(generate_secret))
205        .route_layer(middleware::from_fn_with_state(
206            state.clone(),
207            require_session,
208        ));
209
210    Router::new()
211        .route("/", get(index))
212        // Static front-end assets (vendored Tabulator + first-party app shell).
213        // Carry no secrets, so they sit outside the `/api` session layer but
214        // inside the loopback guard below (KOV-29).
215        .merge(assets::routes())
216        .nest("/api", api)
217        .layer(middleware::from_fn_with_state(
218            state.clone(),
219            loopback_guard,
220        ))
221        .with_state(state)
222}
223
224// ───────────────────────────── middleware ─────────────────────────────
225
226/// I10 / anti-DNS-rebinding: accept only loopback `Host` and same-origin
227/// `Origin`. Runs for every route (including `/`). Also refreshes the
228/// idle-watchdog clock.
229async fn loopback_guard(State(state): State<AppState>, req: Request, next: Next) -> Response {
230    if let Some(host) = req
231        .headers()
232        .get(header::HOST)
233        .and_then(|h| h.to_str().ok())
234        && !is_loopback_host(host)
235    {
236        return AppError::new(StatusCode::FORBIDDEN, "non-loopback Host rejected (I10)")
237            .into_response();
238    }
239    // If an Origin is present (a browser fetch), it must itself be loopback.
240    if let Some(origin) = req
241        .headers()
242        .get(header::ORIGIN)
243        .and_then(|h| h.to_str().ok())
244        && !is_loopback_origin(origin)
245    {
246        return AppError::new(StatusCode::FORBIDDEN, "cross-origin request rejected")
247            .into_response();
248    }
249    state.touch();
250    next.run(req).await
251}
252
253/// Require the ephemeral session token on `/api/*`. The browser shell receives
254/// it from the launch URL and echoes it in [`SESSION_HEADER`].
255async fn require_session(State(state): State<AppState>, req: Request, next: Next) -> Response {
256    let presented = req
257        .headers()
258        .get(SESSION_HEADER)
259        .and_then(|h| h.to_str().ok())
260        .unwrap_or_default();
261    // Constant-ish comparison is unnecessary here (loopback, ephemeral token),
262    // but we still avoid leaking which half mismatched.
263    if presented.is_empty() || presented != state.session_token() {
264        return AppError::new(StatusCode::UNAUTHORIZED, "missing or invalid session token")
265            .into_response();
266    }
267    next.run(req).await
268}
269
270fn is_loopback_host(host: &str) -> bool {
271    // Strip the optional port; accept the loopback names only.
272    let h = host.rsplit_once(':').map(|(h, _)| h).unwrap_or(host);
273    h == "127.0.0.1" || h == "localhost" || h == "[::1]" || h == "::1"
274}
275
276fn is_loopback_origin(origin: &str) -> bool {
277    let rest = match origin.strip_prefix("http://") {
278        Some(r) => r,
279        None => match origin.strip_prefix("https://") {
280            Some(r) => r,
281            None => return false,
282        },
283    };
284    is_loopback_host(rest)
285}
286
287// ───────────────────────────── handlers ─────────────────────────────
288
289#[derive(Deserialize, Default)]
290struct ScopeQuery {
291    project: Option<String>,
292}
293
294#[derive(Deserialize)]
295struct CoordQuery {
296    coord: String,
297    project: Option<String>,
298}
299
300/// `GET /` — the minimal admin shell. Serves no secret; the listing and any
301/// reveal are fetched over `/api/*` on demand with the session token.
302async fn index() -> Html<&'static str> {
303    Html(INDEX_HTML)
304}
305
306/// `GET /api/secrets` — metadata-only inventory (never a value, §9.3). Lists the
307/// global vault plus every project (or one project), marking shadowing and
308/// reference pointers.
309async fn list_secrets(
310    State(state): State<AppState>,
311    Query(q): Query<ScopeQuery>,
312) -> Result<Json<Value>, AppError> {
313    let registry = state.registry()?;
314    let mut rows: Vec<Value> = Vec::new();
315    let mut global_coords: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
316
317    let mut collect = |dir: PathBuf, origin: String| -> Result<(), AppError> {
318        let outcome =
319            store::load_all(&dir, state.key()).map_err(|e| AppError::internal(e.to_string()))?;
320        for (_, record) in outcome.records {
321            if origin == "global" {
322                global_coords.insert(record.canonical_path());
323            }
324            rows.push(row_for(&record, &origin));
325        }
326        Ok(())
327    };
328
329    match q.project.as_deref() {
330        Some(p) => collect(registry.project_dir(p), format!("project:{p}"))?,
331        None => {
332            collect(registry.global_dir(), "global".to_string())?;
333            for name in registry
334                .list_projects()
335                .map_err(|e| AppError::internal(e.to_string()))?
336            {
337                collect(registry.project_dir(&name), format!("project:{name}"))?;
338            }
339        }
340    }
341
342    // Mark project rows that shadow a homonymous global coordinate (§9.3).
343    for row in &mut rows {
344        let is_project = row
345            .get("origin")
346            .and_then(|o| o.as_str())
347            .is_some_and(|o| o.starts_with("project:"));
348        let coord = row.get("coordinate").and_then(|c| c.as_str()).unwrap_or("");
349        if is_project && global_coords.contains(coord) {
350            row["shadows_global"] = json!(true);
351        }
352    }
353
354    Ok(Json(json!({ "secrets": rows })))
355}
356
357/// One inventory row — metadata only (I1/I12). Literals carry a truncated
358/// fingerprint; references carry the pointer; keypair/totp carry their
359/// non-secret descriptors. **No value, private key, or seed is ever included.**
360fn row_for(record: &SecretRecord, origin: &str) -> Value {
361    let base = json!({
362        "origin": origin,
363        "coordinate": record.canonical_path(),
364        "environment": record.environment(),
365        "component": record.component(),
366        "key": record.key(),
367        "sensitivity": sensitivity_str(record.sensitivity()),
368        "revealable": record.revealable(),
369        "shadows_global": false,
370    });
371    let mut v = base;
372    match record {
373        SecretRecord::Literal { value, .. } => {
374            v["mode"] = json!("literal");
375            v["fingerprint"] = json!(fingerprint(value.expose()));
376        }
377        SecretRecord::Reference { reference, .. } => {
378            v["mode"] = json!("reference");
379            v["pointer"] = json!(reference);
380        }
381        SecretRecord::Keypair {
382            algorithm,
383            private,
384            public,
385            ..
386        } => {
387            v["mode"] = json!(if private.is_some() {
388                "keypair"
389            } else {
390                "public-only"
391            });
392            v["algorithm"] = json!(algorithm.as_str());
393            v["public"] = json!(public); // public key is not a secret
394            v["fingerprint"] = json!(fingerprint(public.as_bytes()));
395        }
396        SecretRecord::Totp {
397            algorithm,
398            digits,
399            period,
400            ..
401        } => {
402            v["mode"] = json!("totp");
403            v["algorithm"] = json!(algorithm.as_str());
404            v["digits"] = json!(digits);
405            v["period"] = json!(period);
406        }
407    }
408    v
409}
410
411/// `GET /api/reveal?coord=&project=` — reveal a value **on demand**, governed by
412/// sensitivity through [`decide`] (I1/I2). Only `low`/`medium` literals return a
413/// value; `high` returns masked + fingerprint; `inject-only` returns metadata
414/// only; references/keypairs/totp never return their secret material.
415async fn reveal_secret(
416    State(state): State<AppState>,
417    Query(q): Query<CoordQuery>,
418) -> Result<Json<Value>, AppError> {
419    let coord = parse_coord(&q.coord)?;
420    let registry = state.registry()?;
421    let record = match registry
422        .resolve_with_key(&coord, q.project.as_deref(), state.key())
423        .map_err(|e| AppError::internal(e.to_string()))?
424    {
425        Resolution::Found { record, origin } => {
426            let _ = origin; // origin is surfaced by the listing, not the reveal
427            record
428        }
429        Resolution::NotFound => {
430            return Err(AppError::not_found(format!("no secret at `{}`", q.coord)));
431        }
432    };
433    let canonical = record.canonical_path();
434    let env = record.environment().to_string();
435    let sensitivity = record.sensitivity();
436
437    // Non-literal modalities never expose their secret material in the browser.
438    match &record {
439        SecretRecord::Reference { reference, .. } => {
440            return Ok(Json(json!({
441                "coordinate": canonical,
442                "kind": "reference",
443                "pointer": reference,
444                "status": "unverified",
445                "note": "value not stored; materialized at run time by the provider (I8)"
446            })));
447        }
448        SecretRecord::Keypair {
449            algorithm,
450            private,
451            public,
452            ..
453        } => {
454            return Ok(Json(json!({
455                "coordinate": canonical,
456                "kind": if private.is_some() { "keypair" } else { "public-only" },
457                "algorithm": algorithm.as_str(),
458                "public": public,
459                "note": "private half is custodied; use the CLI (sign/decrypt/ssh-add)"
460            })));
461        }
462        SecretRecord::Totp {
463            algorithm,
464            digits,
465            period,
466            ..
467        } => {
468            return Ok(Json(json!({
469                "coordinate": canonical,
470                "kind": "totp",
471                "algorithm": algorithm.as_str(),
472                "digits": digits,
473                "period": period,
474                "note": "seed is custodied; derive a code with the CLI (`kovra code`)"
475            })));
476        }
477        SecretRecord::Literal { .. } => {}
478    }
479
480    let SecretRecord::Literal {
481        value, revealable, ..
482    } = &record
483    else {
484        unreachable!("non-literal handled above");
485    };
486
487    let request = AccessRequest {
488        coordinate: &coord,
489        project: q.project.as_deref(),
490        sensitivity,
491        revealable: *revealable,
492        operation: Operation::Reveal,
493        surface: Surface::WebUi,
494        origin: Origin::Human,
495    };
496    match decide(&request, &AgentScope::full()) {
497        Decision::Allow => {
498            // low/medium: the only path that returns a literal value, and only on
499            // this explicit per-coordinate fetch (never in the listing).
500            let value_str = String::from_utf8_lossy(value.expose()).into_owned();
501            state.audit(AuditAction::Reveal, "revealed", &canonical, &env);
502            Ok(Json(json!({
503                "coordinate": canonical,
504                "kind": "literal",
505                "sensitivity": sensitivity_str(sensitivity),
506                "value": value_str
507            })))
508        }
509        Decision::Deny(reason) => {
510            // high → masked + fingerprint (defer to CLI); inject-only → metadata
511            // only. The value never leaves the core (I1/I2).
512            use kovra_core::DenyReason;
513            let body = match reason {
514                DenyReason::WebUiCriticalMasked => json!({
515                    "coordinate": canonical,
516                    "kind": "literal",
517                    "sensitivity": sensitivity_str(sensitivity),
518                    "masked": true,
519                    "fingerprint": fingerprint(value.expose()),
520                    "note": "high — masked in the browser (I1); reveal via the CLI's biometric channel"
521                }),
522                DenyReason::InjectOnlyNeverRevealed => json!({
523                    "coordinate": canonical,
524                    "kind": "literal",
525                    "sensitivity": sensitivity_str(sensitivity),
526                    "inject_only": true,
527                    "note": "inject-only — never revealed on any surface (I2)"
528                }),
529                other => json!({
530                    "coordinate": canonical,
531                    "kind": "literal",
532                    "masked": true,
533                    "note": format!("not revealable here: {other:?}")
534                }),
535            };
536            state.audit(AuditAction::Reveal, "masked", &canonical, &env);
537            Ok(Json(body))
538        }
539        Decision::Unaddressable => Err(AppError::not_found("not addressable")),
540        Decision::RequireConfirmation => {
541            // The Web UI never prompts for confirmation; it masks instead (the
542            // CLI is the confirmation channel). Treat as masked.
543            Ok(Json(json!({
544                "coordinate": canonical,
545                "kind": "literal",
546                "masked": true,
547                "fingerprint": fingerprint(value.expose()),
548                "note": "requires confirmation — reveal via the CLI"
549            })))
550        }
551    }
552}
553
554#[derive(Deserialize)]
555struct CreateBody {
556    coord: String,
557    project: Option<String>,
558    value: Option<String>,
559    reference: Option<String>,
560    sensitivity: Option<String>,
561    description: Option<String>,
562    #[serde(default)]
563    revealable: bool,
564}
565
566/// `POST /api/secret` — create a literal or reference secret. Values arrive in
567/// the request body over loopback (never argv); prod is born `high` (I5).
568async fn create_secret(
569    State(state): State<AppState>,
570    Json(body): Json<CreateBody>,
571) -> Result<Json<Value>, AppError> {
572    let coord = parse_coord(&body.coord)?;
573    let (env, component, key) = segments(&coord);
574    let registry = state.registry()?;
575    let dir = vault_dir(&registry, body.project.as_deref());
576
577    if store::read_record(&dir, &coord, state.key())
578        .map_err(|e| AppError::internal(e.to_string()))?
579        .is_some()
580    {
581        return Err(AppError::bad(format!("`{}` already exists", body.coord)));
582    }
583    let chosen = parse_sensitivity(body.sensitivity.as_deref())?.unwrap_or(Sensitivity::Medium);
584    let born = birth_sensitivity(&env, chosen);
585    let now = SystemClock.now_rfc3339();
586    let record = match (&body.reference, &body.value) {
587        (Some(reference), _) => SecretRecord::Reference {
588            reference: reference.clone(),
589            sensitivity: born,
590            revealable: body.revealable,
591            environment: env.clone(),
592            component,
593            key,
594            description: body.description.clone(),
595            created: now.clone(),
596            updated: now,
597        },
598        (None, Some(value)) => SecretRecord::Literal {
599            value: SecretValue::from(value.as_str()),
600            sensitivity: born,
601            revealable: body.revealable,
602            environment: env.clone(),
603            component,
604            key,
605            description: body.description.clone(),
606            created: now.clone(),
607            updated: now,
608        },
609        (None, None) => return Err(AppError::bad("provide `value` or `reference`")),
610    };
611    write(&dir, &coord, &record, state.key())?;
612    state.audit(
613        AuditAction::Create,
614        "created",
615        &record.canonical_path(),
616        &env,
617    );
618    Ok(Json(
619        json!({ "created": record.canonical_path(), "sensitivity": sensitivity_str(born) }),
620    ))
621}
622
623#[derive(Deserialize)]
624struct UpdateBody {
625    coord: String,
626    project: Option<String>,
627    value: String,
628}
629
630/// `PUT /api/secret` — replace a literal's value (metadata preserved). Refuses
631/// to overwrite a keypair/totp/reference (those are not plain values).
632async fn update_value(
633    State(state): State<AppState>,
634    Json(body): Json<UpdateBody>,
635) -> Result<Json<Value>, AppError> {
636    let coord = parse_coord(&body.coord)?;
637    let registry = state.registry()?;
638    let dir = vault_dir(&registry, body.project.as_deref());
639    let existing = store::read_record(&dir, &coord, state.key())
640        .map_err(|e| AppError::internal(e.to_string()))?
641        .ok_or_else(|| AppError::not_found(format!("`{}` not found", body.coord)))?;
642    let now = SystemClock.now_rfc3339();
643    let record = match existing {
644        SecretRecord::Literal {
645            sensitivity,
646            revealable,
647            environment,
648            component,
649            key,
650            description,
651            created,
652            ..
653        } => SecretRecord::Literal {
654            value: SecretValue::from(body.value.as_str()),
655            sensitivity,
656            revealable,
657            environment,
658            component,
659            key,
660            description,
661            created,
662            updated: now,
663        },
664        _ => return Err(AppError::bad("only a literal's value can be updated here")),
665    };
666    write(&dir, &coord, &record, state.key())?;
667    state.audit(
668        AuditAction::Edit,
669        "value-updated",
670        &record.canonical_path(),
671        record.environment(),
672    );
673    Ok(Json(json!({ "updated": record.canonical_path() })))
674}
675
676#[derive(Deserialize)]
677struct EditBody {
678    coord: String,
679    project: Option<String>,
680    sensitivity: Option<String>,
681    description: Option<String>,
682    reference: Option<String>,
683    revealable: Option<bool>,
684}
685
686/// `PATCH /api/secret` — edit metadata (sensitivity / description / reference
687/// pointer / revealable). Lowering sensitivity is an audited downgrade (I5).
688async fn edit_metadata(
689    State(state): State<AppState>,
690    Json(body): Json<EditBody>,
691) -> Result<Json<Value>, AppError> {
692    let coord = parse_coord(&body.coord)?;
693    let registry = state.registry()?;
694    let dir = vault_dir(&registry, body.project.as_deref());
695    let existing = store::read_record(&dir, &coord, state.key())
696        .map_err(|e| AppError::internal(e.to_string()))?
697        .ok_or_else(|| AppError::not_found(format!("`{}` not found", body.coord)))?;
698    let new_sensitivity = parse_sensitivity(body.sensitivity.as_deref())?;
699    let env = existing.environment().to_string();
700    let lowered = matches!(new_sensitivity, Some(s) if is_downgrade(existing.sensitivity(), s));
701
702    // KOV-30 — lowering a CRITICAL secret's sensitivity from the UI is an
703    // attended action (I5 + I16), gated through the same broker the CLI uses
704    // (commands.rs::edit). The downgrade is applied only on an approved
705    // confirmation; deny/timeout leave the record untouched.
706    if let Some(new) = new_sensitivity
707        && downgrade_requires_confirmation(existing.sensitivity(), new)
708    {
709        let canonical = existing.canonical_path();
710        let req = ui_action_request(
711            &existing,
712            format!(
713                "edit {canonical} --sensitivity {} (downgrade, web ui)",
714                sensitivity_str(new)
715            ),
716        );
717        match confirm_action(state.confirmer(), req).await {
718            ConfirmOutcome::Approved => {
719                state.audit(AuditAction::Approve, "approved-downgrade", &canonical, &env);
720            }
721            ConfirmOutcome::Denied => {
722                state.audit(AuditAction::Deny, "denied-downgrade", &canonical, &env);
723                return Err(AppError::new(
724                    StatusCode::FORBIDDEN,
725                    "denied — sensitivity not lowered",
726                ));
727            }
728            ConfirmOutcome::TimedOut => {
729                state.audit(AuditAction::Timeout, "timeout-downgrade", &canonical, &env);
730                return Err(AppError::new(
731                    StatusCode::REQUEST_TIMEOUT,
732                    "timed out — sensitivity not lowered",
733                ));
734            }
735        }
736    }
737
738    let now = SystemClock.now_rfc3339();
739    let updated = apply_edit(
740        existing,
741        new_sensitivity,
742        body.description.clone(),
743        body.reference.clone(),
744        body.revealable,
745        now,
746    )?;
747    write(&dir, &coord, &updated, state.key())?;
748    if lowered {
749        state.audit(
750            AuditAction::SensitivityDowngrade,
751            "downgraded",
752            &updated.canonical_path(),
753            &env,
754        );
755    }
756    state.audit(
757        AuditAction::Edit,
758        "metadata-updated",
759        &updated.canonical_path(),
760        &env,
761    );
762    Ok(Json(json!({ "edited": updated.canonical_path() })))
763}
764
765/// `DELETE /api/secret?coord=&project=`.
766async fn delete_secret(
767    State(state): State<AppState>,
768    Query(q): Query<CoordQuery>,
769) -> Result<Json<Value>, AppError> {
770    let coord = parse_coord(&q.coord)?;
771    let registry = state.registry()?;
772    let dir = vault_dir(&registry, q.project.as_deref());
773    let existing = store::read_record(&dir, &coord, state.key())
774        .map_err(|e| AppError::internal(e.to_string()))?
775        .ok_or_else(|| AppError::not_found(format!("`{}` not found", q.coord)))?;
776    let canonical = existing.canonical_path();
777    let env = existing.environment().to_string();
778
779    // KOV-30 — deleting a CRITICAL secret (high / inject-only) from the UI is an
780    // attended action, gated through the same broker the rest of kovra uses
781    // (Touch ID / `kovra approve`, I16). Non-critical secrets (low / medium) are
782    // viewable on demand without biometrics, so their deletion is NOT broker-
783    // gated here — the browser guards it with a type-the-name confirmation modal
784    // (client-side friction against accidents, matching the reveal tier). The
785    // record is removed only on an approved confirmation when gating applies.
786    if delete_requires_confirmation(existing.sensitivity()) {
787        let req = ui_action_request(&existing, format!("delete {canonical} (web ui)"));
788        match confirm_action(state.confirmer(), req).await {
789            ConfirmOutcome::Approved => {
790                state.audit(AuditAction::Approve, "approved-delete", &canonical, &env);
791            }
792            ConfirmOutcome::Denied => {
793                state.audit(AuditAction::Deny, "denied-delete", &canonical, &env);
794                return Err(AppError::new(StatusCode::FORBIDDEN, "denied — not deleted"));
795            }
796            ConfirmOutcome::TimedOut => {
797                state.audit(AuditAction::Timeout, "timeout-delete", &canonical, &env);
798                return Err(AppError::new(
799                    StatusCode::REQUEST_TIMEOUT,
800                    "timed out — not deleted",
801                ));
802            }
803        }
804    }
805
806    store::delete_record(&dir, &coord).map_err(|e| AppError::internal(e.to_string()))?;
807    state.audit(AuditAction::Delete, "deleted", &canonical, &env);
808    Ok(Json(json!({ "deleted": canonical })))
809}
810
811#[derive(Deserialize)]
812struct GenerateBody {
813    coord: String,
814    project: Option<String>,
815    length: Option<usize>,
816    sensitivity: Option<String>,
817    description: Option<String>,
818}
819
820/// `POST /api/generate` — generate a random value server-side, store it, and
821/// **never return it** (the value is born in the core, §9.2).
822async fn generate_secret(
823    State(state): State<AppState>,
824    Json(body): Json<GenerateBody>,
825) -> Result<Json<Value>, AppError> {
826    let coord = parse_coord(&body.coord)?;
827    let (env, component, key) = segments(&coord);
828    let registry = state.registry()?;
829    let dir = vault_dir(&registry, body.project.as_deref());
830    if store::read_record(&dir, &coord, state.key())
831        .map_err(|e| AppError::internal(e.to_string()))?
832        .is_some()
833    {
834        return Err(AppError::bad(format!("`{}` already exists", body.coord)));
835    }
836    let length = body.length.unwrap_or(32);
837    if length == 0 {
838        return Err(AppError::bad("length must be at least 1"));
839    }
840    use rand::Rng;
841    use rand::distributions::Alphanumeric;
842    let generated: String = rand::rngs::OsRng
843        .sample_iter(&Alphanumeric)
844        .take(length)
845        .map(char::from)
846        .collect();
847    let chosen = parse_sensitivity(body.sensitivity.as_deref())?.unwrap_or(Sensitivity::Medium);
848    let born = birth_sensitivity(&env, chosen);
849    let now = SystemClock.now_rfc3339();
850    let record = SecretRecord::Literal {
851        value: SecretValue::from(generated),
852        sensitivity: born,
853        revealable: false,
854        environment: env.clone(),
855        component,
856        key,
857        description: body.description.clone(),
858        created: now.clone(),
859        updated: now,
860    };
861    write(&dir, &coord, &record, state.key())?;
862    state.audit(
863        AuditAction::Create,
864        "generated",
865        &record.canonical_path(),
866        &env,
867    );
868    Ok(Json(json!({
869        "generated": record.canonical_path(),
870        "length": length,
871        "sensitivity": sensitivity_str(born),
872        "note": "value stored, never returned"
873    })))
874}
875
876// ───────────────────────────── helpers ─────────────────────────────
877
878/// How long a destructive-action confirmation waits for an attended decision
879/// before failing safe to denial — mirrors the CLI's `CONFIRM_TIMEOUT` (§8).
880const CONFIRM_TIMEOUT: Duration = Duration::from_secs(120);
881
882/// Run a (blocking) broker confirmation off the async reactor. `Confirmer::confirm`
883/// polls a file / blocks on a condvar, so it must not run on a Tokio worker
884/// thread. A join error fails safe to denial (§8). The `ConfirmRequest` is built
885/// by the core from the **stored record** (I16), never from the request body.
886async fn confirm_action(
887    confirmer: Arc<dyn Confirmer + Send + Sync>,
888    req: ConfirmRequest,
889) -> ConfirmOutcome {
890    tokio::task::spawn_blocking(move || confirmer.confirm(&req, CONFIRM_TIMEOUT))
891        .await
892        .unwrap_or(ConfirmOutcome::Denied)
893}
894
895/// Build the authoritative `ConfirmRequest` for a destructive UI action against a
896/// stored `record`. All fields are core-observed facts (coordinate / sensitivity
897/// / environment from the record; the surface identity is server-authored), so
898/// the prompt can never be steered by untrusted request input (I16).
899fn ui_action_request(record: &SecretRecord, command: String) -> ConfirmRequest {
900    ConfirmRequest::new(
901        record.canonical_path(),
902        record.sensitivity(),
903        record.environment().to_string(),
904        Origin::Human,
905    )
906    .with_command(command)
907    // Trusted, server-authored surface identity (never the browser/requester).
908    .with_requesting_process("kovra ui (web admin)")
909    // KOV-30 — these are administrative *actions* (delete / downgrade), not
910    // delivery of the secret value, so the native Touch ID prompt always offers
911    // the device-password fallback ("Use Password"). The secret broker (high
912    // reveal/inject) stays biometrics-only via `ConfirmRequest::new` (§8/I3).
913    .with_allow_password(true)
914}
915
916fn parse_coord(s: &str) -> Result<Coordinate, AppError> {
917    let with_scheme = if s.starts_with("secret:") {
918        s.to_string()
919    } else {
920        format!("secret:{s}")
921    };
922    let coord = Coordinate::from_str(&with_scheme).map_err(|e| AppError::bad(e.to_string()))?;
923    // A web coordinate must be concrete (no `${ENV}` placeholder).
924    coord
925        .canonical_path()
926        .map_err(|e| AppError::bad(format!("{e} (coordinate must be concrete)")))?;
927    Ok(coord)
928}
929
930fn segments(coord: &Coordinate) -> (String, String, String) {
931    use kovra_core::EnvSegment;
932    let env = match &coord.environment {
933        EnvSegment::Literal(e) => e.clone(),
934        EnvSegment::Placeholder => unreachable!("parse_coord rejects placeholders"),
935    };
936    (env, coord.component.clone(), coord.key.clone())
937}
938
939fn vault_dir(registry: &Registry, project: Option<&str>) -> PathBuf {
940    match project {
941        Some(p) => registry.project_dir(p),
942        None => registry.global_dir(),
943    }
944}
945
946fn write(
947    dir: &std::path::Path,
948    coord: &Coordinate,
949    record: &SecretRecord,
950    key: &[u8; kovra_core::KEY_LEN],
951) -> Result<(), AppError> {
952    let sealed = kovra_core::seal(record, key).map_err(|e| AppError::internal(e.to_string()))?;
953    store::write_record(dir, coord, &sealed).map_err(|e| AppError::internal(e.to_string()))
954}
955
956fn sensitivity_str(s: Sensitivity) -> &'static str {
957    match s {
958        Sensitivity::Low => "low",
959        Sensitivity::Medium => "medium",
960        Sensitivity::High => "high",
961        Sensitivity::InjectOnly => "inject-only",
962    }
963}
964
965fn parse_sensitivity(s: Option<&str>) -> Result<Option<Sensitivity>, AppError> {
966    match s {
967        None => Ok(None),
968        Some(v) => match v.to_ascii_lowercase().replace('_', "-").as_str() {
969            "low" => Ok(Some(Sensitivity::Low)),
970            "medium" => Ok(Some(Sensitivity::Medium)),
971            "high" => Ok(Some(Sensitivity::High)),
972            "inject-only" => Ok(Some(Sensitivity::InjectOnly)),
973            other => Err(AppError::bad(format!("unknown sensitivity `{other}`"))),
974        },
975    }
976}
977
978fn apply_edit(
979    existing: SecretRecord,
980    new_sensitivity: Option<Sensitivity>,
981    new_description: Option<String>,
982    new_reference: Option<String>,
983    new_revealable: Option<bool>,
984    now: String,
985) -> Result<SecretRecord, AppError> {
986    match existing {
987        SecretRecord::Literal {
988            value,
989            sensitivity,
990            revealable,
991            environment,
992            component,
993            key,
994            description,
995            created,
996            ..
997        } => {
998            if new_reference.is_some() {
999                return Err(AppError::bad(
1000                    "`reference` edits a reference secret; this is a literal",
1001                ));
1002            }
1003            Ok(SecretRecord::Literal {
1004                value,
1005                sensitivity: new_sensitivity.unwrap_or(sensitivity),
1006                revealable: new_revealable.unwrap_or(revealable),
1007                environment,
1008                component,
1009                key,
1010                description: new_description.or(description),
1011                created,
1012                updated: now,
1013            })
1014        }
1015        SecretRecord::Reference {
1016            reference,
1017            sensitivity,
1018            revealable,
1019            environment,
1020            component,
1021            key,
1022            description,
1023            created,
1024            ..
1025        } => Ok(SecretRecord::Reference {
1026            reference: new_reference.unwrap_or(reference),
1027            sensitivity: new_sensitivity.unwrap_or(sensitivity),
1028            revealable: new_revealable.unwrap_or(revealable),
1029            environment,
1030            component,
1031            key,
1032            description: new_description.or(description),
1033            created,
1034            updated: now,
1035        }),
1036        SecretRecord::Keypair {
1037            algorithm,
1038            private,
1039            public,
1040            sensitivity,
1041            revealable,
1042            environment,
1043            component,
1044            key,
1045            description,
1046            created,
1047            ..
1048        } => {
1049            if new_reference.is_some() {
1050                return Err(AppError::bad(
1051                    "`reference` edits a reference secret; this is a keypair",
1052                ));
1053            }
1054            Ok(SecretRecord::Keypair {
1055                algorithm,
1056                private,
1057                public,
1058                sensitivity: new_sensitivity.unwrap_or(sensitivity),
1059                revealable: new_revealable.unwrap_or(revealable),
1060                environment,
1061                component,
1062                key,
1063                description: new_description.or(description),
1064                created,
1065                updated: now,
1066            })
1067        }
1068        SecretRecord::Totp {
1069            seed,
1070            algorithm,
1071            digits,
1072            period,
1073            sensitivity,
1074            revealable,
1075            environment,
1076            component,
1077            key,
1078            description,
1079            created,
1080            ..
1081        } => {
1082            if new_reference.is_some() {
1083                return Err(AppError::bad(
1084                    "`reference` edits a reference secret; this is a TOTP enrollment",
1085                ));
1086            }
1087            Ok(SecretRecord::Totp {
1088                seed,
1089                algorithm,
1090                digits,
1091                period,
1092                sensitivity: new_sensitivity.unwrap_or(sensitivity),
1093                revealable: new_revealable.unwrap_or(revealable),
1094                environment,
1095                component,
1096                key,
1097                description: new_description.or(description),
1098                created,
1099                updated: now,
1100            })
1101        }
1102    }
1103}
1104
1105// ───────────────────────────── serve (host) ─────────────────────────────
1106
1107/// Run the server on an already-bound loopback `listener` until Ctrl-C or
1108/// `idle` of inactivity. `[host]`: the real bind + browser-open are validated on
1109/// hardware; the router itself is covered by the `[mock]` endpoint tests.
1110pub async fn serve(
1111    listener: tokio::net::TcpListener,
1112    state: AppState,
1113    idle: Duration,
1114) -> std::io::Result<()> {
1115    let app = build_app(state.clone());
1116    axum::serve(listener, app)
1117        .with_graceful_shutdown(shutdown_signal(state, idle))
1118        .await
1119}
1120
1121/// Resolve when either Ctrl-C arrives or the server has been idle for `idle`.
1122async fn shutdown_signal(state: AppState, idle: Duration) {
1123    let ctrl_c = async {
1124        let _ = tokio::signal::ctrl_c().await;
1125    };
1126    let idle_watchdog = async {
1127        let tick = Duration::from_secs(5).min(idle);
1128        loop {
1129            tokio::time::sleep(tick).await;
1130            if state.idle_for() >= idle {
1131                break;
1132            }
1133        }
1134    };
1135    tokio::select! {
1136        _ = ctrl_c => {}
1137        _ = idle_watchdog => {}
1138    }
1139}
1140
1141/// The default loopback bind address for `kovra ui`.
1142pub fn default_addr(port: u16) -> SocketAddr {
1143    SocketAddr::from(([127, 0, 0, 1], port))
1144}
1145
1146/// Parse a master key supplied as a file's bytes (L11 Docker entrypoint, I9).
1147///
1148/// Accepts either exactly [`kovra_core::KEY_LEN`] raw bytes, or a hex string of
1149/// `2 * KEY_LEN` characters (with optional surrounding whitespace/newline — the
1150/// common shape of a Docker secret file). Never logs the bytes. The key arrives
1151/// from a Docker secret in `tmpfs` at runtime, never from an image layer (I9).
1152pub fn parse_master_key(raw: &[u8]) -> Result<MasterKey, String> {
1153    // Raw binary key: exact length.
1154    if raw.len() == kovra_core::KEY_LEN {
1155        let mut key = [0u8; kovra_core::KEY_LEN];
1156        key.copy_from_slice(raw);
1157        return Ok(MasterKey::new(key));
1158    }
1159    // Otherwise treat it as hex text (trimmed).
1160    let text = std::str::from_utf8(raw)
1161        .map_err(|_| "master key file is neither raw bytes nor UTF-8 hex".to_string())?
1162        .trim();
1163    if text.len() != kovra_core::KEY_LEN * 2 {
1164        return Err(format!(
1165            "master key must be {} raw bytes or {} hex chars (got {} chars)",
1166            kovra_core::KEY_LEN,
1167            kovra_core::KEY_LEN * 2,
1168            text.len()
1169        ));
1170    }
1171    let mut key = [0u8; kovra_core::KEY_LEN];
1172    for (i, pair) in text.as_bytes().chunks(2).enumerate() {
1173        let hi = (pair[0] as char)
1174            .to_digit(16)
1175            .ok_or_else(|| "master key hex is invalid".to_string())?;
1176        let lo = (pair[1] as char)
1177            .to_digit(16)
1178            .ok_or_else(|| "master key hex is invalid".to_string())?;
1179        key[i] = (hi * 16 + lo) as u8;
1180    }
1181    Ok(MasterKey::new(key))
1182}
1183
1184/// The admin shell. Carries no secret and no inline script — it loads the
1185/// vendored Tabulator grid and the first-party `app.js`/`app.css` from the
1186/// embedded `/assets/*` routes, which then drive the governed `/api` (KOV-29).
1187/// The ephemeral session token rides in the page URL (`?session=`) and is read
1188/// by `app.js`; `high`/`inject-only` values are never delivered here (I1/I2).
1189const INDEX_HTML: &str = r##"<!doctype html>
1190<html lang="en" data-theme="dark"><head>
1191<meta charset="utf-8"><title>kovra — local admin</title>
1192<meta name="viewport" content="width=device-width, initial-scale=1">
1193<link rel="icon" href="/assets/kovra-icon.png">
1194<link rel="stylesheet" href="/assets/tabulator/tabulator.min.css">
1195<link rel="stylesheet" href="/assets/app.css">
1196</head><body>
1197<div class="app">
1198  <aside class="side">
1199    <div class="brand">
1200      <div class="logo"><img src="/assets/kovra-icon.png" alt="kovra"></div>
1201      <div><div class="name">ko<span class="v">v</span>ra</div><div class="tag">local secrets</div></div>
1202    </div>
1203    <nav class="nav">
1204      <a class="on" href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 4 5v6c0 5 3.4 8.5 8 11 4.6-2.5 8-6 8-11V5l-8-3Z"/></svg>Secrets</a>
1205    </nav>
1206    <div class="spacer"></div>
1207    <div class="vault"><span class="dot"></span><div><div class="who">local vault</div><div class="sub">loopback only</div></div></div>
1208  </aside>
1209  <div class="main">
1210    <div class="top">
1211      <div class="search">
1212        <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>
1213        <input id="search" type="search" placeholder="Search secrets, coordinates, projects…" autocomplete="off" spellcheck="false">
1214      </div>
1215      <span class="looppill"><span class="d"></span>loopback</span>
1216      <button class="iconbtn" id="refresh" title="Refresh">
1217        <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>
1218      </button>
1219      <button class="iconbtn" id="theme" title="Toggle theme">
1220        <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>
1221      </button>
1222    </div>
1223    <div class="content">
1224      <div class="head">
1225        <div><h1>Secrets</h1><div class="sub"><span id="status">loading…</span> · governed by sensitivity · loopback only</div></div>
1226        <div class="right">
1227          <div class="seg">
1228            <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>
1229            <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>
1230            <button id="view-projects"><svg 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>Projects</button>
1231          </div>
1232          <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>
1233        </div>
1234      </div>
1235      <div class="stats">
1236        <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>
1237        <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>
1238        <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>
1239        <div class="stat"><div class="n" id="stat-ref">—</div><div class="l"><span class="d" style="background:var(--med)"></span>references</div></div>
1240      </div>
1241      <div class="project-bar" id="project-bar" hidden></div>
1242      <div class="card"><div id="grid"></div></div>
1243    </div>
1244  </div>
1245</div>
1246
1247<div class="scrim" id="scrim"></div>
1248<aside class="drawer" id="drawer">
1249  <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>
1250  <div class="db" id="reveal-body"></div>
1251</aside>
1252
1253<dialog id="form">
1254  <form id="form-el">
1255    <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>
1256    <div class="mb" id="form-body"></div>
1257    <div class="mf">
1258      <button type="button" id="form-cancel-2" class="btn">Cancel</button>
1259      <button type="submit" id="form-submit" class="btn primary">Save</button>
1260    </div>
1261  </form>
1262</dialog>
1263<div id="toasts" aria-live="polite"></div>
1264<script src="/assets/tabulator/tabulator.min.js"></script>
1265<script src="/assets/app.js"></script>
1266</body></html>"##;
1267
1268#[cfg(test)]
1269mod tests {
1270    use super::*;
1271    use axum::body::Body;
1272    use axum::http::Request;
1273    use kovra_core::MockConfirmer;
1274    use tower::ServiceExt; // oneshot
1275
1276    const KEY: [u8; kovra_core::KEY_LEN] = [0x33; kovra_core::KEY_LEN];
1277
1278    /// State whose per-action broker (KOV-30) always returns `outcome` — lets a
1279    /// test assert both the gated (denied/timeout) and the ungated (approved)
1280    /// paths deterministically, without touching biometrics.
1281    fn state_with_confirmer(outcome: ConfirmOutcome) -> (AppState, tempfile::TempDir) {
1282        let dir = tempfile::tempdir().unwrap();
1283        // The registry layout is created on open.
1284        Registry::open(dir.path()).unwrap();
1285        let state = AppState::new(
1286            dir.path().to_path_buf(),
1287            MasterKey::new(KEY),
1288            Arc::new(MockConfirmer::always(outcome)),
1289        );
1290        (state, dir)
1291    }
1292
1293    /// Default test state: confirmations auto-approve, so the pre-existing
1294    /// non-gating tests (reveal/list/create/generate/crud) behave as before.
1295    fn temp_state() -> (AppState, tempfile::TempDir) {
1296        state_with_confirmer(ConfirmOutcome::Approved)
1297    }
1298
1299    fn put_record(state: &AppState, record: &SecretRecord) {
1300        let registry = state.registry().unwrap();
1301        let coord = Coordinate::from_str(&format!("secret:{}", record.canonical_path())).unwrap();
1302        write(&registry.global_dir(), &coord, record, state.key()).unwrap();
1303    }
1304
1305    fn read_back(state: &AppState, coord: &str) -> Option<SecretRecord> {
1306        let c = Coordinate::from_str(&format!("secret:{coord}")).unwrap();
1307        store::read_record(&state.registry().unwrap().global_dir(), &c, state.key()).unwrap()
1308    }
1309
1310    fn api_patch(body: &str, session: &str) -> Request<Body> {
1311        Request::builder()
1312            .method("PATCH")
1313            .uri("/api/secret")
1314            .header(header::HOST, "127.0.0.1:8731")
1315            .header(SESSION_HEADER, session)
1316            .header(header::CONTENT_TYPE, "application/json")
1317            .body(Body::from(body.to_string()))
1318            .unwrap()
1319    }
1320
1321    fn api_delete(coord: &str, session: &str) -> Request<Body> {
1322        Request::builder()
1323            .method("DELETE")
1324            .uri(format!("/api/secret?coord={coord}"))
1325            .header(header::HOST, "127.0.0.1:8731")
1326            .header(SESSION_HEADER, session)
1327            .body(Body::empty())
1328            .unwrap()
1329    }
1330
1331    fn literal(env: &str, key: &str, value: &str, sens: Sensitivity) -> SecretRecord {
1332        SecretRecord::Literal {
1333            value: SecretValue::from(value),
1334            sensitivity: sens,
1335            revealable: false,
1336            environment: env.to_string(),
1337            component: "app".to_string(),
1338            key: key.to_string(),
1339            description: None,
1340            created: "2026-06-01T00:00:00Z".to_string(),
1341            updated: "2026-06-01T00:00:00Z".to_string(),
1342        }
1343    }
1344
1345    async fn body_json(resp: Response) -> Value {
1346        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1347            .await
1348            .unwrap();
1349        serde_json::from_slice(&bytes).unwrap_or(Value::Null)
1350    }
1351
1352    fn api_get(uri: &str, session: &str) -> Request<Body> {
1353        Request::builder()
1354            .method("GET")
1355            .uri(uri)
1356            .header(header::HOST, "127.0.0.1:8731")
1357            .header(SESSION_HEADER, session)
1358            .body(Body::empty())
1359            .unwrap()
1360    }
1361
1362    // A low/medium literal value is revealed on the explicit fetch.
1363    #[tokio::test]
1364    async fn medium_literal_reveals_value() {
1365        let (state, _d) = temp_state();
1366        put_record(
1367            &state,
1368            &literal("dev", "url", "postgres://x", Sensitivity::Medium),
1369        );
1370        let app = build_app(state.clone());
1371        let resp = app
1372            .oneshot(api_get(
1373                "/api/reveal?coord=dev/app/url",
1374                state.session_token(),
1375            ))
1376            .await
1377            .unwrap();
1378        assert_eq!(resp.status(), StatusCode::OK);
1379        let j = body_json(resp).await;
1380        assert_eq!(j["value"], "postgres://x");
1381    }
1382
1383    // I1 — a high literal is never returned as a value; masked + fingerprint only.
1384    #[tokio::test]
1385    async fn high_literal_is_masked_never_value() {
1386        let (state, _d) = temp_state();
1387        put_record(
1388            &state,
1389            &literal("dev", "key", "TOP-SECRET-HIGH", Sensitivity::High),
1390        );
1391        let app = build_app(state.clone());
1392        let resp = app
1393            .oneshot(api_get(
1394                "/api/reveal?coord=dev/app/key",
1395                state.session_token(),
1396            ))
1397            .await
1398            .unwrap();
1399        let j = body_json(resp).await;
1400        assert_eq!(j["masked"], json!(true));
1401        assert!(j.get("value").is_none(), "high must not return a value");
1402        assert!(j["fingerprint"].is_string());
1403        // Defensive: the plaintext appears nowhere in the response.
1404        assert!(
1405            !serde_json::to_string(&j)
1406                .unwrap()
1407                .contains("TOP-SECRET-HIGH")
1408        );
1409    }
1410
1411    // I2 — an inject-only literal returns metadata only, never the value.
1412    #[tokio::test]
1413    async fn inject_only_returns_metadata_only() {
1414        let (state, _d) = temp_state();
1415        put_record(
1416            &state,
1417            &literal("dev", "tok", "INJECT-ONLY-VAL", Sensitivity::InjectOnly),
1418        );
1419        let app = build_app(state.clone());
1420        let resp = app
1421            .oneshot(api_get(
1422                "/api/reveal?coord=dev/app/tok",
1423                state.session_token(),
1424            ))
1425            .await
1426            .unwrap();
1427        let j = body_json(resp).await;
1428        assert_eq!(j["inject_only"], json!(true));
1429        assert!(j.get("value").is_none());
1430        assert!(
1431            !serde_json::to_string(&j)
1432                .unwrap()
1433                .contains("INJECT-ONLY-VAL")
1434        );
1435    }
1436
1437    // A reference reveals only the pointer, never a value (I8 at the surface).
1438    #[tokio::test]
1439    async fn reference_reveals_pointer_only() {
1440        let (state, _d) = temp_state();
1441        put_record(
1442            &state,
1443            &SecretRecord::Reference {
1444                reference: "azure-kv://corp-kv/api".to_string(),
1445                sensitivity: Sensitivity::High,
1446                revealable: false,
1447                environment: "dev".to_string(),
1448                component: "app".to_string(),
1449                key: "api".to_string(),
1450                description: None,
1451                created: "2026-06-01T00:00:00Z".to_string(),
1452                updated: "2026-06-01T00:00:00Z".to_string(),
1453            },
1454        );
1455        let app = build_app(state.clone());
1456        let resp = app
1457            .oneshot(api_get(
1458                "/api/reveal?coord=dev/app/api",
1459                state.session_token(),
1460            ))
1461            .await
1462            .unwrap();
1463        let j = body_json(resp).await;
1464        assert_eq!(j["kind"], "reference");
1465        assert_eq!(j["pointer"], "azure-kv://corp-kv/api");
1466        assert!(j.get("value").is_none());
1467    }
1468
1469    // The inventory lists metadata and never a value.
1470    #[tokio::test]
1471    async fn listing_is_metadata_only() {
1472        let (state, _d) = temp_state();
1473        put_record(
1474            &state,
1475            &literal("dev", "url", "secret-listing-value", Sensitivity::Medium),
1476        );
1477        let app = build_app(state.clone());
1478        let resp = app
1479            .oneshot(api_get("/api/secrets", state.session_token()))
1480            .await
1481            .unwrap();
1482        let j = body_json(resp).await;
1483        let txt = serde_json::to_string(&j).unwrap();
1484        assert!(txt.contains("dev/app/url"));
1485        assert!(
1486            !txt.contains("secret-listing-value"),
1487            "listing must not carry values"
1488        );
1489    }
1490
1491    // The session token is required on /api.
1492    #[tokio::test]
1493    async fn api_requires_session_token() {
1494        let (state, _d) = temp_state();
1495        let app = build_app(state.clone());
1496        let resp = app
1497            .oneshot(api_get("/api/secrets", "wrong-token"))
1498            .await
1499            .unwrap();
1500        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1501    }
1502
1503    // I10 — a non-loopback Host is rejected (anti DNS-rebinding).
1504    #[tokio::test]
1505    async fn non_loopback_host_is_rejected() {
1506        let (state, _d) = temp_state();
1507        let app = build_app(state.clone());
1508        let req = Request::builder()
1509            .method("GET")
1510            .uri("/api/secrets")
1511            .header(header::HOST, "evil.example.com")
1512            .header(SESSION_HEADER, state.session_token())
1513            .body(Body::empty())
1514            .unwrap();
1515        let resp = app.oneshot(req).await.unwrap();
1516        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1517    }
1518
1519    // A cross-origin request is rejected even with a valid session + loopback Host.
1520    #[tokio::test]
1521    async fn cross_origin_is_rejected() {
1522        let (state, _d) = temp_state();
1523        let app = build_app(state.clone());
1524        let req = Request::builder()
1525            .method("GET")
1526            .uri("/api/secrets")
1527            .header(header::HOST, "127.0.0.1:8731")
1528            .header(header::ORIGIN, "http://evil.example.com")
1529            .header(SESSION_HEADER, state.session_token())
1530            .body(Body::empty())
1531            .unwrap();
1532        let resp = app.oneshot(req).await.unwrap();
1533        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1534    }
1535
1536    // CRUD round-trip: create → reveal → delete.
1537    #[tokio::test]
1538    async fn crud_round_trip() {
1539        let (state, _d) = temp_state();
1540        let app = build_app(state.clone());
1541        // create
1542        let body = json!({"coord":"dev/app/new","value":"v1","sensitivity":"medium"}).to_string();
1543        let req = Request::builder()
1544            .method("POST")
1545            .uri("/api/secret")
1546            .header(header::HOST, "127.0.0.1:8731")
1547            .header(SESSION_HEADER, state.session_token())
1548            .header(header::CONTENT_TYPE, "application/json")
1549            .body(Body::from(body))
1550            .unwrap();
1551        let resp = app.clone().oneshot(req).await.unwrap();
1552        assert_eq!(resp.status(), StatusCode::OK, "create failed");
1553        // reveal
1554        let resp = build_app(state.clone())
1555            .oneshot(api_get(
1556                "/api/reveal?coord=dev/app/new",
1557                state.session_token(),
1558            ))
1559            .await
1560            .unwrap();
1561        assert_eq!(body_json(resp).await["value"], "v1");
1562        // delete
1563        let req = Request::builder()
1564            .method("DELETE")
1565            .uri("/api/secret?coord=dev/app/new")
1566            .header(header::HOST, "127.0.0.1:8731")
1567            .header(SESSION_HEADER, state.session_token())
1568            .body(Body::empty())
1569            .unwrap();
1570        let resp = build_app(state.clone()).oneshot(req).await.unwrap();
1571        assert_eq!(resp.status(), StatusCode::OK);
1572    }
1573
1574    // KOV-30 (I5/I16) — lowering a CRITICAL secret from the UI is gated: a denied
1575    // confirmation is refused (403) and the record keeps its sensitivity.
1576    #[tokio::test]
1577    async fn downgrade_of_high_denied_leaves_record_unchanged() {
1578        let (state, _d) = state_with_confirmer(ConfirmOutcome::Denied);
1579        put_record(&state, &literal("dev", "key", "v", Sensitivity::High));
1580        let body = json!({"coord":"dev/app/key","sensitivity":"low"}).to_string();
1581        let resp = build_app(state.clone())
1582            .oneshot(api_patch(&body, state.session_token()))
1583            .await
1584            .unwrap();
1585        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1586        assert_eq!(
1587            read_back(&state, "dev/app/key").unwrap().sensitivity(),
1588            Sensitivity::High,
1589            "denied downgrade must not lower sensitivity"
1590        );
1591    }
1592
1593    // KOV-30 — an approved confirmation applies the critical downgrade.
1594    #[tokio::test]
1595    async fn downgrade_of_high_approved_lowers_sensitivity() {
1596        let (state, _d) = state_with_confirmer(ConfirmOutcome::Approved);
1597        put_record(&state, &literal("dev", "key", "v", Sensitivity::High));
1598        let body = json!({"coord":"dev/app/key","sensitivity":"low"}).to_string();
1599        let resp = build_app(state.clone())
1600            .oneshot(api_patch(&body, state.session_token()))
1601            .await
1602            .unwrap();
1603        assert_eq!(resp.status(), StatusCode::OK);
1604        assert_eq!(
1605            read_back(&state, "dev/app/key").unwrap().sensitivity(),
1606            Sensitivity::Low
1607        );
1608    }
1609
1610    // KOV-30 — a NON-critical downgrade (medium→low) is not gated; it applies
1611    // even with a denying broker (downgrade_requires_confirmation = high|inject).
1612    #[tokio::test]
1613    async fn noncritical_downgrade_is_not_gated() {
1614        let (state, _d) = state_with_confirmer(ConfirmOutcome::Denied);
1615        put_record(&state, &literal("dev", "url", "v", Sensitivity::Medium));
1616        let body = json!({"coord":"dev/app/url","sensitivity":"low"}).to_string();
1617        let resp = build_app(state.clone())
1618            .oneshot(api_patch(&body, state.session_token()))
1619            .await
1620            .unwrap();
1621        assert_eq!(resp.status(), StatusCode::OK);
1622        assert_eq!(
1623            read_back(&state, "dev/app/url").unwrap().sensitivity(),
1624            Sensitivity::Low
1625        );
1626    }
1627
1628    // KOV-30 — deleting a CRITICAL secret is broker-gated: a denied confirmation
1629    // keeps the record (403).
1630    #[tokio::test]
1631    async fn delete_of_high_denied_keeps_record() {
1632        let (state, _d) = state_with_confirmer(ConfirmOutcome::Denied);
1633        put_record(&state, &literal("dev", "key", "v", Sensitivity::High));
1634        let resp = build_app(state.clone())
1635            .oneshot(api_delete("dev/app/key", state.session_token()))
1636            .await
1637            .unwrap();
1638        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1639        assert!(
1640            read_back(&state, "dev/app/key").is_some(),
1641            "denied delete of a critical secret must keep the record"
1642        );
1643    }
1644
1645    // KOV-30 — deleting a NON-critical secret is NOT broker-gated: it succeeds
1646    // even with a denying broker (the browser guards it with a type-the-name
1647    // modal instead, not the broker). The reveal tier and the delete tier match.
1648    #[tokio::test]
1649    async fn delete_of_low_is_not_broker_gated() {
1650        let (state, _d) = state_with_confirmer(ConfirmOutcome::Denied);
1651        put_record(&state, &literal("dev", "url", "v", Sensitivity::Low));
1652        let resp = build_app(state.clone())
1653            .oneshot(api_delete("dev/app/url", state.session_token()))
1654            .await
1655            .unwrap();
1656        assert_eq!(resp.status(), StatusCode::OK);
1657        assert!(
1658            read_back(&state, "dev/app/url").is_none(),
1659            "non-critical delete must not consult the broker"
1660        );
1661    }
1662
1663    // L11 (I9): the master key parses from a Docker-secret file as raw bytes or
1664    // hex; a wrong length is rejected. (The container reads this from tmpfs.)
1665    #[test]
1666    fn master_key_parses_raw_and_hex() {
1667        let raw = [0x33u8; kovra_core::KEY_LEN];
1668        let from_raw = parse_master_key(&raw).unwrap();
1669        assert_eq!(from_raw.expose(), &raw);
1670
1671        let hex: String = raw.iter().map(|b| format!("{b:02x}")).collect();
1672        let from_hex = parse_master_key(hex.as_bytes()).unwrap();
1673        assert_eq!(from_hex.expose(), &raw);
1674
1675        // Trailing newline (typical secret file) is tolerated.
1676        let from_hex_nl = parse_master_key(format!("{hex}\n").as_bytes()).unwrap();
1677        assert_eq!(from_hex_nl.expose(), &raw);
1678
1679        // Wrong length and non-hex are rejected.
1680        assert!(parse_master_key(b"too-short").is_err());
1681        assert!(parse_master_key(&[0u8; kovra_core::KEY_LEN - 1]).is_err());
1682        let bad_hex = "z".repeat(kovra_core::KEY_LEN * 2);
1683        assert!(parse_master_key(bad_hex.as_bytes()).is_err());
1684    }
1685
1686    // generate stores a value and never returns it; prod is born high (I5).
1687    #[tokio::test]
1688    async fn generate_never_returns_value_and_prod_is_high() {
1689        let (state, _d) = temp_state();
1690        let body = json!({"coord":"prod/app/gen","length":24}).to_string();
1691        let req = Request::builder()
1692            .method("POST")
1693            .uri("/api/generate")
1694            .header(header::HOST, "127.0.0.1:8731")
1695            .header(SESSION_HEADER, state.session_token())
1696            .header(header::CONTENT_TYPE, "application/json")
1697            .body(Body::from(body))
1698            .unwrap();
1699        let resp = build_app(state.clone()).oneshot(req).await.unwrap();
1700        let j = body_json(resp).await;
1701        assert_eq!(j["sensitivity"], "high", "prod born high (I5)");
1702        assert!(j.get("value").is_none(), "generate never returns the value");
1703        // And the stored prod value is masked on reveal (I1).
1704        let resp = build_app(state.clone())
1705            .oneshot(api_get(
1706                "/api/reveal?coord=prod/app/gen",
1707                state.session_token(),
1708            ))
1709            .await
1710            .unwrap();
1711        assert_eq!(body_json(resp).await["masked"], json!(true));
1712    }
1713
1714    // ── KOV-29: embedded asset routes + new shell ──────────────────────────
1715
1716    async fn body_text(resp: Response) -> (StatusCode, String, String) {
1717        let status = resp.status();
1718        let ctype = resp
1719            .headers()
1720            .get(header::CONTENT_TYPE)
1721            .and_then(|v| v.to_str().ok())
1722            .unwrap_or_default()
1723            .to_string();
1724        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1725            .await
1726            .unwrap();
1727        (status, ctype, String::from_utf8_lossy(&bytes).into_owned())
1728    }
1729
1730    fn get_loopback(uri: &str, host: &str) -> Request<Body> {
1731        Request::builder()
1732            .method("GET")
1733            .uri(uri)
1734            .header(header::HOST, host)
1735            .body(Body::empty())
1736            .unwrap()
1737    }
1738
1739    // The shell loads the vendored grid + first-party app from `/assets/*` and
1740    // carries no inline application logic (the old inline reveal script is gone).
1741    #[tokio::test]
1742    async fn index_shell_references_assets_and_has_no_inline_logic() {
1743        let (state, _d) = temp_state();
1744        let resp = build_app(state.clone())
1745            .oneshot(get_loopback("/", "127.0.0.1:8731"))
1746            .await
1747            .unwrap();
1748        let (status, _ct, html) = body_text(resp).await;
1749        assert_eq!(status, StatusCode::OK);
1750        assert!(html.contains(r#"src="/assets/tabulator/tabulator.min.js""#));
1751        assert!(html.contains(r#"src="/assets/app.js""#));
1752        assert!(html.contains(r#"<div id="grid">"#));
1753        // No inline app logic in the shell — it must live in the embedded app.js.
1754        assert!(
1755            !html.contains("fetch('/api/secrets'") && !html.contains("/api/reveal?"),
1756            "shell must not embed inline API logic"
1757        );
1758    }
1759
1760    // Each embedded asset is served with the right content type and real content.
1761    #[tokio::test]
1762    async fn embedded_assets_are_served_with_types() {
1763        let (state, _d) = temp_state();
1764        let cases = [
1765            (
1766                "/assets/tabulator/tabulator.min.js",
1767                "javascript",
1768                "Tabulator",
1769            ),
1770            (
1771                "/assets/tabulator/tabulator.min.css",
1772                "text/css",
1773                ".tabulator",
1774            ),
1775            ("/assets/app.js", "javascript", "kovra Web UI v2"),
1776            ("/assets/app.css", "text/css", "kovra Web UI v2"),
1777        ];
1778        for (uri, want_ct, want_body) in cases {
1779            let resp = build_app(state.clone())
1780                .oneshot(get_loopback(uri, "127.0.0.1:8731"))
1781                .await
1782                .unwrap();
1783            let (status, ct, body) = body_text(resp).await;
1784            assert_eq!(status, StatusCode::OK, "{uri}");
1785            assert!(ct.contains(want_ct), "{uri} content-type was `{ct}`");
1786            assert!(body.contains(want_body), "{uri} body missing `{want_body}`");
1787        }
1788    }
1789
1790    // The brand icon + vendored fonts are served as binary assets with the
1791    // right content type and a non-empty body (KOV-29).
1792    #[tokio::test]
1793    async fn embedded_brand_binary_assets_are_served() {
1794        let (state, _d) = temp_state();
1795        let cases = [
1796            ("/assets/kovra-icon.png", "image/png"),
1797            ("/assets/fonts/sora-latin-600-normal.woff2", "font/woff2"),
1798            ("/assets/fonts/inter-latin-400-normal.woff2", "font/woff2"),
1799            ("/assets/fonts/inter-latin-500-normal.woff2", "font/woff2"),
1800            ("/assets/fonts/inter-latin-600-normal.woff2", "font/woff2"),
1801        ];
1802        for (uri, want_ct) in cases {
1803            let resp = build_app(state.clone())
1804                .oneshot(get_loopback(uri, "127.0.0.1:8731"))
1805                .await
1806                .unwrap();
1807            let status = resp.status();
1808            let ct = resp
1809                .headers()
1810                .get(header::CONTENT_TYPE)
1811                .and_then(|v| v.to_str().ok())
1812                .unwrap_or_default()
1813                .to_string();
1814            let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1815                .await
1816                .unwrap();
1817            assert_eq!(status, StatusCode::OK, "{uri}");
1818            assert_eq!(ct, want_ct, "{uri} content-type");
1819            assert!(!bytes.is_empty(), "{uri} body is empty");
1820        }
1821    }
1822
1823    // The shell wires the brand chrome the client depends on: the icon (logo +
1824    // favicon), the theme toggle, the reveal drawer, and the stats strip.
1825    #[tokio::test]
1826    async fn index_shell_has_brand_chrome() {
1827        let (state, _d) = temp_state();
1828        let resp = build_app(state.clone())
1829            .oneshot(get_loopback("/", "127.0.0.1:8731"))
1830            .await
1831            .unwrap();
1832        let (status, _ct, html) = body_text(resp).await;
1833        assert_eq!(status, StatusCode::OK);
1834        assert!(html.contains(r#"href="/assets/kovra-icon.png""#));
1835        assert!(html.contains(r#"id="theme""#));
1836        assert!(html.contains(r#"id="drawer""#));
1837        assert!(html.contains(r#"id="stat-total""#));
1838        // The three grid views, incl. the Projects toggle (KOV-32).
1839        assert!(html.contains(r#"id="view-table""#));
1840        assert!(html.contains(r#"id="view-tree""#));
1841        assert!(html.contains(r#"id="view-projects""#));
1842    }
1843
1844    // Assets carry no secrets, so they need no session token (a `<script src>`
1845    // load cannot attach one) — but they are still loopback-guarded (I10).
1846    #[tokio::test]
1847    async fn assets_need_no_session_but_are_loopback_guarded() {
1848        let (state, _d) = temp_state();
1849        // No session header → still served.
1850        let resp = build_app(state.clone())
1851            .oneshot(get_loopback("/assets/app.js", "127.0.0.1:8731"))
1852            .await
1853            .unwrap();
1854        assert_eq!(resp.status(), StatusCode::OK);
1855        // Non-loopback Host → rejected, like every route.
1856        let resp = build_app(state.clone())
1857            .oneshot(get_loopback("/assets/app.js", "evil.example.com"))
1858            .await
1859            .unwrap();
1860        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1861    }
1862
1863    // Contract the new client depends on: the inventory is metadata-only — it
1864    // carries the coordinate/sensitivity/mode/fingerprint but never a value.
1865    #[tokio::test]
1866    async fn api_secrets_contract_is_metadata_only() {
1867        let (state, _d) = temp_state();
1868        put_record(
1869            &state,
1870            &literal(
1871                "dev",
1872                "url",
1873                "should-not-appear-in-listing",
1874                Sensitivity::Medium,
1875            ),
1876        );
1877        let resp = build_app(state.clone())
1878            .oneshot(api_get("/api/secrets", state.session_token()))
1879            .await
1880            .unwrap();
1881        let j = body_json(resp).await;
1882        let row = &j["secrets"][0];
1883        for k in ["coordinate", "sensitivity", "mode", "fingerprint"] {
1884            assert!(row.get(k).is_some(), "row missing `{k}`");
1885        }
1886        assert!(
1887            row.get("value").is_none(),
1888            "listing must never carry a value"
1889        );
1890        let txt = serde_json::to_string(&j).unwrap();
1891        assert!(!txt.contains("should-not-appear-in-listing"));
1892    }
1893}