//! Control-plane HTTP surface. Mounted by the `picloud` all-in-one //! binary under `/api/admin` and by the future split `picloud-manager` //! binary at its own root. use std::sync::Arc; use axum::{ extract::{Path, Query, State}, http::StatusCode, response::{IntoResponse, Response}, routing::get, Extension, Json, Router, }; use picloud_shared::{ AppId, ExecutionLog, InstanceRole, Principal, Script, ScriptId, ScriptKind, ScriptSandbox, ScriptValidator, ValidatedScript, ValidationError, }; use serde::Deserialize; use crate::app_repo::AppRepository; use crate::authz::{require, AuthzDenied, AuthzRepo, Capability}; use crate::repo::{ ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError, }; use crate::sandbox::{CeilingError, SandboxCeiling}; /// State shared by control-plane handlers. Separates concerns so the /// manager can validate at upload time without depending on the /// concrete executor-core types. pub struct AdminState { pub repo: Arc, pub logs: Arc, /// App lookups: validates `app_id` on create, resolves `?app=` /// filter on list. Trait-object so apps_repo can stay separate. pub apps: Arc, /// Phase 3.5 capability checks — every script handler resolves /// `AppRead/Write/LogRead(script.app_id)` against this repo after /// loading the resource. pub authz: Arc, pub validator: Arc, pub sandbox_ceiling: SandboxCeiling, } impl Clone for AdminState { fn clone(&self) -> Self { Self { repo: self.repo.clone(), logs: self.logs.clone(), apps: self.apps.clone(), authz: self.authz.clone(), validator: self.validator.clone(), sandbox_ceiling: self.sandbox_ceiling, } } } /// Build the admin router. The caller (binary) chooses where to mount /// it (typically `Router::new().nest("/api/admin", admin_router(state))`). pub fn admin_router(state: AdminState) -> Router where R: ScriptRepository + 'static, L: ExecutionLogRepository + 'static, { Router::new() .route( "/scripts", get(list_scripts::).post(create_script::), ) .route( "/scripts/{id}", get(get_script::) .put(update_script::) .delete(delete_script::), ) .route("/scripts/{id}/logs", get(list_logs::)) .with_state(state) } // ---------------------------------------------------------------------------- // DTOs // ---------------------------------------------------------------------------- #[derive(Debug, Deserialize)] pub struct CreateScriptRequest { /// Owning app. Required since Phase 3b — scripts cannot exist /// outside an app. Use `/api/v1/admin/apps` to list known ids. pub app_id: AppId, pub name: String, pub description: Option, pub source: String, /// v1.1.3: `endpoint` (default — handles HTTP routes / trigger /// targets) or `module` (library of fn/const imported by other /// scripts). Modules reject route binding and trigger creation. #[serde(default)] pub kind: ScriptKind, pub timeout_seconds: Option, pub memory_limit_mb: Option, /// Sandbox overrides; absent or empty `{}` means "use platform /// defaults". Each non-null field is checked against the admin /// ceiling at write time. #[serde(default)] pub sandbox: ScriptSandbox, } #[derive(Debug, Deserialize)] pub struct ListScriptsQuery { /// Optional filter: list scripts belonging to a single app, by id /// or slug. Absent = all scripts across all apps (admin-global view). #[serde(default)] pub app: Option, } #[derive(Debug, Deserialize)] pub struct UpdateScriptRequest { pub name: Option, // Double Option lets clients explicitly clear the description by // sending `"description": null`; an absent field leaves it alone. #[serde(default, deserialize_with = "deserialize_optional_optional")] #[allow(clippy::option_option)] pub description: Option>, pub source: Option, pub timeout_seconds: Option, pub memory_limit_mb: Option, /// `Some(sandbox)` replaces the stored overrides wholesale (use /// `Some(ScriptSandbox::empty())` to clear them). Absent leaves /// the stored value unchanged. pub sandbox: Option, /// v1.1.3: `Some(kind)` changes the script's role. Transitions to /// `Module` are rejected if any routes or triggers still reference /// the script. `module → endpoint` is always allowed. pub kind: Option, } #[allow(clippy::option_option)] fn deserialize_optional_optional<'de, D>(d: D) -> Result>, D::Error> where D: serde::Deserializer<'de>, { Option::::deserialize(d).map(Some) } // ---------------------------------------------------------------------------- // Handlers // ---------------------------------------------------------------------------- async fn list_scripts( State(state): State>, Extension(principal): Extension, Query(q): Query, ) -> Result>, ApiError> { // Membership filter: `member` users see only scripts in apps they // belong to. `?app=` filters further by app and additionally // requires the member to belong to that app (the read check uses // the resource's app_id). if let Some(ident) = q.app { let app = resolve_app_ident(state.apps.as_ref(), &ident).await?; require(state.authz.as_ref(), &principal, Capability::AppRead(app)).await?; return Ok(Json(state.repo.list_for_app(app).await?)); } if principal.instance_role == InstanceRole::Member { return Ok(Json(state.repo.list_for_user(principal.user_id).await?)); } Ok(Json(state.repo.list().await?)) } /// Accept `?app=` OR `?app=`. Slugs route through history /// for redirects, but here we just need the live current id; if a /// retired slug is given, we follow it to the current app silently. async fn resolve_app_ident(apps: &dyn AppRepository, ident: &str) -> Result { if let Ok(uuid) = ident.parse::() { let id = AppId::from(uuid); apps.get_by_id(id) .await? .ok_or(ApiError::AppNotFound(ident.to_string()))?; return Ok(id); } let lookup = apps .get_by_slug_or_history(ident) .await? .ok_or(ApiError::AppNotFound(ident.to_string()))?; Ok(lookup.app.id) } async fn get_script( State(state): State>, Extension(principal): Extension, Path(id): Path, ) -> Result, ApiError> { let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?; require( state.authz.as_ref(), &principal, Capability::AppRead(script.app_id), ) .await?; Ok(Json(script)) } async fn create_script( State(state): State>, Extension(principal): Extension, Json(input): Json, ) -> Result<(StatusCode, Json