//! Data-plane HTTP surface. Mounted by the `picloud` all-in-one binary //! under `/api` (so the path becomes `/api/execute/:id`) and by the //! future split `picloud-orchestrator` binary at its own root. use std::collections::BTreeMap; use std::sync::Arc; use std::time::Duration; use axum::{ body::Bytes, extract::{Path, Request, State}, http::{HeaderMap, HeaderName, HeaderValue, StatusCode}, response::{IntoResponse, Response}, routing::post, Extension, Json, Router, }; use base64::Engine as _; use chrono::Utc; use picloud_executor_core::{ build_execution_log, ExecError, ExecRequest, ExecResponse, InvocationType, }; use picloud_shared::{ AppId, DispatchMode, ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionSource, ExecutionStatus, HttpDispatchPayload, InboxFailureKind, InboxResult, NewHttpOutbox, OutboxWriter, Principal, RequestId, ScriptId, }; use serde_json::Value as Json_; use uuid::Uuid; use crate::client::ExecutorClient; use crate::inbox::InboxRegistry; use crate::resolver::{ResolverError, ScriptResolver}; use crate::routing::{AppDomainTable, RouteTable}; /// State shared by data-plane handlers. pub struct DataPlaneState { pub executor: Arc, pub resolver: Arc, pub log_sink: Arc, /// Host → app_id resolver. Run before `routes` to filter to the /// owning app's slice. Shared with the manager (writes invalidate /// the cache by replacing the table). pub app_domains: Arc, /// Routing table for user-defined paths, partitioned per app. /// Shared with the manager (admin router writes; this side reads). pub routes: Arc, /// NATS-style inbox registry (v1.1.1). Used by sync HTTP via /// outbox to await the dispatcher's delivery on a oneshot /// channel. pub inbox: Arc, /// Writer for the universal trigger outbox (v1.1.1). The sync /// HTTP path inserts a row with `reply_to = inbox_id`; the async /// path inserts with `reply_to = None` and returns 202. pub outbox: Arc, } impl Clone for DataPlaneState { fn clone(&self) -> Self { Self { executor: self.executor.clone(), resolver: self.resolver.clone(), log_sink: self.log_sink.clone(), app_domains: self.app_domains.clone(), routes: self.routes.clone(), inbox: self.inbox.clone(), outbox: self.outbox.clone(), } } } /// Build the data-plane router. Handles `POST /execute/:id` — the /// always-available ID-based bypass. /// /// Handlers expect an `Extension>` to be attached by /// upstream middleware (`manager-core::attach_principal_if_present`); /// requests without that extension panic at extraction time. The /// picloud binary wires this in `build_app`. pub fn data_plane_router(state: DataPlaneState) -> Router where E: ExecutorClient + 'static, R: ScriptResolver + 'static, { Router::new() .route("/execute/{id}", post(execute_by_id::)) .with_state(state) } /// Build a router that handles ALL paths via the user-defined routing /// table. Intended to be merged into the picloud app router as a /// fallback (after the system routes are mounted). /// /// Same middleware expectation as `data_plane_router` — wrap with /// `attach_principal_if_present` so handlers can extract /// `Extension>`. pub fn user_routes_router(state: DataPlaneState) -> Router where E: ExecutorClient + 'static, R: ScriptResolver + 'static, { Router::new() .fallback(user_route_handler::) .with_state(state) } // ---------------------------------------------------------------------------- // Handlers // ---------------------------------------------------------------------------- async fn execute_by_id( State(state): State>, Path(id): Path, Extension(principal): Extension>, headers: HeaderMap, body: Bytes, ) -> Result where E: ExecutorClient + 'static, R: ScriptResolver + 'static, { let script = state .resolver .resolve(id) .await? .ok_or(ApiError::NotFound(id))?; // A disabled script (§4.3) is not invocable — 404, indistinguishable // from an absent one (no info leak that the id exists but is off). if !script.enabled { return Err(ApiError::NotFound(id)); } // The direct-execute bypass runs a script under its *owning app's* // context. A group-owned script (Phase 4) is a template with no single // app, so it cannot be invoked through this id-addressed bypass — it // runs only via a descendant app's route/trigger, which supplies the // execution-context app. Fail closed (404) rather than guess an app. let app_id = script.app_id.ok_or(ApiError::NotFound(id))?; let mut req = build_exec_request(id, &script.name, &headers, &body, app_id, principal)?; req.sandbox_overrides = script.sandbox; req.script_owner = script.owner(); let request_id = req.request_id; let request_path = req.path.clone(); let request_headers = req.headers.clone(); let request_body = req.body.clone(); let timeout = Duration::from_secs(u64::from(script.timeout_seconds)); let started = Utc::now(); let identity = crate::client::ScriptIdentity { script_id: script.id, updated_at: script.updated_at, }; let outcome = state .executor .execute_with_identity(identity, &script.source, req, timeout) .await; let finished = Utc::now(); // Build and dispatch the audit log regardless of outcome. We await // the sink — recording the trail is part of correctness for an // audit-visible platform — but a sink failure must not mask the // user-facing result, so we only log a warning if it fails. let log = build_execution_log( app_id, id, request_id, request_path, request_headers, request_body, ExecutionSource::Http, &outcome, started, finished, ); if let Err(e) = state.log_sink.record(log).await { tracing::warn!(error = %e, script_id = %id, "failed to persist execution log"); } Ok(exec_response_to_http(outcome?)) } #[allow(clippy::too_many_lines)] /// Fallback entry for every user route. Wraps the actual dispatch with F-030 /// per-app CORS: resolve Host→app to know the policy, answer a CORS preflight, /// then tag the dispatched response's `Access-Control-Allow-Origin`. CORS is /// the ONE cross-cutting concern applied to every outcome (success, 404, /// error), so it lives here at the single chokepoint rather than in each arm. async fn user_route_handler( State(state): State>, Extension(principal): Extension>, request: Request, ) -> Response where E: ExecutorClient + 'static, R: ScriptResolver + 'static, { let method = request.method().as_str().to_ascii_uppercase(); let host = request .headers() .get("host") .and_then(|h| h.to_str().ok()) .unwrap_or("") .to_string(); let origin = request .headers() .get("origin") .and_then(|h| h.to_str().ok()) .map(str::to_string); // Resolve Host → app first so we know which CORS policy applies. No app // claims this host → flat 404 (no policy to tag against). let Some(app_id) = state.app_domains.resolve_app(&host) else { return ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": format!("no app claims host {host:?}") })), ) .into_response(); }; let allowed = state.app_domains.cors_for(app_id); let allow_origin = pick_allowed_origin(origin.as_deref(), &allowed); // A browser CORS preflight is `OPTIONS` carrying `Origin` + // `Access-Control-Request-Method`. Answer it here — the real request's // method/path need not have a registered OPTIONS route. if method == "OPTIONS" && request .headers() .contains_key("access-control-request-method") { return preflight_response(allow_origin.as_deref(), request.headers()); } let mut response = match dispatch_user_route(&state, app_id, request, principal).await { Ok(r) => r, Err(e) => e.into_response(), }; add_cors_headers(response.headers_mut(), allow_origin.as_deref()); response } /// Two-phase dispatch (blueprint §11.5) minus the Host→app resolution the CORS /// wrapper already did: run the matcher on the app's slice and execute. CORS /// headers are added by the caller. async fn dispatch_user_route( state: &DataPlaneState, app_id: AppId, request: Request, principal: Option, ) -> Result where E: ExecutorClient + 'static, R: ScriptResolver + 'static, { // Uppercased so `ctx.request.method` honors its documented contract // (extension methods arrive in their original case). Route matching is // case-insensitive (see `routing::matcher::method_matches`), so this // only normalizes the value surfaced to scripts / persisted in the // async-dispatch payload. let method = request.method().as_str().to_ascii_uppercase(); let uri = request.uri().clone(); let path = uri.path().to_string(); let query_str = uri.query().unwrap_or("").to_string(); let host = request .headers() .get("host") .and_then(|h| h.to_str().ok()) .unwrap_or("") .to_string(); let headers = request.headers().clone(); let Some(matched) = state .routes .match_request_for_app(app_id, &host, &method, &path) else { return Ok(( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": format!("no route matches {method} {path}") })), ) .into_response()); }; let script = state .resolver .resolve(matched.matched.script_id) .await? .ok_or(ApiError::NotFound(matched.matched.script_id))?; // An enabled route bound to a disabled script (§4.3, §4.7) is unreachable. // Return the SAME flat "no route matches" 404 as the unmatched case — not // `NotFound(script_id)`, which would both leak the internal script id to an // anonymous caller and be distinguishable from absent. if !script.enabled { return Ok(( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": format!("no route matches {method} {path}") })), ) .into_response()); } // Drain the body now that we know we'll execute. 10 MiB cap matches // the conservative default response/request size in the blueprint. let body_bytes = match axum::body::to_bytes(request.into_body(), 10 * 1024 * 1024).await { Ok(b) => b, Err(e) => return Err(ApiError::BadRequest(format!("body read failed: {e}"))), }; let body_json = body_value_from_bytes(&body_bytes, content_type_of(&headers))?; let header_map: BTreeMap = headers .iter() .filter_map(|(k, v)| { v.to_str() .ok() .map(|s| (k.as_str().to_string(), s.to_string())) }) .collect(); let query = parse_query_string(&query_str); let rest = matched.rest.clone().unwrap_or_default(); match matched.matched.dispatch_mode { DispatchMode::Async => { handle_async_route( state, app_id, matched.matched.route_id, matched.matched.script_id, &script.name, path, method, header_map, body_json, matched.params, query, rest, script.timeout_seconds, principal, ) .await } DispatchMode::Sync => { handle_sync_route( state, app_id, matched.matched.route_id, matched.matched.script_id, &script.name, path, method, header_map, body_json, matched.params, query, rest, script.timeout_seconds, principal, ) .await } } } #[allow(clippy::too_many_arguments)] async fn handle_async_route( state: &DataPlaneState, app_id: AppId, route_id: Uuid, script_id: ScriptId, script_name: &str, path: String, method: String, headers: BTreeMap, body: Json_, params: BTreeMap, query: BTreeMap, rest: String, timeout_seconds: u32, principal: Option, ) -> Result where E: ExecutorClient + 'static, R: ScriptResolver + 'static, { let payload = HttpDispatchPayload { script_name: script_name.to_string(), path, method, headers, body, params, query, rest, timeout_seconds, }; let payload_value = serde_json::to_value(&payload) .map_err(|e| ApiError::BadRequest(format!("payload serialize: {e}")))?; let execution_id = ExecutionId::new(); state .outbox .enqueue_http(NewHttpOutbox { app_id, route_id, script_id, reply_to: None, payload: payload_value, origin_principal: principal.map(|p| p.user_id), trigger_depth: 0, root_execution_id: Some(execution_id), }) .await .map_err(|e| ApiError::OutboxWrite(e.to_string()))?; Ok(( StatusCode::ACCEPTED, Json(serde_json::json!({ "accepted_at": Utc::now().to_rfc3339(), "execution_id": execution_id.to_string(), })), ) .into_response()) } #[allow(clippy::too_many_arguments)] async fn handle_sync_route( state: &DataPlaneState, app_id: AppId, route_id: Uuid, script_id: ScriptId, script_name: &str, path: String, method: String, headers: BTreeMap, body: Json_, params: BTreeMap, query: BTreeMap, rest: String, timeout_seconds: u32, principal: Option, ) -> Result where E: ExecutorClient + 'static, R: ScriptResolver + 'static, { let payload = HttpDispatchPayload { script_name: script_name.to_string(), path: path.clone(), method, headers: headers.clone(), body: body.clone(), params, query, rest, timeout_seconds, }; let payload_value = serde_json::to_value(&payload) .map_err(|e| ApiError::BadRequest(format!("payload serialize: {e}")))?; // Register the inbox before writing the outbox row so the // dispatcher can't race-deliver before the orchestrator is // listening. let (inbox_id, rx) = state.inbox.register(); let execution_id = ExecutionId::new(); let outbox_id = state .outbox .enqueue_http(NewHttpOutbox { app_id, route_id, script_id, reply_to: Some(inbox_id), payload: payload_value, origin_principal: principal.map(|p| p.user_id), trigger_depth: 0, root_execution_id: Some(execution_id), }) .await .map_err(|e| { // Failed outbox write — abandon the inbox so the dispatcher // can never deliver to a stale entry. state.inbox.cancel(inbox_id); ApiError::OutboxWrite(e.to_string()) })?; // Wait for the dispatcher's delivery. Outer timeout = script // wall-clock + a small buffer to cover dispatcher latency. let wait_budget = Duration::from_secs(u64::from(timeout_seconds)) + Duration::from_secs(2); let request_id = RequestId::new(); let started = Utc::now(); let result = tokio::time::timeout(wait_budget, rx).await; let finished = Utc::now(); // Tear down the receiver if it's still alive. `inbox.cancel` is a // no-op when the dispatcher already delivered. let _ = state.inbox.cancel(inbox_id); let response = match result { Ok(Ok(InboxResult::Success(summary))) => http_response_from_summary(summary), Ok(Ok(InboxResult::Failure { kind, message })) => failure_to_response(kind, &message), Ok(Err(_recv)) => { // Channel was closed without a value — dispatcher dropped // the sender. Treat as platform failure. tracing::warn!( outbox_id = %outbox_id, "inbox channel closed without delivery" ); failure_to_response( InboxFailureKind::Platform, "dispatcher closed inbox without delivery", ) } Err(_elapsed) => { // Outer timeout — either the script was too slow or the // dispatcher is wedged. Returns 504 by default. failure_to_response(InboxFailureKind::Timeout, "request timed out") } }; let log = build_inbox_execution_log( app_id, script_id, request_id, path, headers, body, response.status().as_u16(), started, finished, ); if let Err(e) = state.log_sink.record(log).await { tracing::warn!( error = %e, %script_id, "failed to persist execution log" ); } Ok(response) } // ---------------------------------------------------------------------------- // F-030 per-app CORS // ---------------------------------------------------------------------------- /// Decide which `Access-Control-Allow-Origin` value to emit for a request /// `Origin` given an app's allow-list. Returns the string to echo, or `None` /// when the origin isn't allowed (⇒ no CORS headers ⇒ the browser blocks). fn pick_allowed_origin(origin: Option<&str>, allowed: &[String]) -> Option { // `["*"]` opts into any origin. The app-user auth model is bearer-token // (no cookies), so a wildcard ACAO carries no CSRF/credentials risk. if allowed.iter().any(|o| o == "*") { return Some("*".to_string()); } let origin = origin?; allowed .iter() .find(|o| o.eq_ignore_ascii_case(origin)) .map(|_| origin.to_string()) } /// Append CORS response headers when the origin is allowed. No-op otherwise, so /// an app that hasn't opted in keeps the exact pre-CORS behaviour (no header). fn add_cors_headers(headers: &mut HeaderMap, allow_origin: Option<&str>) { let Some(origin) = allow_origin else { return; }; if let Ok(v) = HeaderValue::from_str(origin) { headers.insert(HeaderName::from_static("access-control-allow-origin"), v); // A concrete (non-`*`) ACAO varies by request Origin — mark it so a // shared cache keys per-origin. Harmless for `*`. headers.insert( HeaderName::from_static("vary"), HeaderValue::from_static("Origin"), ); } } /// Answer a CORS preflight (`OPTIONS` + `Access-Control-Request-Method`): a 204 /// with the allow headers when the origin is permitted, else a bare 204 with no /// CORS headers (the browser then blocks the real request). fn preflight_response(allow_origin: Option<&str>, request_headers: &HeaderMap) -> Response { let mut headers = HeaderMap::new(); if allow_origin.is_some() { add_cors_headers(&mut headers, allow_origin); headers.insert( HeaderName::from_static("access-control-allow-methods"), HeaderValue::from_static("GET, POST, PUT, PATCH, DELETE, OPTIONS"), ); // Reflect the browser's requested headers, else a sensible default. let allow_headers = request_headers .get("access-control-request-headers") .and_then(|v| v.to_str().ok()) .and_then(|s| HeaderValue::from_str(s).ok()) .unwrap_or_else(|| HeaderValue::from_static("content-type, authorization")); headers.insert( HeaderName::from_static("access-control-allow-headers"), allow_headers, ); headers.insert( HeaderName::from_static("access-control-max-age"), HeaderValue::from_static("600"), ); } (StatusCode::NO_CONTENT, headers).into_response() } fn http_response_from_summary(summary: picloud_shared::ExecResponseSummary) -> Response { let status = StatusCode::from_u16(summary.status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); // F-026: a `body_base64` envelope returns raw bytes with the script's // Content-Type, bypassing JSON encoding. if let Some(b64) = &summary.body_base64 { return binary_response(status, &summary.headers, b64); } let mut http_headers = HeaderMap::new(); for (k, v) in summary.headers { if let (Ok(name), Ok(value)) = (k.parse::(), v.parse::()) { http_headers.insert(name, value); } } http_headers .entry(axum::http::header::CONTENT_TYPE) .or_insert_with(|| HeaderValue::from_static("application/json")); (status, http_headers, Json(summary.body)).into_response() } /// F-026: build an HTTP response whose body is the decoded `body_base64` bytes. /// The script's `Content-Type` header wins; absent, defaults to /// `application/octet-stream`. Invalid base64 is an author error → opaque 500. fn binary_response( status: StatusCode, script_headers: &BTreeMap, body_base64: &str, ) -> Response { let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(body_base64.trim()) else { let correlation_id = Uuid::new_v4(); tracing::warn!(%correlation_id, "script returned invalid body_base64"); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": format!("script execution error (ref: {correlation_id})") })), ) .into_response(); }; // `Vec` defaults the Content-Type to application/octet-stream; the // script's own headers (inserted below, overriding) replace it if set. let mut resp = (status, bytes).into_response(); let headers = resp.headers_mut(); for (k, v) in script_headers { if let (Ok(name), Ok(value)) = (k.parse::(), v.parse::()) { headers.insert(name, value); } } resp } /// Scrub a runtime / SDK error string before it reaches an anonymous /// data-plane caller. The raw detail leaks internals (the app UUID, script /// function names, source line/col, filesystem paths, SSRF-policy hints) and /// reflects attacker-thrown strings (`throw "..."`) back into the response; /// log the full text server-side under a correlation id and return only a /// stable generic message + the id. /// /// Shared by both anonymous data-plane paths — the execute-by-id path /// (`ApiError::into_response`) and the user-route inbox path /// (`failure_to_response`) — so the two can't drift again. (The user-route /// path previously leaked the raw detail: the 2026-06-11 audit scrub landed /// only on execute-by-id.) fn scrub_runtime_detail(detail: &str) -> String { let correlation_id = Uuid::new_v4(); tracing::warn!( %correlation_id, detail = %detail, "script runtime error (detail scrubbed from response)" ); format!("script execution error (ref: {correlation_id})") } /// Map `InboxFailureKind` onto the design-notes §3 status-code table. fn failure_to_response(kind: InboxFailureKind, message: &str) -> Response { let status = match kind { InboxFailureKind::Validation => StatusCode::UNPROCESSABLE_ENTITY, InboxFailureKind::Runtime => StatusCode::BAD_GATEWAY, InboxFailureKind::Overloaded => StatusCode::SERVICE_UNAVAILABLE, InboxFailureKind::Timeout => StatusCode::GATEWAY_TIMEOUT, InboxFailureKind::OperationBudget => StatusCode::INSUFFICIENT_STORAGE, InboxFailureKind::Platform => StatusCode::INTERNAL_SERVER_ERROR, }; // A Runtime failure carries the raw Rhai/SDK error text (app UUID, fn // names, line/col, attacker-thrown strings) — scrub it exactly as the // execute-by-id path does. Other kinds are platform-generated and safe. let error_text = if matches!(kind, InboxFailureKind::Runtime) { scrub_runtime_detail(message) } else { message.to_string() }; let body = Json(serde_json::json!({ "error": error_text })); if matches!(kind, InboxFailureKind::Overloaded) { return (status, [(axum::http::header::RETRY_AFTER, "1")], body).into_response(); } (status, body).into_response() } #[allow(clippy::too_many_arguments)] fn build_inbox_execution_log( app_id: AppId, script_id: ScriptId, request_id: RequestId, request_path: String, request_headers: BTreeMap, request_body: Json_, response_code: u16, started: chrono::DateTime, finished: chrono::DateTime, ) -> ExecutionLog { let duration_ms = u64::try_from( finished .signed_duration_since(started) .num_milliseconds() .max(0), ) .unwrap_or(0); let status = if (200..400).contains(&response_code) { ExecutionStatus::Success } else { ExecutionStatus::Error }; ExecutionLog { id: Uuid::new_v4(), app_id, script_id, request_id, request_path, request_headers, request_body, response_code: Some(response_code), response_body: None, script_logs: Json_::Array(vec![]), duration_ms, status, source: ExecutionSource::Http, created_at: started, } } fn parse_query_string(s: &str) -> BTreeMap { let mut out = BTreeMap::new(); if s.is_empty() { return out; } for pair in s.split('&') { let (k, v) = match pair.split_once('=') { Some((k, v)) => (k, v), None => (pair, ""), }; let key = urlencoding::decode(k) .map(std::borrow::Cow::into_owned) .unwrap_or_default(); let val = urlencoding::decode(v) .map(std::borrow::Cow::into_owned) .unwrap_or_default(); out.insert(key, val); } out } /// F-026: interpret a request body per its `Content-Type`, surfaced to scripts /// as `ctx.request.body`. JSON (or an unspecified type) parses as before — a /// malformed JSON body still 400s. Non-JSON bodies are no longer rejected: /// `application/x-www-form-urlencoded` → an object of string fields, `text/*` → /// the raw string, any other type → a base64 string of the raw bytes. The /// script disambiguates via `ctx.request.headers["content-type"]`. This keeps /// the existing `body: Value` field (no execution-DTO change) while lifting the /// former JSON-only restriction. fn body_value_from_bytes(bytes: &[u8], content_type: Option<&str>) -> Result { if bytes.is_empty() { return Ok(Json_::Null); } let mime = content_type .unwrap_or("") .split(';') .next() .unwrap_or("") .trim() .to_ascii_lowercase(); // JSON (default when unspecified — preserves the historical contract). if mime.is_empty() || mime == "application/json" || mime.ends_with("+json") { return serde_json::from_slice(bytes) .map_err(|e| ApiError::BadRequest(format!("invalid JSON body: {e}"))); } // Form-urlencoded → an object of string fields (the classic HTML form POST). if mime == "application/x-www-form-urlencoded" { let s = std::str::from_utf8(bytes) .map_err(|_| ApiError::BadRequest("form body is not valid UTF-8".into()))?; let obj: serde_json::Map = parse_query_string(s) .into_iter() .map(|(k, v)| (k, Json_::String(v))) .collect(); return Ok(Json_::Object(obj)); } // Text → the raw string. if mime.starts_with("text/") { let s = std::str::from_utf8(bytes) .map_err(|_| ApiError::BadRequest("text body is not valid UTF-8".into()))?; return Ok(Json_::String(s.to_string())); } // Anything else (binary) → base64 of the raw bytes. Ok(Json_::String( base64::engine::general_purpose::STANDARD.encode(bytes), )) } fn content_type_of(headers: &HeaderMap) -> Option<&str> { headers .get(axum::http::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) } // ---------------------------------------------------------------------------- // Marshalling // ---------------------------------------------------------------------------- fn build_exec_request( id: ScriptId, name: &str, headers: &HeaderMap, body: &Bytes, app_id: AppId, principal: Option, ) -> Result { let mut hmap = BTreeMap::new(); for (k, v) in headers { if let Ok(s) = v.to_str() { hmap.insert(k.as_str().to_string(), s.to_string()); } } let body_json = body_value_from_bytes(body, content_type_of(headers))?; let execution_id = ExecutionId::new(); Ok(ExecRequest { execution_id, request_id: RequestId::new(), script_id: id, script_name: name.to_string(), invocation_type: InvocationType::Http, path: format!("/api/execute/{id}"), // The `/api/v1/execute/{id}` bypass is POST-only. method: "POST".into(), headers: hmap, body: body_json, params: BTreeMap::new(), query: BTreeMap::new(), rest: String::new(), // Overwritten by the handler after the script is resolved. sandbox_overrides: picloud_shared::ScriptSandbox::default(), app_id, // Set by the handler from the resolved script's owner. The // id-bypass runs app-owned scripts only (group scripts 404 here), // so this is always `App(app_id)`; `None` falls back to that. script_owner: None, principal, // Direct invocations are at depth 0 with a self-referential // root. The triggers framework (v1.1.1) increments depth and // preserves the original root for chained executions. trigger_depth: 0, root_execution_id: execution_id, // Direct invocations are never DL handlers — that flag is only // set by the dispatcher when it picks a dead_letter trigger row. is_dead_letter_handler: false, // No originating trigger event for direct ingress. event: None, }) } fn exec_response_to_http(resp: ExecResponse) -> Response { let status = StatusCode::from_u16(resp.status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); // F-026: binary response via `body_base64` (same as the user-route path). if let Some(b64) = &resp.body_base64 { return binary_response(status, &resp.headers, b64); } let mut http_headers = HeaderMap::new(); for (k, v) in resp.headers { if let (Ok(name), Ok(value)) = (k.parse::(), v.parse::()) { http_headers.insert(name, value); } } http_headers .entry(axum::http::header::CONTENT_TYPE) .or_insert_with(|| HeaderValue::from_static("application/json")); (status, http_headers, Json(resp.body)).into_response() } // `build_execution_log` moved to `picloud_executor_core` so the manager's // trigger dispatcher can build identically-shaped rows (G1 — trigger // executions are now logged with their `ExecutionSource`). // ---------------------------------------------------------------------------- // Errors // ---------------------------------------------------------------------------- #[derive(Debug, thiserror::Error)] pub enum ApiError { #[error("script not found: {0}")] NotFound(ScriptId), #[error("bad request: {0}")] BadRequest(String), #[error("resolver error: {0}")] Resolver(#[from] ResolverError), #[error("execution error: {0}")] Exec(#[from] ExecError), #[error("outbox write failed: {0}")] OutboxWrite(String), } impl IntoResponse for ApiError { fn into_response(self) -> Response { // Overloaded is the only variant that needs to attach an HTTP // header (Retry-After), so it short-circuits the (status, body) // reduction below. Axum's tuple builder makes per-arm header // injection awkward otherwise. use ApiError as E; if let E::Exec(ExecError::Overloaded { retry_after_secs }) = &self { let retry = retry_after_secs.to_string(); let body = Json(serde_json::json!({ "error": self.to_string() })); return ( StatusCode::SERVICE_UNAVAILABLE, [(axum::http::header::RETRY_AFTER, retry)], body, ) .into_response(); } let (status, message) = match &self { E::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()), E::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()), E::OutboxWrite(e) => { tracing::error!(error = %e, "outbox write failed"); ( StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string(), ) } E::Resolver(e) => { tracing::error!(error = %e, "resolver failure"); ( StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string(), ) } E::Exec(e) => match e { ExecError::Parse(_) | ExecError::InvalidResponse(_) => { (StatusCode::UNPROCESSABLE_ENTITY, e.to_string()) } ExecError::Timeout(_) => (StatusCode::GATEWAY_TIMEOUT, e.to_string()), ExecError::OperationBudgetExceeded => { (StatusCode::INSUFFICIENT_STORAGE, e.to_string()) } ExecError::Runtime(detail) => { // Audit 2026-06-11 (F-SE-H-04) — runtime / SDK error // strings leak internal detail to the public data // plane: filesystem paths, "blocked by SSRF policy: // link-local" (confirms cloud-metadata reachability), // pool-exhaustion timing, and panic/backtrace // fragments. They also reflect attacker-thrown // strings (`throw "..."`) back into operator-facing // JSON. Both anonymous data-plane paths (execute-by-id // here + user routes via `failure_to_response`) scrub // through `scrub_runtime_detail`; the authenticated // script-test path lives in manager-core and keeps // verbose errors. (StatusCode::BAD_GATEWAY, scrub_runtime_detail(detail)) } ExecError::Overloaded { .. } => unreachable!("handled above"), }, }; (status, Json(serde_json::json!({ "error": message }))).into_response() } } #[cfg(test)] mod tests { use base64::Engine as _; use super::*; async fn body_string(resp: Response) -> String { let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024) .await .unwrap(); String::from_utf8(bytes.to_vec()).unwrap() } // The user-route (inbox) path must scrub a Runtime failure: the raw Rhai // error (app UUID, function names, source line/col, attacker-thrown text) // must never reach the anonymous data-plane caller. Regression guard for // the 502 info-leak — the 2026-06-11 audit scrub had only covered the // execute-by-id path. #[tokio::test] async fn runtime_failure_is_scrubbed_on_the_inbox_path() { let leaky = "#{\"statusCode\": 401} @ 'app:2cced4ba-e20b-44c9-9080-d698cc2d4cca' \ (line 43, position 18)\nin call to function 'require_user'"; let resp = failure_to_response(InboxFailureKind::Runtime, leaky); assert_eq!(resp.status(), StatusCode::BAD_GATEWAY); let body = body_string(resp).await; assert!( !body.contains("app:2cced4ba"), "app UUID leaked in body: {body}" ); assert!( !body.contains("require_user"), "function name leaked in body: {body}" ); assert!(!body.contains("line 43"), "source position leaked: {body}"); assert!( body.contains("script execution error (ref: "), "expected scrubbed generic message, got: {body}" ); } // Non-Runtime failures are platform-generated (not attacker-influenced) // and stay descriptive so operators/clients see the real reason. #[tokio::test] async fn non_runtime_failures_pass_through() { let resp = failure_to_response(InboxFailureKind::Timeout, "request timed out"); assert_eq!(resp.status(), StatusCode::GATEWAY_TIMEOUT); let body = body_string(resp).await; assert!(body.contains("request timed out"), "got: {body}"); } #[test] fn scrub_hides_detail_but_keeps_a_reference() { let scrubbed = scrub_runtime_detail("secret-marker-98765 in call to 'admin_guard'"); assert!(!scrubbed.contains("secret-marker-98765")); assert!(!scrubbed.contains("admin_guard")); assert!(scrubbed.starts_with("script execution error (ref: ")); } // --- F-030 CORS --- #[test] fn cors_origin_selection() { let allow = vec!["http://localhost:5173".to_string()]; // Exact match echoes the origin. assert_eq!( pick_allowed_origin(Some("http://localhost:5173"), &allow).as_deref(), Some("http://localhost:5173") ); // Case-insensitive on the scheme/host. assert_eq!( pick_allowed_origin(Some("HTTP://LOCALHOST:5173"), &allow).as_deref(), Some("HTTP://LOCALHOST:5173") ); // Non-listed origin → not allowed. assert_eq!(pick_allowed_origin(Some("https://evil.test"), &allow), None); // No allow-list → CORS off. assert_eq!( pick_allowed_origin(Some("http://localhost:5173"), &[]), None ); // Wildcard allows any origin (and even a missing Origin echoes `*`). let star = vec!["*".to_string()]; assert_eq!( pick_allowed_origin(Some("https://anything.test"), &star).as_deref(), Some("*") ); } #[test] fn cors_headers_added_only_when_allowed() { let mut h = HeaderMap::new(); add_cors_headers(&mut h, Some("http://localhost:5173")); assert_eq!( h.get("access-control-allow-origin").unwrap(), "http://localhost:5173" ); assert_eq!(h.get("vary").unwrap(), "Origin"); let mut none = HeaderMap::new(); add_cors_headers(&mut none, None); assert!(none.get("access-control-allow-origin").is_none()); } #[tokio::test] async fn preflight_allows_permitted_origin() { let mut req_headers = HeaderMap::new(); req_headers.insert( "access-control-request-headers", HeaderValue::from_static("content-type, authorization"), ); let resp = preflight_response(Some("http://localhost:5173"), &req_headers); assert_eq!(resp.status(), StatusCode::NO_CONTENT); let h = resp.headers(); assert_eq!( h.get("access-control-allow-origin").unwrap(), "http://localhost:5173" ); assert!(h.get("access-control-allow-methods").is_some()); assert_eq!( h.get("access-control-allow-headers").unwrap(), "content-type, authorization" ); // A disallowed origin gets a bare 204 with no CORS headers. let blocked = preflight_response(None, &HeaderMap::new()); assert_eq!(blocked.status(), StatusCode::NO_CONTENT); assert!(blocked .headers() .get("access-control-allow-origin") .is_none()); } // --- F-026 non-JSON request bodies --- #[test] fn body_json_is_parsed() { let v = body_value_from_bytes(br#"{"a":1}"#, Some("application/json")).unwrap(); assert_eq!(v, serde_json::json!({ "a": 1 })); } #[test] fn body_unspecified_type_still_requires_json() { // Back-compat: no Content-Type ⇒ JSON, and malformed JSON still 400s. assert!(body_value_from_bytes(b"not json", None).is_err()); let empty = body_value_from_bytes(b"", None).unwrap(); assert_eq!(empty, Json_::Null); } #[test] fn body_form_urlencoded_becomes_object() { let v = body_value_from_bytes( b"name=ada&lang=rust", Some("application/x-www-form-urlencoded"), ) .unwrap(); assert_eq!(v, serde_json::json!({ "name": "ada", "lang": "rust" })); } #[test] fn body_text_becomes_string() { let v = body_value_from_bytes(b"hello world", Some("text/plain; charset=utf-8")).unwrap(); assert_eq!(v, Json_::String("hello world".into())); } #[test] fn body_binary_becomes_base64() { // A tiny PNG-ish byte sequence (not valid UTF-8) → base64 string. let raw = [0x89u8, 0x50, 0x4E, 0x47, 0x00, 0xFF]; let v = body_value_from_bytes(&raw, Some("application/octet-stream")).unwrap(); let expected = base64::engine::general_purpose::STANDARD.encode(raw); assert_eq!(v, Json_::String(expected)); } // --- F-026 binary responses --- #[tokio::test] async fn binary_response_returns_raw_bytes_with_content_type() { let raw = [0x89u8, 0x50, 0x4E, 0x47]; let b64 = base64::engine::general_purpose::STANDARD.encode(raw); let mut headers = BTreeMap::new(); headers.insert("content-type".to_string(), "image/png".to_string()); let resp = binary_response(StatusCode::OK, &headers, &b64); assert_eq!(resp.status(), StatusCode::OK); assert_eq!(resp.headers().get("content-type").unwrap(), "image/png"); let bytes = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap(); assert_eq!(bytes.as_ref(), raw); } #[tokio::test] async fn binary_response_defaults_octet_stream_and_rejects_bad_base64() { // No Content-Type set → octet-stream default. let ok = binary_response(StatusCode::OK, &BTreeMap::new(), "aGk="); // "hi" assert_eq!( ok.headers().get("content-type").unwrap(), "application/octet-stream" ); // Invalid base64 → opaque 500 (author error), not a panic. let bad = binary_response(StatusCode::OK, &BTreeMap::new(), "!!!not base64!!!"); assert_eq!(bad.status(), StatusCode::INTERNAL_SERVER_ERROR); } }