Closes the gaps and the one security finding from the second end-to-end
CLI test (E2E_STASH_REPORT.md), plus the H1 boot-regression found while
re-reviewing those fixes.
Security
- S6: reserved-path validation (`check_reserved`) now case-folds before
comparing, so `/API/v2/x`, `/HEALTHZ`, `/Admin/x` are rejected like
their lowercase forms. Request-time matching stays case-sensitive.
- S10: "public route != public data" callout in sdk-shape.md (script_gate
skips authz when the principal is anonymous).
Observability / features
- G1: trigger executions now write `execution_logs`. Migration 0043 adds
a `source` column (CHECK mirrors ExecutionSource/OutboxSourceKind,
DEFAULT 'http' backfills history); a shared `build_execution_log` helper
in executor-core; dispatcher logging for outbox triggers + queue
consumers (skips sync-HTTP rows the orchestrator already logs). `pic
logs` gains a source column + `--source` filter.
- G5: dev-only in-memory email capture under PICLOUD_DEV_MODE with no SMTP
(email::send succeeds locally), readable at GET /api/v1/admin/dev/emails
(Owner/Admin only; route mounted only in capture mode).
- G6: generalized the Rhai in-place-mutation footgun note (trim/replace/
make_upper/make_lower/crop/truncate/pad return ()).
- G2/G3/G4 (CLI): `pic members`, `pic files`, `pic queues`, read-only
`pic kv` (+ new kv_api.rs); `pic deploy --timeout/--memory/--kind/
--sandbox`; first-class `pic triggers create-{docs,files,pubsub,queue,
email}` wrappers. All new client path segments percent-encoded via seg().
H1 regression fix (found in re-review)
- The S6 change also runs in `compile_routes`, which compiles every stored
route at boot and on each route CRUD. A single stored route the new
validation rejects (creatable while the S6 gap existed) made the whole
compile Err and aborted startup. `compile_routes` is now lenient: it
skips an un-compilable row with a warning instead of bricking boot
(route creation still validates separately). Migration 0044 sweeps
pre-existing reserved-path routes on upgrade (WHERE mirrors
check_reserved exactly). Added regression tests for both.
Verified: cargo fmt, clippy --all-targets --all-features -D warnings, the
schema_snapshot test, and the new S6/lenient-compile unit tests all pass;
boot-resilience and G1/G5 confirmed live.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
655 lines
22 KiB
Rust
655 lines
22 KiB
Rust
//! 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<RR, SR> {
|
|
pub routes: Arc<RR>,
|
|
/// 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<SR>,
|
|
/// Used to validate the route's host against the parent app's
|
|
/// declared domain claims.
|
|
pub domains: Arc<dyn AppDomainRepository>,
|
|
pub table: Arc<RouteTable>,
|
|
/// Capability gate — Phase 3.5.
|
|
pub authz: Arc<dyn AuthzRepo>,
|
|
}
|
|
|
|
impl<RR, SR> Clone for RouteAdminState<RR, SR> {
|
|
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<RR, SR>(state: RouteAdminState<RR, SR>) -> Router
|
|
where
|
|
RR: RouteRepository + 'static,
|
|
SR: ScriptRepository + 'static,
|
|
{
|
|
Router::new()
|
|
.route(
|
|
"/scripts/{id}/routes",
|
|
get(list_routes::<RR, SR>).post(create_route::<RR, SR>),
|
|
)
|
|
.route("/routes/{route_id}", delete(delete_route::<RR, SR>))
|
|
.route("/routes:check", post(check_route::<RR, SR>))
|
|
.route("/routes:match", post(match_route::<RR, SR>))
|
|
.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<String>,
|
|
pub path_kind: PathKind,
|
|
pub path: String,
|
|
pub method: Option<String>,
|
|
/// Per-route dispatch mode (v1.1.1). Defaults to `Sync` when
|
|
/// omitted so older clients aren't broken. `Async` routes return
|
|
/// `202 Accepted` immediately and run the script in the
|
|
/// background via the dispatcher.
|
|
#[serde(default)]
|
|
pub dispatch_mode: picloud_shared::DispatchMode,
|
|
}
|
|
|
|
#[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<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct CheckRouteResponse {
|
|
pub ok: bool,
|
|
pub conflicting_route: Option<Route>,
|
|
pub conflict_reason: Option<String>,
|
|
}
|
|
|
|
#[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<MatchedRoute>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct MatchedRoute {
|
|
pub route_id: Uuid,
|
|
pub script_id: ScriptId,
|
|
pub params: std::collections::BTreeMap<String, String>,
|
|
pub rest: Option<String>,
|
|
pub host_param: Option<(String, String)>,
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Handlers
|
|
// ----------------------------------------------------------------------------
|
|
|
|
async fn list_routes<RR: RouteRepository, SR: ScriptRepository>(
|
|
State(state): State<RouteAdminState<RR, SR>>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(script_id): Path<ScriptId>,
|
|
) -> Result<Json<Vec<Route>>, 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<RR: RouteRepository, SR: ScriptRepository>(
|
|
State(state): State<RouteAdminState<RR, SR>>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(script_id): Path<ScriptId>,
|
|
Json(input): Json<CreateRouteRequest>,
|
|
) -> Result<(StatusCode, Json<Route>), 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?;
|
|
|
|
// v1.1.3: module scripts have no executable entry point — they're
|
|
// libraries imported by other scripts. Reject route bindings here
|
|
// before we touch the routes table.
|
|
if script.kind == picloud_shared::ScriptKind::Module {
|
|
return Err(RouteApiError::BadRequest(format!(
|
|
"script {script_id} has kind=module; modules are imported, \
|
|
not bound to routes — switch the script to kind=endpoint \
|
|
or attach this route to a different script"
|
|
)));
|
|
}
|
|
|
|
// 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,
|
|
dispatch_mode: input.dispatch_mode,
|
|
})
|
|
.await?;
|
|
refresh_table(&state).await?;
|
|
Ok((StatusCode::CREATED, Json(created)))
|
|
}
|
|
|
|
async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
|
|
State(state): State<RouteAdminState<RR, SR>>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(route_id): Path<Uuid>,
|
|
) -> Result<StatusCode, RouteApiError> {
|
|
// 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<RR: RouteRepository, SR: ScriptRepository>(
|
|
State(state): State<RouteAdminState<RR, SR>>,
|
|
Extension(principal): Extension<Principal>,
|
|
Json(input): Json<CheckRouteRequest>,
|
|
) -> Result<Json<CheckRouteResponse>, 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<RR: RouteRepository, SR: ScriptRepository>(
|
|
State(state): State<RouteAdminState<RR, SR>>,
|
|
Extension(principal): Extension<Principal>,
|
|
Json(input): Json<MatchRouteRequest>,
|
|
) -> Result<Json<MatchRouteResponse>, 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<String, pattern::ParseError> {
|
|
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<Option<(Route, String)>, 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<RR: RouteRepository, SR: ScriptRepository>(
|
|
state: &RouteAdminState<RR, SR>,
|
|
) -> Result<(), RouteApiError> {
|
|
let rows = state.routes.list_all().await?;
|
|
let compiled = compile_routes(&rows);
|
|
state.table.replace_all(compiled);
|
|
Ok(())
|
|
}
|
|
|
|
/// Compile stored route rows into the in-memory match table.
|
|
///
|
|
/// **Lenient by design (H1).** A row that fails to parse is *skipped with
|
|
/// a warning*, not propagated as an error. The motivating case: a path
|
|
/// that was valid when created but became reserved under a later, stricter
|
|
/// validation (e.g. the case-insensitive reserved-prefix check) — but this
|
|
/// also covers any other parse failure. A single un-compilable legacy row
|
|
/// must never take down the entire data plane: this function runs at
|
|
/// startup (where a hard error aborts boot) and on every table rebuild
|
|
/// after a route edit (where it would fail an unrelated CRUD op). A skipped
|
|
/// route simply doesn't match; the warning tells the operator to delete or
|
|
/// fix it (and migration 0044 sweeps the reserved-path offenders on
|
|
/// upgrade).
|
|
#[must_use]
|
|
pub fn compile_routes(rows: &[Route]) -> Vec<CompiledRoute> {
|
|
rows.iter()
|
|
.filter_map(|r| match compile_route(r) {
|
|
Ok(compiled) => Some(compiled),
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
route_id = %r.id,
|
|
app_id = %r.app_id,
|
|
path = %r.path,
|
|
error = %e,
|
|
"skipping un-compilable stored route — it will not match; \
|
|
delete or fix it"
|
|
);
|
|
None
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn compile_route(r: &Route) -> Result<CompiledRoute, pattern::ParseError> {
|
|
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(),
|
|
dispatch_mode: r.dispatch_mode,
|
|
})
|
|
}
|
|
|
|
/// 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
|
|
// ".<suffix>" 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<Route>,
|
|
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<String>,
|
|
},
|
|
|
|
#[error("forbidden")]
|
|
Forbidden,
|
|
|
|
#[error("authorization repo error: {0}")]
|
|
AuthzRepo(String),
|
|
|
|
#[error("repository error: {0}")]
|
|
Repo(#[from] ScriptRepositoryError),
|
|
}
|
|
|
|
impl From<AuthzDenied> 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()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use picloud_shared::DispatchMode;
|
|
use uuid::Uuid;
|
|
|
|
fn route_with_path(path: &str) -> Route {
|
|
Route {
|
|
id: Uuid::new_v4(),
|
|
app_id: AppId::from(Uuid::new_v4()),
|
|
script_id: ScriptId::from(Uuid::new_v4()),
|
|
host_kind: HostKind::Any,
|
|
host: String::new(),
|
|
host_param_name: None,
|
|
path_kind: PathKind::Exact,
|
|
path: path.to_string(),
|
|
method: None,
|
|
dispatch_mode: DispatchMode::default(),
|
|
created_at: chrono::Utc::now(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn compile_routes_skips_uncompilable_rows_instead_of_failing() {
|
|
// H1 regression guard: a stored route whose path is now reserved
|
|
// (creatable before the case-insensitive reserved-prefix fix) must
|
|
// be skipped, not abort the whole compile — otherwise one legacy
|
|
// row bricks startup (`compile_routes` runs in `build_app`).
|
|
let good_a = route_with_path("/ok");
|
|
let bad = route_with_path("/API/v2/x"); // now reserved, case-insensitive
|
|
let good_b = route_with_path("/items");
|
|
let rows = vec![good_a.clone(), bad.clone(), good_b.clone()];
|
|
|
|
let compiled = compile_routes(&rows);
|
|
|
|
let ids: Vec<Uuid> = compiled.iter().map(|c| c.route_id).collect();
|
|
assert_eq!(compiled.len(), 2, "the reserved row must be dropped");
|
|
assert!(ids.contains(&good_a.id));
|
|
assert!(ids.contains(&good_b.id));
|
|
assert!(
|
|
!ids.contains(&bad.id),
|
|
"a reserved-path route must be skipped, never abort the compile"
|
|
);
|
|
}
|
|
}
|