//! Admin endpoints for routes. Mounted under `/api/v1/admin` alongside //! the script CRUD endpoints; the picloud binary wires the //! `RouteTable` shared with the orchestrator dispatcher in here so //! writes invalidate the in-memory snapshot. use std::sync::Arc; use axum::{ extract::{Path, State}, http::StatusCode, response::{IntoResponse, Response}, routing::{delete, get, post}, Extension, Json, Router, }; use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable}; use picloud_shared::{AppId, HostKind, PathKind, Principal, Route, ScriptId}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::app_domain_repo::AppDomainRepository; use crate::authz::{require, AuthzDenied, AuthzRepo, Capability}; use crate::repo::{ScriptRepository, ScriptRepositoryError}; use crate::route_repo::{NewRoute, RouteRepository}; pub struct RouteAdminState { pub routes: Arc, /// Used to resolve `script_id → app_id` when creating routes (the /// route inherits the script's app) and to scope conflict checks. pub scripts: Arc, /// Used to validate the route's host against the parent app's /// declared domain claims. pub domains: Arc, pub table: Arc, /// Capability gate — Phase 3.5. pub authz: Arc, } impl Clone for RouteAdminState { fn clone(&self) -> Self { Self { routes: self.routes.clone(), scripts: self.scripts.clone(), domains: self.domains.clone(), table: self.table.clone(), authz: self.authz.clone(), } } } pub fn route_admin_router(state: RouteAdminState) -> Router where RR: RouteRepository + 'static, SR: ScriptRepository + 'static, { Router::new() .route( "/scripts/{id}/routes", get(list_routes::).post(create_route::), ) .route("/routes/{route_id}", delete(delete_route::)) .route("/routes:check", post(check_route::)) .route("/routes:match", post(match_route::)) .with_state(state) } // ---------------------------------------------------------------------------- // DTOs // ---------------------------------------------------------------------------- #[derive(Debug, Deserialize)] pub struct CreateRouteRequest { pub host_kind: HostKind, #[serde(default)] pub host: String, #[serde(default)] pub host_param_name: Option, pub path_kind: PathKind, pub path: String, pub method: Option, } #[derive(Debug, Deserialize)] pub struct CheckRouteRequest { /// Required: which app's route table this hypothetical route would /// join. Conflict checks are strictly intra-app (cross-app route /// errors would leak tenant info — see blueprint §11.5). pub app_id: AppId, pub host_kind: HostKind, #[serde(default)] pub host: String, pub path_kind: PathKind, pub path: String, pub method: Option, } #[derive(Debug, Serialize)] pub struct CheckRouteResponse { pub ok: bool, pub conflicting_route: Option, pub conflict_reason: Option, } #[derive(Debug, Deserialize)] pub struct MatchRouteRequest { /// Which app's route table to dispatch against. The dashboard's /// route-preview tester always knows the current app context. pub app_id: AppId, pub url: String, #[serde(default = "default_method")] pub method: String, } fn default_method() -> String { "GET".into() } #[derive(Debug, Serialize)] pub struct MatchRouteResponse { pub matched: Option, } #[derive(Debug, Serialize)] pub struct MatchedRoute { pub route_id: Uuid, pub script_id: ScriptId, pub params: std::collections::BTreeMap, pub rest: Option, pub host_param: Option<(String, String)>, } // ---------------------------------------------------------------------------- // Handlers // ---------------------------------------------------------------------------- async fn list_routes( State(state): State>, Extension(principal): Extension, Path(script_id): Path, ) -> Result>, RouteApiError> { let script = state .scripts .get(script_id) .await? .ok_or(RouteApiError::ScriptNotFound(script_id))?; require( state.authz.as_ref(), &principal, Capability::AppRead(script.app_id), ) .await?; Ok(Json(state.routes.list_for_script(script_id).await?)) } async fn create_route( State(state): State>, Extension(principal): Extension, Path(script_id): Path, Json(input): Json, ) -> Result<(StatusCode, Json), RouteApiError> { let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?; pattern::parse_host( input.host_kind, &input.host, input.host_param_name.as_deref(), )?; // Look up the script's owning app — every route inherits it. let script = state .scripts .get(script_id) .await? .ok_or(RouteApiError::ScriptNotFound(script_id))?; let app_id = script.app_id; require( state.authz.as_ref(), &principal, Capability::AppWriteRoute(app_id), ) .await?; // Validate the route's host is consistent with one of the app's // domain claims. `HostKind::Any` is always permitted (catches every // host the app already owns). Specific hosts must match a claim. validate_route_host_against_app(state.domains.as_ref(), app_id, input.host_kind, &input.host) .await?; // Within-app conflict check (cross-app is impossible by construction). let existing = state.routes.list_for_app(app_id).await?; if let Some((conflicting, reason)) = first_conflict( &existing, input.host_kind, &input.host, input.path_kind, &normalized_path, input.method.as_deref(), )? { return Err(RouteApiError::Conflict { conflicting_route: Box::new(conflicting), reason, }); } let created = state .routes .create(NewRoute { app_id, script_id, host_kind: input.host_kind, host: input.host, host_param_name: input.host_param_name, path_kind: input.path_kind, path: normalized_path, method: input.method, }) .await?; refresh_table(&state).await?; Ok((StatusCode::CREATED, Json(created))) } async fn delete_route( State(state): State>, Extension(principal): Extension, Path(route_id): Path, ) -> Result { // Resolve the route's app before we delete, so the capability // binds to the actual route's app_id (not a path param). let route = state .routes .get(route_id) .await? .ok_or(RouteApiError::RouteNotFound(route_id))?; require( state.authz.as_ref(), &principal, Capability::AppWriteRoute(route.app_id), ) .await?; state.routes.delete(route_id).await?; refresh_table(&state).await?; Ok(StatusCode::NO_CONTENT) } async fn check_route( State(state): State>, Extension(principal): Extension, Json(input): Json, ) -> Result, RouteApiError> { // routes:check is read-only — peeking at a hypothetical conflict // is bounded by AppRead on the target app (otherwise members // could probe other apps). require( state.authz.as_ref(), &principal, Capability::AppRead(input.app_id), ) .await?; let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?; pattern::parse_host(input.host_kind, &input.host, None)?; let existing = state.routes.list_for_app(input.app_id).await?; let conflict = first_conflict( &existing, input.host_kind, &input.host, input.path_kind, &normalized_path, input.method.as_deref(), )?; Ok(Json(match conflict { None => CheckRouteResponse { ok: true, conflicting_route: None, conflict_reason: None, }, Some((route, reason)) => CheckRouteResponse { ok: false, conflicting_route: Some(route), conflict_reason: Some(reason), }, })) } async fn match_route( State(state): State>, Extension(principal): Extension, Json(input): Json, ) -> Result, RouteApiError> { require( state.authz.as_ref(), &principal, Capability::AppRead(input.app_id), ) .await?; let parsed = url::Url::parse(&input.url) .map_err(|e| RouteApiError::BadRequest(format!("invalid url: {e}")))?; let host = parsed.host_str().unwrap_or("").to_string(); let path = parsed.path().to_string(); let result = state .table .match_request_for_app(input.app_id, &host, &input.method, &path); Ok(Json(MatchRouteResponse { matched: result.map(|r| MatchedRoute { route_id: r.matched.route_id, script_id: r.matched.script_id, params: r.params, rest: r.rest, host_param: r.host_param, }), })) } // ---------------------------------------------------------------------------- // Helpers // ---------------------------------------------------------------------------- /// Validate the raw user-typed path string and return it verbatim if /// it parses cleanly. Prefix normalization (`/echo/*` → `/echo/`) /// happens only in memory at compile time; persisted strings stay in /// the form the user submitted so re-parses are idempotent. fn parse_and_normalize_path(kind: PathKind, raw: &str) -> Result { pattern::parse_path(kind, raw)?; Ok(raw.to_string()) } #[allow(clippy::type_complexity)] fn first_conflict( existing: &[Route], host_kind: HostKind, host: &str, path_kind: PathKind, path: &str, method: Option<&str>, ) -> Result, RouteApiError> { let new_host = pattern::parse_host(host_kind, host, None)?; let new_path = pattern::parse_path(path_kind, path)?; for r in existing { let r_host = pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?; if !conflict::hosts_overlap(&new_host, &r_host) { continue; } if !conflict::methods_overlap(method, r.method.as_deref()) { continue; } let r_path = pattern::parse_path(r.path_kind, &r.path)?; if let Some(reason) = conflict::conflicts(&new_path, &r_path) { return Ok(Some((r.clone(), format!("{reason:?}")))); } } Ok(None) } async fn refresh_table( state: &RouteAdminState, ) -> Result<(), RouteApiError> { let rows = state.routes.list_all().await?; let compiled = compile_routes(&rows)?; state.table.replace_all(compiled); Ok(()) } pub fn compile_routes(rows: &[Route]) -> Result, pattern::ParseError> { rows.iter() .map(|r| { Ok(CompiledRoute { route_id: r.id, app_id: r.app_id, script_id: r.script_id, host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?, path: pattern::parse_path(r.path_kind, &r.path)?, method: r.method.clone(), }) }) .collect() } /// Validate that a new route's (host_kind, host) is consistent with at /// least one of the parent app's domain claims. `HostKind::Any` is /// always permitted — it catches every host the app already owns. async fn validate_route_host_against_app( domains: &dyn AppDomainRepository, app_id: AppId, host_kind: HostKind, host: &str, ) -> Result<(), RouteApiError> { if matches!(host_kind, HostKind::Any) { return Ok(()); } let claims = domains.list_for_app(app_id).await?; if claims.is_empty() { return Err(RouteApiError::HostNotClaimed { host: host.to_string(), available_claims: vec![], }); } let host_lower = host.to_ascii_lowercase(); for claim in &claims { let claim_lower = claim.pattern.to_ascii_lowercase(); match (host_kind, claim.shape) { // Strict route under exact claim: must match exactly. (HostKind::Strict, picloud_shared::DomainShape::Exact) => { if host_lower == claim_lower { return Ok(()); } } // Strict route under wildcard/parameterized: must end with // "." where the claim's suffix is the part after // `*.` or `{...}.`. ( HostKind::Strict, picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized, ) => { let suffix = claim_lower .split_once('.') .map(|(_, s)| s.to_string()) .unwrap_or_default(); let needle = format!(".{suffix}"); if !suffix.is_empty() && host_lower.ends_with(&needle) { return Ok(()); } } // Wildcard route: must match a wildcard or parameterized // claim with identical suffix. ( HostKind::Wildcard, picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized, ) => { let claim_suffix = claim_lower .split_once('.') .map(|(_, s)| s.to_string()) .unwrap_or_default(); if claim_suffix == host_lower { return Ok(()); } } // Wildcard route under exact claim: not allowed (would // shadow other apps' subdomains the operator didn't claim). (HostKind::Wildcard, picloud_shared::DomainShape::Exact) => {} (HostKind::Any, _) => unreachable!("handled above"), } } Err(RouteApiError::HostNotClaimed { host: host.to_string(), available_claims: claims.into_iter().map(|c| c.pattern).collect(), }) } // ---------------------------------------------------------------------------- // Errors // ---------------------------------------------------------------------------- #[derive(Debug, thiserror::Error)] pub enum RouteApiError { #[error("route conflicts with existing route ({reason})")] Conflict { conflicting_route: Box, reason: String, }, #[error("invalid route: {0}")] Pattern(#[from] pattern::ParseError), #[error("bad request: {0}")] BadRequest(String), #[error("script not found: {0}")] ScriptNotFound(ScriptId), #[error("route not found: {0}")] RouteNotFound(Uuid), #[error("host {host:?} is not claimed by this app")] HostNotClaimed { host: String, available_claims: Vec, }, #[error("forbidden")] Forbidden, #[error("authorization repo error: {0}")] AuthzRepo(String), #[error("repository error: {0}")] Repo(#[from] ScriptRepositoryError), } impl From for RouteApiError { fn from(d: AuthzDenied) -> Self { match d { AuthzDenied::Denied => Self::Forbidden, AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()), } } } impl IntoResponse for RouteApiError { fn into_response(self) -> Response { let (status, body) = match &self { Self::Conflict { conflicting_route, reason, } => ( StatusCode::CONFLICT, serde_json::json!({ "error": self.to_string(), "reason": reason, "conflicting_route": &**conflicting_route, }), ), Self::Pattern(_) | Self::BadRequest(_) => ( StatusCode::UNPROCESSABLE_ENTITY, serde_json::json!({ "error": self.to_string() }), ), Self::ScriptNotFound(_) | Self::RouteNotFound(_) | Self::Repo(ScriptRepositoryError::NotFound(_)) => ( StatusCode::NOT_FOUND, serde_json::json!({ "error": self.to_string() }), ), Self::Forbidden => ( StatusCode::FORBIDDEN, serde_json::json!({ "error": self.to_string() }), ), Self::AuthzRepo(e) => { tracing::error!(error = %e, "route authz repo error"); ( StatusCode::INTERNAL_SERVER_ERROR, serde_json::json!({ "error": "internal error" }), ) } Self::HostNotClaimed { host, available_claims, } => ( StatusCode::UNPROCESSABLE_ENTITY, serde_json::json!({ "error": self.to_string(), "host": host, "available_claims": available_claims, }), ), Self::Repo(ScriptRepositoryError::Conflict(_)) => ( StatusCode::CONFLICT, serde_json::json!({ "error": self.to_string() }), ), Self::Repo(ScriptRepositoryError::Db(e)) => { tracing::error!(error = %e, "route admin db error"); ( StatusCode::INTERNAL_SERVER_ERROR, serde_json::json!({ "error": "internal error" }), ) } }; (status, Json(body)).into_response() } }