fix(cms-poc): remediate findings surfaced by the CMS proof-of-concept

Additive fixes surfaced by building a full CMS on PiCloud
(examples/cms-poc/). One migration (0077_apps_cors.sql); no destructive
changes.

Security:
- Close the 502 info-leak on user routes: an uncaught script error (incl.
  a throw) leaked the app UUID + script fn names + source line/col. The
  user-route (inbox) path now shares scrub_runtime_detail with the
  execute-by-id path — raw detail is logged under a correlation id and the
  client sees only "script execution error (ref: <uuid>)".

Added:
- docs::find $contains operator (array membership via JSONB @>), per-app
  and group-shared — the inverse of $in.
- App-user role management from API/CLI: pic users add-role / rm-role,
  backed by the apps/{id}/users/{user_id}/roles endpoints (AppUsersAdmin).
- Per-app CORS: pic apps cors set, apps.cors_allowed_origins; the
  orchestrator echoes an allowed Origin and answers OPTIONS preflight.
- Non-JSON request bodies: form-urlencoded -> object, text/* -> string,
  other -> base64; ctx.request.content_type added. Malformed JSON still 400s.
- Binary responses via #{ body_base64 } with a script-set Content-Type.
- workflow::run_status(run_id) SDK (F-038): the starting script can poll a
  run — #{ status, output, error, steps } or () when no such run belongs to
  the app (app_id is the isolation boundary); same AppInvoke gate as start.

Fixed:
- pic apply "app not found" is now actionable (points at pic apps create).
- Interceptor- (F-021) and workflow-step-bound (F-037) scripts no longer
  warn "no route or trigger" at plan time — both count as reachability.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-19 20:15:17 +02:00
parent dbdd398d90
commit 5682be366c
32 changed files with 1361 additions and 92 deletions

View File

@@ -358,12 +358,13 @@ impl Engine {
|m| m.into_inner().unwrap_or_default(),
);
let (status_code, headers, body) = parse_response(value)?;
let (status_code, headers, body, body_base64) = parse_response(value)?;
Ok(ExecResponse {
status_code,
headers,
body,
body_base64,
logs,
stats: ExecStats {
duration_ms: u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
@@ -531,6 +532,20 @@ fn build_ctx_map(req: &ExecRequest) -> Map {
request.insert("body".into(), json_to_dynamic(req.body.clone()));
// F-026 convenience: the request Content-Type (also present in `headers`).
// Lets a script branch on how to read `body` — parsed JSON for a JSON
// request, a raw string for `text/*`, or a base64 string for other binary
// types — without a case-insensitive header lookup. `()` when absent.
let content_type = req
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
.map(|(_, v)| v.clone());
request.insert(
"content_type".into(),
content_type.map_or(Dynamic::UNIT, Into::into),
);
// SDK 1.1 additions — route-captured params, query string, prefix
// tail. Empty when not applicable so scripts can always read them.
let mut params = Map::new();
@@ -768,7 +783,9 @@ fn invocation_type_str(it: InvocationType) -> &'static str {
// Response parsing
// ----------------------------------------------------------------------------
fn parse_response(value: Dynamic) -> Result<(u16, BTreeMap<String, String>, Json), ExecError> {
type ParsedResponse = (u16, BTreeMap<String, String>, Json, Option<String>);
fn parse_response(value: Dynamic) -> Result<ParsedResponse, ExecError> {
// Convention: a Map with a `statusCode` field is the structured shape.
// Anything else is treated as a 200 response with the value as body.
if value.is_map() {
@@ -782,10 +799,10 @@ fn parse_response(value: Dynamic) -> Result<(u16, BTreeMap<String, String>, Json
// cheaply-aliased huge value can't OOM the node on materialization.
let body = dynamic_to_json_capped(&value, MAX_JSON_MATERIALIZE_BYTES)
.map_err(|e| ExecError::InvalidResponse(e.to_string()))?;
Ok((200, BTreeMap::new(), body))
Ok((200, BTreeMap::new(), body, None))
}
fn parse_structured_response(map: Map) -> Result<(u16, BTreeMap<String, String>, Json), ExecError> {
fn parse_structured_response(map: Map) -> Result<ParsedResponse, ExecError> {
let status_dyn = map
.get("statusCode")
.ok_or_else(|| ExecError::InvalidResponse("missing statusCode".into()))?;
@@ -804,13 +821,29 @@ fn parse_structured_response(map: Map) -> Result<(u16, BTreeMap<String, String>,
}
}
let body = match map.get("body") {
Some(b) => dynamic_to_json_capped(b, MAX_JSON_MATERIALIZE_BYTES)
.map_err(|e| ExecError::InvalidResponse(e.to_string()))?,
None => Json::Null,
// F-026: a `body_base64` string field returns raw bytes with the script-set
// `Content-Type`, instead of JSON-encoding `body`. When set, `body` is
// ignored (the bytes win) so an author can't accidentally send both.
let body_base64 = match map.get("body_base64") {
Some(b) => Some(
b.clone()
.into_string()
.map_err(|_| ExecError::InvalidResponse("body_base64 must be a string".into()))?,
),
None => None,
};
Ok((status_code, headers, body))
let body = if body_base64.is_some() {
Json::Null
} else {
match map.get("body") {
Some(b) => dynamic_to_json_capped(b, MAX_JSON_MATERIALIZE_BYTES)
.map_err(|e| ExecError::InvalidResponse(e.to_string()))?,
None => Json::Null,
}
};
Ok((status_code, headers, body, body_base64))
}
// ----------------------------------------------------------------------------

View File

@@ -1,22 +1,25 @@
//! `workflow::` Rhai bridge — start a durable workflow run (v1.2 M5).
//! `workflow::` Rhai bridge — start a durable workflow run + read its status.
//!
//! ```rhai
//! let run_id = workflow::start("nightly_report", #{ date: "2026-07-12" });
//! let run_id = workflow::start("cleanup"); // no input
//! let st = workflow::run_status(run_id); // #{ status, output, error, steps } | ()
//! ```
//!
//! Returns the run id as a string. The owning workflow resolves from
//! `cx.app_id` inside the service — never a script arg (the isolation
//! boundary). The durable orchestrator advances the run asynchronously; the
//! script does not block on completion.
//! script does not block on completion. `run_status` (F-038) lets the starter
//! poll progress without the platform-admin API — `()` when no such run
//! belongs to this app.
use std::sync::Arc;
use picloud_shared::{SdkCallCx, Services, WorkflowService};
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Module};
use picloud_shared::{SdkCallCx, Services, WorkflowRunId, WorkflowRunView, WorkflowService};
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
use tokio::runtime::Handle as TokioHandle;
use crate::sdk::bridge::{dynamic_to_json_capped, MAX_JSON_MATERIALIZE_BYTES};
use crate::sdk::bridge::{dynamic_to_json_capped, json_to_dynamic, MAX_JSON_MATERIALIZE_BYTES};
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
let svc = services.workflow.clone();
@@ -46,6 +49,18 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
);
}
// `workflow::run_status(run_id)` — read-only poll (F-038). `()` if absent.
{
let svc = svc.clone();
let cx = cx.clone();
module.set_native_fn(
"run_status",
move |run_id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
run_status_blocking(&svc, &cx, run_id)
},
);
}
engine.register_static_module("workflow", module.into());
}
@@ -81,3 +96,61 @@ fn start_blocking(
})?;
Ok(run_id.to_string())
}
fn run_status_blocking(
svc: &Arc<dyn WorkflowService>,
cx: &Arc<SdkCallCx>,
run_id: &str,
) -> Result<Dynamic, Box<EvalAltResult>> {
let run_id: WorkflowRunId = run_id
.parse::<uuid::Uuid>()
.map(WorkflowRunId::from)
.map_err(|_| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime(
format!("workflow::run_status: invalid run id {run_id:?}").into(),
rhai::Position::NONE,
)
.into()
})?;
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime(
format!("workflow::run_status: no tokio runtime available: {e}").into(),
rhai::Position::NONE,
)
.into()
})?;
let svc = svc.clone();
let cx = cx.clone();
let view = handle
.block_on(async move { svc.run_status(&cx, run_id).await })
.map_err(|e| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime(
format!("workflow::run_status: {e}").into(),
rhai::Position::NONE,
)
.into()
})?;
// Absent run → `()` so a script can test `if status == ()`.
Ok(view.map_or(Dynamic::UNIT, |v| view_to_dynamic(&v)))
}
/// Convert a run view into the Rhai shape
/// `#{ status, output, error, steps: #{ name: status } }`.
fn view_to_dynamic(v: &WorkflowRunView) -> Dynamic {
let mut m = Map::new();
m.insert("status".into(), v.status.as_str().into());
m.insert(
"output".into(),
v.output.clone().map_or(Dynamic::UNIT, json_to_dynamic),
);
m.insert(
"error".into(),
v.error.clone().map_or(Dynamic::UNIT, Into::into),
);
let mut steps = Map::new();
for s in &v.steps {
steps.insert(s.name.clone().into(), s.status.as_str().into());
}
m.insert("steps".into(), steps.into());
Dynamic::from_map(m)
}

View File

@@ -123,6 +123,13 @@ pub struct ExecResponse {
pub status_code: u16,
pub headers: BTreeMap<String, String>,
pub body: serde_json::Value,
/// F-026 binary response. When a script returns an envelope with a
/// `body_base64` field, the HTTP layer decodes it and returns the raw
/// bytes with the script-set `Content-Type` (instead of JSON-encoding
/// `body`). `None` ⇒ the normal JSON `body` path. `#[serde(default)]`
/// keeps the cluster-mode wire format backward-compatible.
#[serde(default)]
pub body_base64: Option<String>,
pub logs: Vec<LogEntry>,
pub stats: ExecStats,
}

View File

@@ -93,6 +93,26 @@ fn returns_structured_response_when_status_code_present() {
assert_eq!(resp.body, json!({ "ok": true, "msg": "created" }));
}
#[test]
fn body_base64_envelope_sets_binary_response() {
// F-026: a `body_base64` field carries raw bytes; `body` is ignored.
let src = r#"
#{ statusCode: 200,
headers: #{ "content-type": "image/png" },
body: #{ ignored: true },
body_base64: "aGVsbG8=" }
"#;
let resp = engine().execute(src, req(json!(null))).unwrap();
assert_eq!(resp.status_code, 200);
assert_eq!(resp.body_base64.as_deref(), Some("aGVsbG8="));
// `body` is nulled out when body_base64 is present.
assert_eq!(resp.body, json!(null));
assert_eq!(
resp.headers.get("content-type").map(String::as_str),
Some("image/png")
);
}
#[test]
fn ctx_exposes_request_data() {
let src = r"