//! `/api/v1/admin/apps/*` — app + domain claim CRUD. //! //! All endpoints are guarded by `require_admin`. Per-app permissions //! are deferred (every authenticated admin can act on every app); the //! middleware seam exists for when that lands. //! //! Slug validation: regex `^[a-z0-9][a-z0-9-]{0,62}$`, reserved-word //! list rejected. Slug renames record the old slug in //! `app_slug_history` for permanent 301 redirects; reclaiming a //! historical slug requires `"force_takeover": true` in the request. use std::sync::Arc; use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Json, Response}; use axum::routing::{delete, get, post}; use axum::{Extension, Router}; use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain}; use picloud_shared::{App, AppDomain, AppId, AppRole, InstanceRole, Principal}; use serde::{Deserialize, Serialize}; use serde_json::json; use uuid::Uuid; use crate::app_domain_repo::{AppDomainRepository, NewAppDomain}; use crate::app_repo::AppRepository; use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability}; use crate::repo::ScriptRepositoryError; use crate::route_repo::RouteRepository; const SLUG_MIN: usize = 1; const SLUG_MAX: usize = 63; const RESERVED_SLUGS: &[&str] = &[ "new", "api", "admin", "admins", "healthz", "version", "login", "logout", "apps", ]; #[derive(Clone)] pub struct AppsState { pub apps: Arc, pub domains: Arc, pub routes: Arc, /// Cached host → app_id lookup; replaced after every domain CRUD /// operation so the orchestrator sees changes immediately. pub domain_table: Arc, /// Capability gate — Phase 3.5. pub authz: Arc, } pub fn apps_router(state: AppsState) -> Router { Router::new() .route("/apps", get(list_apps).post(create_app)) .route( "/apps/{id_or_slug}", get(get_app).patch(patch_app).delete(delete_app), ) .route("/apps/{id_or_slug}/slug:check", post(slug_check)) .route( "/apps/{id_or_slug}/domains", get(list_domains).post(create_domain), ) .route( "/apps/{id_or_slug}/domains/{domain_id}", delete(delete_domain), ) .with_state(state) } // ---------------------------------------------------------------------------- // DTOs // ---------------------------------------------------------------------------- #[derive(Debug, Serialize)] pub struct AppDto { #[serde(flatten)] pub app: App, } #[derive(Debug, Deserialize)] pub struct CreateAppRequest { pub slug: String, pub name: String, pub description: Option, /// Set to `true` to consume an existing `app_slug_history` row for /// the requested slug (breaking old redirects). #[serde(default)] pub force_takeover: bool, } #[derive(Debug, Deserialize)] pub struct PatchAppRequest { pub name: Option, #[serde(default, deserialize_with = "deserialize_optional_optional")] #[allow(clippy::option_option)] pub description: Option>, pub slug: Option, #[serde(default)] pub force_takeover: bool, } #[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) } #[derive(Debug, Deserialize)] pub struct SlugCheckRequest { pub new_slug: String, } #[derive(Debug, Serialize)] pub struct SlugCheckResponse { pub ok: bool, pub conflict_kind: Option<&'static str>, pub current_app: Option, pub reason: Option, } #[derive(Debug, Deserialize)] pub struct CreateDomainRequest { pub pattern: String, } /// Query params for `DELETE /apps/{id_or_slug}`. `force=true` opts into /// a cascading delete that also removes every script in the app (and /// thereby their routes and execution logs). Without it the request is /// rejected when the app still contains scripts. #[derive(Debug, Default, Deserialize)] pub struct DeleteAppQuery { #[serde(default)] pub force: bool, } #[derive(Debug, Serialize)] pub struct AppLookupResponse { #[serde(flatten)] pub app: App, /// When the operator hits the API with a retired slug, this points /// at the live slug so dashboards can redirect. #[serde(skip_serializing_if = "Option::is_none")] pub redirect_to: Option, /// The caller's role on this app, used by the dashboard to decide /// whether to render admin-only surfaces (Members tab, settings). /// `Owner` maps to `app_admin`, `Admin` to `editor` (both implicit /// per blueprint §11.6); `Member` carries its explicit /// `app_members.role`. pub my_role: Option, } // ---------------------------------------------------------------------------- // Handlers // ---------------------------------------------------------------------------- async fn list_apps( State(s): State, Extension(principal): Extension, ) -> Result>, AppsApiError> { // Member callers see only apps they're a member of; owner/admin // see everything. Filter at the SQL layer (not just in the // dashboard) — that's the strict-isolation guarantee from §11.6. let apps = if principal.instance_role == InstanceRole::Member { s.apps.list_for_user(principal.user_id).await? } else { s.apps.list().await? }; Ok(Json(apps)) } async fn create_app( State(s): State, Extension(principal): Extension, Json(input): Json, ) -> Result<(StatusCode, Json), AppsApiError> { require(s.authz.as_ref(), &principal, Capability::InstanceCreateApp).await?; validate_slug(&input.slug)?; // Historical-slug check before insert: if the slug is in history // and the caller hasn't asked to force takeover, surface a clean // 409 so the dashboard can present a "this will break old links" // confirmation. if !input.force_takeover { if let Some(current) = s.apps.slug_in_history(&input.slug).await? { return Err(AppsApiError::SlugInHistory(current)); } } let created = if input.force_takeover { s.apps .create_with_takeover(&input.slug, &input.name, input.description.as_deref()) .await? } else { s.apps .create(&input.slug, &input.name, input.description.as_deref()) .await? }; Ok((StatusCode::CREATED, Json(created))) } async fn get_app( State(s): State, Extension(principal): Extension, Path(id_or_slug): Path, ) -> Result, AppsApiError> { let lookup = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, Capability::AppRead(lookup.app.id), ) .await?; let redirect_to = if lookup.redirected { Some(lookup.app.slug.clone()) } else { None }; let my_role = compute_my_role(s.authz.as_ref(), &principal, lookup.app.id).await?; Ok(Json(AppLookupResponse { app: lookup.app, redirect_to, my_role, })) } /// Compute the caller's effective `AppRole` on a specific app. Mirrors /// the implicit-grant logic in `authz::role_grants` but returns the /// role itself (for UI gating) rather than a yes/no decision. `Owner` /// is implicit `AppAdmin` everywhere; `Admin` is implicit `Editor` /// everywhere; `Member` consults `app_members`. async fn compute_my_role( authz: &dyn AuthzRepo, principal: &Principal, app_id: AppId, ) -> Result, AppsApiError> { match principal.instance_role { InstanceRole::Owner => Ok(Some(AppRole::AppAdmin)), InstanceRole::Admin => Ok(Some(AppRole::Editor)), InstanceRole::Member => Ok(authz.membership(principal.user_id, app_id).await?), } } async fn patch_app( State(s): State, Extension(principal): Extension, Path(id_or_slug): Path, Json(input): Json, ) -> Result, AppsApiError> { let current = resolve_app(&*s.apps, &id_or_slug).await?.app; require( s.authz.as_ref(), &principal, Capability::AppAdmin(current.id), ) .await?; // Edits to name/description go first (separate from rename so we // don't conflate the two errors). let after_meta = if input.name.is_some() || input.description.is_some() { s.apps .update( current.id, input.name.as_deref(), input.description.as_ref().map(|d| d.as_deref()), ) .await? } else { current }; // Slug rename is a separate operation; the rename method does its // own history bookkeeping in a transaction. let after_rename = if let Some(new_slug) = input.slug.as_deref() { validate_slug(new_slug)?; match s .apps .rename_slug(after_meta.id, new_slug, input.force_takeover) .await { Ok(app) => app, Err(ScriptRepositoryError::Conflict(msg)) if msg.contains("history") => { if let Some(current) = s.apps.slug_in_history(new_slug).await? { return Err(AppsApiError::SlugInHistory(current)); } return Err(AppsApiError::Conflict(msg)); } Err(e) => return Err(e.into()), } } else { after_meta }; Ok(Json(after_rename)) } async fn delete_app( State(s): State, Extension(principal): Extension, Path(id_or_slug): Path, Query(q): Query, ) -> Result { let app = resolve_app(&*s.apps, &id_or_slug).await?.app; require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?; if q.force { s.apps.delete_cascade(app.id).await?; } else { // Soft pre-check for a clean error; the DB FK is the real guard // (ON DELETE RESTRICT on scripts.app_id). let n_scripts = s.apps.count_scripts_in_app(app.id).await?; if n_scripts > 0 { return Err(AppsApiError::HasScripts(n_scripts)); } s.apps.delete(app.id).await?; } refresh_domain_cache(&s).await?; Ok(StatusCode::NO_CONTENT) } async fn slug_check( State(s): State, Extension(principal): Extension, Path(id_or_slug): Path, Json(input): Json, ) -> Result, AppsApiError> { let app = resolve_app(&*s.apps, &id_or_slug).await?.app; require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?; match validate_slug(&input.new_slug) { Err(AppsApiError::InvalidSlug(reason)) => { return Ok(Json(SlugCheckResponse { ok: false, conflict_kind: Some("invalid"), current_app: None, reason: Some(reason), })); } Err(other) => return Err(other), Ok(()) => {} } if let Some(app) = s.apps.get_by_slug(&input.new_slug).await? { return Ok(Json(SlugCheckResponse { ok: false, conflict_kind: Some("current"), current_app: Some(app), reason: Some("another app currently uses this slug".into()), })); } if let Some(app) = s.apps.slug_in_history(&input.new_slug).await? { return Ok(Json(SlugCheckResponse { ok: false, conflict_kind: Some("historical"), current_app: Some(app), reason: Some("slug is a retired redirect; using it will break old links".into()), })); } Ok(Json(SlugCheckResponse { ok: true, conflict_kind: None, current_app: None, reason: None, })) } async fn list_domains( State(s): State, Extension(principal): Extension, Path(id_or_slug): Path, ) -> Result>, AppsApiError> { let app = resolve_app(&*s.apps, &id_or_slug).await?.app; require(s.authz.as_ref(), &principal, Capability::AppRead(app.id)).await?; Ok(Json(s.domains.list_for_app(app.id).await?)) } async fn create_domain( State(s): State, Extension(principal): Extension, Path(id_or_slug): Path, Json(input): Json, ) -> Result<(StatusCode, Json), AppsApiError> { let app = resolve_app(&*s.apps, &id_or_slug).await?.app; require( s.authz.as_ref(), &principal, Capability::AppManageDomains(app.id), ) .await?; let parsed = pattern::parse_app_domain(&input.pattern)?; let created = s .domains .create(NewAppDomain { app_id: app.id, pattern: input.pattern, shape: parsed.shape, shape_key: parsed.shape_key, }) .await?; refresh_domain_cache(&s).await?; Ok((StatusCode::CREATED, Json(created))) } async fn delete_domain( State(s): State, Extension(principal): Extension, Path((id_or_slug, domain_id)): Path<(String, Uuid)>, ) -> Result { let app = resolve_app(&*s.apps, &id_or_slug).await?.app; require( s.authz.as_ref(), &principal, Capability::AppManageDomains(app.id), ) .await?; let Some(domain) = s.domains.get(domain_id).await? else { return Err(AppsApiError::DomainNotFound(domain_id)); }; if domain.app_id != app.id { return Err(AppsApiError::DomainNotFound(domain_id)); } // Guard: routes inside this app may reference this exact host // pattern. The host-kind on the route is `strict` or `wildcard` // (Any routes don't pin a specific host). We block deletion in // either case and let the operator clean up first. let strict = s .routes .count_for_app_host(app.id, picloud_shared::HostKind::Strict, &domain.pattern) .await?; let wild_suffix = domain .pattern .split_once('.') .map(|(_, s)| s.to_string()) .unwrap_or_default(); let wild = if wild_suffix.is_empty() { 0 } else { s.routes .count_for_app_host(app.id, picloud_shared::HostKind::Wildcard, &wild_suffix) .await? }; if strict + wild > 0 { return Err(AppsApiError::DomainHasRoutes(strict + wild)); } s.domains.delete(domain_id).await?; refresh_domain_cache(&s).await?; Ok(StatusCode::NO_CONTENT) } // ---------------------------------------------------------------------------- // Helpers // ---------------------------------------------------------------------------- async fn resolve_app( apps: &dyn AppRepository, ident: &str, ) -> Result { if let Ok(uuid) = ident.parse::() { if let Some(app) = apps.get_by_id(AppId::from(uuid)).await? { return Ok(crate::app_repo::AppLookup { app, redirected: false, }); } return Err(AppsApiError::AppNotFound(ident.to_string())); } apps.get_by_slug_or_history(ident) .await? .ok_or_else(|| AppsApiError::AppNotFound(ident.to_string())) } fn validate_slug(slug: &str) -> Result<(), AppsApiError> { if slug.len() < SLUG_MIN || slug.len() > SLUG_MAX { return Err(AppsApiError::InvalidSlug(format!( "slug length must be between {SLUG_MIN} and {SLUG_MAX}" ))); } if !slug .chars() .next() .is_some_and(|c| c.is_ascii_alphanumeric()) { return Err(AppsApiError::InvalidSlug( "slug must start with [a-z0-9]".into(), )); } for c in slug.chars() { if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { return Err(AppsApiError::InvalidSlug( "slug may only contain lowercase letters, digits, and '-'".into(), )); } } if RESERVED_SLUGS.contains(&slug) { return Err(AppsApiError::InvalidSlug(format!( "slug {slug:?} is reserved for system use" ))); } Ok(()) } /// Rebuild the in-memory host → app_id cache used by the orchestrator. /// Called after every domain CRUD operation. pub async fn refresh_domain_cache(state: &AppsState) -> Result<(), AppsApiError> { let all = state.domains.list_all().await?; let compiled = all .into_iter() .filter_map(|d| { // Parse the stored pattern; skip on parse error rather than // poisoning the entire cache. The handlers reject bad input, // so this is purely defensive against a future migration // that loosens the constraints. pattern::parse_app_domain(&d.pattern) .ok() .map(|p| CompiledAppDomain { app_id: d.app_id, pattern: p.pattern, shape_key: p.shape_key, }) }) .collect(); state.domain_table.replace(compiled); Ok(()) } // ---------------------------------------------------------------------------- // Errors // ---------------------------------------------------------------------------- #[derive(Debug, thiserror::Error)] pub enum AppsApiError { #[error("app not found: {0}")] AppNotFound(String), #[error("domain not found: {0}")] DomainNotFound(Uuid), #[error("invalid slug: {0}")] InvalidSlug(String), #[error("slug {0:?} is in history; will break old redirects — pass force_takeover")] SlugInHistory(App), #[error("app still contains {0} script(s); delete or move them first")] HasScripts(i64), #[error("domain has {0} route(s) bound to it; delete the routes first")] DomainHasRoutes(i64), #[error("invalid pattern: {0}")] Pattern(#[from] pattern::ParseError), #[error("conflict: {0}")] Conflict(String), #[error("forbidden")] Forbidden, #[error("authorization repo error: {0}")] AuthzRepo(String), #[error("repository error: {0}")] Repo(#[from] ScriptRepositoryError), } impl From for AppsApiError { fn from(d: AuthzDenied) -> Self { match d { AuthzDenied::Denied => Self::Forbidden, AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()), } } } impl From for AppsApiError { fn from(e: AuthzError) -> Self { Self::AuthzRepo(e.to_string()) } } impl IntoResponse for AppsApiError { fn into_response(self) -> Response { let (status, body) = match &self { Self::AppNotFound(_) | Self::DomainNotFound(_) | Self::Repo(ScriptRepositoryError::NotFound(_)) => { (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })) } Self::InvalidSlug(_) | Self::Pattern(_) => ( StatusCode::UNPROCESSABLE_ENTITY, json!({ "error": self.to_string() }), ), Self::SlugInHistory(current) => ( StatusCode::CONFLICT, json!({ "error": self.to_string(), "conflict_kind": "historical", "current_app": current, }), ), Self::HasScripts(n) => ( StatusCode::CONFLICT, json!({ "error": self.to_string(), "script_count": n }), ), Self::DomainHasRoutes(n) => ( StatusCode::CONFLICT, json!({ "error": self.to_string(), "route_count": n }), ), Self::Conflict(_) | Self::Repo(ScriptRepositoryError::Conflict(_)) => { (StatusCode::CONFLICT, json!({ "error": self.to_string() })) } Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })), Self::AuthzRepo(e) => { tracing::error!(error = %e, "apps authz repo error"); ( StatusCode::INTERNAL_SERVER_ERROR, json!({ "error": "internal error" }), ) } Self::Repo(ScriptRepositoryError::Db(e)) => { tracing::error!(error = %e, "apps api db error"); ( StatusCode::INTERNAL_SERVER_ERROR, json!({ "error": "internal error" }), ) } }; (status, Json(body)).into_response() } }