Skip to main content

kranz_server/
lib.rs

1#![allow(rustdoc::private_intra_doc_links)]
2
3//! kranz-server — axum REST + WebSocket layer over a repo's mission data
4//! (docs/protocol.md is authoritative for every route and frame shape).
5//!
6//! The read/steer routes NEVER write `events.jsonl` (single-writer rule
7//! §4.3): their only write path is the control inbox
8//! (`POST /api/missions/:id/control` → [`kranz_engine::control::enqueue`]).
9//! Every read handler re-reads from disk on each request — the engine owns
10//! truth and requests are localhost-cheap at human timescales, so there is
11//! no in-memory cache to invalidate.
12//!
13//! Missions created via `POST /api/missions` are HOSTED (M2.5): for those,
14//! this process holds the [`MissionEngine`](kranz_engine::orchestrator) —
15//! and therefore the single-writer lock — in [`MissionHost`], which is the
16//! engine writing `events.jsonl`. See [`host`].
17
18mod error;
19mod hooks;
20mod host;
21mod multi;
22mod read_work;
23mod rest;
24mod tickets;
25mod ws;
26
27pub use error::{ApiError, ApiErrorCode};
28pub use host::{MissionHost, PendingApproval};
29pub use multi::{
30    load_host_config, HostConfig, MultiRepoHost, RepoActivity, RepoConfig, RepoContext,
31    RepoSlackConfig, RepoSummary, SlackChannelRoute,
32};
33
34use axum::body::{Body, HttpBody};
35use axum::extract::{Request, State};
36use axum::http::{header, HeaderName, HeaderValue, Method, StatusCode, Uri};
37use axum::middleware::{self, Next};
38use axum::response::{IntoResponse, Response};
39use axum::routing::{any, get, post};
40use axum::{Json, Router};
41use serde_json::json;
42use std::fmt;
43use std::net::{IpAddr, Ipv4Addr, SocketAddr};
44use std::path::PathBuf;
45use std::sync::Arc;
46use tower_http::cors::{AllowOrigin, CorsLayer};
47use tower_http::services::{ServeDir, ServeFile};
48
49/// Header carrying the per-serve mutation token (docs/protocol.md
50/// "Authority: mutation token").
51pub const TOKEN_HEADER: &str = "x-kranz-token";
52
53/// Validated mutation authority required by every published router and serve
54/// constructor. Keeping the unauthenticated state unrepresentable prevents an
55/// embedder from accidentally exposing money-spending `POST /api/...` routes.
56#[derive(Clone, PartialEq, Eq)]
57pub struct MutationAuthority(String);
58
59impl MutationAuthority {
60    /// Validate a token for transport in [`TOKEN_HEADER`]. Tokens are opaque,
61    /// but must be non-empty visible ASCII with no whitespace or control
62    /// characters so every HTTP client presents the same bytes.
63    pub fn new(token: impl Into<String>) -> Result<Self, InvalidMutationAuthority> {
64        // Bind as `value` (not the more obvious name): generic-secret-assignment
65        // fires on `let <secret-ish> = …` shapes even when the bytes are
66        // caller-supplied and never a literal secret.
67        let value = token.into();
68        if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_graphic()) {
69            return Err(InvalidMutationAuthority);
70        }
71        Ok(Self(value))
72    }
73
74    /// Borrow the token for operator storage or an authenticated client.
75    pub fn as_str(&self) -> &str {
76        &self.0
77    }
78}
79
80impl fmt::Debug for MutationAuthority {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        f.write_str("MutationAuthority([REDACTED])")
83    }
84}
85
86/// Error returned when a mutation token cannot be represented safely in an
87/// HTTP header.
88#[derive(Clone, Copy, Debug, PartialEq, Eq)]
89pub struct InvalidMutationAuthority;
90
91impl fmt::Display for InvalidMutationAuthority {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        f.write_str("mutation authority must be non-empty visible ASCII without whitespace")
94    }
95}
96
97impl std::error::Error for InvalidMutationAuthority {}
98
99/// A fresh mutation token: uuid v4 as simple hex. Exposed so embedding
100/// shells (the CLI, the Tauri app) mint tokens without their own uuid dep.
101pub fn generate_token() -> String {
102    uuid::Uuid::new_v4().simple().to_string()
103}
104
105/// A single dashboard file embedded into a caller's binary.
106#[derive(Clone, Copy, Debug)]
107pub struct EmbeddedFile {
108    pub path: &'static str,
109    pub bytes: &'static [u8],
110    pub content_type: &'static str,
111}
112
113/// Static dashboard source for the catch-all frontend routes.
114pub enum DashboardStatic {
115    Dir(PathBuf),
116    Embedded(&'static [EmbeddedFile]),
117}
118
119/// Shared handler state: the repo root for the read-only routes (all mission
120/// data is re-read from disk per request) plus the hosted-engine registry.
121pub struct ServerState {
122    pub repo_root: PathBuf,
123    /// Shared (`Arc`) so `kranz serve --slack` can hand the SAME registry to
124    /// the Slack bridge: web and Slack are two clients of one set of live
125    /// engines, never two engines fighting over one mission lock.
126    pub host: Arc<MissionHost>,
127    /// Serve bind address threaded into CORS / WS origin checks. `None`
128    /// keeps the test/back-compat wildcard (any loopback host, any port).
129    pub bind_addr: Option<SocketAddr>,
130    /// Whether the serve bind is loopback. Loopback keeps the strict browser
131    /// origin allowlist on the WS upgrade (reads are tokenless there, so the
132    /// Origin check is the guard); off loopback the read token authenticates
133    /// and IP-literal / missing origins are accepted.
134    pub bind_is_loopback: bool,
135}
136
137/// Build a read-only convenience router with a fresh, undisclosed mutation
138/// authority. GETs work normally; every mutation is refused because callers
139/// cannot present that authority. Embedders that need mutations must call
140/// [`router_with_token`] with an explicit [`MutationAuthority`].
141///
142/// When `static_dir` is `Some`, non-`/api` paths are served from it with an
143/// SPA fallback to its `index.html`; otherwise `/` returns a minimal
144/// informational text response.
145pub fn router(repo_root: PathBuf, static_dir: Option<PathBuf>) -> Router {
146    router_with_static(repo_root, static_dir.map(DashboardStatic::Dir))
147}
148
149/// Build the read-only convenience router with either filesystem or embedded
150/// dashboard assets. Use [`router_with_token`] for authenticated mutations.
151pub fn router_with_static(repo_root: PathBuf, static_assets: Option<DashboardStatic>) -> Router {
152    let authority = MutationAuthority::new(generate_token())
153        .expect("generated UUID mutation authority is valid");
154    router_with_token(repo_root, static_assets, authority)
155}
156
157/// Build the full router with mutation authority gating every `POST /api/...`.
158/// CORS allows any localhost/127.0.0.1 port (and Tauri), and GETs stay
159/// tokenless.
160pub fn router_with_token(
161    repo_root: PathBuf,
162    static_assets: Option<DashboardStatic>,
163    authority: MutationAuthority,
164) -> Router {
165    router_with_host(MissionHost::new(repo_root), static_assets, authority)
166}
167
168/// The real router constructor: an explicit [`MissionHost`] (tests inject a
169/// mock agent backend via [`MissionHost::with_backend`]) plus mandatory
170/// mutation authority.
171pub fn router_with_host(
172    host: MissionHost,
173    static_assets: Option<DashboardStatic>,
174    authority: MutationAuthority,
175) -> Router {
176    router_with_shared_host(Arc::new(host), static_assets, authority)
177}
178
179/// [`router_with_host`] over an already-shared registry — the `kranz serve
180/// --slack` path, where the Slack bridge holds a clone of the same host.
181///
182/// Test/back-compat path: CORS allows any localhost/127.0.0.1 port (and
183/// Tauri), and GETs stay tokenless. Prefer
184/// [`router_with_shared_host_and_bind`] when the bind address/port are known.
185pub fn router_with_shared_host(
186    host: Arc<MissionHost>,
187    static_assets: Option<DashboardStatic>,
188    authority: MutationAuthority,
189) -> Router {
190    router_with_shared_host_and_bind(host, static_assets, authority, None, true, false)
191}
192
193/// Router constructor that threads the serve bind port into CORS / WS origin
194/// checks and optionally requires the mutation token on GET + WS upgrade
195/// (`require_read_token`, independent of `bind_is_loopback` — `--read-auth`
196/// can arm it on a loopback bind without relaxing the loopback Host/origin
197/// allowlist).
198///
199/// Port-only back-compat wrapper: assumes the canonical loopback bind IP
200/// (127.0.0.1). Real serving goes through [`router_with_shared_host_and_addr`]
201/// with the actual bound address so origin approval can pin the exact IP.
202pub fn router_with_shared_host_and_bind(
203    host: Arc<MissionHost>,
204    static_assets: Option<DashboardStatic>,
205    authority: MutationAuthority,
206    bind_port: Option<u16>,
207    bind_is_loopback: bool,
208    require_read_token: bool,
209) -> Router {
210    router_with_shared_host_and_addr(
211        host,
212        static_assets,
213        authority,
214        bind_port.map(|port| SocketAddr::from((Ipv4Addr::LOCALHOST, port))),
215        bind_is_loopback,
216        require_read_token,
217    )
218}
219
220/// The full router constructor: the REAL bound address (when known) scopes
221/// CORS / WS origin approval to that exact ip:port plus the dev-server
222/// ports, `bind_is_loopback` selects the strict loopback Host/origin
223/// allowlist vs the LAN one, and `require_read_token` independently arms the
224/// read/WS token gate.
225pub fn router_with_shared_host_and_addr(
226    host: Arc<MissionHost>,
227    static_assets: Option<DashboardStatic>,
228    authority: MutationAuthority,
229    bind_addr: Option<SocketAddr>,
230    bind_is_loopback: bool,
231    require_read_token: bool,
232) -> Router {
233    router_with_multi_repo_host_and_addr(
234        Arc::new(MultiRepoHost::with_host(host)),
235        static_assets,
236        authority,
237        bind_addr,
238        bind_is_loopback,
239        require_read_token,
240    )
241}
242
243/// Build one process router around a static catalog of per-repository hosts.
244/// Each configured id is mounted at `/api/repos/{id}`; the historical
245/// unscoped routes are mounted only when the catalog has an explicit default
246/// or exactly one healthy repository.
247pub fn router_with_multi_repo_host_and_addr(
248    multi_host: Arc<MultiRepoHost>,
249    static_assets: Option<DashboardStatic>,
250    authority: MutationAuthority,
251    bind_addr: Option<SocketAddr>,
252    bind_is_loopback: bool,
253    require_read_token: bool,
254) -> Router {
255    router_with_read_authority_and_addr(
256        multi_host,
257        static_assets,
258        authority,
259        None,
260        bind_addr,
261        bind_is_loopback,
262        require_read_token,
263    )
264}
265
266/// [`router_with_multi_repo_host_and_addr`] plus a distinct READ-ONLY token
267/// (docs/protocol.md "Authority: mutation token"). `read_authority`
268/// authenticates GET/HEAD (and the WS upgrade) wherever the read gate is
269/// armed, but is never accepted on a mutating route — it is the token safe
270/// to hand to dashboards and agents. If absent, empty, or equal to mutation authority,
271/// a distinct read token is generated. Clients obtain it from `/api/read-token`
272/// using either valid token in the header; mutation tokens remain header-only.
273pub fn router_with_read_authority_and_addr(
274    multi_host: Arc<MultiRepoHost>,
275    static_assets: Option<DashboardStatic>,
276    authority: MutationAuthority,
277    read_authority: Option<String>,
278    bind_addr: Option<SocketAddr>,
279    bind_is_loopback: bool,
280    require_read_token: bool,
281) -> Router {
282    let gate = TokenGate {
283        read_authority: read_authority
284            .filter(|read| !read.is_empty() && !token_matches(read, authority.as_str()))
285            .unwrap_or_else(generate_token),
286        authority,
287        require_read_token,
288    };
289    let exchange_gate = gate.clone();
290    let mut repos = Router::new();
291    let catalog = Arc::clone(&multi_host);
292    let catalog_reads = read_work::ReadWork::default();
293    let mut app = Router::new()
294        .route("/api/health", get(rest::health))
295        .route(
296            "/api/repos",
297            get(move || {
298                let catalog = Arc::clone(&catalog);
299                let reads = catalog_reads.clone();
300                async move { reads.run(move || Ok(Json(catalog.summaries()))).await }
301            }),
302        )
303        .route("/api/read-token", get(read_token).with_state(exchange_gate));
304
305    // The unscoped compatibility alias shares capacity with its scoped repo.
306    let mut repo_reads = std::collections::HashMap::new();
307    for context in multi_host.contexts() {
308        let prefix = format!("/api/repos/{}", context.id());
309        match context.host().cloned() {
310            Some(host) => {
311                let reads = read_work::ReadWork::default();
312                repo_reads.insert(context.id().to_string(), reads.clone());
313                repos = repos.nest(
314                    &prefix,
315                    repo_context_router(
316                        context,
317                        host,
318                        bind_addr,
319                        bind_is_loopback,
320                        reads,
321                        gate.clone(),
322                    ),
323                );
324            }
325            None => {
326                // A nested router's fallback registers in the outer *fallback*
327                // router, which the `/api/{*path}` catch-all below always
328                // shadows — an unavailable repository must claim its paths as
329                // explicit routes (which beat the catch-all on the static
330                // `repos/<id>` segments) for the designed 503 to ever fire.
331                let handler = repo_unavailable_handler(&context);
332                app = app
333                    .route(&prefix, any(handler.clone()))
334                    .route(&format!("{prefix}/{{*path}}"), any(handler));
335            }
336        }
337    }
338
339    let mut unavailable_default = None;
340    if let Some(context) = multi_host.compatibility_context() {
341        match context.host().cloned() {
342            Some(host) => {
343                let reads = repo_reads
344                    .entry(context.id().to_string())
345                    .or_default()
346                    .clone();
347                repos = repos.nest(
348                    "/api",
349                    repo_context_router(
350                        context,
351                        host,
352                        bind_addr,
353                        bind_is_loopback,
354                        reads,
355                        gate.clone(),
356                    ),
357                );
358            }
359            // An explicit `defaultRepo` is not health-filtered; the whole
360            // unscoped alias belongs to it, so report its unavailability
361            // below instead of mounting anything.
362            None => unavailable_default = Some(repo_unavailable_handler(&context)),
363        }
364    }
365
366    // API misses must never fall through to the SPA fallback. In particular,
367    // an ambiguous unscoped mutation in multi-repo mode must fail as JSON,
368    // not return `200 index.html` and look successful to an API client. When
369    // the unscoped alias targets an unavailable default repository, misses
370    // report that unavailability (503 + reason) instead of a generic 404.
371    app = match unavailable_default {
372        Some(handler) => app
373            .route("/api", any(handler.clone()))
374            .route("/api/{*path}", any(handler)),
375        None => app
376            .route("/api", any(api_not_found))
377            .route("/api/{*path}", any(api_not_found)),
378    };
379
380    // Catalog, unavailable repositories, and API misses are protected too.
381    // Healthy repositories apply the same gate before adding their two
382    // independently authenticated POST routes.
383    let app = app
384        .layer(middleware::from_fn_with_state(gate, require_mutation_token))
385        .merge(repos);
386
387    let app = match static_assets {
388        Some(DashboardStatic::Dir(dir)) => {
389            let index = dir.join("index.html");
390            app.fallback_service(ServeDir::new(&dir).fallback(ServeFile::new(index)))
391        }
392        Some(DashboardStatic::Embedded(files)) => {
393            app.fallback(move |uri: Uri| async move { embedded_static_response(uri, files) })
394        }
395        None => app.route("/", get(root_info)),
396    };
397
398    // Layer order (outermost last): the CORS layer wraps the Host gate wraps
399    // the JSON gate wraps the token gate, so even rejections carry CORS
400    // headers for approved origins and a non-JSON POST is rejected before the
401    // token is examined.
402    app.layer(middleware::from_fn(require_json_api_posts))
403        .layer(middleware::from_fn_with_state(
404            HostGate { bind_is_loopback },
405            require_host,
406        ))
407        .layer(cors_layer(bind_addr))
408        // Outermost: every response — including gate rejections — carries the
409        // cache policy, so no rejection HTML can poison a browser cache either.
410        .layer(middleware::from_fn(cache_response_headers))
411}
412
413async fn api_not_found() -> impl IntoResponse {
414    (
415        StatusCode::NOT_FOUND,
416        Json(json!({ "error": "API route not found or repository scope required" })),
417    )
418}
419
420/// API answers and the SPA shell must never be cached. A browser that caches
421/// an HTML fallback at an /api URL replays it to `fetch()` long after the
422/// server is fixed (2026-07-19: a stale pre-API-404 serve poisoned the
423/// dashboard behind a heuristic cache entry; only an incognito window
424/// escaped). Hashed /assets/* bundles stay implicitly cacheable; `no-cache`
425/// on the shell revalidates per load rather than forbidding storage.
426async fn cache_response_headers(request: Request, next: Next) -> Response {
427    let is_api = request.uri().path().starts_with("/api");
428    let mut response = next.run(request).await;
429    let cache_control = if is_api {
430        Some("no-store")
431    } else if response
432        .headers()
433        .get(header::CONTENT_TYPE)
434        .and_then(|value| value.to_str().ok())
435        .is_some_and(|content_type| content_type.starts_with("text/html"))
436    {
437        Some("no-cache")
438    } else {
439        None
440    };
441    if let Some(value) = cache_control {
442        response
443            .headers_mut()
444            .insert(header::CACHE_CONTROL, HeaderValue::from_static(value));
445    }
446    response
447}
448
449/// `503 {"error":"repository unavailable", ...}` handler for every path under
450/// an unmounted repository. Returned as a `Clone` closure so one context can
451/// back both the bare-prefix and `{*path}` routes.
452fn repo_unavailable_handler(
453    context: &RepoContext,
454) -> impl Fn() -> std::future::Ready<(StatusCode, Json<serde_json::Value>)> + Clone {
455    let id = context.id().to_string();
456    let reason = context
457        .unavailable_reason()
458        .unwrap_or("repository is unavailable")
459        .to_string();
460    move || {
461        std::future::ready((
462            StatusCode::SERVICE_UNAVAILABLE,
463            Json(json!({
464                "error": "repository unavailable",
465                "repoId": id.clone(),
466                "detail": reason.clone(),
467            })),
468        ))
469    }
470}
471
472fn repo_context_router(
473    context: Arc<RepoContext>,
474    host: Arc<MissionHost>,
475    bind_addr: Option<SocketAddr>,
476    bind_is_loopback: bool,
477    reads: read_work::ReadWork,
478    gate: TokenGate,
479) -> Router {
480    let state = Arc::new(ServerState {
481        repo_root: context.root().to_path_buf(),
482        host,
483        bind_addr,
484        bind_is_loopback,
485    });
486    repo_api_routes(gate)
487        .layer(axum::Extension(reads))
488        .with_state(state)
489}
490
491fn repo_api_routes(gate: TokenGate) -> Router<Arc<ServerState>> {
492    protected_repo_api_routes()
493        .route_layer(middleware::from_fn_with_state(gate, require_mutation_token))
494        .merge(independently_authenticated_hook_routes())
495}
496
497fn protected_repo_api_routes() -> Router<Arc<ServerState>> {
498    let routes = Router::new()
499        .route(
500            "/missions",
501            get(rest::list_missions).post(host::create_mission),
502        )
503        .route("/missions/outcomes", get(rest::mission_outcomes))
504        .route("/escalation-metrics", get(rest::escalation_metrics))
505        .route("/standards-metrics", get(rest::standards_metrics))
506        .route("/cost-per-merged-change", get(rest::cost_per_merged_change))
507        .route("/missions/{id}/state", get(rest::mission_state))
508        .route(
509            "/missions/{id}/review-packet",
510            get(rest::mission_review_packet),
511        )
512        .route("/missions/{id}/standards", get(rest::mission_standards))
513        .route(
514            "/missions/{id}/standards/waiver",
515            post(rest::post_standards_waiver),
516        )
517        .route("/missions/{id}/workspace", get(rest::mission_workspace))
518        .route("/missions/{id}/events", get(rest::mission_events))
519        .route("/missions/{id}/plan", get(rest::mission_plan))
520        .route("/missions/{id}/plan.md", get(rest::mission_plan_md))
521        .route(
522            "/missions/{id}/revision-diff",
523            get(rest::mission_revision_diff),
524        )
525        .route("/missions/{id}/report.md", get(rest::mission_report_md))
526        .route("/missions/{id}/diff-stat", get(rest::mission_diff_stat))
527        .route("/missions/{id}/pr-handoff", get(rest::mission_pr_handoff))
528        .route(
529            "/missions/{id}/pr-handoff/create",
530            post(rest::mission_pr_create),
531        )
532        .route("/missions/{id}/readiness", get(rest::mission_readiness))
533        .route(
534            "/missions/{id}/runs/{run_id}/transcript",
535            get(rest::run_transcript),
536        )
537        .route("/missions/{id}/hook-status", get(rest::mission_hook_status))
538        .route("/missions/{id}/control", post(rest::post_control))
539        .route("/missions/{id}/revise", post(rest::post_revise))
540        .route(
541            "/missions/{id}/revision/approve",
542            post(rest::post_revision_approve),
543        )
544        .route(
545            "/missions/{id}/revision/reject",
546            post(rest::post_revision_reject),
547        )
548        .route(
549            "/missions/{id}/grant/approve",
550            post(rest::post_grant_approve),
551        )
552        .route("/missions/{id}/grant/deny", post(rest::post_grant_deny))
553        .route(
554            "/missions/{id}/permission/answer",
555            post(rest::post_permission_answer),
556        )
557        .route(
558            "/missions/{id}/question/answer",
559            post(rest::post_question_answer),
560        )
561        .route("/missions/{id}/planning/turn", post(host::planning_turn))
562        .route(
563            "/missions/{id}/planning/request-plan",
564            post(host::request_plan),
565        )
566        .route("/missions/{id}/approve", post(host::approve_mission))
567        .route("/missions/{id}/start", post(host::start_mission))
568        .route("/missions/{id}/pending-plan", get(host::pending_plan_route))
569        .route(
570            "/missions/{id}/approve-pending",
571            post(host::approve_pending_route),
572        )
573        .route("/missions/{id}/abandon", post(host::abandon_mission_route))
574        .route("/missions/{id}/release", post(host::release_mission_route))
575        .route("/missions/{id}/delete", post(host::delete_mission_route))
576        .route("/missions/{id}/merge", post(host::merge_mission_route))
577        .route("/missions/{id}/ws", get(ws::ws_handler))
578        .route(
579            "/tickets",
580            get(tickets::list_tickets).post(tickets::create_ticket),
581        )
582        .route("/tickets/{slug}", get(tickets::get_ticket))
583        .route("/tickets/{slug}/draft", post(tickets::draft_ticket))
584        .route("/tickets/{slug}/approve", post(tickets::approve_ticket))
585        .route("/queue", get(host::queue_state_route))
586        .route("/queue/drain", post(host::drain_queue_route));
587    // Exercise future suffix collisions through the real router composition,
588    // without exposing synthetic endpoints in production or a public test API.
589    #[cfg(test)]
590    let routes = routes
591        .route(
592            "/future/hook-status",
593            post(|| async { StatusCode::NO_CONTENT }),
594        )
595        .route(
596            "/future/hooks/github",
597            post(|| async { StatusCode::NO_CONTENT }),
598        );
599    routes
600}
601
602/// Only these POSTs bypass serve-token authentication. Each handler checks
603/// its own authority: GitHub HMAC or a per-run hook capability.
604fn independently_authenticated_hook_routes() -> Router<Arc<ServerState>> {
605    Router::new()
606        .route("/hooks/github", post(hooks::github_hook))
607        .route(
608            "/hook-status",
609            post(rest::post_hook_status).route_layer(axum::extract::DefaultBodyLimit::max(
610                kranz_engine::hook_status::SIGNAL_BODY_MAX_BYTES,
611            )),
612        )
613}
614
615fn embedded_static_response(uri: Uri, files: &'static [EmbeddedFile]) -> Response {
616    let requested = uri.path().trim_start_matches('/');
617    let requested = if requested.is_empty() {
618        "index.html"
619    } else {
620        requested
621    };
622    let file = files
623        .iter()
624        .find(|file| file.path == requested)
625        .or_else(|| files.iter().find(|file| file.path == "index.html"));
626
627    let Some(file) = file else {
628        return StatusCode::NOT_FOUND.into_response();
629    };
630
631    Response::builder()
632        .status(StatusCode::OK)
633        .header(header::CONTENT_TYPE, file.content_type)
634        .body(Body::from(file.bytes))
635        .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
636}
637
638/// CORS for localhost tooling (dashboard dev server, Tauri webview).
639///
640/// `CorsLayer::permissive()` echoed ANY `Origin` back in
641/// `access-control-allow-origin`, so a script on any website could read
642/// mission data from this (unauthenticated, fixed-port) server and POST
643/// `ControlCommand`s into the orchestrator — a drive-by instruction
644/// injection. Only the origins the dashboard can actually run under are
645/// approved instead. That closes the drive-by vector because browsers
646/// enforce CORS per-origin:
647///
648/// - the control POST must be `application/json` (see
649///   [`require_json_api_posts`]), which is never a CORS-"simple" request, so
650///   browsers always send a preflight — an unapproved origin's preflight
651///   comes back without allow headers and the POST is never sent;
652/// - cross-origin reads require the response to carry an
653///   `access-control-allow-origin` approving the reader, which is only
654///   emitted for the origins below.
655///
656/// Non-browser clients (curl, the engine, tests) send no `Origin` header and
657/// pass through untouched — CORS is a browser-enforced mechanism.
658fn cors_layer(bind_addr: Option<SocketAddr>) -> CorsLayer {
659    CorsLayer::new()
660        .allow_origin(AllowOrigin::predicate(
661            move |origin: &HeaderValue, _request_parts| {
662                origin.to_str().is_ok_and(|o| origin_allowed(o, bind_addr))
663            },
664        ))
665        .allow_methods([Method::GET, Method::POST])
666        .allow_headers([header::CONTENT_TYPE, HeaderName::from_static(TOKEN_HEADER)])
667}
668
669/// Trusted origins for CORS and the browser WebSocket upgrade Origin check.
670///
671/// Always: `tauri://localhost` (macOS/Linux Tauri) and
672/// `http://tauri.localhost` (Windows Tauri). Beyond those, an origin is
673/// approved only when its host is LOCAL (the `localhost` name or a loopback
674/// IP literal — DNS names like `localhost.evil.example` fail the IP parse)
675/// AND its port fits the bind scoping below. On loopback binds GETs and the
676/// WS upgrade are tokenless, so this allowlist is what stands between an
677/// unrelated local page and mission state.
678///
679/// Scoping against the bound address (`Some(bind)`):
680/// - the dev-server ports (vite :5173, Tauri devUrl :1420) are approved for
681///   canonical localhost only (`localhost`, `127.0.0.1`, or `::1`), never
682///   another address in 127/8 that a co-resident process can claim;
683/// - the bind port is approved only for the SAME-ORIGIN page: an IP host
684///   must equal the bound IP (any loopback IP when the bind is
685///   unspecified/0.0.0.0, which listens on them all), and the `localhost`
686///   name only when the bind IP is one localhost resolves to (127.0.0.1,
687///   ::1, or unspecified). Pinning the IP — not just the port — matters: on
688///   Linux an unprivileged co-resident process can bind ANOTHER loopback
689///   address (127.0.0.2) on kranz's own port and serve a hostile page; a
690///   port-only rule would hand that page tokenless cross-origin reads.
691///
692/// `bind_addr: None` (back-compat test wrappers only) keeps the old
693/// any-loopback-host, any-port wildcard.
694pub(crate) fn origin_allowed(origin: &str, bind_addr: Option<SocketAddr>) -> bool {
695    if origin == "tauri://localhost" || origin == "http://tauri.localhost" {
696        return true;
697    }
698    // Dev-server origins that must keep working on every bind: the vite
699    // proxy (5173) and Tauri's devUrl (1420). Restrict these privileged ports
700    // to canonical localhost; every other 127/8 address is independently
701    // bindable by an unprivileged co-resident process.
702    const DEV_PORTS: [u16; 2] = [5173, 1420];
703    let Some(authority) = origin.strip_prefix("http://") else {
704        return false;
705    };
706    let Some((host, port)) = split_host_port(authority) else {
707        return false;
708    };
709    let host_ip = host.parse::<std::net::IpAddr>().ok();
710    let host_local = host == "localhost" || host_ip.is_some_and(|ip| ip.is_loopback());
711    if !host_local {
712        return false;
713    }
714    let Some(bind) = bind_addr else {
715        return true; // back-compat wildcard
716    };
717    if DEV_PORTS.contains(&port) {
718        return host == "localhost"
719            || host_ip == Some(std::net::IpAddr::V4(Ipv4Addr::LOCALHOST))
720            || host_ip == Some(std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST));
721    }
722    if port != bind.port() {
723        return false;
724    }
725    match host_ip {
726        Some(ip) => ip == bind.ip() || (bind.ip().is_unspecified() && ip.is_loopback()),
727        // The `localhost` NAME resolves to 127.0.0.1 / ::1 — approve it only
728        // when the server actually answers there.
729        None => {
730            bind.ip().is_unspecified()
731                || bind.ip() == std::net::IpAddr::V4(Ipv4Addr::LOCALHOST)
732                || bind.ip() == std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)
733        }
734    }
735}
736
737/// Split an origin authority — `host[:port]` or `[v6][:port]` — into
738/// hostname and port (default 80). `None` on malformed brackets or ports.
739/// An unbracketed hostname containing `:` is a bare IPv6 authority, which
740/// cannot carry a port.
741fn split_host_port(authority: &str) -> Option<(&str, u16)> {
742    if let Some(rest) = authority.strip_prefix('[') {
743        let (addr, tail) = rest.split_once(']')?;
744        let port = if tail.is_empty() {
745            80
746        } else {
747            tail.strip_prefix(':')?.parse().ok()?
748        };
749        return Some((addr, port));
750    }
751    match authority.rsplit_once(':') {
752        Some((host, _)) if host.contains(':') => Some((authority, 80)),
753        Some((host, port)) => Some((host, port.parse().ok()?)),
754        None => Some((authority, 80)),
755    }
756}
757
758/// Origin policy for the WebSocket upgrade.
759///
760/// Browsers send the page origin on SAME-origin WS handshakes too, so a
761/// dashboard served off a LAN/tailnet bind presents `http://<ip>:<port>` —
762/// which [`origin_allowed`] rejects. Off loopback the read token already
763/// gates the upgrade, so the Origin check only needs to keep DNS-named
764/// (rebinding) pages out: accept any origin whose host parses as an IP
765/// literal, and accept a MISSING Origin (native, non-browser clients). On
766/// loopback binds reads are tokenless and the strict browser allowlist
767/// stays the guard — missing Origin remains rejected there.
768pub(crate) fn ws_origin_allowed(
769    origin: Option<&str>,
770    bind_addr: Option<SocketAddr>,
771    bind_is_loopback: bool,
772) -> bool {
773    match origin {
774        None => !bind_is_loopback,
775        Some(origin) => {
776            origin_allowed(origin, bind_addr) || (!bind_is_loopback && origin_host_is_ip(origin))
777        }
778    }
779}
780
781/// `http://<ip>[:port]` (v4 or bracketed v6) — the origin shape a browser
782/// sends for a page loaded straight off a LAN/tailnet serve. DNS-named
783/// origins fail the IP parse, keeping rebinding pages out.
784fn origin_host_is_ip(origin: &str) -> bool {
785    origin
786        .strip_prefix("http://")
787        .and_then(split_host_port)
788        .is_some_and(|(host, _)| host.parse::<std::net::IpAddr>().is_ok())
789}
790
791/// Host gate: browsers always send `Host`, so DNS rebinding attempts arrive
792/// as the attacker-controlled hostname and are rejected before tokenless
793/// reads can return mission state or transcripts.
794///
795/// Path-only in-process requests used by `tower::ServiceExt::oneshot` carry no
796/// Host header and are allowed; real network HTTP/1.1 requests present Host.
797async fn require_host(State(gate): State<HostGate>, request: Request, next: Next) -> Response {
798    if let Some(host) = request.headers().get(header::HOST) {
799        if !host
800            .to_str()
801            .is_ok_and(|h| host_allowed(h, gate.bind_is_loopback))
802        {
803            return (
804                StatusCode::FORBIDDEN,
805                Json(json!({ "error": "invalid host" })),
806            )
807                .into_response();
808        }
809    }
810    next.run(request).await
811}
812
813/// Trusted HTTP Host values.
814///
815/// Always: `localhost` (optional `:<u16>`) and any LOOPBACK IP literal —
816/// `127.0.0.1`, other 127/8 addresses, `::1` (bare or bracketed), each with
817/// an optional port. Loopback literals cannot be planted by DNS rebinding
818/// (browsers send the attacker's hostname, not the IP it resolves to), and
819/// operators legitimately bind e.g. `--host 127.0.0.2`.
820///
821/// When `bind_is_loopback` is false (LAN / tailnet serve): any Host whose
822/// hostname parses as an IP is accepted — the operator intentionally
823/// exposed non-loopback, and the mutation token (including on GET/WS)
824/// is what authenticates. Hostname DNS-rebinding still fails the IP
825/// parse; browsers sending `evil.example` are rejected.
826fn host_allowed(host: &str, bind_is_loopback: bool) -> bool {
827    let host = host.trim().to_ascii_lowercase();
828    if host_is_loopback(&host) {
829        return true;
830    }
831    if bind_is_loopback {
832        return false;
833    }
834    host_ip(&host).is_some()
835}
836
837fn host_is_loopback(host: &str) -> bool {
838    if host == "localhost" {
839        return true;
840    }
841    if let Some(port) = host.strip_prefix("localhost:") {
842        return port.parse::<u16>().is_ok();
843    }
844    host_ip(host).is_some_and(|ip| ip.is_loopback())
845}
846
847/// Parse the hostname of a `Host` header value — bare IP (v4, or unbracketed
848/// v6, which may itself contain `:` and carries no port), `v4:port`, or
849/// `[v6]` with optional `:port` — as an IP address. `None` for DNS names,
850/// malformed brackets, and invalid ports.
851fn host_ip(host: &str) -> Option<std::net::IpAddr> {
852    if let Ok(ip) = host.parse::<std::net::IpAddr>() {
853        return Some(ip);
854    }
855    if let Some(rest) = host.strip_prefix('[') {
856        let (addr, tail) = rest.split_once(']')?;
857        if !(tail.is_empty()
858            || tail
859                .strip_prefix(':')
860                .is_some_and(|p| p.parse::<u16>().is_ok()))
861        {
862            return None;
863        }
864        return addr.parse().ok();
865    }
866    let (addr, port) = host.rsplit_once(':')?;
867    if port.parse::<u16>().is_err() {
868        return None;
869    }
870    addr.parse().ok()
871}
872
873/// Reject any `POST /api/...` with a non-empty body whose content-type is
874/// not `application/json`.
875///
876/// The control handler parses raw bytes, so without this gate a drive-by
877/// page could bypass the CORS preflight entirely: `text/plain` (or
878/// form-encoded) POSTs are CORS-"simple" and browsers send them cross-origin
879/// WITHOUT a preflight — the attacker cannot read the response, but the
880/// ControlCommand side effect would already have happened. Requiring JSON
881/// forces every browser POST into the preflighted path that
882/// [`cors_layer`] guards.
883///
884/// A body already known to be empty is exempt. Unknown-length streams must
885/// declare JSON even if they later end without data. This uses the body's
886/// end-of-stream signal without buffering or consuming a request; missing
887/// Content-Length (including chunked requests) is not proof of emptiness.
888async fn require_json_api_posts(request: Request, next: Next) -> Response {
889    if request.method() == Method::POST && request.uri().path().starts_with("/api/") {
890        let is_empty_body = request.body().is_end_stream();
891        let is_json = request
892            .headers()
893            .get(header::CONTENT_TYPE)
894            .and_then(|value| value.to_str().ok())
895            .and_then(|value| value.split(';').next())
896            .is_some_and(|mime| mime.trim().eq_ignore_ascii_case("application/json"));
897        if !is_empty_body && !is_json {
898            return (
899                StatusCode::UNSUPPORTED_MEDIA_TYPE,
900                Json(json!({ "error": "POST bodies must be application/json" })),
901            )
902                .into_response();
903        }
904    }
905    next.run(request).await
906}
907
908/// Token gate state: mandatory mutation authority plus whether non-loopback
909/// binds also require it on GET / WS upgrade, and the distinct read-only token
910/// accepted on gated reads only.
911#[derive(Clone)]
912struct TokenGate {
913    authority: MutationAuthority,
914    read_authority: String,
915    require_read_token: bool,
916}
917
918/// Host gate state: whether the serve bind is loopback (strict Host) or
919/// LAN/tailnet (accept any Host that parses as an IP).
920#[derive(Clone)]
921struct HostGate {
922    bind_is_loopback: bool,
923}
924
925/// Require mutation authority on protected POST routes. Gated GET/HEAD reads
926/// accept either token in the header, but only read authority in a query.
927/// Browser WebSockets obtain that read authority through [`read_token`];
928/// rejecting mutation tokens in URLs also protects non-WebSocket reads.
929/// Hook routes apply their own authentication at registration instead.
930/// Layer this middleware only on protected API routes, never the SPA/static
931/// fallback. Nested routers may already have stripped their `/api` prefix.
932async fn require_mutation_token(
933    State(gate): State<TokenGate>,
934    request: Request,
935    next: Next,
936) -> Response {
937    let expected = gate.authority.as_str();
938    let path = request.uri().path();
939    let is_health = path == "/api/health";
940    let is_read = request.method() == Method::GET || request.method() == Method::HEAD;
941    // Human review must not be reachable by a sandboxed validator through
942    // otherwise anonymous loopback reads. Reuse the existing read capability.
943    let human_review = path.ends_with("/review-packet")
944        || (path.ends_with("/report.md")
945            && axum::extract::Query::<rest::ReportQuery>::try_from_uri(request.uri())
946                .map_or(true, |query| query.review));
947    let needs_auth = !is_health
948        && (request.method() == Method::POST
949            || ((gate.require_read_token || human_review) && is_read));
950    if needs_auth {
951        let read_ok = |presented: &str| is_read && token_matches(presented, &gate.read_authority);
952        let header_ok = request
953            .headers()
954            .get(TOKEN_HEADER)
955            .and_then(|value| value.to_str().ok())
956            .is_some_and(|presented| token_matches(presented, expected) || read_ok(presented));
957        let query_ok = gate.require_read_token
958            && !human_review
959            && is_read
960            && request
961                .uri()
962                .query()
963                .map(|q| {
964                    q.split('&').any(|pair| {
965                        let mut parts = pair.splitn(2, '=');
966                        matches!(parts.next(), Some("token"))
967                            && parts.next().is_some_and(|v| {
968                                let decoded = percent_decode_token(v);
969                                read_ok(&decoded)
970                            })
971                    })
972                })
973                .unwrap_or(false);
974        if !header_ok && !query_ok {
975            return (
976                StatusCode::UNAUTHORIZED,
977                Json(json!({ "error": "missing or invalid token" })),
978            )
979                .into_response();
980        }
981    }
982    next.run(request).await
983}
984
985/// Exchange either header credential for read-only authority. This handler
986/// always checks its own header, including on otherwise tokenless loopback
987/// reads. Query credentials never grant access to the exchange response.
988async fn read_token(State(gate): State<TokenGate>, request: Request) -> Response {
989    let valid = request
990        .headers()
991        .get(TOKEN_HEADER)
992        .and_then(|value| value.to_str().ok())
993        .is_some_and(|presented| {
994            token_matches(presented, gate.authority.as_str())
995                || token_matches(presented, &gate.read_authority)
996        });
997    if !valid {
998        return (
999            StatusCode::UNAUTHORIZED,
1000            Json(json!({ "error": "missing or invalid token" })),
1001        )
1002            .into_response();
1003    }
1004    let value = gate.read_authority;
1005    Json(json!({ "token": value })).into_response()
1006}
1007
1008/// Constant-time token equality: off-loopback binds expose the token gate
1009/// to remote timing probes, and a short-circuiting `==` leaks how many
1010/// leading bytes matched. Only the length is observable (standard for
1011/// `ct_eq`, and unavoidable), which reveals nothing useful about a
1012/// fixed-length uuid-hex token.
1013fn token_matches(presented: &str, expected: &str) -> bool {
1014    use subtle::ConstantTimeEq;
1015    presented.as_bytes().ct_eq(expected.as_bytes()).into()
1016}
1017
1018/// Minimal percent-decode for `?token=` values (`%XX` only — tokens are
1019/// uuid hex so this only needs to round-trip `encodeURIComponent`).
1020fn percent_decode_token(raw: &str) -> String {
1021    let bytes = raw.as_bytes();
1022    let mut out = Vec::with_capacity(bytes.len());
1023    let mut i = 0;
1024    while i < bytes.len() {
1025        if bytes[i] == b'%' && i + 2 < bytes.len() {
1026            if let (Some(hi), Some(lo)) = (
1027                (bytes[i + 1] as char).to_digit(16),
1028                (bytes[i + 2] as char).to_digit(16),
1029            ) {
1030                out.push((hi * 16 + lo) as u8);
1031                i += 3;
1032                continue;
1033            }
1034        }
1035        if bytes[i] == b'+' {
1036            out.push(b' ');
1037        } else {
1038            out.push(bytes[i]);
1039        }
1040        i += 1;
1041    }
1042    String::from_utf8_lossy(&out).into_owned()
1043}
1044
1045/// `GET /` when no dashboard bundle is configured.
1046async fn root_info() -> &'static str {
1047    "kranz server is running (no dashboard bundle configured).\n\
1048     REST + WebSocket API under /api — see docs/protocol.md.\n"
1049}
1050
1051/// Bind `127.0.0.1:<port>` and serve the router until the process exits.
1052/// `authority` gates every `POST /api/...`.
1053pub async fn serve(
1054    repo_root: PathBuf,
1055    port: u16,
1056    static_dir: Option<PathBuf>,
1057    authority: MutationAuthority,
1058) -> anyhow::Result<()> {
1059    serve_with_static(
1060        repo_root,
1061        port,
1062        static_dir.map(DashboardStatic::Dir),
1063        authority,
1064    )
1065    .await
1066}
1067
1068/// Bind `127.0.0.1:<port>` and serve the router until the process exits.
1069pub async fn serve_with_static(
1070    repo_root: PathBuf,
1071    port: u16,
1072    static_assets: Option<DashboardStatic>,
1073    authority: MutationAuthority,
1074) -> anyhow::Result<()> {
1075    serve_with_shared_host(
1076        Arc::new(MissionHost::new(repo_root)),
1077        IpAddr::V4(Ipv4Addr::LOCALHOST),
1078        port,
1079        static_assets,
1080        authority,
1081    )
1082    .await
1083}
1084
1085/// [`serve_with_static`] over an already-shared registry (see
1086/// [`router_with_shared_host`]).
1087/// `bind` widens reachability beyond loopback (e.g. for the glasses app on
1088/// the same LAN / tailnet). Every POST stays mutation-token-gated; when
1089/// `bind` is not loopback, GETs and WS upgrades require the token too. The
1090/// CLI prints a loud warning for non-loopback binds.
1091pub async fn serve_with_shared_host(
1092    host: Arc<MissionHost>,
1093    bind: IpAddr,
1094    port: u16,
1095    static_assets: Option<DashboardStatic>,
1096    authority: MutationAuthority,
1097) -> anyhow::Result<()> {
1098    let shutdown = async {
1099        if let Err(e) = tokio::signal::ctrl_c().await {
1100            tracing::error!(error = %e, "failed to install ctrl-c handler");
1101        }
1102    };
1103    serve_with_shutdown(host, bind, port, static_assets, authority, shutdown).await
1104}
1105
1106/// Same as [`serve_with_shared_host`], but takes an explicit shutdown
1107/// signal instead of always waiting on Ctrl-C — the testable seam that lets
1108/// callers (and tests) make the serve future return deterministically.
1109pub async fn serve_with_shutdown(
1110    host: Arc<MissionHost>,
1111    bind: IpAddr,
1112    port: u16,
1113    static_assets: Option<DashboardStatic>,
1114    authority: MutationAuthority,
1115    shutdown: impl std::future::Future<Output = ()> + Send + 'static,
1116) -> anyhow::Result<()> {
1117    let listener = bind_listener(bind, port).await?;
1118    serve_on_listener(host, listener, static_assets, authority, shutdown).await
1119}
1120
1121/// Bind `bind:port` and return the listener. Callers that need the REAL
1122/// bound address before serving — `--port 0` picks an ephemeral port, and
1123/// the CLI prints/opens the URL — bind first and hand the listener to
1124/// [`serve_on_listener`].
1125pub async fn bind_listener(bind: IpAddr, port: u16) -> anyhow::Result<tokio::net::TcpListener> {
1126    Ok(tokio::net::TcpListener::bind(SocketAddr::from((bind, port))).await?)
1127}
1128
1129/// Serve the router on an already-bound listener. The router is built from
1130/// the listener's REAL local address, so `--port 0` scopes the origin
1131/// allowlist to the actual ephemeral port and a non-loopback bind gets the
1132/// read-token gate.
1133pub async fn serve_on_listener(
1134    host: Arc<MissionHost>,
1135    listener: tokio::net::TcpListener,
1136    static_assets: Option<DashboardStatic>,
1137    authority: MutationAuthority,
1138    shutdown: impl std::future::Future<Output = ()> + Send + 'static,
1139) -> anyhow::Result<()> {
1140    serve_multi_on_listener(
1141        Arc::new(MultiRepoHost::with_host(host)),
1142        listener,
1143        static_assets,
1144        authority,
1145        None,
1146        false,
1147        shutdown,
1148    )
1149    .await
1150}
1151
1152/// Serve a static multi-repository catalog on an already-bound listener.
1153/// `read_auth` forces the read-token gate (GETs and the WS upgrade) even on
1154/// a loopback bind — off-loopback binds always require it regardless.
1155/// `read_authority`, when set, is the read-only token accepted on those
1156/// gated reads (never on mutations); the mutation `authority` keeps working
1157/// for reads too.
1158pub async fn serve_multi_on_listener(
1159    multi_host: Arc<MultiRepoHost>,
1160    listener: tokio::net::TcpListener,
1161    static_assets: Option<DashboardStatic>,
1162    authority: MutationAuthority,
1163    read_authority: Option<String>,
1164    read_auth: bool,
1165    shutdown: impl std::future::Future<Output = ()> + Send + 'static,
1166) -> anyhow::Result<()> {
1167    let local_addr = listener.local_addr()?;
1168    let bind_is_loopback = local_addr.ip().is_loopback();
1169    let require_read_token = !bind_is_loopback || read_auth;
1170    let app = router_with_read_authority_and_addr(
1171        multi_host,
1172        static_assets,
1173        authority,
1174        read_authority,
1175        Some(local_addr),
1176        bind_is_loopback,
1177        require_read_token,
1178    );
1179    tracing::info!("kranz server listening on http://{local_addr}");
1180    axum::serve(listener, app)
1181        .with_graceful_shutdown(shutdown)
1182        .await?;
1183    Ok(())
1184}
1185
1186#[cfg(test)]
1187mod tests {
1188    use super::{
1189        host_allowed, origin_allowed, router_with_multi_repo_host_and_addr, EmbeddedFile,
1190        HostConfig, MultiRepoHost, RepoConfig, RepoSlackConfig,
1191    };
1192    use axum::body::Body;
1193    use axum::http::{Request, StatusCode};
1194    use http_body_util::BodyExt;
1195    use kranz_engine::event_log::{EventLog, LockForce};
1196    use kranz_engine::events::EventKind;
1197    use kranz_engine::paths::MissionPaths;
1198    use kranz_engine::types::MissionConfig;
1199    use std::path::{Path, PathBuf};
1200    use std::sync::Arc;
1201    use std::time::Duration;
1202    use tower::ServiceExt;
1203
1204    fn authority() -> super::MutationAuthority {
1205        super::MutationAuthority::new("tok").unwrap()
1206    }
1207
1208    #[tokio::test]
1209    async fn registered_hook_suffix_posts_require_mutation_authority() {
1210        let temp = tempfile::tempdir().unwrap();
1211        let app = super::repo_api_routes(super::TokenGate {
1212            authority: authority(),
1213            read_authority: "dummy-read".into(),
1214            require_read_token: true,
1215        })
1216        .with_state(Arc::new(super::ServerState {
1217            repo_root: temp.path().into(),
1218            host: Arc::new(super::MissionHost::new(temp.path().into())),
1219            bind_addr: None,
1220            bind_is_loopback: true,
1221        }));
1222        for path in ["/future/hook-status", "/future/hooks/github"] {
1223            for (presented, expected) in [
1224                (None, StatusCode::UNAUTHORIZED),
1225                (Some("dummy-read"), StatusCode::UNAUTHORIZED),
1226                (Some("tok"), StatusCode::NO_CONTENT),
1227            ] {
1228                let mut request = Request::post(path);
1229                if let Some(value) = presented {
1230                    request = request.header(super::TOKEN_HEADER, value);
1231                }
1232                let response = app
1233                    .clone()
1234                    .oneshot(request.body(Body::empty()).unwrap())
1235                    .await
1236                    .unwrap();
1237                assert_eq!(response.status(), expected, "registered route: {path}");
1238            }
1239        }
1240    }
1241
1242    fn seed_planning_mission(root: &Path, goal: &str) {
1243        std::fs::create_dir_all(root).unwrap();
1244        let status = std::process::Command::new("git")
1245            .args(["init", "-q"])
1246            .arg(root)
1247            .status()
1248            .unwrap();
1249        assert!(status.success());
1250        let paths = MissionPaths::new(root, "same-id");
1251        let mut log = EventLog::acquire(&paths, "same-id", Duration::ZERO, LockForce::No).unwrap();
1252        log.append(EventKind::MissionCreated {
1253            goal: goal.to_string(),
1254            base_branch: "main".to_string(),
1255            mission_branch: "kranz/mission-same-id".to_string(),
1256            config: MissionConfig::default(),
1257        })
1258        .unwrap();
1259    }
1260
1261    fn repo_config(id: &str, root: PathBuf) -> RepoConfig {
1262        RepoConfig {
1263            id: id.to_string(),
1264            root,
1265            display_name: None,
1266            group: None,
1267            pinned: false,
1268            slack: RepoSlackConfig::default(),
1269        }
1270    }
1271
1272    #[tokio::test]
1273    async fn unavailable_repo_routes_return_503_with_reason() {
1274        let temp = tempfile::tempdir().unwrap();
1275        let good = temp.path().join("good");
1276        seed_planning_mission(&good, "goal");
1277        let missing = temp.path().join("missing");
1278
1279        let multi = Arc::new(
1280            MultiRepoHost::from_config(HostConfig {
1281                default_repo: None,
1282                max_concurrent_repos: 1,
1283                repos: vec![repo_config("good", good), repo_config("gone", missing)],
1284            })
1285            .unwrap(),
1286        );
1287        let app = router_with_multi_repo_host_and_addr(multi, None, authority(), None, true, false);
1288
1289        // Bare prefix and deep path both 503 with the reason (explicit routes
1290        // beat the `/api/{*path}` catch-all; a nested fallback would not).
1291        for uri in ["/api/repos/gone", "/api/repos/gone/queue"] {
1292            let response = app
1293                .clone()
1294                .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
1295                .await
1296                .unwrap();
1297            assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE, "{uri}");
1298            let body = response.into_body().collect().await.unwrap().to_bytes();
1299            let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1300            assert_eq!(json["error"], "repository unavailable", "{uri}");
1301            assert_eq!(json["repoId"], "gone", "{uri}");
1302            assert!(json["detail"].as_str().unwrap().contains("does not exist"));
1303        }
1304
1305        // An unknown repo id still misses as 404 — unavailable stays
1306        // distinguishable from a typo.
1307        let response = app
1308            .clone()
1309            .oneshot(
1310                Request::builder()
1311                    .uri("/api/repos/nope/queue")
1312                    .body(Body::empty())
1313                    .unwrap(),
1314            )
1315            .await
1316            .unwrap();
1317        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1318
1319        // The healthy sibling is unaffected.
1320        let response = app
1321            .clone()
1322            .oneshot(
1323                Request::builder()
1324                    .uri("/api/repos/good/missions/same-id/state")
1325                    .body(Body::empty())
1326                    .unwrap(),
1327            )
1328            .await
1329            .unwrap();
1330        assert_eq!(response.status(), StatusCode::OK);
1331    }
1332
1333    #[tokio::test]
1334    async fn unavailable_default_repo_reports_503_on_the_unscoped_alias() {
1335        let temp = tempfile::tempdir().unwrap();
1336        let good = temp.path().join("good");
1337        seed_planning_mission(&good, "goal");
1338        let missing = temp.path().join("missing");
1339
1340        let multi = Arc::new(
1341            MultiRepoHost::from_config(HostConfig {
1342                default_repo: Some("gone".to_string()),
1343                max_concurrent_repos: 1,
1344                repos: vec![repo_config("good", good), repo_config("gone", missing)],
1345            })
1346            .unwrap(),
1347        );
1348        let app = router_with_multi_repo_host_and_addr(multi, None, authority(), None, true, false);
1349
1350        // Every unscoped route addresses the default repo; its unavailability
1351        // is reported instead of a generic "scope required" 404.
1352        let response = app
1353            .clone()
1354            .oneshot(
1355                Request::builder()
1356                    .uri("/api/queue")
1357                    .body(Body::empty())
1358                    .unwrap(),
1359            )
1360            .await
1361            .unwrap();
1362        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1363        let body = response.into_body().collect().await.unwrap().to_bytes();
1364        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1365        assert_eq!(json["repoId"], "gone");
1366
1367        // The health probe and healthy scoped routes stay live.
1368        let response = app
1369            .clone()
1370            .oneshot(
1371                Request::builder()
1372                    .uri("/api/health")
1373                    .body(Body::empty())
1374                    .unwrap(),
1375            )
1376            .await
1377            .unwrap();
1378        assert_eq!(response.status(), StatusCode::OK);
1379        let response = app
1380            .clone()
1381            .oneshot(
1382                Request::builder()
1383                    .uri("/api/repos/good/missions/same-id/state")
1384                    .body(Body::empty())
1385                    .unwrap(),
1386            )
1387            .await
1388            .unwrap();
1389        assert_eq!(response.status(), StatusCode::OK);
1390    }
1391
1392    #[tokio::test]
1393    async fn repo_scoped_routes_isolate_duplicate_mission_ids_and_mutations() {
1394        let temp = tempfile::tempdir().unwrap();
1395        let a = temp.path().join("a");
1396        let b = temp.path().join("b");
1397        seed_planning_mission(&a, "goal-a");
1398        seed_planning_mission(&b, "goal-b");
1399
1400        let multi = Arc::new(
1401            MultiRepoHost::from_config(HostConfig {
1402                default_repo: None,
1403                max_concurrent_repos: 1,
1404                repos: vec![repo_config("a", a.clone()), repo_config("b", b.clone())],
1405            })
1406            .unwrap(),
1407        );
1408        static EMBEDDED: &[EmbeddedFile] = &[EmbeddedFile {
1409            path: "index.html",
1410            bytes: b"dashboard",
1411            content_type: "text/html",
1412        }];
1413        let app = router_with_multi_repo_host_and_addr(
1414            multi,
1415            Some(super::DashboardStatic::Embedded(EMBEDDED)),
1416            authority(),
1417            None,
1418            true,
1419            false,
1420        );
1421
1422        for (repo_id, expected_goal) in [("a", "goal-a"), ("b", "goal-b")] {
1423            let response = app
1424                .clone()
1425                .oneshot(
1426                    Request::builder()
1427                        .uri(format!("/api/repos/{repo_id}/missions/same-id/state"))
1428                        .body(Body::empty())
1429                        .unwrap(),
1430                )
1431                .await
1432                .unwrap();
1433            assert_eq!(response.status(), StatusCode::OK);
1434            let body = response.into_body().collect().await.unwrap().to_bytes();
1435            let state: serde_json::Value = serde_json::from_slice(&body).unwrap();
1436            assert_eq!(state["mission"]["goal"], expected_goal);
1437        }
1438
1439        let response = app
1440            .clone()
1441            .oneshot(
1442                Request::builder()
1443                    .method("POST")
1444                    .uri("/api/repos/a/missions/same-id/control")
1445                    .header("content-type", "application/json")
1446                    .header(super::TOKEN_HEADER, "tok")
1447                    .body(Body::from(r#"{"kind":"pause"}"#))
1448                    .unwrap(),
1449            )
1450            .await
1451            .unwrap();
1452        assert_eq!(response.status(), StatusCode::ACCEPTED);
1453        assert_eq!(
1454            std::fs::read_dir(MissionPaths::new(&a, "same-id").control_dir())
1455                .unwrap()
1456                .count(),
1457            1
1458        );
1459        assert_eq!(
1460            std::fs::read_dir(MissionPaths::new(&b, "same-id").control_dir())
1461                .unwrap()
1462                .count(),
1463            0
1464        );
1465
1466        // No explicit default and two healthy roots: the legacy mutation path
1467        // is not mounted and therefore cannot guess a target.
1468        let response = app
1469            .clone()
1470            .oneshot(
1471                Request::builder()
1472                    .method("POST")
1473                    .uri("/api/missions/same-id/control")
1474                    .header("content-type", "application/json")
1475                    .header(super::TOKEN_HEADER, "tok")
1476                    .body(Body::from(r#"{"kind":"pause"}"#))
1477                    .unwrap(),
1478            )
1479            .await
1480            .unwrap();
1481        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1482        assert_eq!(response.headers()["content-type"], "application/json");
1483
1484        let response = app
1485            .oneshot(Request::builder().uri("/api").body(Body::empty()).unwrap())
1486            .await
1487            .unwrap();
1488        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1489        assert_eq!(response.headers()["content-type"], "application/json");
1490    }
1491
1492    #[test]
1493    fn origin_allowlist_accepts_only_local_dev_and_tauri() {
1494        // Back-compat / test path (bind None): any port, any loopback host —
1495        // localhost by name or a loopback IP literal (127/8, [::1]); a page
1496        // on 127.0.0.10 is the same trust class as one on 127.0.0.1.
1497        for allowed in [
1498            "http://localhost",
1499            "http://localhost:80",
1500            "http://localhost:5173",
1501            "http://127.0.0.1",
1502            "http://127.0.0.1:65535",
1503            "http://127.0.0.10:8080",
1504            "http://[::1]:5173",
1505            "tauri://localhost",
1506            "http://tauri.localhost",
1507        ] {
1508            assert!(origin_allowed(allowed, None), "should allow {allowed}");
1509        }
1510        for denied in [
1511            "https://evil.example",
1512            // Prefix tricks a substring check would fall for.
1513            "http://localhost.evil.example",
1514            "http://localhost.evil.example:5173",
1515            "http://127.0.0.1.evil.example",
1516            "http://localhostx",
1517            // Non-loopback IP origins are the WS LAN path's business
1518            // (ws_origin_allowed), never CORS-approved here.
1519            "http://192.168.1.5:4560",
1520            // Not a valid u16 port.
1521            "http://localhost:99999",
1522            "http://localhost:5173.evil.example",
1523            // Only the schemes/hosts the dashboard actually runs under.
1524            "https://localhost:5173",
1525            "https://tauri.localhost",
1526            "tauri://evil.example",
1527            "null",
1528            "",
1529        ] {
1530            assert!(!origin_allowed(denied, None), "should deny {denied}");
1531        }
1532    }
1533
1534    #[test]
1535    fn origin_allowlist_scopes_localhost_to_bind_and_dev_ports() {
1536        // Same-origin (bound ip:port), the vite proxy (:5173), and Tauri dev
1537        // (:1420) must work; any OTHER localhost port is an unrelated local
1538        // app whose page must not get cross-origin read approval.
1539        let bind = Some(std::net::SocketAddr::from(([127, 0, 0, 1], 4560)));
1540        for allowed in [
1541            "http://localhost:4560",
1542            "http://127.0.0.1:4560",
1543            "http://localhost:5173",
1544            "http://127.0.0.1:5173",
1545            "http://localhost:1420",
1546            "tauri://localhost",
1547            "http://tauri.localhost",
1548        ] {
1549            assert!(
1550                origin_allowed(allowed, bind),
1551                "should allow {allowed} for bind 127.0.0.1:4560"
1552            );
1553        }
1554        for denied in [
1555            "http://localhost:8080",
1556            "http://127.0.0.1:8080",
1557            "http://localhost", // implied :80 != 4560
1558            "http://127.0.0.1",
1559            // Co-resident loopback listener on kranz's OWN port: a different
1560            // loopback IP is a different process (unprivileged bind on
1561            // Linux); its page must not get tokenless cross-origin reads.
1562            "http://127.0.0.2:4560",
1563            "http://127.0.0.10:4560",
1564            "http://127.0.0.2:5173",
1565            "http://127.0.0.10:1420",
1566            "http://[::1]:4560",
1567            "http://localhost.evil.example:4560",
1568            "https://localhost:4560",
1569            "https://evil.example",
1570        ] {
1571            assert!(
1572                !origin_allowed(denied, bind),
1573                "should deny {denied} for bind 127.0.0.1:4560"
1574            );
1575        }
1576        // A serve actually bound on :80 keeps its own portless same-origin.
1577        assert!(origin_allowed(
1578            "http://localhost",
1579            Some(std::net::SocketAddr::from(([127, 0, 0, 1], 80)))
1580        ));
1581    }
1582
1583    #[test]
1584    fn origin_allowlist_follows_the_actual_bound_ip() {
1585        // `--host 127.0.0.2`: its own page works, the canonical-localhost
1586        // forms (which that serve does NOT answer on) do not.
1587        let bind = Some(std::net::SocketAddr::from(([127, 0, 0, 2], 4560)));
1588        assert!(origin_allowed("http://127.0.0.2:4560", bind));
1589        assert!(!origin_allowed("http://127.0.0.1:4560", bind));
1590        assert!(!origin_allowed("http://localhost:4560", bind));
1591        // Dev-server pages stay approved regardless of bind IP.
1592        assert!(origin_allowed("http://localhost:5173", bind));
1593
1594        // `--host ::1`: bracketed v6 same-origin plus the localhost name.
1595        let bind_v6 = Some(std::net::SocketAddr::from((
1596            std::net::Ipv6Addr::LOCALHOST,
1597            4560,
1598        )));
1599        assert!(origin_allowed("http://[::1]:4560", bind_v6));
1600        assert!(origin_allowed("http://localhost:4560", bind_v6));
1601        assert!(!origin_allowed("http://127.0.0.2:4560", bind_v6));
1602
1603        // `--host 0.0.0.0` listens on every interface: any loopback page on
1604        // the bind port is genuinely this server.
1605        let bind_any = Some(std::net::SocketAddr::from(([0, 0, 0, 0], 4560)));
1606        assert!(origin_allowed("http://127.0.0.1:4560", bind_any));
1607        assert!(origin_allowed("http://127.0.0.5:4560", bind_any));
1608        assert!(origin_allowed("http://localhost:4560", bind_any));
1609        assert!(!origin_allowed("http://localhost:8080", bind_any));
1610    }
1611
1612    #[test]
1613    fn ws_origin_loopback_keeps_strict_browser_allowlist() {
1614        use super::ws_origin_allowed;
1615        let bind = Some(std::net::SocketAddr::from(([127, 0, 0, 1], 4560)));
1616        assert!(ws_origin_allowed(Some("http://localhost:4560"), bind, true));
1617        assert!(ws_origin_allowed(Some("http://localhost:5173"), bind, true));
1618        assert!(
1619            !ws_origin_allowed(None, bind, true),
1620            "missing Origin stays rejected on loopback (reads are tokenless)"
1621        );
1622        assert!(!ws_origin_allowed(
1623            Some("http://192.168.1.5:4560"),
1624            bind,
1625            true
1626        ));
1627        assert!(
1628            !ws_origin_allowed(Some("http://127.0.0.2:4560"), bind, true),
1629            "co-resident loopback listener page must not open the tokenless WS"
1630        );
1631        assert!(
1632            !ws_origin_allowed(Some("http://127.0.0.2:5173"), bind, true),
1633            "a dev port must not privilege another independently bindable loopback IP"
1634        );
1635        assert!(!ws_origin_allowed(Some("http://evil.example"), bind, true));
1636    }
1637
1638    #[test]
1639    fn ws_origin_lan_accepts_ip_literals_and_native_clients() {
1640        use super::ws_origin_allowed;
1641        let bind = Some(std::net::SocketAddr::from(([0, 0, 0, 0], 4560)));
1642        // Same-origin LAN dashboard, bracketed v6, dev proxy, and header-less
1643        // native clients all pass — the read token authenticates the upgrade.
1644        assert!(ws_origin_allowed(
1645            Some("http://192.168.1.5:4560"),
1646            bind,
1647            false
1648        ));
1649        assert!(ws_origin_allowed(
1650            Some("http://[fd00::5]:4560"),
1651            bind,
1652            false
1653        ));
1654        assert!(ws_origin_allowed(
1655            Some("http://localhost:5173"),
1656            bind,
1657            false
1658        ));
1659        assert!(ws_origin_allowed(None, bind, false));
1660        // DNS-named (rebinding) pages and non-http schemes stay out.
1661        for denied in [
1662            "http://evil.example:4560",
1663            "http://192.168.1.5.evil.example:4560",
1664            "https://192.168.1.5:4560",
1665            "http://[::1:4560",
1666            "null",
1667            "",
1668        ] {
1669            assert!(
1670                !ws_origin_allowed(Some(denied), bind, false),
1671                "should deny {denied} off loopback"
1672            );
1673        }
1674    }
1675
1676    #[test]
1677    fn host_allowlist_loopback_rejects_lan_and_dns() {
1678        for allowed in [
1679            "localhost",
1680            "localhost:4560",
1681            "LOCALHOST:5173",
1682            "127.0.0.1",
1683            "127.0.0.1:65535",
1684            // Any loopback literal serves: `--host 127.0.0.2` must answer.
1685            "127.0.0.2:4560",
1686            "::1",
1687            "[::1]",
1688            "[::1]:4560",
1689        ] {
1690            assert!(
1691                host_allowed(allowed, true),
1692                "loopback bind should allow {allowed}"
1693            );
1694        }
1695        for denied in [
1696            "evil.example",
1697            "evil.example:4560",
1698            "localhost.evil.example",
1699            "192.168.1.10",
1700            "192.168.1.10:4560",
1701            "10.0.0.1:8080",
1702            // A full (non-loopback) IPv6 address, NOT ::1 with a port.
1703            "::1:4560",
1704            // Unclosed bracket.
1705            "[::1",
1706            "",
1707        ] {
1708            assert!(
1709                !host_allowed(denied, true),
1710                "loopback bind should deny {denied}"
1711            );
1712        }
1713    }
1714
1715    #[test]
1716    fn host_allowlist_lan_accepts_ip_hosts() {
1717        for allowed in [
1718            "192.168.1.10",
1719            "192.168.1.10:4560",
1720            "10.0.0.1:8080",
1721            "localhost",
1722            "127.0.0.1:4560",
1723            "[::1]:4560",
1724        ] {
1725            assert!(
1726                host_allowed(allowed, false),
1727                "LAN bind should allow {allowed}"
1728            );
1729        }
1730        for denied in [
1731            "evil.example",
1732            "evil.example:4560",
1733            "localhost.evil.example",
1734            "",
1735        ] {
1736            assert!(
1737                !host_allowed(denied, false),
1738                "LAN bind should still deny DNS Host {denied}"
1739            );
1740        }
1741    }
1742}