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;
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. `None` keeps the single-token posture:
271/// gated reads then present the operator's mutation token, as before.
272pub fn router_with_read_authority_and_addr(
273    multi_host: Arc<MultiRepoHost>,
274    static_assets: Option<DashboardStatic>,
275    authority: MutationAuthority,
276    read_authority: Option<String>,
277    bind_addr: Option<SocketAddr>,
278    bind_is_loopback: bool,
279    require_read_token: bool,
280) -> Router {
281    let catalog = Arc::clone(&multi_host);
282    let catalog_reads = read_work::ReadWork::default();
283    let mut app = Router::new().route("/api/health", get(rest::health)).route(
284        "/api/repos",
285        get(move || {
286            let catalog = Arc::clone(&catalog);
287            let reads = catalog_reads.clone();
288            async move { reads.run(move || Ok(Json(catalog.summaries()))).await }
289        }),
290    );
291
292    // The unscoped compatibility alias shares capacity with its scoped repo.
293    let mut repo_reads = std::collections::HashMap::new();
294    for context in multi_host.contexts() {
295        let prefix = format!("/api/repos/{}", context.id());
296        match context.host().cloned() {
297            Some(host) => {
298                let reads = read_work::ReadWork::default();
299                repo_reads.insert(context.id().to_string(), reads.clone());
300                app = app.nest(
301                    &prefix,
302                    repo_context_router(context, host, bind_addr, bind_is_loopback, reads),
303                );
304            }
305            None => {
306                // A nested router's fallback registers in the outer *fallback*
307                // router, which the `/api/{*path}` catch-all below always
308                // shadows — an unavailable repository must claim its paths as
309                // explicit routes (which beat the catch-all on the static
310                // `repos/<id>` segments) for the designed 503 to ever fire.
311                let handler = repo_unavailable_handler(&context);
312                app = app
313                    .route(&prefix, any(handler.clone()))
314                    .route(&format!("{prefix}/{{*path}}"), any(handler));
315            }
316        }
317    }
318
319    let mut unavailable_default = None;
320    if let Some(context) = multi_host.compatibility_context() {
321        match context.host().cloned() {
322            Some(host) => {
323                let reads = repo_reads
324                    .entry(context.id().to_string())
325                    .or_default()
326                    .clone();
327                app = app.nest(
328                    "/api",
329                    repo_context_router(context, host, bind_addr, bind_is_loopback, reads),
330                );
331            }
332            // An explicit `defaultRepo` is not health-filtered; the whole
333            // unscoped alias belongs to it, so report its unavailability
334            // below instead of mounting anything.
335            None => unavailable_default = Some(repo_unavailable_handler(&context)),
336        }
337    }
338
339    // API misses must never fall through to the SPA fallback. In particular,
340    // an ambiguous unscoped mutation in multi-repo mode must fail as JSON,
341    // not return `200 index.html` and look successful to an API client. When
342    // the unscoped alias targets an unavailable default repository, misses
343    // report that unavailability (503 + reason) instead of a generic 404.
344    app = match unavailable_default {
345        Some(handler) => app
346            .route("/api", any(handler.clone()))
347            .route("/api/{*path}", any(handler)),
348        None => app
349            .route("/api", any(api_not_found))
350            .route("/api/{*path}", any(api_not_found)),
351    };
352
353    let app = match static_assets {
354        Some(DashboardStatic::Dir(dir)) => {
355            let index = dir.join("index.html");
356            app.fallback_service(ServeDir::new(&dir).fallback(ServeFile::new(index)))
357        }
358        Some(DashboardStatic::Embedded(files)) => {
359            app.fallback(move |uri: Uri| async move { embedded_static_response(uri, files) })
360        }
361        None => app.route("/", get(root_info)),
362    };
363
364    // Layer order (outermost last): the CORS layer wraps the Host gate wraps
365    // the JSON gate wraps the token gate, so even rejections carry CORS
366    // headers for approved origins and a non-JSON POST is rejected before the
367    // token is examined.
368    app.layer(middleware::from_fn_with_state(
369        TokenGate {
370            authority,
371            read_authority,
372            require_read_token,
373        },
374        require_mutation_token,
375    ))
376    .layer(middleware::from_fn(require_json_api_posts))
377    .layer(middleware::from_fn_with_state(
378        HostGate { bind_is_loopback },
379        require_host,
380    ))
381    .layer(cors_layer(bind_addr))
382    // Outermost: every response — including gate rejections — carries the
383    // cache policy, so no rejection HTML can poison a browser cache either.
384    .layer(middleware::from_fn(cache_response_headers))
385}
386
387async fn api_not_found() -> impl IntoResponse {
388    (
389        StatusCode::NOT_FOUND,
390        Json(json!({ "error": "API route not found or repository scope required" })),
391    )
392}
393
394/// API answers and the SPA shell must never be cached. A browser that caches
395/// an HTML fallback at an /api URL replays it to `fetch()` long after the
396/// server is fixed (2026-07-19: a stale pre-API-404 serve poisoned the
397/// dashboard behind a heuristic cache entry; only an incognito window
398/// escaped). Hashed /assets/* bundles stay implicitly cacheable; `no-cache`
399/// on the shell revalidates per load rather than forbidding storage.
400async fn cache_response_headers(request: Request, next: Next) -> Response {
401    let is_api = request.uri().path().starts_with("/api");
402    let mut response = next.run(request).await;
403    let cache_control = if is_api {
404        Some("no-store")
405    } else if response
406        .headers()
407        .get(header::CONTENT_TYPE)
408        .and_then(|value| value.to_str().ok())
409        .is_some_and(|content_type| content_type.starts_with("text/html"))
410    {
411        Some("no-cache")
412    } else {
413        None
414    };
415    if let Some(value) = cache_control {
416        response
417            .headers_mut()
418            .insert(header::CACHE_CONTROL, HeaderValue::from_static(value));
419    }
420    response
421}
422
423/// `503 {"error":"repository unavailable", ...}` handler for every path under
424/// an unmounted repository. Returned as a `Clone` closure so one context can
425/// back both the bare-prefix and `{*path}` routes.
426fn repo_unavailable_handler(
427    context: &RepoContext,
428) -> impl Fn() -> std::future::Ready<(StatusCode, Json<serde_json::Value>)> + Clone {
429    let id = context.id().to_string();
430    let reason = context
431        .unavailable_reason()
432        .unwrap_or("repository is unavailable")
433        .to_string();
434    move || {
435        std::future::ready((
436            StatusCode::SERVICE_UNAVAILABLE,
437            Json(json!({
438                "error": "repository unavailable",
439                "repoId": id.clone(),
440                "detail": reason.clone(),
441            })),
442        ))
443    }
444}
445
446fn repo_context_router(
447    context: Arc<RepoContext>,
448    host: Arc<MissionHost>,
449    bind_addr: Option<SocketAddr>,
450    bind_is_loopback: bool,
451    reads: read_work::ReadWork,
452) -> Router {
453    let state = Arc::new(ServerState {
454        repo_root: context.root().to_path_buf(),
455        host,
456        bind_addr,
457        bind_is_loopback,
458    });
459    repo_api_routes()
460        .layer(axum::Extension(reads))
461        .with_state(state)
462}
463
464fn repo_api_routes() -> Router<Arc<ServerState>> {
465    Router::new()
466        .route(
467            "/missions",
468            get(rest::list_missions).post(host::create_mission),
469        )
470        .route("/missions/outcomes", get(rest::mission_outcomes))
471        .route("/escalation-metrics", get(rest::escalation_metrics))
472        .route("/standards-metrics", get(rest::standards_metrics))
473        .route("/cost-per-merged-change", get(rest::cost_per_merged_change))
474        .route("/missions/{id}/state", get(rest::mission_state))
475        .route("/missions/{id}/standards", get(rest::mission_standards))
476        .route(
477            "/missions/{id}/standards/waiver",
478            post(rest::post_standards_waiver),
479        )
480        .route("/missions/{id}/workspace", get(rest::mission_workspace))
481        .route("/missions/{id}/events", get(rest::mission_events))
482        .route("/missions/{id}/plan", get(rest::mission_plan))
483        .route("/missions/{id}/plan.md", get(rest::mission_plan_md))
484        .route(
485            "/missions/{id}/revision-diff",
486            get(rest::mission_revision_diff),
487        )
488        .route("/missions/{id}/report.md", get(rest::mission_report_md))
489        .route("/missions/{id}/diff-stat", get(rest::mission_diff_stat))
490        .route("/missions/{id}/pr-handoff", get(rest::mission_pr_handoff))
491        .route(
492            "/missions/{id}/pr-handoff/create",
493            post(rest::mission_pr_create),
494        )
495        .route("/missions/{id}/readiness", get(rest::mission_readiness))
496        .route(
497            "/missions/{id}/runs/{run_id}/transcript",
498            get(rest::run_transcript),
499        )
500        // The hook-status lane (ticket agent-hooks-status-signals): the
501        // POST is the lane's ONLY write, authenticated by the per-run
502        // capability token (exempt from the mutation-token gate below, like
503        // the GitHub webhook's HMAC route); the GET is an ordinary
504        // tokenless-loopback read. The body limit is the lane's own
505        // 16 KiB bound — the relay's body is a handful of small fields.
506        .route(
507            "/hook-status",
508            post(rest::post_hook_status).route_layer(axum::extract::DefaultBodyLimit::max(
509                kranz_engine::hook_status::SIGNAL_BODY_MAX_BYTES,
510            )),
511        )
512        .route("/missions/{id}/hook-status", get(rest::mission_hook_status))
513        .route("/missions/{id}/control", post(rest::post_control))
514        .route("/missions/{id}/revise", post(rest::post_revise))
515        .route(
516            "/missions/{id}/revision/approve",
517            post(rest::post_revision_approve),
518        )
519        .route(
520            "/missions/{id}/revision/reject",
521            post(rest::post_revision_reject),
522        )
523        .route(
524            "/missions/{id}/grant/approve",
525            post(rest::post_grant_approve),
526        )
527        .route("/missions/{id}/grant/deny", post(rest::post_grant_deny))
528        .route(
529            "/missions/{id}/question/answer",
530            post(rest::post_question_answer),
531        )
532        .route("/missions/{id}/planning/turn", post(host::planning_turn))
533        .route(
534            "/missions/{id}/planning/request-plan",
535            post(host::request_plan),
536        )
537        .route("/missions/{id}/approve", post(host::approve_mission))
538        .route("/missions/{id}/start", post(host::start_mission))
539        .route("/missions/{id}/pending-plan", get(host::pending_plan_route))
540        .route(
541            "/missions/{id}/approve-pending",
542            post(host::approve_pending_route),
543        )
544        .route("/missions/{id}/abandon", post(host::abandon_mission_route))
545        .route("/missions/{id}/release", post(host::release_mission_route))
546        .route("/missions/{id}/delete", post(host::delete_mission_route))
547        .route("/missions/{id}/merge", post(host::merge_mission_route))
548        .route("/missions/{id}/ws", get(ws::ws_handler))
549        .route(
550            "/tickets",
551            get(tickets::list_tickets).post(tickets::create_ticket),
552        )
553        .route("/tickets/{slug}", get(tickets::get_ticket))
554        .route("/tickets/{slug}/draft", post(tickets::draft_ticket))
555        .route("/tickets/{slug}/approve", post(tickets::approve_ticket))
556        .route("/queue", get(host::queue_state_route))
557        .route("/queue/drain", post(host::drain_queue_route))
558        .route("/hooks/github", post(hooks::github_hook))
559}
560
561fn embedded_static_response(uri: Uri, files: &'static [EmbeddedFile]) -> Response {
562    let requested = uri.path().trim_start_matches('/');
563    let requested = if requested.is_empty() {
564        "index.html"
565    } else {
566        requested
567    };
568    let file = files
569        .iter()
570        .find(|file| file.path == requested)
571        .or_else(|| files.iter().find(|file| file.path == "index.html"));
572
573    let Some(file) = file else {
574        return StatusCode::NOT_FOUND.into_response();
575    };
576
577    Response::builder()
578        .status(StatusCode::OK)
579        .header(header::CONTENT_TYPE, file.content_type)
580        .body(Body::from(file.bytes))
581        .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
582}
583
584/// CORS for localhost tooling (dashboard dev server, Tauri webview).
585///
586/// `CorsLayer::permissive()` echoed ANY `Origin` back in
587/// `access-control-allow-origin`, so a script on any website could read
588/// mission data from this (unauthenticated, fixed-port) server and POST
589/// `ControlCommand`s into the orchestrator — a drive-by instruction
590/// injection. Only the origins the dashboard can actually run under are
591/// approved instead. That closes the drive-by vector because browsers
592/// enforce CORS per-origin:
593///
594/// - the control POST must be `application/json` (see
595///   [`require_json_api_posts`]), which is never a CORS-"simple" request, so
596///   browsers always send a preflight — an unapproved origin's preflight
597///   comes back without allow headers and the POST is never sent;
598/// - cross-origin reads require the response to carry an
599///   `access-control-allow-origin` approving the reader, which is only
600///   emitted for the origins below.
601///
602/// Non-browser clients (curl, the engine, tests) send no `Origin` header and
603/// pass through untouched — CORS is a browser-enforced mechanism.
604fn cors_layer(bind_addr: Option<SocketAddr>) -> CorsLayer {
605    CorsLayer::new()
606        .allow_origin(AllowOrigin::predicate(
607            move |origin: &HeaderValue, _request_parts| {
608                origin.to_str().is_ok_and(|o| origin_allowed(o, bind_addr))
609            },
610        ))
611        .allow_methods([Method::GET, Method::POST])
612        .allow_headers([header::CONTENT_TYPE, HeaderName::from_static(TOKEN_HEADER)])
613}
614
615/// Trusted origins for CORS and the browser WebSocket upgrade Origin check.
616///
617/// Always: `tauri://localhost` (macOS/Linux Tauri) and
618/// `http://tauri.localhost` (Windows Tauri). Beyond those, an origin is
619/// approved only when its host is LOCAL (the `localhost` name or a loopback
620/// IP literal — DNS names like `localhost.evil.example` fail the IP parse)
621/// AND its port fits the bind scoping below. On loopback binds GETs and the
622/// WS upgrade are tokenless, so this allowlist is what stands between an
623/// unrelated local page and mission state.
624///
625/// Scoping against the bound address (`Some(bind)`):
626/// - the dev-server ports (vite :5173, Tauri devUrl :1420) are approved for
627///   canonical localhost only (`localhost`, `127.0.0.1`, or `::1`), never
628///   another address in 127/8 that a co-resident process can claim;
629/// - the bind port is approved only for the SAME-ORIGIN page: an IP host
630///   must equal the bound IP (any loopback IP when the bind is
631///   unspecified/0.0.0.0, which listens on them all), and the `localhost`
632///   name only when the bind IP is one localhost resolves to (127.0.0.1,
633///   ::1, or unspecified). Pinning the IP — not just the port — matters: on
634///   Linux an unprivileged co-resident process can bind ANOTHER loopback
635///   address (127.0.0.2) on kranz's own port and serve a hostile page; a
636///   port-only rule would hand that page tokenless cross-origin reads.
637///
638/// `bind_addr: None` (back-compat test wrappers only) keeps the old
639/// any-loopback-host, any-port wildcard.
640pub(crate) fn origin_allowed(origin: &str, bind_addr: Option<SocketAddr>) -> bool {
641    if origin == "tauri://localhost" || origin == "http://tauri.localhost" {
642        return true;
643    }
644    // Dev-server origins that must keep working on every bind: the vite
645    // proxy (5173) and Tauri's devUrl (1420). Restrict these privileged ports
646    // to canonical localhost; every other 127/8 address is independently
647    // bindable by an unprivileged co-resident process.
648    const DEV_PORTS: [u16; 2] = [5173, 1420];
649    let Some(authority) = origin.strip_prefix("http://") else {
650        return false;
651    };
652    let Some((host, port)) = split_host_port(authority) else {
653        return false;
654    };
655    let host_ip = host.parse::<std::net::IpAddr>().ok();
656    let host_local = host == "localhost" || host_ip.is_some_and(|ip| ip.is_loopback());
657    if !host_local {
658        return false;
659    }
660    let Some(bind) = bind_addr else {
661        return true; // back-compat wildcard
662    };
663    if DEV_PORTS.contains(&port) {
664        return host == "localhost"
665            || host_ip == Some(std::net::IpAddr::V4(Ipv4Addr::LOCALHOST))
666            || host_ip == Some(std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST));
667    }
668    if port != bind.port() {
669        return false;
670    }
671    match host_ip {
672        Some(ip) => ip == bind.ip() || (bind.ip().is_unspecified() && ip.is_loopback()),
673        // The `localhost` NAME resolves to 127.0.0.1 / ::1 — approve it only
674        // when the server actually answers there.
675        None => {
676            bind.ip().is_unspecified()
677                || bind.ip() == std::net::IpAddr::V4(Ipv4Addr::LOCALHOST)
678                || bind.ip() == std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)
679        }
680    }
681}
682
683/// Split an origin authority — `host[:port]` or `[v6][:port]` — into
684/// hostname and port (default 80). `None` on malformed brackets or ports.
685/// An unbracketed hostname containing `:` is a bare IPv6 authority, which
686/// cannot carry a port.
687fn split_host_port(authority: &str) -> Option<(&str, u16)> {
688    if let Some(rest) = authority.strip_prefix('[') {
689        let (addr, tail) = rest.split_once(']')?;
690        let port = if tail.is_empty() {
691            80
692        } else {
693            tail.strip_prefix(':')?.parse().ok()?
694        };
695        return Some((addr, port));
696    }
697    match authority.rsplit_once(':') {
698        Some((host, _)) if host.contains(':') => Some((authority, 80)),
699        Some((host, port)) => Some((host, port.parse().ok()?)),
700        None => Some((authority, 80)),
701    }
702}
703
704/// Origin policy for the WebSocket upgrade.
705///
706/// Browsers send the page origin on SAME-origin WS handshakes too, so a
707/// dashboard served off a LAN/tailnet bind presents `http://<ip>:<port>` —
708/// which [`origin_allowed`] rejects. Off loopback the read token already
709/// gates the upgrade, so the Origin check only needs to keep DNS-named
710/// (rebinding) pages out: accept any origin whose host parses as an IP
711/// literal, and accept a MISSING Origin (native, non-browser clients). On
712/// loopback binds reads are tokenless and the strict browser allowlist
713/// stays the guard — missing Origin remains rejected there.
714pub(crate) fn ws_origin_allowed(
715    origin: Option<&str>,
716    bind_addr: Option<SocketAddr>,
717    bind_is_loopback: bool,
718) -> bool {
719    match origin {
720        None => !bind_is_loopback,
721        Some(origin) => {
722            origin_allowed(origin, bind_addr) || (!bind_is_loopback && origin_host_is_ip(origin))
723        }
724    }
725}
726
727/// `http://<ip>[:port]` (v4 or bracketed v6) — the origin shape a browser
728/// sends for a page loaded straight off a LAN/tailnet serve. DNS-named
729/// origins fail the IP parse, keeping rebinding pages out.
730fn origin_host_is_ip(origin: &str) -> bool {
731    origin
732        .strip_prefix("http://")
733        .and_then(split_host_port)
734        .is_some_and(|(host, _)| host.parse::<std::net::IpAddr>().is_ok())
735}
736
737/// Host gate: browsers always send `Host`, so DNS rebinding attempts arrive
738/// as the attacker-controlled hostname and are rejected before tokenless
739/// reads can return mission state or transcripts.
740///
741/// Path-only in-process requests used by `tower::ServiceExt::oneshot` carry no
742/// Host header and are allowed; real network HTTP/1.1 requests present Host.
743async fn require_host(State(gate): State<HostGate>, request: Request, next: Next) -> Response {
744    if let Some(host) = request.headers().get(header::HOST) {
745        if !host
746            .to_str()
747            .is_ok_and(|h| host_allowed(h, gate.bind_is_loopback))
748        {
749            return (
750                StatusCode::FORBIDDEN,
751                Json(json!({ "error": "invalid host" })),
752            )
753                .into_response();
754        }
755    }
756    next.run(request).await
757}
758
759/// Trusted HTTP Host values.
760///
761/// Always: `localhost` (optional `:<u16>`) and any LOOPBACK IP literal —
762/// `127.0.0.1`, other 127/8 addresses, `::1` (bare or bracketed), each with
763/// an optional port. Loopback literals cannot be planted by DNS rebinding
764/// (browsers send the attacker's hostname, not the IP it resolves to), and
765/// operators legitimately bind e.g. `--host 127.0.0.2`.
766///
767/// When `bind_is_loopback` is false (LAN / tailnet serve): any Host whose
768/// hostname parses as an IP is accepted — the operator intentionally
769/// exposed non-loopback, and the mutation token (including on GET/WS)
770/// is what authenticates. Hostname DNS-rebinding still fails the IP
771/// parse; browsers sending `evil.example` are rejected.
772fn host_allowed(host: &str, bind_is_loopback: bool) -> bool {
773    let host = host.trim().to_ascii_lowercase();
774    if host_is_loopback(&host) {
775        return true;
776    }
777    if bind_is_loopback {
778        return false;
779    }
780    host_ip(&host).is_some()
781}
782
783fn host_is_loopback(host: &str) -> bool {
784    if host == "localhost" {
785        return true;
786    }
787    if let Some(port) = host.strip_prefix("localhost:") {
788        return port.parse::<u16>().is_ok();
789    }
790    host_ip(host).is_some_and(|ip| ip.is_loopback())
791}
792
793/// Parse the hostname of a `Host` header value — bare IP (v4, or unbracketed
794/// v6, which may itself contain `:` and carries no port), `v4:port`, or
795/// `[v6]` with optional `:port` — as an IP address. `None` for DNS names,
796/// malformed brackets, and invalid ports.
797fn host_ip(host: &str) -> Option<std::net::IpAddr> {
798    if let Ok(ip) = host.parse::<std::net::IpAddr>() {
799        return Some(ip);
800    }
801    if let Some(rest) = host.strip_prefix('[') {
802        let (addr, tail) = rest.split_once(']')?;
803        if !(tail.is_empty()
804            || tail
805                .strip_prefix(':')
806                .is_some_and(|p| p.parse::<u16>().is_ok()))
807        {
808            return None;
809        }
810        return addr.parse().ok();
811    }
812    let (addr, port) = host.rsplit_once(':')?;
813    if port.parse::<u16>().is_err() {
814        return None;
815    }
816    addr.parse().ok()
817}
818
819/// Reject any `POST /api/...` with a non-empty body whose content-type is
820/// not `application/json`.
821///
822/// The control handler parses raw bytes, so without this gate a drive-by
823/// page could bypass the CORS preflight entirely: `text/plain` (or
824/// form-encoded) POSTs are CORS-"simple" and browsers send them cross-origin
825/// WITHOUT a preflight — the attacker cannot read the response, but the
826/// ControlCommand side effect would already have happened. Requiring JSON
827/// forces every browser POST into the preflighted path that
828/// [`cors_layer`] guards.
829///
830/// An EMPTY body (no `Content-Length`, or `Content-Length: 0`) is exempt
831/// from the content-type check: it carries no payload for a drive-by page
832/// to control, so relaxing the mime requirement here doesn't reopen the
833/// CSRF vector above — bodyless POSTs still have to clear
834/// [`require_mutation_token`] downstream, which is the gate actually
835/// closing it. (A chunked/streaming request that omits `Content-Length`
836/// but streams a non-empty body is not treated as empty here — that's an
837/// accepted non-goal, not a bypass this middleware promises to catch.)
838async fn require_json_api_posts(request: Request, next: Next) -> Response {
839    if request.method() == Method::POST && request.uri().path().starts_with("/api/") {
840        let is_empty_body = request
841            .headers()
842            .get(header::CONTENT_LENGTH)
843            .and_then(|value| value.to_str().ok())
844            .is_none_or(|value| value == "0");
845        let is_json = request
846            .headers()
847            .get(header::CONTENT_TYPE)
848            .and_then(|value| value.to_str().ok())
849            .and_then(|value| value.split(';').next())
850            .is_some_and(|mime| mime.trim().eq_ignore_ascii_case("application/json"));
851        if !is_empty_body && !is_json {
852            return (
853                StatusCode::UNSUPPORTED_MEDIA_TYPE,
854                Json(json!({ "error": "POST bodies must be application/json" })),
855            )
856                .into_response();
857        }
858    }
859    next.run(request).await
860}
861
862/// Token gate state: mandatory mutation authority plus whether non-loopback
863/// binds also require it on GET / WS upgrade, and the optional read-only token
864/// accepted on gated reads only.
865#[derive(Clone)]
866struct TokenGate {
867    authority: MutationAuthority,
868    read_authority: Option<String>,
869    require_read_token: bool,
870}
871
872/// Host gate state: whether the serve bind is loopback (strict Host) or
873/// LAN/tailnet (accept any Host that parses as an IP).
874#[derive(Clone)]
875struct HostGate {
876    bind_is_loopback: bool,
877}
878
879/// Require the per-serve mutation token on every `POST /api/...` (protocol
880/// "Authority: mutation token"). When [`TokenGate::require_read_token`] is
881/// set (non-loopback bind), GETs / HEADs under `/api/` (except `/api/health`)
882/// require a token too — via the `x-kranz-token` header or a `?token=`
883/// query. The query form exists ONLY for the browser WS upgrade (no way to
884/// set headers on `new WebSocket`) and is honored solely on token-gated
885/// reads: POSTs are header-only, so mutation authority never rides in a URL
886/// that can land in shell history or an intermediary's access log.
887/// A configured [`TokenGate::read_authority`] authenticates those same gated
888/// READS (header or query) but is never accepted on a mutating route: it is
889/// the token safe to hand to dashboards and agents, while the mutation token
890/// stays the operator's alone.
891///
892/// Rationale: the 127.0.0.1 bind + CORS allowlist stop the network and the
893/// browser; the token stops other local processes and link-borne CSRF from
894/// creating or steering missions that spend money. Off-loopback, tokenless
895/// reads would expose mission state to the LAN, so reads are gated too.
896async fn require_mutation_token(
897    State(gate): State<TokenGate>,
898    request: Request,
899    next: Next,
900) -> Response {
901    let expected = gate.authority.as_str();
902    let path = request.uri().path();
903    let is_health = path == "/api/health";
904    // The GitHub webhook route authenticates with its own per-repo HMAC
905    // (`X-Hub-Signature-256` against `hooks.secret`) and refuses closed
906    // when unconfigured — GitHub cannot present the mutation token.
907    let is_github_hook = path.ends_with("/hooks/github");
908    // The hook-status signal POST authenticates with its own per-RUN
909    // capability token (validated against the registration in the handler —
910    // worker-readable files never carry the serve token). Scoped to POSTs so
911    // a read-gated GET of the projection still requires the read token.
912    let is_hook_signal_post = request.method() == Method::POST && path.ends_with("/hook-status");
913    let is_read = request.method() == Method::GET || request.method() == Method::HEAD;
914    let needs_auth = path.starts_with("/api/")
915        && !is_health
916        && !is_github_hook
917        && !is_hook_signal_post
918        && (request.method() == Method::POST || (gate.require_read_token && is_read));
919    if needs_auth {
920        // The read-only token authenticates reads ONLY — never a mutation.
921        let read_ok = |presented: &str| {
922            is_read
923                && gate
924                    .read_authority
925                    .as_deref()
926                    .is_some_and(|read| token_matches(presented, read))
927        };
928        let header_ok = request
929            .headers()
930            .get(TOKEN_HEADER)
931            .and_then(|value| value.to_str().ok())
932            .is_some_and(|presented| token_matches(presented, expected) || read_ok(presented));
933        let query_ok = gate.require_read_token
934            && is_read
935            && request
936                .uri()
937                .query()
938                .map(|q| {
939                    q.split('&').any(|pair| {
940                        let mut parts = pair.splitn(2, '=');
941                        matches!(parts.next(), Some("token"))
942                            && parts.next().is_some_and(|v| {
943                                let decoded = percent_decode_token(v);
944                                token_matches(&decoded, expected) || read_ok(&decoded)
945                            })
946                    })
947                })
948                .unwrap_or(false);
949        if !header_ok && !query_ok {
950            return (
951                StatusCode::UNAUTHORIZED,
952                Json(json!({ "error": "missing or invalid token" })),
953            )
954                .into_response();
955        }
956    }
957    next.run(request).await
958}
959
960/// Constant-time token equality: off-loopback binds expose the token gate
961/// to remote timing probes, and a short-circuiting `==` leaks how many
962/// leading bytes matched. Only the length is observable (standard for
963/// `ct_eq`, and unavoidable), which reveals nothing useful about a
964/// fixed-length uuid-hex token.
965fn token_matches(presented: &str, expected: &str) -> bool {
966    use subtle::ConstantTimeEq;
967    presented.as_bytes().ct_eq(expected.as_bytes()).into()
968}
969
970/// Minimal percent-decode for `?token=` values (`%XX` only — tokens are
971/// uuid hex so this only needs to round-trip `encodeURIComponent`).
972fn percent_decode_token(raw: &str) -> String {
973    let bytes = raw.as_bytes();
974    let mut out = Vec::with_capacity(bytes.len());
975    let mut i = 0;
976    while i < bytes.len() {
977        if bytes[i] == b'%' && i + 2 < bytes.len() {
978            if let (Some(hi), Some(lo)) = (
979                (bytes[i + 1] as char).to_digit(16),
980                (bytes[i + 2] as char).to_digit(16),
981            ) {
982                out.push((hi * 16 + lo) as u8);
983                i += 3;
984                continue;
985            }
986        }
987        if bytes[i] == b'+' {
988            out.push(b' ');
989        } else {
990            out.push(bytes[i]);
991        }
992        i += 1;
993    }
994    String::from_utf8_lossy(&out).into_owned()
995}
996
997/// `GET /` when no dashboard bundle is configured.
998async fn root_info() -> &'static str {
999    "kranz server is running (no dashboard bundle configured).\n\
1000     REST + WebSocket API under /api — see docs/protocol.md.\n"
1001}
1002
1003/// Bind `127.0.0.1:<port>` and serve the router until the process exits.
1004/// `authority` gates every `POST /api/...`.
1005pub async fn serve(
1006    repo_root: PathBuf,
1007    port: u16,
1008    static_dir: Option<PathBuf>,
1009    authority: MutationAuthority,
1010) -> anyhow::Result<()> {
1011    serve_with_static(
1012        repo_root,
1013        port,
1014        static_dir.map(DashboardStatic::Dir),
1015        authority,
1016    )
1017    .await
1018}
1019
1020/// Bind `127.0.0.1:<port>` and serve the router until the process exits.
1021pub async fn serve_with_static(
1022    repo_root: PathBuf,
1023    port: u16,
1024    static_assets: Option<DashboardStatic>,
1025    authority: MutationAuthority,
1026) -> anyhow::Result<()> {
1027    serve_with_shared_host(
1028        Arc::new(MissionHost::new(repo_root)),
1029        IpAddr::V4(Ipv4Addr::LOCALHOST),
1030        port,
1031        static_assets,
1032        authority,
1033    )
1034    .await
1035}
1036
1037/// [`serve_with_static`] over an already-shared registry (see
1038/// [`router_with_shared_host`]).
1039/// `bind` widens reachability beyond loopback (e.g. for the glasses app on
1040/// the same LAN / tailnet). Every POST stays mutation-token-gated; when
1041/// `bind` is not loopback, GETs and WS upgrades require the token too. The
1042/// CLI prints a loud warning for non-loopback binds.
1043pub async fn serve_with_shared_host(
1044    host: Arc<MissionHost>,
1045    bind: IpAddr,
1046    port: u16,
1047    static_assets: Option<DashboardStatic>,
1048    authority: MutationAuthority,
1049) -> anyhow::Result<()> {
1050    let shutdown = async {
1051        if let Err(e) = tokio::signal::ctrl_c().await {
1052            tracing::error!(error = %e, "failed to install ctrl-c handler");
1053        }
1054    };
1055    serve_with_shutdown(host, bind, port, static_assets, authority, shutdown).await
1056}
1057
1058/// Same as [`serve_with_shared_host`], but takes an explicit shutdown
1059/// signal instead of always waiting on Ctrl-C — the testable seam that lets
1060/// callers (and tests) make the serve future return deterministically.
1061pub async fn serve_with_shutdown(
1062    host: Arc<MissionHost>,
1063    bind: IpAddr,
1064    port: u16,
1065    static_assets: Option<DashboardStatic>,
1066    authority: MutationAuthority,
1067    shutdown: impl std::future::Future<Output = ()> + Send + 'static,
1068) -> anyhow::Result<()> {
1069    let listener = bind_listener(bind, port).await?;
1070    serve_on_listener(host, listener, static_assets, authority, shutdown).await
1071}
1072
1073/// Bind `bind:port` and return the listener. Callers that need the REAL
1074/// bound address before serving — `--port 0` picks an ephemeral port, and
1075/// the CLI prints/opens the URL — bind first and hand the listener to
1076/// [`serve_on_listener`].
1077pub async fn bind_listener(bind: IpAddr, port: u16) -> anyhow::Result<tokio::net::TcpListener> {
1078    Ok(tokio::net::TcpListener::bind(SocketAddr::from((bind, port))).await?)
1079}
1080
1081/// Serve the router on an already-bound listener. The router is built from
1082/// the listener's REAL local address, so `--port 0` scopes the origin
1083/// allowlist to the actual ephemeral port and a non-loopback bind gets the
1084/// read-token gate.
1085pub async fn serve_on_listener(
1086    host: Arc<MissionHost>,
1087    listener: tokio::net::TcpListener,
1088    static_assets: Option<DashboardStatic>,
1089    authority: MutationAuthority,
1090    shutdown: impl std::future::Future<Output = ()> + Send + 'static,
1091) -> anyhow::Result<()> {
1092    serve_multi_on_listener(
1093        Arc::new(MultiRepoHost::with_host(host)),
1094        listener,
1095        static_assets,
1096        authority,
1097        None,
1098        false,
1099        shutdown,
1100    )
1101    .await
1102}
1103
1104/// Serve a static multi-repository catalog on an already-bound listener.
1105/// `read_auth` forces the read-token gate (GETs and the WS upgrade) even on
1106/// a loopback bind — off-loopback binds always require it regardless.
1107/// `read_authority`, when set, is the read-only token accepted on those
1108/// gated reads (never on mutations); the mutation `authority` keeps working
1109/// for reads too.
1110pub async fn serve_multi_on_listener(
1111    multi_host: Arc<MultiRepoHost>,
1112    listener: tokio::net::TcpListener,
1113    static_assets: Option<DashboardStatic>,
1114    authority: MutationAuthority,
1115    read_authority: Option<String>,
1116    read_auth: bool,
1117    shutdown: impl std::future::Future<Output = ()> + Send + 'static,
1118) -> anyhow::Result<()> {
1119    let local_addr = listener.local_addr()?;
1120    let bind_is_loopback = local_addr.ip().is_loopback();
1121    let require_read_token = !bind_is_loopback || read_auth;
1122    let app = router_with_read_authority_and_addr(
1123        multi_host,
1124        static_assets,
1125        authority,
1126        read_authority,
1127        Some(local_addr),
1128        bind_is_loopback,
1129        require_read_token,
1130    );
1131    tracing::info!("kranz server listening on http://{local_addr}");
1132    axum::serve(listener, app)
1133        .with_graceful_shutdown(shutdown)
1134        .await?;
1135    Ok(())
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140    use super::{
1141        host_allowed, origin_allowed, router_with_multi_repo_host_and_addr, EmbeddedFile,
1142        HostConfig, MultiRepoHost, RepoConfig, RepoSlackConfig,
1143    };
1144    use axum::body::Body;
1145    use axum::http::{Request, StatusCode};
1146    use http_body_util::BodyExt;
1147    use kranz_engine::event_log::{EventLog, LockForce};
1148    use kranz_engine::events::EventKind;
1149    use kranz_engine::paths::MissionPaths;
1150    use kranz_engine::types::MissionConfig;
1151    use std::path::{Path, PathBuf};
1152    use std::sync::Arc;
1153    use std::time::Duration;
1154    use tower::ServiceExt;
1155
1156    fn authority() -> super::MutationAuthority {
1157        super::MutationAuthority::new("tok").unwrap()
1158    }
1159
1160    fn seed_planning_mission(root: &Path, goal: &str) {
1161        std::fs::create_dir_all(root).unwrap();
1162        let status = std::process::Command::new("git")
1163            .args(["init", "-q"])
1164            .arg(root)
1165            .status()
1166            .unwrap();
1167        assert!(status.success());
1168        let paths = MissionPaths::new(root, "same-id");
1169        let mut log = EventLog::acquire(&paths, "same-id", Duration::ZERO, LockForce::No).unwrap();
1170        log.append(EventKind::MissionCreated {
1171            goal: goal.to_string(),
1172            base_branch: "main".to_string(),
1173            mission_branch: "kranz/mission-same-id".to_string(),
1174            config: MissionConfig::default(),
1175        })
1176        .unwrap();
1177    }
1178
1179    fn repo_config(id: &str, root: PathBuf) -> RepoConfig {
1180        RepoConfig {
1181            id: id.to_string(),
1182            root,
1183            display_name: None,
1184            group: None,
1185            pinned: false,
1186            slack: RepoSlackConfig::default(),
1187        }
1188    }
1189
1190    #[tokio::test]
1191    async fn unavailable_repo_routes_return_503_with_reason() {
1192        let temp = tempfile::tempdir().unwrap();
1193        let good = temp.path().join("good");
1194        seed_planning_mission(&good, "goal");
1195        let missing = temp.path().join("missing");
1196
1197        let multi = Arc::new(
1198            MultiRepoHost::from_config(HostConfig {
1199                default_repo: None,
1200                max_concurrent_repos: 1,
1201                repos: vec![repo_config("good", good), repo_config("gone", missing)],
1202            })
1203            .unwrap(),
1204        );
1205        let app = router_with_multi_repo_host_and_addr(multi, None, authority(), None, true, false);
1206
1207        // Bare prefix and deep path both 503 with the reason (explicit routes
1208        // beat the `/api/{*path}` catch-all; a nested fallback would not).
1209        for uri in ["/api/repos/gone", "/api/repos/gone/queue"] {
1210            let response = app
1211                .clone()
1212                .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
1213                .await
1214                .unwrap();
1215            assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE, "{uri}");
1216            let body = response.into_body().collect().await.unwrap().to_bytes();
1217            let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1218            assert_eq!(json["error"], "repository unavailable", "{uri}");
1219            assert_eq!(json["repoId"], "gone", "{uri}");
1220            assert!(json["detail"].as_str().unwrap().contains("does not exist"));
1221        }
1222
1223        // An unknown repo id still misses as 404 — unavailable stays
1224        // distinguishable from a typo.
1225        let response = app
1226            .clone()
1227            .oneshot(
1228                Request::builder()
1229                    .uri("/api/repos/nope/queue")
1230                    .body(Body::empty())
1231                    .unwrap(),
1232            )
1233            .await
1234            .unwrap();
1235        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1236
1237        // The healthy sibling is unaffected.
1238        let response = app
1239            .clone()
1240            .oneshot(
1241                Request::builder()
1242                    .uri("/api/repos/good/missions/same-id/state")
1243                    .body(Body::empty())
1244                    .unwrap(),
1245            )
1246            .await
1247            .unwrap();
1248        assert_eq!(response.status(), StatusCode::OK);
1249    }
1250
1251    #[tokio::test]
1252    async fn unavailable_default_repo_reports_503_on_the_unscoped_alias() {
1253        let temp = tempfile::tempdir().unwrap();
1254        let good = temp.path().join("good");
1255        seed_planning_mission(&good, "goal");
1256        let missing = temp.path().join("missing");
1257
1258        let multi = Arc::new(
1259            MultiRepoHost::from_config(HostConfig {
1260                default_repo: Some("gone".to_string()),
1261                max_concurrent_repos: 1,
1262                repos: vec![repo_config("good", good), repo_config("gone", missing)],
1263            })
1264            .unwrap(),
1265        );
1266        let app = router_with_multi_repo_host_and_addr(multi, None, authority(), None, true, false);
1267
1268        // Every unscoped route addresses the default repo; its unavailability
1269        // is reported instead of a generic "scope required" 404.
1270        let response = app
1271            .clone()
1272            .oneshot(
1273                Request::builder()
1274                    .uri("/api/queue")
1275                    .body(Body::empty())
1276                    .unwrap(),
1277            )
1278            .await
1279            .unwrap();
1280        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1281        let body = response.into_body().collect().await.unwrap().to_bytes();
1282        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1283        assert_eq!(json["repoId"], "gone");
1284
1285        // The health probe and healthy scoped routes stay live.
1286        let response = app
1287            .clone()
1288            .oneshot(
1289                Request::builder()
1290                    .uri("/api/health")
1291                    .body(Body::empty())
1292                    .unwrap(),
1293            )
1294            .await
1295            .unwrap();
1296        assert_eq!(response.status(), StatusCode::OK);
1297        let response = app
1298            .clone()
1299            .oneshot(
1300                Request::builder()
1301                    .uri("/api/repos/good/missions/same-id/state")
1302                    .body(Body::empty())
1303                    .unwrap(),
1304            )
1305            .await
1306            .unwrap();
1307        assert_eq!(response.status(), StatusCode::OK);
1308    }
1309
1310    #[tokio::test]
1311    async fn repo_scoped_routes_isolate_duplicate_mission_ids_and_mutations() {
1312        let temp = tempfile::tempdir().unwrap();
1313        let a = temp.path().join("a");
1314        let b = temp.path().join("b");
1315        seed_planning_mission(&a, "goal-a");
1316        seed_planning_mission(&b, "goal-b");
1317
1318        let multi = Arc::new(
1319            MultiRepoHost::from_config(HostConfig {
1320                default_repo: None,
1321                max_concurrent_repos: 1,
1322                repos: vec![repo_config("a", a.clone()), repo_config("b", b.clone())],
1323            })
1324            .unwrap(),
1325        );
1326        static EMBEDDED: &[EmbeddedFile] = &[EmbeddedFile {
1327            path: "index.html",
1328            bytes: b"dashboard",
1329            content_type: "text/html",
1330        }];
1331        let app = router_with_multi_repo_host_and_addr(
1332            multi,
1333            Some(super::DashboardStatic::Embedded(EMBEDDED)),
1334            authority(),
1335            None,
1336            true,
1337            false,
1338        );
1339
1340        for (repo_id, expected_goal) in [("a", "goal-a"), ("b", "goal-b")] {
1341            let response = app
1342                .clone()
1343                .oneshot(
1344                    Request::builder()
1345                        .uri(format!("/api/repos/{repo_id}/missions/same-id/state"))
1346                        .body(Body::empty())
1347                        .unwrap(),
1348                )
1349                .await
1350                .unwrap();
1351            assert_eq!(response.status(), StatusCode::OK);
1352            let body = response.into_body().collect().await.unwrap().to_bytes();
1353            let state: serde_json::Value = serde_json::from_slice(&body).unwrap();
1354            assert_eq!(state["mission"]["goal"], expected_goal);
1355        }
1356
1357        let response = app
1358            .clone()
1359            .oneshot(
1360                Request::builder()
1361                    .method("POST")
1362                    .uri("/api/repos/a/missions/same-id/control")
1363                    .header("content-type", "application/json")
1364                    .header(super::TOKEN_HEADER, "tok")
1365                    .body(Body::from(r#"{"kind":"pause"}"#))
1366                    .unwrap(),
1367            )
1368            .await
1369            .unwrap();
1370        assert_eq!(response.status(), StatusCode::ACCEPTED);
1371        assert_eq!(
1372            std::fs::read_dir(MissionPaths::new(&a, "same-id").control_dir())
1373                .unwrap()
1374                .count(),
1375            1
1376        );
1377        assert_eq!(
1378            std::fs::read_dir(MissionPaths::new(&b, "same-id").control_dir())
1379                .unwrap()
1380                .count(),
1381            0
1382        );
1383
1384        // No explicit default and two healthy roots: the legacy mutation path
1385        // is not mounted and therefore cannot guess a target.
1386        let response = app
1387            .clone()
1388            .oneshot(
1389                Request::builder()
1390                    .method("POST")
1391                    .uri("/api/missions/same-id/control")
1392                    .header("content-type", "application/json")
1393                    .header(super::TOKEN_HEADER, "tok")
1394                    .body(Body::from(r#"{"kind":"pause"}"#))
1395                    .unwrap(),
1396            )
1397            .await
1398            .unwrap();
1399        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1400        assert_eq!(response.headers()["content-type"], "application/json");
1401
1402        let response = app
1403            .oneshot(Request::builder().uri("/api").body(Body::empty()).unwrap())
1404            .await
1405            .unwrap();
1406        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1407        assert_eq!(response.headers()["content-type"], "application/json");
1408    }
1409
1410    #[test]
1411    fn origin_allowlist_accepts_only_local_dev_and_tauri() {
1412        // Back-compat / test path (bind None): any port, any loopback host —
1413        // localhost by name or a loopback IP literal (127/8, [::1]); a page
1414        // on 127.0.0.10 is the same trust class as one on 127.0.0.1.
1415        for allowed in [
1416            "http://localhost",
1417            "http://localhost:80",
1418            "http://localhost:5173",
1419            "http://127.0.0.1",
1420            "http://127.0.0.1:65535",
1421            "http://127.0.0.10:8080",
1422            "http://[::1]:5173",
1423            "tauri://localhost",
1424            "http://tauri.localhost",
1425        ] {
1426            assert!(origin_allowed(allowed, None), "should allow {allowed}");
1427        }
1428        for denied in [
1429            "https://evil.example",
1430            // Prefix tricks a substring check would fall for.
1431            "http://localhost.evil.example",
1432            "http://localhost.evil.example:5173",
1433            "http://127.0.0.1.evil.example",
1434            "http://localhostx",
1435            // Non-loopback IP origins are the WS LAN path's business
1436            // (ws_origin_allowed), never CORS-approved here.
1437            "http://192.168.1.5:4560",
1438            // Not a valid u16 port.
1439            "http://localhost:99999",
1440            "http://localhost:5173.evil.example",
1441            // Only the schemes/hosts the dashboard actually runs under.
1442            "https://localhost:5173",
1443            "https://tauri.localhost",
1444            "tauri://evil.example",
1445            "null",
1446            "",
1447        ] {
1448            assert!(!origin_allowed(denied, None), "should deny {denied}");
1449        }
1450    }
1451
1452    #[test]
1453    fn origin_allowlist_scopes_localhost_to_bind_and_dev_ports() {
1454        // Same-origin (bound ip:port), the vite proxy (:5173), and Tauri dev
1455        // (:1420) must work; any OTHER localhost port is an unrelated local
1456        // app whose page must not get cross-origin read approval.
1457        let bind = Some(std::net::SocketAddr::from(([127, 0, 0, 1], 4560)));
1458        for allowed in [
1459            "http://localhost:4560",
1460            "http://127.0.0.1:4560",
1461            "http://localhost:5173",
1462            "http://127.0.0.1:5173",
1463            "http://localhost:1420",
1464            "tauri://localhost",
1465            "http://tauri.localhost",
1466        ] {
1467            assert!(
1468                origin_allowed(allowed, bind),
1469                "should allow {allowed} for bind 127.0.0.1:4560"
1470            );
1471        }
1472        for denied in [
1473            "http://localhost:8080",
1474            "http://127.0.0.1:8080",
1475            "http://localhost", // implied :80 != 4560
1476            "http://127.0.0.1",
1477            // Co-resident loopback listener on kranz's OWN port: a different
1478            // loopback IP is a different process (unprivileged bind on
1479            // Linux); its page must not get tokenless cross-origin reads.
1480            "http://127.0.0.2:4560",
1481            "http://127.0.0.10:4560",
1482            "http://127.0.0.2:5173",
1483            "http://127.0.0.10:1420",
1484            "http://[::1]:4560",
1485            "http://localhost.evil.example:4560",
1486            "https://localhost:4560",
1487            "https://evil.example",
1488        ] {
1489            assert!(
1490                !origin_allowed(denied, bind),
1491                "should deny {denied} for bind 127.0.0.1:4560"
1492            );
1493        }
1494        // A serve actually bound on :80 keeps its own portless same-origin.
1495        assert!(origin_allowed(
1496            "http://localhost",
1497            Some(std::net::SocketAddr::from(([127, 0, 0, 1], 80)))
1498        ));
1499    }
1500
1501    #[test]
1502    fn origin_allowlist_follows_the_actual_bound_ip() {
1503        // `--host 127.0.0.2`: its own page works, the canonical-localhost
1504        // forms (which that serve does NOT answer on) do not.
1505        let bind = Some(std::net::SocketAddr::from(([127, 0, 0, 2], 4560)));
1506        assert!(origin_allowed("http://127.0.0.2:4560", bind));
1507        assert!(!origin_allowed("http://127.0.0.1:4560", bind));
1508        assert!(!origin_allowed("http://localhost:4560", bind));
1509        // Dev-server pages stay approved regardless of bind IP.
1510        assert!(origin_allowed("http://localhost:5173", bind));
1511
1512        // `--host ::1`: bracketed v6 same-origin plus the localhost name.
1513        let bind_v6 = Some(std::net::SocketAddr::from((
1514            std::net::Ipv6Addr::LOCALHOST,
1515            4560,
1516        )));
1517        assert!(origin_allowed("http://[::1]:4560", bind_v6));
1518        assert!(origin_allowed("http://localhost:4560", bind_v6));
1519        assert!(!origin_allowed("http://127.0.0.2:4560", bind_v6));
1520
1521        // `--host 0.0.0.0` listens on every interface: any loopback page on
1522        // the bind port is genuinely this server.
1523        let bind_any = Some(std::net::SocketAddr::from(([0, 0, 0, 0], 4560)));
1524        assert!(origin_allowed("http://127.0.0.1:4560", bind_any));
1525        assert!(origin_allowed("http://127.0.0.5:4560", bind_any));
1526        assert!(origin_allowed("http://localhost:4560", bind_any));
1527        assert!(!origin_allowed("http://localhost:8080", bind_any));
1528    }
1529
1530    #[test]
1531    fn ws_origin_loopback_keeps_strict_browser_allowlist() {
1532        use super::ws_origin_allowed;
1533        let bind = Some(std::net::SocketAddr::from(([127, 0, 0, 1], 4560)));
1534        assert!(ws_origin_allowed(Some("http://localhost:4560"), bind, true));
1535        assert!(ws_origin_allowed(Some("http://localhost:5173"), bind, true));
1536        assert!(
1537            !ws_origin_allowed(None, bind, true),
1538            "missing Origin stays rejected on loopback (reads are tokenless)"
1539        );
1540        assert!(!ws_origin_allowed(
1541            Some("http://192.168.1.5:4560"),
1542            bind,
1543            true
1544        ));
1545        assert!(
1546            !ws_origin_allowed(Some("http://127.0.0.2:4560"), bind, true),
1547            "co-resident loopback listener page must not open the tokenless WS"
1548        );
1549        assert!(
1550            !ws_origin_allowed(Some("http://127.0.0.2:5173"), bind, true),
1551            "a dev port must not privilege another independently bindable loopback IP"
1552        );
1553        assert!(!ws_origin_allowed(Some("http://evil.example"), bind, true));
1554    }
1555
1556    #[test]
1557    fn ws_origin_lan_accepts_ip_literals_and_native_clients() {
1558        use super::ws_origin_allowed;
1559        let bind = Some(std::net::SocketAddr::from(([0, 0, 0, 0], 4560)));
1560        // Same-origin LAN dashboard, bracketed v6, dev proxy, and header-less
1561        // native clients all pass — the read token authenticates the upgrade.
1562        assert!(ws_origin_allowed(
1563            Some("http://192.168.1.5:4560"),
1564            bind,
1565            false
1566        ));
1567        assert!(ws_origin_allowed(
1568            Some("http://[fd00::5]:4560"),
1569            bind,
1570            false
1571        ));
1572        assert!(ws_origin_allowed(
1573            Some("http://localhost:5173"),
1574            bind,
1575            false
1576        ));
1577        assert!(ws_origin_allowed(None, bind, false));
1578        // DNS-named (rebinding) pages and non-http schemes stay out.
1579        for denied in [
1580            "http://evil.example:4560",
1581            "http://192.168.1.5.evil.example:4560",
1582            "https://192.168.1.5:4560",
1583            "http://[::1:4560",
1584            "null",
1585            "",
1586        ] {
1587            assert!(
1588                !ws_origin_allowed(Some(denied), bind, false),
1589                "should deny {denied} off loopback"
1590            );
1591        }
1592    }
1593
1594    #[test]
1595    fn host_allowlist_loopback_rejects_lan_and_dns() {
1596        for allowed in [
1597            "localhost",
1598            "localhost:4560",
1599            "LOCALHOST:5173",
1600            "127.0.0.1",
1601            "127.0.0.1:65535",
1602            // Any loopback literal serves: `--host 127.0.0.2` must answer.
1603            "127.0.0.2:4560",
1604            "::1",
1605            "[::1]",
1606            "[::1]:4560",
1607        ] {
1608            assert!(
1609                host_allowed(allowed, true),
1610                "loopback bind should allow {allowed}"
1611            );
1612        }
1613        for denied in [
1614            "evil.example",
1615            "evil.example:4560",
1616            "localhost.evil.example",
1617            "192.168.1.10",
1618            "192.168.1.10:4560",
1619            "10.0.0.1:8080",
1620            // A full (non-loopback) IPv6 address, NOT ::1 with a port.
1621            "::1:4560",
1622            // Unclosed bracket.
1623            "[::1",
1624            "",
1625        ] {
1626            assert!(
1627                !host_allowed(denied, true),
1628                "loopback bind should deny {denied}"
1629            );
1630        }
1631    }
1632
1633    #[test]
1634    fn host_allowlist_lan_accepts_ip_hosts() {
1635        for allowed in [
1636            "192.168.1.10",
1637            "192.168.1.10:4560",
1638            "10.0.0.1:8080",
1639            "localhost",
1640            "127.0.0.1:4560",
1641            "[::1]:4560",
1642        ] {
1643            assert!(
1644                host_allowed(allowed, false),
1645                "LAN bind should allow {allowed}"
1646            );
1647        }
1648        for denied in [
1649            "evil.example",
1650            "evil.example:4560",
1651            "localhost.evil.example",
1652            "",
1653        ] {
1654            assert!(
1655                !host_allowed(denied, false),
1656                "LAN bind should still deny DNS Host {denied}"
1657            );
1658        }
1659    }
1660}