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("/missions/{id}/standards", get(rest::mission_standards))
509        .route(
510            "/missions/{id}/standards/waiver",
511            post(rest::post_standards_waiver),
512        )
513        .route("/missions/{id}/workspace", get(rest::mission_workspace))
514        .route("/missions/{id}/events", get(rest::mission_events))
515        .route("/missions/{id}/plan", get(rest::mission_plan))
516        .route("/missions/{id}/plan.md", get(rest::mission_plan_md))
517        .route(
518            "/missions/{id}/revision-diff",
519            get(rest::mission_revision_diff),
520        )
521        .route("/missions/{id}/report.md", get(rest::mission_report_md))
522        .route("/missions/{id}/diff-stat", get(rest::mission_diff_stat))
523        .route("/missions/{id}/pr-handoff", get(rest::mission_pr_handoff))
524        .route(
525            "/missions/{id}/pr-handoff/create",
526            post(rest::mission_pr_create),
527        )
528        .route("/missions/{id}/readiness", get(rest::mission_readiness))
529        .route(
530            "/missions/{id}/runs/{run_id}/transcript",
531            get(rest::run_transcript),
532        )
533        .route("/missions/{id}/hook-status", get(rest::mission_hook_status))
534        .route("/missions/{id}/control", post(rest::post_control))
535        .route("/missions/{id}/revise", post(rest::post_revise))
536        .route(
537            "/missions/{id}/revision/approve",
538            post(rest::post_revision_approve),
539        )
540        .route(
541            "/missions/{id}/revision/reject",
542            post(rest::post_revision_reject),
543        )
544        .route(
545            "/missions/{id}/grant/approve",
546            post(rest::post_grant_approve),
547        )
548        .route("/missions/{id}/grant/deny", post(rest::post_grant_deny))
549        .route(
550            "/missions/{id}/question/answer",
551            post(rest::post_question_answer),
552        )
553        .route("/missions/{id}/planning/turn", post(host::planning_turn))
554        .route(
555            "/missions/{id}/planning/request-plan",
556            post(host::request_plan),
557        )
558        .route("/missions/{id}/approve", post(host::approve_mission))
559        .route("/missions/{id}/start", post(host::start_mission))
560        .route("/missions/{id}/pending-plan", get(host::pending_plan_route))
561        .route(
562            "/missions/{id}/approve-pending",
563            post(host::approve_pending_route),
564        )
565        .route("/missions/{id}/abandon", post(host::abandon_mission_route))
566        .route("/missions/{id}/release", post(host::release_mission_route))
567        .route("/missions/{id}/delete", post(host::delete_mission_route))
568        .route("/missions/{id}/merge", post(host::merge_mission_route))
569        .route("/missions/{id}/ws", get(ws::ws_handler))
570        .route(
571            "/tickets",
572            get(tickets::list_tickets).post(tickets::create_ticket),
573        )
574        .route("/tickets/{slug}", get(tickets::get_ticket))
575        .route("/tickets/{slug}/draft", post(tickets::draft_ticket))
576        .route("/tickets/{slug}/approve", post(tickets::approve_ticket))
577        .route("/queue", get(host::queue_state_route))
578        .route("/queue/drain", post(host::drain_queue_route));
579    // Exercise future suffix collisions through the real router composition,
580    // without exposing synthetic endpoints in production or a public test API.
581    #[cfg(test)]
582    let routes = routes
583        .route(
584            "/future/hook-status",
585            post(|| async { StatusCode::NO_CONTENT }),
586        )
587        .route(
588            "/future/hooks/github",
589            post(|| async { StatusCode::NO_CONTENT }),
590        );
591    routes
592}
593
594/// Only these POSTs bypass serve-token authentication. Each handler checks
595/// its own authority: GitHub HMAC or a per-run hook capability.
596fn independently_authenticated_hook_routes() -> Router<Arc<ServerState>> {
597    Router::new()
598        .route("/hooks/github", post(hooks::github_hook))
599        .route(
600            "/hook-status",
601            post(rest::post_hook_status).route_layer(axum::extract::DefaultBodyLimit::max(
602                kranz_engine::hook_status::SIGNAL_BODY_MAX_BYTES,
603            )),
604        )
605}
606
607fn embedded_static_response(uri: Uri, files: &'static [EmbeddedFile]) -> Response {
608    let requested = uri.path().trim_start_matches('/');
609    let requested = if requested.is_empty() {
610        "index.html"
611    } else {
612        requested
613    };
614    let file = files
615        .iter()
616        .find(|file| file.path == requested)
617        .or_else(|| files.iter().find(|file| file.path == "index.html"));
618
619    let Some(file) = file else {
620        return StatusCode::NOT_FOUND.into_response();
621    };
622
623    Response::builder()
624        .status(StatusCode::OK)
625        .header(header::CONTENT_TYPE, file.content_type)
626        .body(Body::from(file.bytes))
627        .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
628}
629
630/// CORS for localhost tooling (dashboard dev server, Tauri webview).
631///
632/// `CorsLayer::permissive()` echoed ANY `Origin` back in
633/// `access-control-allow-origin`, so a script on any website could read
634/// mission data from this (unauthenticated, fixed-port) server and POST
635/// `ControlCommand`s into the orchestrator — a drive-by instruction
636/// injection. Only the origins the dashboard can actually run under are
637/// approved instead. That closes the drive-by vector because browsers
638/// enforce CORS per-origin:
639///
640/// - the control POST must be `application/json` (see
641///   [`require_json_api_posts`]), which is never a CORS-"simple" request, so
642///   browsers always send a preflight — an unapproved origin's preflight
643///   comes back without allow headers and the POST is never sent;
644/// - cross-origin reads require the response to carry an
645///   `access-control-allow-origin` approving the reader, which is only
646///   emitted for the origins below.
647///
648/// Non-browser clients (curl, the engine, tests) send no `Origin` header and
649/// pass through untouched — CORS is a browser-enforced mechanism.
650fn cors_layer(bind_addr: Option<SocketAddr>) -> CorsLayer {
651    CorsLayer::new()
652        .allow_origin(AllowOrigin::predicate(
653            move |origin: &HeaderValue, _request_parts| {
654                origin.to_str().is_ok_and(|o| origin_allowed(o, bind_addr))
655            },
656        ))
657        .allow_methods([Method::GET, Method::POST])
658        .allow_headers([header::CONTENT_TYPE, HeaderName::from_static(TOKEN_HEADER)])
659}
660
661/// Trusted origins for CORS and the browser WebSocket upgrade Origin check.
662///
663/// Always: `tauri://localhost` (macOS/Linux Tauri) and
664/// `http://tauri.localhost` (Windows Tauri). Beyond those, an origin is
665/// approved only when its host is LOCAL (the `localhost` name or a loopback
666/// IP literal — DNS names like `localhost.evil.example` fail the IP parse)
667/// AND its port fits the bind scoping below. On loopback binds GETs and the
668/// WS upgrade are tokenless, so this allowlist is what stands between an
669/// unrelated local page and mission state.
670///
671/// Scoping against the bound address (`Some(bind)`):
672/// - the dev-server ports (vite :5173, Tauri devUrl :1420) are approved for
673///   canonical localhost only (`localhost`, `127.0.0.1`, or `::1`), never
674///   another address in 127/8 that a co-resident process can claim;
675/// - the bind port is approved only for the SAME-ORIGIN page: an IP host
676///   must equal the bound IP (any loopback IP when the bind is
677///   unspecified/0.0.0.0, which listens on them all), and the `localhost`
678///   name only when the bind IP is one localhost resolves to (127.0.0.1,
679///   ::1, or unspecified). Pinning the IP — not just the port — matters: on
680///   Linux an unprivileged co-resident process can bind ANOTHER loopback
681///   address (127.0.0.2) on kranz's own port and serve a hostile page; a
682///   port-only rule would hand that page tokenless cross-origin reads.
683///
684/// `bind_addr: None` (back-compat test wrappers only) keeps the old
685/// any-loopback-host, any-port wildcard.
686pub(crate) fn origin_allowed(origin: &str, bind_addr: Option<SocketAddr>) -> bool {
687    if origin == "tauri://localhost" || origin == "http://tauri.localhost" {
688        return true;
689    }
690    // Dev-server origins that must keep working on every bind: the vite
691    // proxy (5173) and Tauri's devUrl (1420). Restrict these privileged ports
692    // to canonical localhost; every other 127/8 address is independently
693    // bindable by an unprivileged co-resident process.
694    const DEV_PORTS: [u16; 2] = [5173, 1420];
695    let Some(authority) = origin.strip_prefix("http://") else {
696        return false;
697    };
698    let Some((host, port)) = split_host_port(authority) else {
699        return false;
700    };
701    let host_ip = host.parse::<std::net::IpAddr>().ok();
702    let host_local = host == "localhost" || host_ip.is_some_and(|ip| ip.is_loopback());
703    if !host_local {
704        return false;
705    }
706    let Some(bind) = bind_addr else {
707        return true; // back-compat wildcard
708    };
709    if DEV_PORTS.contains(&port) {
710        return host == "localhost"
711            || host_ip == Some(std::net::IpAddr::V4(Ipv4Addr::LOCALHOST))
712            || host_ip == Some(std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST));
713    }
714    if port != bind.port() {
715        return false;
716    }
717    match host_ip {
718        Some(ip) => ip == bind.ip() || (bind.ip().is_unspecified() && ip.is_loopback()),
719        // The `localhost` NAME resolves to 127.0.0.1 / ::1 — approve it only
720        // when the server actually answers there.
721        None => {
722            bind.ip().is_unspecified()
723                || bind.ip() == std::net::IpAddr::V4(Ipv4Addr::LOCALHOST)
724                || bind.ip() == std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)
725        }
726    }
727}
728
729/// Split an origin authority — `host[:port]` or `[v6][:port]` — into
730/// hostname and port (default 80). `None` on malformed brackets or ports.
731/// An unbracketed hostname containing `:` is a bare IPv6 authority, which
732/// cannot carry a port.
733fn split_host_port(authority: &str) -> Option<(&str, u16)> {
734    if let Some(rest) = authority.strip_prefix('[') {
735        let (addr, tail) = rest.split_once(']')?;
736        let port = if tail.is_empty() {
737            80
738        } else {
739            tail.strip_prefix(':')?.parse().ok()?
740        };
741        return Some((addr, port));
742    }
743    match authority.rsplit_once(':') {
744        Some((host, _)) if host.contains(':') => Some((authority, 80)),
745        Some((host, port)) => Some((host, port.parse().ok()?)),
746        None => Some((authority, 80)),
747    }
748}
749
750/// Origin policy for the WebSocket upgrade.
751///
752/// Browsers send the page origin on SAME-origin WS handshakes too, so a
753/// dashboard served off a LAN/tailnet bind presents `http://<ip>:<port>` —
754/// which [`origin_allowed`] rejects. Off loopback the read token already
755/// gates the upgrade, so the Origin check only needs to keep DNS-named
756/// (rebinding) pages out: accept any origin whose host parses as an IP
757/// literal, and accept a MISSING Origin (native, non-browser clients). On
758/// loopback binds reads are tokenless and the strict browser allowlist
759/// stays the guard — missing Origin remains rejected there.
760pub(crate) fn ws_origin_allowed(
761    origin: Option<&str>,
762    bind_addr: Option<SocketAddr>,
763    bind_is_loopback: bool,
764) -> bool {
765    match origin {
766        None => !bind_is_loopback,
767        Some(origin) => {
768            origin_allowed(origin, bind_addr) || (!bind_is_loopback && origin_host_is_ip(origin))
769        }
770    }
771}
772
773/// `http://<ip>[:port]` (v4 or bracketed v6) — the origin shape a browser
774/// sends for a page loaded straight off a LAN/tailnet serve. DNS-named
775/// origins fail the IP parse, keeping rebinding pages out.
776fn origin_host_is_ip(origin: &str) -> bool {
777    origin
778        .strip_prefix("http://")
779        .and_then(split_host_port)
780        .is_some_and(|(host, _)| host.parse::<std::net::IpAddr>().is_ok())
781}
782
783/// Host gate: browsers always send `Host`, so DNS rebinding attempts arrive
784/// as the attacker-controlled hostname and are rejected before tokenless
785/// reads can return mission state or transcripts.
786///
787/// Path-only in-process requests used by `tower::ServiceExt::oneshot` carry no
788/// Host header and are allowed; real network HTTP/1.1 requests present Host.
789async fn require_host(State(gate): State<HostGate>, request: Request, next: Next) -> Response {
790    if let Some(host) = request.headers().get(header::HOST) {
791        if !host
792            .to_str()
793            .is_ok_and(|h| host_allowed(h, gate.bind_is_loopback))
794        {
795            return (
796                StatusCode::FORBIDDEN,
797                Json(json!({ "error": "invalid host" })),
798            )
799                .into_response();
800        }
801    }
802    next.run(request).await
803}
804
805/// Trusted HTTP Host values.
806///
807/// Always: `localhost` (optional `:<u16>`) and any LOOPBACK IP literal —
808/// `127.0.0.1`, other 127/8 addresses, `::1` (bare or bracketed), each with
809/// an optional port. Loopback literals cannot be planted by DNS rebinding
810/// (browsers send the attacker's hostname, not the IP it resolves to), and
811/// operators legitimately bind e.g. `--host 127.0.0.2`.
812///
813/// When `bind_is_loopback` is false (LAN / tailnet serve): any Host whose
814/// hostname parses as an IP is accepted — the operator intentionally
815/// exposed non-loopback, and the mutation token (including on GET/WS)
816/// is what authenticates. Hostname DNS-rebinding still fails the IP
817/// parse; browsers sending `evil.example` are rejected.
818fn host_allowed(host: &str, bind_is_loopback: bool) -> bool {
819    let host = host.trim().to_ascii_lowercase();
820    if host_is_loopback(&host) {
821        return true;
822    }
823    if bind_is_loopback {
824        return false;
825    }
826    host_ip(&host).is_some()
827}
828
829fn host_is_loopback(host: &str) -> bool {
830    if host == "localhost" {
831        return true;
832    }
833    if let Some(port) = host.strip_prefix("localhost:") {
834        return port.parse::<u16>().is_ok();
835    }
836    host_ip(host).is_some_and(|ip| ip.is_loopback())
837}
838
839/// Parse the hostname of a `Host` header value — bare IP (v4, or unbracketed
840/// v6, which may itself contain `:` and carries no port), `v4:port`, or
841/// `[v6]` with optional `:port` — as an IP address. `None` for DNS names,
842/// malformed brackets, and invalid ports.
843fn host_ip(host: &str) -> Option<std::net::IpAddr> {
844    if let Ok(ip) = host.parse::<std::net::IpAddr>() {
845        return Some(ip);
846    }
847    if let Some(rest) = host.strip_prefix('[') {
848        let (addr, tail) = rest.split_once(']')?;
849        if !(tail.is_empty()
850            || tail
851                .strip_prefix(':')
852                .is_some_and(|p| p.parse::<u16>().is_ok()))
853        {
854            return None;
855        }
856        return addr.parse().ok();
857    }
858    let (addr, port) = host.rsplit_once(':')?;
859    if port.parse::<u16>().is_err() {
860        return None;
861    }
862    addr.parse().ok()
863}
864
865/// Reject any `POST /api/...` with a non-empty body whose content-type is
866/// not `application/json`.
867///
868/// The control handler parses raw bytes, so without this gate a drive-by
869/// page could bypass the CORS preflight entirely: `text/plain` (or
870/// form-encoded) POSTs are CORS-"simple" and browsers send them cross-origin
871/// WITHOUT a preflight — the attacker cannot read the response, but the
872/// ControlCommand side effect would already have happened. Requiring JSON
873/// forces every browser POST into the preflighted path that
874/// [`cors_layer`] guards.
875///
876/// A body already known to be empty is exempt. Unknown-length streams must
877/// declare JSON even if they later end without data. This uses the body's
878/// end-of-stream signal without buffering or consuming a request; missing
879/// Content-Length (including chunked requests) is not proof of emptiness.
880async fn require_json_api_posts(request: Request, next: Next) -> Response {
881    if request.method() == Method::POST && request.uri().path().starts_with("/api/") {
882        let is_empty_body = request.body().is_end_stream();
883        let is_json = request
884            .headers()
885            .get(header::CONTENT_TYPE)
886            .and_then(|value| value.to_str().ok())
887            .and_then(|value| value.split(';').next())
888            .is_some_and(|mime| mime.trim().eq_ignore_ascii_case("application/json"));
889        if !is_empty_body && !is_json {
890            return (
891                StatusCode::UNSUPPORTED_MEDIA_TYPE,
892                Json(json!({ "error": "POST bodies must be application/json" })),
893            )
894                .into_response();
895        }
896    }
897    next.run(request).await
898}
899
900/// Token gate state: mandatory mutation authority plus whether non-loopback
901/// binds also require it on GET / WS upgrade, and the distinct read-only token
902/// accepted on gated reads only.
903#[derive(Clone)]
904struct TokenGate {
905    authority: MutationAuthority,
906    read_authority: String,
907    require_read_token: bool,
908}
909
910/// Host gate state: whether the serve bind is loopback (strict Host) or
911/// LAN/tailnet (accept any Host that parses as an IP).
912#[derive(Clone)]
913struct HostGate {
914    bind_is_loopback: bool,
915}
916
917/// Require mutation authority on protected POST routes. Gated GET/HEAD reads
918/// accept either token in the header, but only read authority in a query.
919/// Browser WebSockets obtain that read authority through [`read_token`];
920/// rejecting mutation tokens in URLs also protects non-WebSocket reads.
921/// Hook routes apply their own authentication at registration instead.
922/// Layer this middleware only on protected API routes, never the SPA/static
923/// fallback. Nested routers may already have stripped their `/api` prefix.
924async fn require_mutation_token(
925    State(gate): State<TokenGate>,
926    request: Request,
927    next: Next,
928) -> Response {
929    let expected = gate.authority.as_str();
930    let path = request.uri().path();
931    let is_health = path == "/api/health";
932    let is_read = request.method() == Method::GET || request.method() == Method::HEAD;
933    let needs_auth =
934        !is_health && (request.method() == Method::POST || (gate.require_read_token && is_read));
935    if needs_auth {
936        let read_ok = |presented: &str| is_read && token_matches(presented, &gate.read_authority);
937        let header_ok = request
938            .headers()
939            .get(TOKEN_HEADER)
940            .and_then(|value| value.to_str().ok())
941            .is_some_and(|presented| token_matches(presented, expected) || read_ok(presented));
942        let query_ok = gate.require_read_token
943            && is_read
944            && request
945                .uri()
946                .query()
947                .map(|q| {
948                    q.split('&').any(|pair| {
949                        let mut parts = pair.splitn(2, '=');
950                        matches!(parts.next(), Some("token"))
951                            && parts.next().is_some_and(|v| {
952                                let decoded = percent_decode_token(v);
953                                read_ok(&decoded)
954                            })
955                    })
956                })
957                .unwrap_or(false);
958        if !header_ok && !query_ok {
959            return (
960                StatusCode::UNAUTHORIZED,
961                Json(json!({ "error": "missing or invalid token" })),
962            )
963                .into_response();
964        }
965    }
966    next.run(request).await
967}
968
969/// Exchange either header credential for read-only authority. This handler
970/// always checks its own header, including on otherwise tokenless loopback
971/// reads. Query credentials never grant access to the exchange response.
972async fn read_token(State(gate): State<TokenGate>, request: Request) -> Response {
973    let valid = request
974        .headers()
975        .get(TOKEN_HEADER)
976        .and_then(|value| value.to_str().ok())
977        .is_some_and(|presented| {
978            token_matches(presented, gate.authority.as_str())
979                || token_matches(presented, &gate.read_authority)
980        });
981    if !valid {
982        return (
983            StatusCode::UNAUTHORIZED,
984            Json(json!({ "error": "missing or invalid token" })),
985        )
986            .into_response();
987    }
988    let value = gate.read_authority;
989    Json(json!({ "token": value })).into_response()
990}
991
992/// Constant-time token equality: off-loopback binds expose the token gate
993/// to remote timing probes, and a short-circuiting `==` leaks how many
994/// leading bytes matched. Only the length is observable (standard for
995/// `ct_eq`, and unavoidable), which reveals nothing useful about a
996/// fixed-length uuid-hex token.
997fn token_matches(presented: &str, expected: &str) -> bool {
998    use subtle::ConstantTimeEq;
999    presented.as_bytes().ct_eq(expected.as_bytes()).into()
1000}
1001
1002/// Minimal percent-decode for `?token=` values (`%XX` only — tokens are
1003/// uuid hex so this only needs to round-trip `encodeURIComponent`).
1004fn percent_decode_token(raw: &str) -> String {
1005    let bytes = raw.as_bytes();
1006    let mut out = Vec::with_capacity(bytes.len());
1007    let mut i = 0;
1008    while i < bytes.len() {
1009        if bytes[i] == b'%' && i + 2 < bytes.len() {
1010            if let (Some(hi), Some(lo)) = (
1011                (bytes[i + 1] as char).to_digit(16),
1012                (bytes[i + 2] as char).to_digit(16),
1013            ) {
1014                out.push((hi * 16 + lo) as u8);
1015                i += 3;
1016                continue;
1017            }
1018        }
1019        if bytes[i] == b'+' {
1020            out.push(b' ');
1021        } else {
1022            out.push(bytes[i]);
1023        }
1024        i += 1;
1025    }
1026    String::from_utf8_lossy(&out).into_owned()
1027}
1028
1029/// `GET /` when no dashboard bundle is configured.
1030async fn root_info() -> &'static str {
1031    "kranz server is running (no dashboard bundle configured).\n\
1032     REST + WebSocket API under /api — see docs/protocol.md.\n"
1033}
1034
1035/// Bind `127.0.0.1:<port>` and serve the router until the process exits.
1036/// `authority` gates every `POST /api/...`.
1037pub async fn serve(
1038    repo_root: PathBuf,
1039    port: u16,
1040    static_dir: Option<PathBuf>,
1041    authority: MutationAuthority,
1042) -> anyhow::Result<()> {
1043    serve_with_static(
1044        repo_root,
1045        port,
1046        static_dir.map(DashboardStatic::Dir),
1047        authority,
1048    )
1049    .await
1050}
1051
1052/// Bind `127.0.0.1:<port>` and serve the router until the process exits.
1053pub async fn serve_with_static(
1054    repo_root: PathBuf,
1055    port: u16,
1056    static_assets: Option<DashboardStatic>,
1057    authority: MutationAuthority,
1058) -> anyhow::Result<()> {
1059    serve_with_shared_host(
1060        Arc::new(MissionHost::new(repo_root)),
1061        IpAddr::V4(Ipv4Addr::LOCALHOST),
1062        port,
1063        static_assets,
1064        authority,
1065    )
1066    .await
1067}
1068
1069/// [`serve_with_static`] over an already-shared registry (see
1070/// [`router_with_shared_host`]).
1071/// `bind` widens reachability beyond loopback (e.g. for the glasses app on
1072/// the same LAN / tailnet). Every POST stays mutation-token-gated; when
1073/// `bind` is not loopback, GETs and WS upgrades require the token too. The
1074/// CLI prints a loud warning for non-loopback binds.
1075pub async fn serve_with_shared_host(
1076    host: Arc<MissionHost>,
1077    bind: IpAddr,
1078    port: u16,
1079    static_assets: Option<DashboardStatic>,
1080    authority: MutationAuthority,
1081) -> anyhow::Result<()> {
1082    let shutdown = async {
1083        if let Err(e) = tokio::signal::ctrl_c().await {
1084            tracing::error!(error = %e, "failed to install ctrl-c handler");
1085        }
1086    };
1087    serve_with_shutdown(host, bind, port, static_assets, authority, shutdown).await
1088}
1089
1090/// Same as [`serve_with_shared_host`], but takes an explicit shutdown
1091/// signal instead of always waiting on Ctrl-C — the testable seam that lets
1092/// callers (and tests) make the serve future return deterministically.
1093pub async fn serve_with_shutdown(
1094    host: Arc<MissionHost>,
1095    bind: IpAddr,
1096    port: u16,
1097    static_assets: Option<DashboardStatic>,
1098    authority: MutationAuthority,
1099    shutdown: impl std::future::Future<Output = ()> + Send + 'static,
1100) -> anyhow::Result<()> {
1101    let listener = bind_listener(bind, port).await?;
1102    serve_on_listener(host, listener, static_assets, authority, shutdown).await
1103}
1104
1105/// Bind `bind:port` and return the listener. Callers that need the REAL
1106/// bound address before serving — `--port 0` picks an ephemeral port, and
1107/// the CLI prints/opens the URL — bind first and hand the listener to
1108/// [`serve_on_listener`].
1109pub async fn bind_listener(bind: IpAddr, port: u16) -> anyhow::Result<tokio::net::TcpListener> {
1110    Ok(tokio::net::TcpListener::bind(SocketAddr::from((bind, port))).await?)
1111}
1112
1113/// Serve the router on an already-bound listener. The router is built from
1114/// the listener's REAL local address, so `--port 0` scopes the origin
1115/// allowlist to the actual ephemeral port and a non-loopback bind gets the
1116/// read-token gate.
1117pub async fn serve_on_listener(
1118    host: Arc<MissionHost>,
1119    listener: tokio::net::TcpListener,
1120    static_assets: Option<DashboardStatic>,
1121    authority: MutationAuthority,
1122    shutdown: impl std::future::Future<Output = ()> + Send + 'static,
1123) -> anyhow::Result<()> {
1124    serve_multi_on_listener(
1125        Arc::new(MultiRepoHost::with_host(host)),
1126        listener,
1127        static_assets,
1128        authority,
1129        None,
1130        false,
1131        shutdown,
1132    )
1133    .await
1134}
1135
1136/// Serve a static multi-repository catalog on an already-bound listener.
1137/// `read_auth` forces the read-token gate (GETs and the WS upgrade) even on
1138/// a loopback bind — off-loopback binds always require it regardless.
1139/// `read_authority`, when set, is the read-only token accepted on those
1140/// gated reads (never on mutations); the mutation `authority` keeps working
1141/// for reads too.
1142pub async fn serve_multi_on_listener(
1143    multi_host: Arc<MultiRepoHost>,
1144    listener: tokio::net::TcpListener,
1145    static_assets: Option<DashboardStatic>,
1146    authority: MutationAuthority,
1147    read_authority: Option<String>,
1148    read_auth: bool,
1149    shutdown: impl std::future::Future<Output = ()> + Send + 'static,
1150) -> anyhow::Result<()> {
1151    let local_addr = listener.local_addr()?;
1152    let bind_is_loopback = local_addr.ip().is_loopback();
1153    let require_read_token = !bind_is_loopback || read_auth;
1154    let app = router_with_read_authority_and_addr(
1155        multi_host,
1156        static_assets,
1157        authority,
1158        read_authority,
1159        Some(local_addr),
1160        bind_is_loopback,
1161        require_read_token,
1162    );
1163    tracing::info!("kranz server listening on http://{local_addr}");
1164    axum::serve(listener, app)
1165        .with_graceful_shutdown(shutdown)
1166        .await?;
1167    Ok(())
1168}
1169
1170#[cfg(test)]
1171mod tests {
1172    use super::{
1173        host_allowed, origin_allowed, router_with_multi_repo_host_and_addr, EmbeddedFile,
1174        HostConfig, MultiRepoHost, RepoConfig, RepoSlackConfig,
1175    };
1176    use axum::body::Body;
1177    use axum::http::{Request, StatusCode};
1178    use http_body_util::BodyExt;
1179    use kranz_engine::event_log::{EventLog, LockForce};
1180    use kranz_engine::events::EventKind;
1181    use kranz_engine::paths::MissionPaths;
1182    use kranz_engine::types::MissionConfig;
1183    use std::path::{Path, PathBuf};
1184    use std::sync::Arc;
1185    use std::time::Duration;
1186    use tower::ServiceExt;
1187
1188    fn authority() -> super::MutationAuthority {
1189        super::MutationAuthority::new("tok").unwrap()
1190    }
1191
1192    #[tokio::test]
1193    async fn registered_hook_suffix_posts_require_mutation_authority() {
1194        let temp = tempfile::tempdir().unwrap();
1195        let app = super::repo_api_routes(super::TokenGate {
1196            authority: authority(),
1197            read_authority: "dummy-read".into(),
1198            require_read_token: true,
1199        })
1200        .with_state(Arc::new(super::ServerState {
1201            repo_root: temp.path().into(),
1202            host: Arc::new(super::MissionHost::new(temp.path().into())),
1203            bind_addr: None,
1204            bind_is_loopback: true,
1205        }));
1206        for path in ["/future/hook-status", "/future/hooks/github"] {
1207            for (presented, expected) in [
1208                (None, StatusCode::UNAUTHORIZED),
1209                (Some("dummy-read"), StatusCode::UNAUTHORIZED),
1210                (Some("tok"), StatusCode::NO_CONTENT),
1211            ] {
1212                let mut request = Request::post(path);
1213                if let Some(value) = presented {
1214                    request = request.header(super::TOKEN_HEADER, value);
1215                }
1216                let response = app
1217                    .clone()
1218                    .oneshot(request.body(Body::empty()).unwrap())
1219                    .await
1220                    .unwrap();
1221                assert_eq!(response.status(), expected, "registered route: {path}");
1222            }
1223        }
1224    }
1225
1226    fn seed_planning_mission(root: &Path, goal: &str) {
1227        std::fs::create_dir_all(root).unwrap();
1228        let status = std::process::Command::new("git")
1229            .args(["init", "-q"])
1230            .arg(root)
1231            .status()
1232            .unwrap();
1233        assert!(status.success());
1234        let paths = MissionPaths::new(root, "same-id");
1235        let mut log = EventLog::acquire(&paths, "same-id", Duration::ZERO, LockForce::No).unwrap();
1236        log.append(EventKind::MissionCreated {
1237            goal: goal.to_string(),
1238            base_branch: "main".to_string(),
1239            mission_branch: "kranz/mission-same-id".to_string(),
1240            config: MissionConfig::default(),
1241        })
1242        .unwrap();
1243    }
1244
1245    fn repo_config(id: &str, root: PathBuf) -> RepoConfig {
1246        RepoConfig {
1247            id: id.to_string(),
1248            root,
1249            display_name: None,
1250            group: None,
1251            pinned: false,
1252            slack: RepoSlackConfig::default(),
1253        }
1254    }
1255
1256    #[tokio::test]
1257    async fn unavailable_repo_routes_return_503_with_reason() {
1258        let temp = tempfile::tempdir().unwrap();
1259        let good = temp.path().join("good");
1260        seed_planning_mission(&good, "goal");
1261        let missing = temp.path().join("missing");
1262
1263        let multi = Arc::new(
1264            MultiRepoHost::from_config(HostConfig {
1265                default_repo: None,
1266                max_concurrent_repos: 1,
1267                repos: vec![repo_config("good", good), repo_config("gone", missing)],
1268            })
1269            .unwrap(),
1270        );
1271        let app = router_with_multi_repo_host_and_addr(multi, None, authority(), None, true, false);
1272
1273        // Bare prefix and deep path both 503 with the reason (explicit routes
1274        // beat the `/api/{*path}` catch-all; a nested fallback would not).
1275        for uri in ["/api/repos/gone", "/api/repos/gone/queue"] {
1276            let response = app
1277                .clone()
1278                .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
1279                .await
1280                .unwrap();
1281            assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE, "{uri}");
1282            let body = response.into_body().collect().await.unwrap().to_bytes();
1283            let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1284            assert_eq!(json["error"], "repository unavailable", "{uri}");
1285            assert_eq!(json["repoId"], "gone", "{uri}");
1286            assert!(json["detail"].as_str().unwrap().contains("does not exist"));
1287        }
1288
1289        // An unknown repo id still misses as 404 — unavailable stays
1290        // distinguishable from a typo.
1291        let response = app
1292            .clone()
1293            .oneshot(
1294                Request::builder()
1295                    .uri("/api/repos/nope/queue")
1296                    .body(Body::empty())
1297                    .unwrap(),
1298            )
1299            .await
1300            .unwrap();
1301        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1302
1303        // The healthy sibling is unaffected.
1304        let response = app
1305            .clone()
1306            .oneshot(
1307                Request::builder()
1308                    .uri("/api/repos/good/missions/same-id/state")
1309                    .body(Body::empty())
1310                    .unwrap(),
1311            )
1312            .await
1313            .unwrap();
1314        assert_eq!(response.status(), StatusCode::OK);
1315    }
1316
1317    #[tokio::test]
1318    async fn unavailable_default_repo_reports_503_on_the_unscoped_alias() {
1319        let temp = tempfile::tempdir().unwrap();
1320        let good = temp.path().join("good");
1321        seed_planning_mission(&good, "goal");
1322        let missing = temp.path().join("missing");
1323
1324        let multi = Arc::new(
1325            MultiRepoHost::from_config(HostConfig {
1326                default_repo: Some("gone".to_string()),
1327                max_concurrent_repos: 1,
1328                repos: vec![repo_config("good", good), repo_config("gone", missing)],
1329            })
1330            .unwrap(),
1331        );
1332        let app = router_with_multi_repo_host_and_addr(multi, None, authority(), None, true, false);
1333
1334        // Every unscoped route addresses the default repo; its unavailability
1335        // is reported instead of a generic "scope required" 404.
1336        let response = app
1337            .clone()
1338            .oneshot(
1339                Request::builder()
1340                    .uri("/api/queue")
1341                    .body(Body::empty())
1342                    .unwrap(),
1343            )
1344            .await
1345            .unwrap();
1346        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1347        let body = response.into_body().collect().await.unwrap().to_bytes();
1348        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1349        assert_eq!(json["repoId"], "gone");
1350
1351        // The health probe and healthy scoped routes stay live.
1352        let response = app
1353            .clone()
1354            .oneshot(
1355                Request::builder()
1356                    .uri("/api/health")
1357                    .body(Body::empty())
1358                    .unwrap(),
1359            )
1360            .await
1361            .unwrap();
1362        assert_eq!(response.status(), StatusCode::OK);
1363        let response = app
1364            .clone()
1365            .oneshot(
1366                Request::builder()
1367                    .uri("/api/repos/good/missions/same-id/state")
1368                    .body(Body::empty())
1369                    .unwrap(),
1370            )
1371            .await
1372            .unwrap();
1373        assert_eq!(response.status(), StatusCode::OK);
1374    }
1375
1376    #[tokio::test]
1377    async fn repo_scoped_routes_isolate_duplicate_mission_ids_and_mutations() {
1378        let temp = tempfile::tempdir().unwrap();
1379        let a = temp.path().join("a");
1380        let b = temp.path().join("b");
1381        seed_planning_mission(&a, "goal-a");
1382        seed_planning_mission(&b, "goal-b");
1383
1384        let multi = Arc::new(
1385            MultiRepoHost::from_config(HostConfig {
1386                default_repo: None,
1387                max_concurrent_repos: 1,
1388                repos: vec![repo_config("a", a.clone()), repo_config("b", b.clone())],
1389            })
1390            .unwrap(),
1391        );
1392        static EMBEDDED: &[EmbeddedFile] = &[EmbeddedFile {
1393            path: "index.html",
1394            bytes: b"dashboard",
1395            content_type: "text/html",
1396        }];
1397        let app = router_with_multi_repo_host_and_addr(
1398            multi,
1399            Some(super::DashboardStatic::Embedded(EMBEDDED)),
1400            authority(),
1401            None,
1402            true,
1403            false,
1404        );
1405
1406        for (repo_id, expected_goal) in [("a", "goal-a"), ("b", "goal-b")] {
1407            let response = app
1408                .clone()
1409                .oneshot(
1410                    Request::builder()
1411                        .uri(format!("/api/repos/{repo_id}/missions/same-id/state"))
1412                        .body(Body::empty())
1413                        .unwrap(),
1414                )
1415                .await
1416                .unwrap();
1417            assert_eq!(response.status(), StatusCode::OK);
1418            let body = response.into_body().collect().await.unwrap().to_bytes();
1419            let state: serde_json::Value = serde_json::from_slice(&body).unwrap();
1420            assert_eq!(state["mission"]["goal"], expected_goal);
1421        }
1422
1423        let response = app
1424            .clone()
1425            .oneshot(
1426                Request::builder()
1427                    .method("POST")
1428                    .uri("/api/repos/a/missions/same-id/control")
1429                    .header("content-type", "application/json")
1430                    .header(super::TOKEN_HEADER, "tok")
1431                    .body(Body::from(r#"{"kind":"pause"}"#))
1432                    .unwrap(),
1433            )
1434            .await
1435            .unwrap();
1436        assert_eq!(response.status(), StatusCode::ACCEPTED);
1437        assert_eq!(
1438            std::fs::read_dir(MissionPaths::new(&a, "same-id").control_dir())
1439                .unwrap()
1440                .count(),
1441            1
1442        );
1443        assert_eq!(
1444            std::fs::read_dir(MissionPaths::new(&b, "same-id").control_dir())
1445                .unwrap()
1446                .count(),
1447            0
1448        );
1449
1450        // No explicit default and two healthy roots: the legacy mutation path
1451        // is not mounted and therefore cannot guess a target.
1452        let response = app
1453            .clone()
1454            .oneshot(
1455                Request::builder()
1456                    .method("POST")
1457                    .uri("/api/missions/same-id/control")
1458                    .header("content-type", "application/json")
1459                    .header(super::TOKEN_HEADER, "tok")
1460                    .body(Body::from(r#"{"kind":"pause"}"#))
1461                    .unwrap(),
1462            )
1463            .await
1464            .unwrap();
1465        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1466        assert_eq!(response.headers()["content-type"], "application/json");
1467
1468        let response = app
1469            .oneshot(Request::builder().uri("/api").body(Body::empty()).unwrap())
1470            .await
1471            .unwrap();
1472        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1473        assert_eq!(response.headers()["content-type"], "application/json");
1474    }
1475
1476    #[test]
1477    fn origin_allowlist_accepts_only_local_dev_and_tauri() {
1478        // Back-compat / test path (bind None): any port, any loopback host —
1479        // localhost by name or a loopback IP literal (127/8, [::1]); a page
1480        // on 127.0.0.10 is the same trust class as one on 127.0.0.1.
1481        for allowed in [
1482            "http://localhost",
1483            "http://localhost:80",
1484            "http://localhost:5173",
1485            "http://127.0.0.1",
1486            "http://127.0.0.1:65535",
1487            "http://127.0.0.10:8080",
1488            "http://[::1]:5173",
1489            "tauri://localhost",
1490            "http://tauri.localhost",
1491        ] {
1492            assert!(origin_allowed(allowed, None), "should allow {allowed}");
1493        }
1494        for denied in [
1495            "https://evil.example",
1496            // Prefix tricks a substring check would fall for.
1497            "http://localhost.evil.example",
1498            "http://localhost.evil.example:5173",
1499            "http://127.0.0.1.evil.example",
1500            "http://localhostx",
1501            // Non-loopback IP origins are the WS LAN path's business
1502            // (ws_origin_allowed), never CORS-approved here.
1503            "http://192.168.1.5:4560",
1504            // Not a valid u16 port.
1505            "http://localhost:99999",
1506            "http://localhost:5173.evil.example",
1507            // Only the schemes/hosts the dashboard actually runs under.
1508            "https://localhost:5173",
1509            "https://tauri.localhost",
1510            "tauri://evil.example",
1511            "null",
1512            "",
1513        ] {
1514            assert!(!origin_allowed(denied, None), "should deny {denied}");
1515        }
1516    }
1517
1518    #[test]
1519    fn origin_allowlist_scopes_localhost_to_bind_and_dev_ports() {
1520        // Same-origin (bound ip:port), the vite proxy (:5173), and Tauri dev
1521        // (:1420) must work; any OTHER localhost port is an unrelated local
1522        // app whose page must not get cross-origin read approval.
1523        let bind = Some(std::net::SocketAddr::from(([127, 0, 0, 1], 4560)));
1524        for allowed in [
1525            "http://localhost:4560",
1526            "http://127.0.0.1:4560",
1527            "http://localhost:5173",
1528            "http://127.0.0.1:5173",
1529            "http://localhost:1420",
1530            "tauri://localhost",
1531            "http://tauri.localhost",
1532        ] {
1533            assert!(
1534                origin_allowed(allowed, bind),
1535                "should allow {allowed} for bind 127.0.0.1:4560"
1536            );
1537        }
1538        for denied in [
1539            "http://localhost:8080",
1540            "http://127.0.0.1:8080",
1541            "http://localhost", // implied :80 != 4560
1542            "http://127.0.0.1",
1543            // Co-resident loopback listener on kranz's OWN port: a different
1544            // loopback IP is a different process (unprivileged bind on
1545            // Linux); its page must not get tokenless cross-origin reads.
1546            "http://127.0.0.2:4560",
1547            "http://127.0.0.10:4560",
1548            "http://127.0.0.2:5173",
1549            "http://127.0.0.10:1420",
1550            "http://[::1]:4560",
1551            "http://localhost.evil.example:4560",
1552            "https://localhost:4560",
1553            "https://evil.example",
1554        ] {
1555            assert!(
1556                !origin_allowed(denied, bind),
1557                "should deny {denied} for bind 127.0.0.1:4560"
1558            );
1559        }
1560        // A serve actually bound on :80 keeps its own portless same-origin.
1561        assert!(origin_allowed(
1562            "http://localhost",
1563            Some(std::net::SocketAddr::from(([127, 0, 0, 1], 80)))
1564        ));
1565    }
1566
1567    #[test]
1568    fn origin_allowlist_follows_the_actual_bound_ip() {
1569        // `--host 127.0.0.2`: its own page works, the canonical-localhost
1570        // forms (which that serve does NOT answer on) do not.
1571        let bind = Some(std::net::SocketAddr::from(([127, 0, 0, 2], 4560)));
1572        assert!(origin_allowed("http://127.0.0.2:4560", bind));
1573        assert!(!origin_allowed("http://127.0.0.1:4560", bind));
1574        assert!(!origin_allowed("http://localhost:4560", bind));
1575        // Dev-server pages stay approved regardless of bind IP.
1576        assert!(origin_allowed("http://localhost:5173", bind));
1577
1578        // `--host ::1`: bracketed v6 same-origin plus the localhost name.
1579        let bind_v6 = Some(std::net::SocketAddr::from((
1580            std::net::Ipv6Addr::LOCALHOST,
1581            4560,
1582        )));
1583        assert!(origin_allowed("http://[::1]:4560", bind_v6));
1584        assert!(origin_allowed("http://localhost:4560", bind_v6));
1585        assert!(!origin_allowed("http://127.0.0.2:4560", bind_v6));
1586
1587        // `--host 0.0.0.0` listens on every interface: any loopback page on
1588        // the bind port is genuinely this server.
1589        let bind_any = Some(std::net::SocketAddr::from(([0, 0, 0, 0], 4560)));
1590        assert!(origin_allowed("http://127.0.0.1:4560", bind_any));
1591        assert!(origin_allowed("http://127.0.0.5:4560", bind_any));
1592        assert!(origin_allowed("http://localhost:4560", bind_any));
1593        assert!(!origin_allowed("http://localhost:8080", bind_any));
1594    }
1595
1596    #[test]
1597    fn ws_origin_loopback_keeps_strict_browser_allowlist() {
1598        use super::ws_origin_allowed;
1599        let bind = Some(std::net::SocketAddr::from(([127, 0, 0, 1], 4560)));
1600        assert!(ws_origin_allowed(Some("http://localhost:4560"), bind, true));
1601        assert!(ws_origin_allowed(Some("http://localhost:5173"), bind, true));
1602        assert!(
1603            !ws_origin_allowed(None, bind, true),
1604            "missing Origin stays rejected on loopback (reads are tokenless)"
1605        );
1606        assert!(!ws_origin_allowed(
1607            Some("http://192.168.1.5:4560"),
1608            bind,
1609            true
1610        ));
1611        assert!(
1612            !ws_origin_allowed(Some("http://127.0.0.2:4560"), bind, true),
1613            "co-resident loopback listener page must not open the tokenless WS"
1614        );
1615        assert!(
1616            !ws_origin_allowed(Some("http://127.0.0.2:5173"), bind, true),
1617            "a dev port must not privilege another independently bindable loopback IP"
1618        );
1619        assert!(!ws_origin_allowed(Some("http://evil.example"), bind, true));
1620    }
1621
1622    #[test]
1623    fn ws_origin_lan_accepts_ip_literals_and_native_clients() {
1624        use super::ws_origin_allowed;
1625        let bind = Some(std::net::SocketAddr::from(([0, 0, 0, 0], 4560)));
1626        // Same-origin LAN dashboard, bracketed v6, dev proxy, and header-less
1627        // native clients all pass — the read token authenticates the upgrade.
1628        assert!(ws_origin_allowed(
1629            Some("http://192.168.1.5:4560"),
1630            bind,
1631            false
1632        ));
1633        assert!(ws_origin_allowed(
1634            Some("http://[fd00::5]:4560"),
1635            bind,
1636            false
1637        ));
1638        assert!(ws_origin_allowed(
1639            Some("http://localhost:5173"),
1640            bind,
1641            false
1642        ));
1643        assert!(ws_origin_allowed(None, bind, false));
1644        // DNS-named (rebinding) pages and non-http schemes stay out.
1645        for denied in [
1646            "http://evil.example:4560",
1647            "http://192.168.1.5.evil.example:4560",
1648            "https://192.168.1.5:4560",
1649            "http://[::1:4560",
1650            "null",
1651            "",
1652        ] {
1653            assert!(
1654                !ws_origin_allowed(Some(denied), bind, false),
1655                "should deny {denied} off loopback"
1656            );
1657        }
1658    }
1659
1660    #[test]
1661    fn host_allowlist_loopback_rejects_lan_and_dns() {
1662        for allowed in [
1663            "localhost",
1664            "localhost:4560",
1665            "LOCALHOST:5173",
1666            "127.0.0.1",
1667            "127.0.0.1:65535",
1668            // Any loopback literal serves: `--host 127.0.0.2` must answer.
1669            "127.0.0.2:4560",
1670            "::1",
1671            "[::1]",
1672            "[::1]:4560",
1673        ] {
1674            assert!(
1675                host_allowed(allowed, true),
1676                "loopback bind should allow {allowed}"
1677            );
1678        }
1679        for denied in [
1680            "evil.example",
1681            "evil.example:4560",
1682            "localhost.evil.example",
1683            "192.168.1.10",
1684            "192.168.1.10:4560",
1685            "10.0.0.1:8080",
1686            // A full (non-loopback) IPv6 address, NOT ::1 with a port.
1687            "::1:4560",
1688            // Unclosed bracket.
1689            "[::1",
1690            "",
1691        ] {
1692            assert!(
1693                !host_allowed(denied, true),
1694                "loopback bind should deny {denied}"
1695            );
1696        }
1697    }
1698
1699    #[test]
1700    fn host_allowlist_lan_accepts_ip_hosts() {
1701        for allowed in [
1702            "192.168.1.10",
1703            "192.168.1.10:4560",
1704            "10.0.0.1:8080",
1705            "localhost",
1706            "127.0.0.1:4560",
1707            "[::1]:4560",
1708        ] {
1709            assert!(
1710                host_allowed(allowed, false),
1711                "LAN bind should allow {allowed}"
1712            );
1713        }
1714        for denied in [
1715            "evil.example",
1716            "evil.example:4560",
1717            "localhost.evil.example",
1718            "",
1719        ] {
1720            assert!(
1721                !host_allowed(denied, false),
1722                "LAN bind should still deny DNS Host {denied}"
1723            );
1724        }
1725    }
1726}