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:
@@ -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))
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user