From 5682be366ccd968df0ba2bace6c71be64812acea Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sun, 19 Jul 2026 20:15:17 +0200 Subject: [PATCH] fix(cms-poc): remediate findings surfaced by the CMS proof-of-concept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: )". 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 --- CHANGELOG.md | 57 ++ Cargo.lock | 1 + crates/executor-core/src/engine.rs | 51 +- crates/executor-core/src/sdk/workflow.rs | 83 ++- crates/executor-core/src/types.rs | 7 + crates/executor-core/tests/engine.rs | 20 + .../migrations/0077_apps_cors.sql | 11 + crates/manager-core/src/app_repo.rs | 45 ++ crates/manager-core/src/apply_api.rs | 9 +- crates/manager-core/src/apply_service.rs | 50 +- crates/manager-core/src/apps_api.rs | 53 ++ crates/manager-core/src/dispatcher.rs | 1 + crates/manager-core/src/docs_filter.rs | 19 + crates/manager-core/src/docs_repo.rs | 40 ++ crates/manager-core/src/docs_service.rs | 45 ++ crates/manager-core/src/users_admin_api.rs | 57 ++ crates/manager-core/src/workflow_service.rs | 42 +- .../tests/workflow_orchestrator.rs | 2 + crates/orchestrator-core/Cargo.toml | 1 + crates/orchestrator-core/src/api.rs | 513 ++++++++++++++++-- crates/orchestrator-core/src/inbox.rs | 1 + .../src/routing/app_domains.rs | 24 + crates/picloud-cli/src/client.rs | 61 +++ crates/picloud-cli/src/cmds/apps_cors.rs | 41 ++ crates/picloud-cli/src/cmds/mod.rs | 1 + crates/picloud-cli/src/cmds/users.rs | 84 ++- crates/picloud-cli/src/main.rs | 54 ++ crates/picloud/src/lib.rs | 5 + crates/shared/src/exec_summary.rs | 5 + crates/shared/src/lib.rs | 3 +- crates/shared/src/workflow.rs | 36 ++ docs/stdlib-reference.md | 31 ++ 32 files changed, 1361 insertions(+), 92 deletions(-) create mode 100644 crates/manager-core/migrations/0077_apps_cors.sql create mode 100644 crates/picloud-cli/src/cmds/apps_cors.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d2a9de0..3a642c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,62 @@ # PiCloud Changelog +## Unreleased — CMS proof-of-concept remediation + +Fixes surfaced by building a full CMS on PiCloud (`examples/cms-poc/`). Pure +additive surface + one migration (`0077_apps_cors.sql`), no destructive changes. + +### Security + +- **502 info-leak closed on user routes.** An uncaught script error (incl. a + `throw`) on a user route returned HTTP 502 whose body embedded the app UUID, + script function names, and source line/col. The 2026-06-11 audit scrub had + only covered the execute-by-id path; the user-route (inbox) path still leaked. + Both paths now share `scrub_runtime_detail` — the raw detail is logged + server-side under a correlation id and the client sees only + `script execution error (ref: )`. + +### Added + +- **`docs::find` `$contains` operator** — array-membership via JSONB `@>`, e.g. + `docs::find("posts", #{ tags: #{ "$contains": "rust" } })` finds every post + tagged `rust`. The inverse of `$in`. Covers per-app and group-shared docs. +- **App-user role management from the API/CLI** — `pic users add-role --app + ` / `rm-role`, backed by `POST`/`DELETE + /api/v1/admin/apps/{id}/users/{user_id}/roles[/{role}]` (`AppUsersAdmin`). + Role assignment was previously reachable only from a Rhai script. +- **Per-app CORS** — `pic apps cors set ` (or `*`), stored on + `apps.cors_allowed_origins`. The orchestrator echoes an allowed `Origin` on + every response and answers the `OPTIONS` preflight. Empty = off (unchanged). +- **Non-JSON request bodies** — a user route no longer 400s on a non-JSON body. + `application/x-www-form-urlencoded` → `ctx.request.body` object, `text/*` → + string, other types → base64 string; `ctx.request.content_type` added as a + convenience. JSON (or unspecified) is unchanged; malformed JSON still 400s. +- **Binary responses** — a script may return `#{ statusCode, headers, + body_base64 }`; the platform decodes the base64 and returns raw bytes with the + script-set `Content-Type` (default `application/octet-stream`). +- **`workflow::run_status(run_id)` SDK** (F-038) — the script that started a run + can now poll it: returns `#{ status, output, error, steps: #{ name: status } }` + or `()` when no such run belongs to the app. Read-only, same-app scoped (the + run's `app_id` is the isolation boundary), same `AppInvoke` gate as + `workflow::start`. Closes the gap where run status was platform-admin-API only. + +### Fixed + +- **`pic apply` "app not found" is now actionable** — points at + `pic apps create ` (apply reconciles groups but never creates an app). +- **Interceptor- and workflow-step-bound scripts no longer warned "no route or + trigger"** at plan time — an `[[interceptors]]` binding (F-021) *and* a + `[[workflows]]` step `function` (F-037) now count as reachability. + +### Notes + +- **Workflow `skipped` ≠ `failed` (F-039, by design).** A step whose `when` + evaluates false is `skipped` and counts as *satisfied-but-empty* for its + dependents — so a step downstream of a skipped one still runs. To gate a + dependent on an upstream actually having run, give it its own `when` + (e.g. `when = "steps.publish.output.published == true"`), or have the upstream + step return an explicit outcome the dependent checks. + ## v1.1.9 — Durable Queues & Function Composition (unreleased) Per-app durable queues + same-app function composition + caller- diff --git a/Cargo.lock b/Cargo.lock index 6d75c05..3e26b05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2077,6 +2077,7 @@ version = "1.1.9" dependencies = [ "async-trait", "axum", + "base64", "chrono", "lru", "picloud-executor-core", diff --git a/crates/executor-core/src/engine.rs b/crates/executor-core/src/engine.rs index d5660b0..e3fb7ab 100644 --- a/crates/executor-core/src/engine.rs +++ b/crates/executor-core/src/engine.rs @@ -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, Json), ExecError> { +type ParsedResponse = (u16, BTreeMap, Json, Option); + +fn parse_response(value: Dynamic) -> Result { // 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, 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, Json), ExecError> { +fn parse_structured_response(map: Map) -> Result { 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, } } - 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)) } // ---------------------------------------------------------------------------- diff --git a/crates/executor-core/src/sdk/workflow.rs b/crates/executor-core/src/sdk/workflow.rs index 3d0d43c..175885e 100644 --- a/crates/executor-core/src/sdk/workflow.rs +++ b/crates/executor-core/src/sdk/workflow.rs @@ -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) { let svc = services.workflow.clone(); @@ -46,6 +49,18 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc Result> { + 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, + cx: &Arc, + run_id: &str, +) -> Result> { + let run_id: WorkflowRunId = run_id + .parse::() + .map(WorkflowRunId::from) + .map_err(|_| -> Box { + 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::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::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) +} diff --git a/crates/executor-core/src/types.rs b/crates/executor-core/src/types.rs index 750c133..6182ed0 100644 --- a/crates/executor-core/src/types.rs +++ b/crates/executor-core/src/types.rs @@ -123,6 +123,13 @@ pub struct ExecResponse { pub status_code: u16, pub headers: BTreeMap, 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, pub logs: Vec, pub stats: ExecStats, } diff --git a/crates/executor-core/tests/engine.rs b/crates/executor-core/tests/engine.rs index 5dd35ff..b736517 100644 --- a/crates/executor-core/tests/engine.rs +++ b/crates/executor-core/tests/engine.rs @@ -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" diff --git a/crates/manager-core/migrations/0077_apps_cors.sql b/crates/manager-core/migrations/0077_apps_cors.sql new file mode 100644 index 0000000..abfc071 --- /dev/null +++ b/crates/manager-core/migrations/0077_apps_cors.sql @@ -0,0 +1,11 @@ +-- F-030 per-app CORS. A decoupled browser SPA on a different origin cannot +-- call an app's user routes today: the orchestrator emits no +-- `Access-Control-Allow-Origin` header and never answers an OPTIONS preflight. +-- +-- This adds a per-app allow-list of origins. The orchestrator (which has no DB) +-- reads it into its in-memory cache on each domain-cache rebuild, echoes the +-- request `Origin` when it matches, and short-circuits preflights. Empty array +-- (the default) = CORS off, preserving today's behaviour. `["*"]` allows any +-- origin. +ALTER TABLE apps + ADD COLUMN cors_allowed_origins JSONB NOT NULL DEFAULT '[]'::jsonb; diff --git a/crates/manager-core/src/app_repo.rs b/crates/manager-core/src/app_repo.rs index 8936a3c..02422d1 100644 --- a/crates/manager-core/src/app_repo.rs +++ b/crates/manager-core/src/app_repo.rs @@ -103,6 +103,25 @@ pub trait AppRepository: Send + Sync { /// single transaction so a partial delete cannot be observed. async fn delete_cascade(&self, id: AppId) -> Result<(), ScriptRepositoryError>; async fn count_scripts_in_app(&self, id: AppId) -> Result; + + /// F-030 per-app CORS. The allow-list of origins each app permits on its + /// user routes, keyed by app_id — read once per domain-cache rebuild to + /// push into the orchestrator (which has no DB). Default: none (CORS off). + async fn cors_all(&self) -> Result)>, ScriptRepositoryError> { + Ok(Vec::new()) + } + /// The CORS allow-list for one app (`[]` = CORS disabled). + async fn get_cors(&self, _id: AppId) -> Result, ScriptRepositoryError> { + Ok(Vec::new()) + } + /// Replace an app's CORS allow-list. `["*"]` allows any origin. + async fn set_cors( + &self, + _id: AppId, + _origins: Vec, + ) -> Result<(), ScriptRepositoryError> { + Ok(()) + } } pub struct PostgresAppRepository { @@ -443,6 +462,32 @@ impl AppRepository for PostgresAppRepository { .await?; Ok(count.0) } + + async fn cors_all(&self) -> Result)>, ScriptRepositoryError> { + let rows: Vec<(uuid::Uuid, sqlx::types::Json>)> = + sqlx::query_as("SELECT id, cors_allowed_origins FROM apps") + .fetch_all(&self.pool) + .await?; + Ok(rows.into_iter().map(|(id, j)| (id.into(), j.0)).collect()) + } + + async fn get_cors(&self, id: AppId) -> Result, ScriptRepositoryError> { + let row: Option<(sqlx::types::Json>,)> = + sqlx::query_as("SELECT cors_allowed_origins FROM apps WHERE id = $1") + .bind(id.into_inner()) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(|(j,)| j.0).unwrap_or_default()) + } + + async fn set_cors(&self, id: AppId, origins: Vec) -> Result<(), ScriptRepositoryError> { + sqlx::query("UPDATE apps SET cors_allowed_origins = $2, updated_at = NOW() WHERE id = $1") + .bind(id.into_inner()) + .bind(sqlx::types::Json(origins)) + .execute(&self.pool) + .await?; + Ok(()) + } } #[derive(sqlx::FromRow)] diff --git a/crates/manager-core/src/apply_api.rs b/crates/manager-core/src/apply_api.rs index d343453..33d07eb 100644 --- a/crates/manager-core/src/apply_api.rs +++ b/crates/manager-core/src/apply_api.rs @@ -705,7 +705,14 @@ async fn resolve_app_id(apps: &dyn AppRepository, ident: &str) -> Result ApplyError { diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index 4d1e4f8..4d8e7d4 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -3362,7 +3362,16 @@ impl ApplyService { .await .map_err(|e| ApplyError::Backend(e.to_string()))? .map(|l| l.app) - .ok_or_else(|| ApplyError::AppNotFound(ident.to_string())) + // `pic apply` reconciles the group tree (groups are created + // declaratively) but never creates an app — unlike a group, an + // app node must pre-exist. Make the failure actionable instead of + // a bare "app not found: cms" (F-006). + .ok_or_else(|| { + ApplyError::AppNotFound(format!( + "{ident} — apply does not create apps; create it first with \ + `pic apps create {ident}`, then re-run apply" + )) + }) } async fn resolve_group(&self, ident: &str) -> Result { @@ -5573,11 +5582,25 @@ fn reject_reserved_path(path: &str) -> Result<(), ApplyError> { /// execute-by-id bypass / `invoke()`. Modules are exempt (never invoked /// directly); disabled endpoints are intentionally inert, so skip them. fn unreachable_endpoint_warnings(bundle: &Bundle) -> Vec { + // A script is reachable if a route, a trigger, an interceptor, OR a + // workflow step binds it. Interceptors were the first missing binding-type + // (F-021); workflow-step `function`s were the second (F-037) — a step + // script reached only via `workflow::start` was wrongly flagged "no route + // or trigger". Interceptors reach a script via the `invoke()` re-entry + // path; workflow steps via the durable orchestrator. let bound: HashSet<&str> = bundle .routes .iter() .map(|r| r.script.as_str()) .chain(bundle.triggers.iter().map(BundleTrigger::script)) + .chain(bundle.interceptors.iter().map(|i| i.script.as_str())) + .chain( + bundle + .workflows + .iter() + .flat_map(|w| w.definition.steps.iter()) + .filter_map(|s| s.function.as_deref()), + ) .collect(); bundle .scripts @@ -6698,6 +6721,31 @@ mod tests { lib.kind = ScriptKind::Module; m.scripts = vec![lib]; assert!(unreachable_endpoint_warnings(&m).is_empty()); + // F-021: bound only by an interceptor → reachable, no warning. + let mut h = empty_bundle(); + h.scripts = vec![bundle_script("guard", "x")]; + h.interceptors = vec![BundleInterceptor { + service: "docs".into(), + op: "create".into(), + phase: "before".into(), + script: "guard".into(), + timeout_ms: None, + }]; + assert!( + unreachable_endpoint_warnings(&h).is_empty(), + "an interceptor-bound endpoint must not be flagged unreachable" + ); + // F-037: bound only as a workflow step's `function` → reachable. + let mut w = empty_bundle(); + w.scripts = vec![bundle_script("stepfn", "x")]; + w.workflows = vec![bundle_workflow( + "pipeline", + serde_json::json!([{ "name": "s1", "function": "stepfn" }]), + )]; + assert!( + unreachable_endpoint_warnings(&w).is_empty(), + "a workflow-step-bound endpoint must not be flagged unreachable" + ); } #[test] diff --git a/crates/manager-core/src/apps_api.rs b/crates/manager-core/src/apps_api.rs index 3b0c24c..179e98a 100644 --- a/crates/manager-core/src/apps_api.rs +++ b/crates/manager-core/src/apps_api.rs @@ -74,6 +74,10 @@ pub fn apps_router(state: AppsState) -> Router { "/apps/{id_or_slug}/domains/{domain_id}", delete(delete_domain), ) + .route( + "/apps/{id_or_slug}/cors", + get(get_cors_handler).put(set_cors_handler), + ) .with_state(state) } @@ -134,6 +138,13 @@ pub struct SlugCheckResponse { pub reason: Option, } +/// F-030 per-app CORS config: the set of browser origins allowed to call this +/// app's user routes. `["*"]` = any origin. Empty = CORS off. +#[derive(Debug, Serialize, Deserialize)] +pub struct CorsConfig { + pub allowed_origins: Vec, +} + #[derive(Debug, Deserialize)] pub struct CreateDomainRequest { pub pattern: String, @@ -513,6 +524,44 @@ async fn delete_domain( Ok(StatusCode::NO_CONTENT) } +/// F-030: read an app's CORS allow-list. `AppRead` — same gate as viewing +/// domain claims (CORS is host-adjacent config). +async fn get_cors_handler( + 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?; + let allowed_origins = s.apps.get_cors(app.id).await?; + Ok(Json(CorsConfig { allowed_origins })) +} + +/// F-030: replace an app's CORS allow-list, then refresh the domain cache so +/// the orchestrator serves the new policy immediately. `AppManageDomains` — +/// the same gate as domain-claim CRUD. +async fn set_cors_handler( + 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::AppManageDomains(app.id), + ) + .await?; + s.apps + .set_cors(app.id, input.allowed_origins.clone()) + .await?; + refresh_domain_cache(&s).await?; + Ok(Json(CorsConfig { + allowed_origins: input.allowed_origins, + })) +} + // ---------------------------------------------------------------------------- // Helpers // ---------------------------------------------------------------------------- @@ -602,6 +651,10 @@ pub async fn refresh_domain_cache(state: &AppsState) -> Result<(), AppsApiError> }) .collect(); state.domain_table.replace(compiled); + // F-030: push the per-app CORS allow-lists into the same in-memory cache so + // the orchestrator can answer preflights + tag responses without a DB hit. + let cors: std::collections::HashMap<_, _> = state.apps.cors_all().await?.into_iter().collect(); + state.domain_table.replace_cors(cors); Ok(()) } diff --git a/crates/manager-core/src/dispatcher.rs b/crates/manager-core/src/dispatcher.rs index 8e9cfbf..79d5cbe 100644 --- a/crates/manager-core/src/dispatcher.rs +++ b/crates/manager-core/src/dispatcher.rs @@ -1681,6 +1681,7 @@ fn summarize(resp: &ExecResponse) -> ExecResponseSummary { status_code: resp.status_code, headers: resp.headers.clone(), body: resp.body.clone(), + body_base64: resp.body_base64.clone(), } } diff --git a/crates/manager-core/src/docs_filter.rs b/crates/manager-core/src/docs_filter.rs index 368fb57..d12e242 100644 --- a/crates/manager-core/src/docs_filter.rs +++ b/crates/manager-core/src/docs_filter.rs @@ -150,6 +150,12 @@ pub enum ComparisonOp { /// `$in` — `= ANY($M::text[])` where the value list is bound as /// a TEXT[]. In, + /// `$contains` — JSONB containment (`@>`) for array-membership: + /// matches a doc whose stored *array* field contains the given + /// scalar element (e.g. `{ tags: { "$contains": "rust" } }` finds + /// docs tagged `rust`). The `$in` operator is the inverse (field is + /// one of a list); this one is (list field contains a value). + Contains, } impl ComparisonOp { @@ -169,6 +175,7 @@ impl ComparisonOp { "$lt" => Ok(Self::Lt), "$lte" => Ok(Self::Lte), "$in" => Ok(Self::In), + "$contains" => Ok(Self::Contains), other => Err(FilterParseError::UnsupportedOperator(format!( "docs::find: operator '{other}' is not supported in v1.1.2; planned for v1.2 advanced query" ))), @@ -321,6 +328,7 @@ const fn op_name(op: ComparisonOp) -> &'static str { ComparisonOp::Lt => "$lt", ComparisonOp::Lte => "$lte", ComparisonOp::In => "$in", + ComparisonOp::Contains => "$contains", } } @@ -465,6 +473,17 @@ mod tests { // $in needs an array. let f = parse(json!({ "tier": { "$in": ["gold", "platinum"] } })).unwrap(); assert_eq!(f.conditions[0].op, ComparisonOp::In); + // $contains takes a scalar (the element to find in an array field). + let f = parse(json!({ "tags": { "$contains": "rust" } })).unwrap(); + assert_eq!(f.conditions[0].op, ComparisonOp::Contains); + } + + #[test] + fn contains_with_object_value_rejected() { + // The element to find must be a scalar, not a nested object/array. + let err = parse(json!({ "tags": { "$contains": { "k": 1 } } })).unwrap_err(); + assert!(err.to_string().contains("'$contains'")); + assert!(err.to_string().contains("scalar")); } #[test] diff --git a/crates/manager-core/src/docs_repo.rs b/crates/manager-core/src/docs_repo.rs index 39bbad6..9810383 100644 --- a/crates/manager-core/src/docs_repo.rs +++ b/crates/manager-core/src/docs_repo.rs @@ -311,6 +311,20 @@ fn emit_condition<'a>( qb: &mut QueryBuilder<'a, Postgres>, cond: &'a crate::docs_filter::FieldCondition, ) { + // `$contains` uses JSONB containment (`@>`), which needs the field's + // *JSONB* value, not the text rendering the other operators compare + // against — so it builds its own path expression and short-circuits. + // Emits `jsonb_extract_path(data, $seg…) @> $val::jsonb`, matching a doc + // whose stored array field contains the given scalar element (F-015). + if cond.op == ComparisonOp::Contains { + push_jsonb_path_value(qb, cond.path.segments()); + qb.push(" @> "); + // `Value::to_string()` is the compact JSON encoding (`"rust"`, `5`, + // `true`), i.e. a valid JSONB literal — bound, never interpolated. + qb.push_bind(cond.value.to_string()); + qb.push("::jsonb"); + return; + } push_jsonb_path(qb, cond.path.segments()); match cond.op { ComparisonOp::Eq => { @@ -358,6 +372,7 @@ fn emit_condition<'a>( qb.push_bind(texts); qb.push(")"); } + ComparisonOp::Contains => unreachable!("handled before the match"), } } @@ -373,6 +388,18 @@ fn push_jsonb_path<'a>(qb: &mut QueryBuilder<'a, Postgres>, segments: &'a [Strin qb.push(")"); } +/// Like [`push_jsonb_path`] but returns the *JSONB* value rather than its +/// text rendering (`jsonb_extract_path`, no `_text`). Used by `$contains`, +/// whose `@>` operator needs a JSONB left operand. Segments bound as params. +fn push_jsonb_path_value<'a>(qb: &mut QueryBuilder<'a, Postgres>, segments: &'a [String]) { + qb.push("jsonb_extract_path(data"); + for seg in segments { + qb.push(", "); + qb.push_bind(seg.as_str()); + } + qb.push(")"); +} + /// JSON scalar → TEXT for binding. `Value::Null` is preserved as /// `None` so the binding lands as SQL NULL (handled specially above for /// `Eq` / `Ne`). Arrays + objects serialize to compact JSON; the user @@ -534,6 +561,7 @@ mod sql_shape_tests { json!({ "$sort": { "created_at": -1 }, "$limit": 5 }), json!({ "tier": "gold", "$sort": { "created_at": 1 } }), json!({ "deleted_at": { "$ne": null } }), + json!({ "tags": { "$contains": "rust" } }), ]; for case in cases { let sql = sql_for(case.clone()); @@ -620,4 +648,16 @@ mod sql_shape_tests { let sql = sql_for(json!({ "user.email": "a@b" })); assert!(sql.contains("jsonb_extract_path_text(data"), "sql: {sql}"); } + + #[test] + fn contains_emits_jsonb_containment() { + // `$contains` must use the JSONB (not text) extractor + `@>`, with the + // element bound as a `::jsonb` parameter — never interpolated (F-015). + let sql = sql_for(json!({ "tags": { "$contains": "rust" } })); + assert!(sql.contains("jsonb_extract_path(data"), "sql: {sql}"); + assert!(!sql.contains("jsonb_extract_path_text(data"), "sql: {sql}"); + assert!(sql.contains(" @> "), "sql: {sql}"); + assert!(sql.contains("::jsonb"), "sql: {sql}"); + assert!(!sql.contains("rust"), "value leaked into SQL string: {sql}"); + } } diff --git a/crates/manager-core/src/docs_service.rs b/crates/manager-core/src/docs_service.rs index 93233fb..56979c6 100644 --- a/crates/manager-core/src/docs_service.rs +++ b/crates/manager-core/src/docs_service.rs @@ -452,6 +452,22 @@ mod tests { }; arr.iter().any(|v| actual == json_text(v).as_deref()) } + Contains => { + // `actual` is the text rendering of the stored field; for an + // array field that's compact JSON. Parse it back and test + // element membership, mirroring Postgres `@>` (a scalar field + // equal to the value also "contains" it). + let Some(text) = actual else { + return false; + }; + match serde_json::from_str::(text) { + Ok(serde_json::Value::Array(items)) => { + items.iter().any(|v| json_text(v).as_deref() == want_ref) + } + Ok(other) => json_text(&other).as_deref() == want_ref, + Err(_) => false, + } + } } } @@ -829,6 +845,35 @@ mod tests { assert_eq!(hits.len(), 2); } + #[tokio::test] + async fn find_with_contains_matches_array_membership() { + // F-015: `$contains` finds docs whose ARRAY field holds the element — + // the "posts with tag X" query that `$in` cannot express. + let s = svc(); + let cx = anon_cx(AppId::new()); + s.create(&cx, "posts", json!({ "tags": ["intro", "rust"] })) + .await + .unwrap(); + s.create(&cx, "posts", json!({ "tags": ["rust", "async"] })) + .await + .unwrap(); + s.create(&cx, "posts", json!({ "tags": ["cooking"] })) + .await + .unwrap(); + + let hits = s + .find(&cx, "posts", json!({ "tags": { "$contains": "rust" } })) + .await + .unwrap(); + assert_eq!(hits.len(), 2, "expected the two rust-tagged posts"); + + let none = s + .find(&cx, "posts", json!({ "tags": { "$contains": "python" } })) + .await + .unwrap(); + assert!(none.is_empty()); + } + #[tokio::test] async fn find_one_explicit_limit_is_honoured() { // The service injects limit=1 ONLY when caller didn't set diff --git a/crates/manager-core/src/users_admin_api.rs b/crates/manager-core/src/users_admin_api.rs index e04d780..d0b83a8 100644 --- a/crates/manager-core/src/users_admin_api.rs +++ b/crates/manager-core/src/users_admin_api.rs @@ -56,6 +56,14 @@ pub fn app_users_router(state: AppUsersState) -> Router { "/apps/{id_or_slug}/users/{user_id}/revoke-sessions", post(revoke_sessions), ) + .route( + "/apps/{id_or_slug}/users/{user_id}/roles", + post(add_role_handler), + ) + .route( + "/apps/{id_or_slug}/users/{user_id}/roles/{role}", + delete(remove_role_handler), + ) .route( "/apps/{id_or_slug}/invitations", get(list_invitations_handler).post(create_invitation_handler), @@ -100,6 +108,11 @@ pub struct PatchUserRequest { pub display_name: Option>, } +#[derive(Debug, Deserialize)] +pub struct AddRoleRequest { + pub role: String, +} + #[derive(Debug, Serialize)] pub struct ResetPasswordResponse { /// The one-shot raw token. Returned exactly once — the admin @@ -304,6 +317,50 @@ async fn revoke_sessions( Ok(Json(RevokeSessionsResponse { revoked })) } +/// Grant an app-user a role (F-012). Role assignment was previously reachable +/// only from a Rhai script via `users::add_role` — so bootstrapping the first +/// admin needed a bespoke setup-token endpoint. Idempotent (the repo upsert is +/// `ON CONFLICT DO NOTHING`). Authz (`AppUsersAdmin`) is enforced inside the +/// service. Returns the refreshed user so the caller sees the new role set. +async fn add_role_handler( + State(s): State, + Extension(principal): Extension, + Path((id_or_slug, user_id)): Path<(String, Uuid)>, + Json(input): Json, +) -> Result, AppUsersApiError> { + let app = resolve_app(&*s.apps, &id_or_slug).await?; + let cx = synth_cx(app.id, &principal); + let uid = AppUserId::from(user_id); + s.users.add_role(&cx, uid, &input.role).await?; + let user = s + .users + .get(&cx, uid) + .await? + .ok_or(AppUsersApiError::UserNotFound)?; + Ok(Json(user)) +} + +/// Revoke an app-user role (F-012). Idempotent — a `DELETE` of a role the user +/// doesn't hold still succeeds (200) and returns the current user. +async fn remove_role_handler( + State(s): State, + Extension(principal): Extension, + Path((id_or_slug, user_id, role)): Path<(String, Uuid, String)>, +) -> Result, AppUsersApiError> { + let app = resolve_app(&*s.apps, &id_or_slug).await?; + let cx = synth_cx(app.id, &principal); + let uid = AppUserId::from(user_id); + // The bool (whether a row was actually removed) is intentionally ignored — + // DELETE is idempotent. + let _ = s.users.remove_role(&cx, uid, &role).await?; + let user = s + .users + .get(&cx, uid) + .await? + .ok_or(AppUsersApiError::UserNotFound)?; + Ok(Json(user)) +} + async fn list_invitations_handler( State(s): State, Extension(principal): Extension, diff --git a/crates/manager-core/src/workflow_service.rs b/crates/manager-core/src/workflow_service.rs index 32ed114..69e1f4e 100644 --- a/crates/manager-core/src/workflow_service.rs +++ b/crates/manager-core/src/workflow_service.rs @@ -13,11 +13,15 @@ use std::sync::Arc; use async_trait::async_trait; -use picloud_shared::{SdkCallCx, WorkflowError, WorkflowRunId, WorkflowService}; +use picloud_shared::{ + SdkCallCx, WorkflowError, WorkflowRunId, WorkflowRunView, WorkflowService, WorkflowStepView, +}; use sqlx::PgPool; use crate::authz::{self, AuthzRepo, Capability}; -use crate::workflow_repo::{get_workflow_by_name, start_run, NewWorkflowRun}; +use crate::workflow_repo::{ + get_run, get_workflow_by_name, list_run_steps, start_run, NewWorkflowRun, +}; pub struct WorkflowServiceImpl { pool: PgPool, @@ -84,4 +88,38 @@ impl WorkflowService for WorkflowServiceImpl { .await .map_err(|e| WorkflowError::Backend(e.to_string())) } + + async fn run_status( + &self, + cx: &SdkCallCx, + run_id: WorkflowRunId, + ) -> Result, WorkflowError> { + // Same gate as `start` — an authenticated caller needs AppInvoke; an + // anonymous public script is its own gate. The read is app-scoped: + // `get_run` filters on `cx.app_id`, so a run in another app resolves to + // `None` (the isolation boundary), never leaks. + self.check(cx).await?; + let Some(run) = get_run(&self.pool, cx.app_id, run_id) + .await + .map_err(|e| WorkflowError::Backend(e.to_string()))? + else { + return Ok(None); + }; + let steps = list_run_steps(&self.pool, cx.app_id, run_id) + .await + .map_err(|e| WorkflowError::Backend(e.to_string()))?; + Ok(Some(WorkflowRunView { + run_id, + status: run.status, + output: run.output, + error: run.error, + steps: steps + .into_iter() + .map(|s| WorkflowStepView { + name: s.step_name, + status: s.status, + }) + .collect(), + })) + } } diff --git a/crates/manager-core/tests/workflow_orchestrator.rs b/crates/manager-core/tests/workflow_orchestrator.rs index b414c34..40bb4b2 100644 --- a/crates/manager-core/tests/workflow_orchestrator.rs +++ b/crates/manager-core/tests/workflow_orchestrator.rs @@ -428,6 +428,7 @@ impl ExecutorClient for FakeExecutor { status_code: 200, headers: std::collections::BTreeMap::new(), body: json!({ "ran": req.script_name }), + body_base64: None, logs: vec![], stats: ExecStats::default(), }) @@ -559,6 +560,7 @@ impl ExecutorClient for ScriptedExecutor { status_code: 200, headers: std::collections::BTreeMap::new(), body, + body_base64: None, logs: vec![], stats: ExecStats::default(), }) diff --git a/crates/orchestrator-core/Cargo.toml b/crates/orchestrator-core/Cargo.toml index a4e924f..79befd0 100644 --- a/crates/orchestrator-core/Cargo.toml +++ b/crates/orchestrator-core/Cargo.toml @@ -22,6 +22,7 @@ uuid.workspace = true chrono.workspace = true reqwest.workspace = true rhai.workspace = true +base64.workspace = true tokio.workspace = true tokio-stream.workspace = true urlencoding.workspace = true diff --git a/crates/orchestrator-core/src/api.rs b/crates/orchestrator-core/src/api.rs index e5109c5..37dd7b0 100644 --- a/crates/orchestrator-core/src/api.rs +++ b/crates/orchestrator-core/src/api.rs @@ -14,6 +14,7 @@ use axum::{ routing::post, Extension, Json, Router, }; +use base64::Engine as _; use chrono::Utc; use picloud_executor_core::{ build_execution_log, ExecError, ExecRequest, ExecResponse, InvocationType, @@ -177,10 +178,75 @@ where } #[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, @@ -203,19 +269,6 @@ where .to_string(); let headers = request.headers().clone(); - // Two-phase dispatch (blueprint §11.5): first resolve Host → app_id, - // then run the existing matcher on that app's slice. No app claims - // this host → flat 404; the path doesn't get the chance to fire. - let Some(app_id) = state.app_domains.resolve_app(&host) else { - return Ok(( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "error": format!("no app claims host {host:?}") - })), - ) - .into_response()); - }; - let Some(matched) = state .routes .match_request_for_app(app_id, &host, &method, &path) @@ -255,12 +308,7 @@ where Err(e) => return Err(ApiError::BadRequest(format!("body read failed: {e}"))), }; - let body_json: Json_ = if body_bytes.is_empty() { - Json_::Null - } else { - serde_json::from_slice(&body_bytes) - .map_err(|e| ApiError::BadRequest(format!("invalid JSON body: {e}")))? - }; + let body_json = body_value_from_bytes(&body_bytes, content_type_of(&headers))?; let header_map: BTreeMap = headers .iter() .filter_map(|(k, v)| { @@ -275,7 +323,7 @@ where match matched.matched.dispatch_mode { DispatchMode::Async => { handle_async_route( - &state, + state, app_id, matched.matched.route_id, matched.matched.script_id, @@ -294,7 +342,7 @@ where } DispatchMode::Sync => { handle_sync_route( - &state, + state, app_id, matched.matched.route_id, matched.matched.script_id, @@ -490,9 +538,80 @@ where 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::()) { @@ -505,6 +624,59 @@ fn http_response_from_summary(summary: picloud_shared::ExecResponseSummary) -> R (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 { @@ -515,7 +687,15 @@ fn failure_to_response(kind: InboxFailureKind, message: &str) -> Response { InboxFailureKind::OperationBudget => StatusCode::INSUFFICIENT_STORAGE, InboxFailureKind::Platform => StatusCode::INTERNAL_SERVER_ERROR, }; - let body = Json(serde_json::json!({ "error": message })); + // 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(); } @@ -585,6 +765,59 @@ fn parse_query_string(s: &str) -> BTreeMap { 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 // ---------------------------------------------------------------------------- @@ -604,12 +837,7 @@ fn build_exec_request( } } - let body_json: Json_ = if body.is_empty() { - Json_::Null - } else { - serde_json::from_slice(body) - .map_err(|e| ApiError::BadRequest(format!("invalid JSON body: {e}")))? - }; + let body_json = body_value_from_bytes(body, content_type_of(headers))?; let execution_id = ExecutionId::new(); Ok(ExecRequest { @@ -651,6 +879,11 @@ 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::()) { @@ -741,22 +974,12 @@ impl IntoResponse for ApiError { // pool-exhaustion timing, and panic/backtrace // fragments. They also reflect attacker-thrown // strings (`throw "..."`) back into operator-facing - // JSON. This handler only ever serves anonymous - // data-plane callers (execute-by-id + user routes; - // the authenticated script-test path lives in - // manager-core and keeps verbose errors), so log the - // full text server-side under a correlation id and - // return only a stable generic message + the id. - let correlation_id = Uuid::new_v4(); - tracing::warn!( - %correlation_id, - detail = %detail, - "script runtime error (detail scrubbed from response)" - ); - ( - StatusCode::BAD_GATEWAY, - format!("script execution error (ref: {correlation_id})"), - ) + // 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"), }, @@ -764,3 +987,205 @@ impl IntoResponse for ApiError { (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); + } +} diff --git a/crates/orchestrator-core/src/inbox.rs b/crates/orchestrator-core/src/inbox.rs index 805902a..f8020ff 100644 --- a/crates/orchestrator-core/src/inbox.rs +++ b/crates/orchestrator-core/src/inbox.rs @@ -105,6 +105,7 @@ mod tests { status_code: 200, headers: BTreeMap::new(), body: serde_json::json!({ "ok": true }), + body_base64: None, }) } diff --git a/crates/orchestrator-core/src/routing/app_domains.rs b/crates/orchestrator-core/src/routing/app_domains.rs index 1467949..e3215b1 100644 --- a/crates/orchestrator-core/src/routing/app_domains.rs +++ b/crates/orchestrator-core/src/routing/app_domains.rs @@ -5,6 +5,7 @@ //! Cached in memory; the manager rebuilds the table after each //! domain-claim CRUD operation (same pattern as `RouteTable`). +use std::collections::HashMap; use std::sync::RwLock; use picloud_shared::AppId; @@ -22,6 +23,10 @@ pub struct CompiledAppDomain { #[derive(Default)] pub struct AppDomainTable { inner: RwLock>, + /// F-030 per-app CORS allow-lists, keyed by app_id. Pushed by the manager + /// on each domain-cache rebuild (the orchestrator has no DB). Absent / + /// empty ⇒ CORS off for that app. + cors: RwLock>>, } impl AppDomainTable { @@ -37,6 +42,25 @@ impl AppDomainTable { *guard = domains; } + /// Atomic full replacement of the per-app CORS allow-lists (F-030). + /// Called from the same manager rebuild path as [`replace`]. + pub fn replace_cors(&self, cors: HashMap>) { + let mut guard = self.cors.write().expect("app domain table poisoned"); + *guard = cors; + } + + /// The CORS allow-list an app permits on its user routes (F-030). Empty ⇒ + /// CORS is off for the app. `["*"]` means any origin is allowed. + #[must_use] + pub fn cors_for(&self, app_id: AppId) -> Vec { + self.cors + .read() + .expect("app domain table poisoned") + .get(&app_id) + .cloned() + .unwrap_or_default() + } + /// Resolve a request's `Host` header to an `AppId`. Most-specific /// claim wins: exact > longest wildcard > shorter wildcard. Returns /// `None` when no claim covers `host` (orchestrator should 404). diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index 45f1a9f..429181f 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -954,6 +954,28 @@ impl Client { decode_status(resp).await } + /// `GET /api/v1/admin/apps/{id_or_slug}/cors` (F-030) + pub async fn app_cors_get(&self, app: &str) -> Result { + let app = seg(app); + let resp = self + .request(Method::GET, &format!("/api/v1/admin/apps/{app}/cors")) + .send() + .await?; + decode(resp).await + } + + /// `PUT /api/v1/admin/apps/{id_or_slug}/cors` (F-030) + pub async fn app_cors_set(&self, app: &str, origins: Vec) -> Result { + let app = seg(app); + let body = serde_json::json!({ "allowed_origins": origins }); + let resp = self + .request(Method::PUT, &format!("/api/v1/admin/apps/{app}/cors")) + .json(&body) + .send() + .await?; + decode(resp).await + } + // ---------- topics ---------- /// `GET /api/v1/admin/apps/{id_or_slug}/topics` @@ -1092,6 +1114,39 @@ impl Client { decode(resp).await } + /// `POST /api/v1/admin/apps/{id_or_slug}/users/{user_id}/roles` (F-012) + pub async fn app_user_add_role(&self, app: &str, user_id: &str, role: &str) -> Result { + let (app, user_id) = (seg(app), seg(user_id)); + let body = serde_json::json!({ "role": role }); + let resp = self + .request( + Method::POST, + &format!("/api/v1/admin/apps/{app}/users/{user_id}/roles"), + ) + .json(&body) + .send() + .await?; + decode(resp).await + } + + /// `DELETE /api/v1/admin/apps/{id_or_slug}/users/{user_id}/roles/{role}` (F-012) + pub async fn app_user_remove_role( + &self, + app: &str, + user_id: &str, + role: &str, + ) -> Result { + let (app, user_id, role) = (seg(app), seg(user_id), seg(role)); + let resp = self + .request( + Method::DELETE, + &format!("/api/v1/admin/apps/{app}/users/{user_id}/roles/{role}"), + ) + .send() + .await?; + decode(resp).await + } + // --- App membership (G2) ---------------------------------------------- /// `GET /api/v1/admin/apps/{id_or_slug}/members` @@ -2204,6 +2259,12 @@ pub struct RevokeSessionsResponseDto { pub revoked: u64, } +/// F-030 per-app CORS config (mirrors `apps_api::CorsConfig`). +#[derive(Debug, Deserialize)] +pub struct CorsConfigDto { + pub allowed_origins: Vec, +} + #[derive(Debug, Deserialize)] pub struct DeadLetterCountDto { pub unresolved: i64, diff --git a/crates/picloud-cli/src/cmds/apps_cors.rs b/crates/picloud-cli/src/cmds/apps_cors.rs new file mode 100644 index 0000000..572e2ff --- /dev/null +++ b/crates/picloud-cli/src/cmds/apps_cors.rs @@ -0,0 +1,41 @@ +//! `pic apps cors {ls,set}` — manage an app's CORS allow-list (F-030). +//! +//! A browser SPA served from a different origin (e.g. a Vite dev server on +//! `:5173`) can't call an app's user routes unless the app allows that origin. +//! `set` replaces the whole list; `*` allows any origin; an empty `set` +//! disables CORS. This is the CLI surface over the `apps/{id}/cors` admin API. + +use anyhow::Result; + +use crate::client::Client; +use crate::config; +use crate::output::{KvBlock, OutputMode}; + +fn print_cors(origins: &[String], mode: OutputMode) { + let mut block = KvBlock::new(); + block.field( + "allowed_origins", + if origins.is_empty() { + "- (CORS disabled)".into() + } else { + origins.join(", ") + }, + ); + block.print(mode); +} + +pub async fn ls(app: &str, mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let cfg = client.app_cors_get(app).await?; + print_cors(&cfg.allowed_origins, mode); + Ok(()) +} + +pub async fn set(app: &str, origins: Vec, mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let cfg = client.app_cors_set(app, origins).await?; + print_cors(&cfg.allowed_origins, mode); + Ok(()) +} diff --git a/crates/picloud-cli/src/cmds/mod.rs b/crates/picloud-cli/src/cmds/mod.rs index dfead43..4ac9fa4 100644 --- a/crates/picloud-cli/src/cmds/mod.rs +++ b/crates/picloud-cli/src/cmds/mod.rs @@ -4,6 +4,7 @@ pub mod admins; pub mod api_keys; pub mod apply; pub mod apps; +pub mod apps_cors; pub mod apps_domains; pub mod collections; pub mod config; diff --git a/crates/picloud-cli/src/cmds/users.rs b/crates/picloud-cli/src/cmds/users.rs index 7a29bc9..568b6bb 100644 --- a/crates/picloud-cli/src/cmds/users.rs +++ b/crates/picloud-cli/src/cmds/users.rs @@ -1,41 +1,21 @@ -//! `pic users {ls,show,reset-password,revoke-sessions}` — admin view of -//! an app's *end-users* (people who registered via `users::create` in a -//! script), distinct from `pic admins` (instance control-plane accounts) -//! and app membership/collaborators. +//! `pic users {ls,show,reset-password,revoke-sessions,add-role,rm-role}` — +//! admin view of an app's *end-users* (people who registered via +//! `users::create` in a script), distinct from `pic admins` (instance +//! control-plane accounts) and app membership/collaborators. //! -//! Scoped to read + the two admin actions the E2E report needed. Create, +//! Scoped to read + the admin actions the E2E report needed. Create, //! delete, and invitations are deferred to a follow-up. use anyhow::Result; +use picloud_shared::AppUser; use crate::client::Client; use crate::config; use crate::output::{KvBlock, OutputMode, Table}; -pub async fn ls(app: &str, limit: u32, mode: OutputMode) -> Result<()> { - let creds = config::resolve()?; - let client = Client::from_creds(&creds)?; - let users = client.app_users_list(app, limit).await?; - let mut table = Table::new(["id", "email", "display_name", "last_login_at", "created_at"]); - for u in users { - table.row([ - u.id.to_string(), - u.email.clone(), - u.display_name.clone().unwrap_or_else(|| "-".into()), - u.last_login_at - .map(|t| t.to_rfc3339()) - .unwrap_or_else(|| "-".into()), - u.created_at.to_rfc3339(), - ]); - } - table.print(mode); - Ok(()) -} - -pub async fn show(app: &str, user_id: &str, mode: OutputMode) -> Result<()> { - let creds = config::resolve()?; - let client = Client::from_creds(&creds)?; - let u = client.app_user_get(app, user_id).await?; +/// Render an end-user's identity + role set as a key/value block. Shared by +/// `show`, `add-role`, and `rm-role` so the role mutations echo the result. +fn print_user(u: &AppUser, mode: OutputMode) { let mut block = KvBlock::new(); block .field("id", u.id.to_string()) @@ -67,6 +47,52 @@ pub async fn show(app: &str, user_id: &str, mode: OutputMode) -> Result<()> { .field("created_at", u.created_at.to_rfc3339()) .field("updated_at", u.updated_at.to_rfc3339()); block.print(mode); +} + +pub async fn ls(app: &str, limit: u32, mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let users = client.app_users_list(app, limit).await?; + let mut table = Table::new(["id", "email", "display_name", "last_login_at", "created_at"]); + for u in users { + table.row([ + u.id.to_string(), + u.email.clone(), + u.display_name.clone().unwrap_or_else(|| "-".into()), + u.last_login_at + .map(|t| t.to_rfc3339()) + .unwrap_or_else(|| "-".into()), + u.created_at.to_rfc3339(), + ]); + } + table.print(mode); + Ok(()) +} + +pub async fn show(app: &str, user_id: &str, mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let u = client.app_user_get(app, user_id).await?; + print_user(&u, mode); + Ok(()) +} + +/// Grant an end-user a role (F-012) — the operator equivalent of the +/// script-only `users::add_role`. Echoes the refreshed user. +pub async fn add_role(app: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let u = client.app_user_add_role(app, user_id, role).await?; + print_user(&u, mode); + Ok(()) +} + +/// Revoke a role from an end-user (F-012). Echoes the refreshed user. +pub async fn rm_role(app: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let u = client.app_user_remove_role(app, user_id, role).await?; + print_user(&u, mode); Ok(()) } diff --git a/crates/picloud-cli/src/main.rs b/crates/picloud-cli/src/main.rs index 76d3b89..87d0e2e 100644 --- a/crates/picloud-cli/src/main.rs +++ b/crates/picloud-cli/src/main.rs @@ -567,6 +567,26 @@ enum AppsCmd { #[command(subcommand)] cmd: DomainsCmd, }, + + /// Manage an app's CORS allow-list so a browser SPA on another origin + /// can call its user routes. + Cors { + #[command(subcommand)] + cmd: CorsCmd, + }, +} + +#[derive(Subcommand)] +enum CorsCmd { + /// Show the app's allowed CORS origins. + Ls { app: String }, + /// Replace the app's allowed CORS origins (space-separated). Pass `*` + /// to allow any origin, or no origins to disable CORS. + Set { + app: String, + /// Allowed origins, e.g. `http://localhost:5173 https://app.example.com`. + origins: Vec, + }, } #[derive(Subcommand)] @@ -1327,6 +1347,24 @@ enum UsersCmd { app: String, user_id: String, }, + + /// Grant an end-user a role (e.g. `admin`, `author`). Idempotent. + #[command(name = "add-role")] + AddRole { + #[arg(long)] + app: String, + user_id: String, + role: String, + }, + + /// Revoke a role from an end-user. Idempotent. + #[command(name = "rm-role")] + RmRole { + #[arg(long)] + app: String, + user_id: String, + role: String, + }, } #[derive(Subcommand)] @@ -1663,6 +1701,16 @@ async fn main() -> ExitCode { cmd: DomainsCmd::Rm { app, domain_id }, }, } => cmds::apps_domains::rm(&app, &domain_id).await, + Cmd::Apps { + cmd: AppsCmd::Cors { + cmd: CorsCmd::Ls { app }, + }, + } => cmds::apps_cors::ls(&app, mode).await, + Cmd::Apps { + cmd: AppsCmd::Cors { + cmd: CorsCmd::Set { app, origins }, + }, + } => cmds::apps_cors::set(&app, origins, mode).await, Cmd::Projects { cmd: ProjectsCmd::Ls, } => cmds::projects::ls(mode).await, @@ -2119,6 +2167,12 @@ async fn main() -> ExitCode { Cmd::Users { cmd: UsersCmd::RevokeSessions { app, user_id }, } => cmds::users::revoke_sessions(&app, &user_id, mode).await, + Cmd::Users { + cmd: UsersCmd::AddRole { app, user_id, role }, + } => cmds::users::add_role(&app, &user_id, &role, mode).await, + Cmd::Users { + cmd: UsersCmd::RmRole { app, user_id, role }, + } => cmds::users::rm_role(&app, &user_id, &role, mode).await, Cmd::DeadLetters { cmd: DeadLettersCmd::Count { app }, } => cmds::dead_letters::count(&app, mode).await, diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index 8d18506..b972599 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -503,6 +503,11 @@ pub async fn build_app( }) .collect(); app_domain_table.replace(compiled_domains); + // F-030: seed the per-app CORS allow-lists into the same cache so CORS is + // live from boot (not just after the first domain CRUD refresh). + let initial_cors: std::collections::HashMap<_, _> = + apps_repo.cors_all().await?.into_iter().collect(); + app_domain_table.replace_cors(initial_cors); let resolver = Arc::new(RepoResolver::new(script_repo.clone())); // Single global gate — overflow is rejected with 503 + Retry-After. diff --git a/crates/shared/src/exec_summary.rs b/crates/shared/src/exec_summary.rs index 3ba96a9..7c23f2f 100644 --- a/crates/shared/src/exec_summary.rs +++ b/crates/shared/src/exec_summary.rs @@ -13,4 +13,9 @@ pub struct ExecResponseSummary { pub status_code: u16, pub headers: BTreeMap, pub body: serde_json::Value, + /// F-026 binary response: base64 raw bytes to return instead of JSON-encoding + /// `body`. `None` ⇒ the normal JSON path. `#[serde(default)]` keeps the wire + /// format backward-compatible. + #[serde(default)] + pub body_base64: Option, } diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index c86e123..dbcd8ce 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -127,5 +127,6 @@ pub use vars::{NoopVarsService, VarsError, VarsService}; pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION}; pub use workflow::{ default_base_ms, NoopWorkflowService, OnError, RunStatus, StepStatus, WorkflowBackoff, - WorkflowDefinition, WorkflowError, WorkflowRetry, WorkflowService, WorkflowStepDef, + WorkflowDefinition, WorkflowError, WorkflowRetry, WorkflowRunView, WorkflowService, + WorkflowStepDef, WorkflowStepView, }; diff --git a/crates/shared/src/workflow.rs b/crates/shared/src/workflow.rs index 92eba2f..55afadc 100644 --- a/crates/shared/src/workflow.rs +++ b/crates/shared/src/workflow.rs @@ -29,6 +29,42 @@ pub trait WorkflowService: Send + Sync { name: &str, input: serde_json::Value, ) -> Result; + + /// Read the status of a run started in `cx.app_id` (F-038): a script that + /// called `workflow::start` can poll its progress without the admin API. + /// Returns `None` when no such run belongs to this app (the app-scope walk + /// is the isolation boundary). Default errors so a misconfigured service + /// can't silently report "no run". + async fn run_status( + &self, + cx: &SdkCallCx, + run_id: WorkflowRunId, + ) -> Result, WorkflowError> { + let _ = (cx, run_id); + Err(WorkflowError::Backend( + "workflow service is not configured".into(), + )) + } +} + +/// A read-only view of a workflow run for `workflow::run_status` (F-038). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkflowRunView { + pub run_id: WorkflowRunId, + pub status: RunStatus, + /// The run's final output (present once succeeded), else `None`. + pub output: Option, + /// The failure reason (present once failed), else `None`. + pub error: Option, + /// Per-step status, in step-name order. + pub steps: Vec, +} + +/// One step's status within a [`WorkflowRunView`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkflowStepView { + pub name: String, + pub status: StepStatus, } #[derive(Debug, Error)] diff --git a/docs/stdlib-reference.md b/docs/stdlib-reference.md index 1da8b25..85e6edf 100644 --- a/docs/stdlib-reference.md +++ b/docs/stdlib-reference.md @@ -272,6 +272,37 @@ Always include `statusCode` when you mean to set headers or a specific body shape. Returning a bare value (`42`, `#{ ok: true }`) is fine — it becomes a `200` body as-is. +**Abort with a status by *returning* the envelope, never `throw`.** A +`throw` (even `throw #{ statusCode: 403 }`) is an uncaught runtime error → +HTTP 502, not your status. Return a `statusCode` envelope to short-circuit: + +```rhai +if !authorized { return #{ statusCode: 403, body: #{ error: "forbidden" } }; } +``` + +### Binary responses — `body_base64` + +To return raw bytes (an image, a PDF, a download) instead of JSON, put the +base64 of the bytes in a `body_base64` field and set `Content-Type` +yourself. `body` is ignored when `body_base64` is present. + +```rhai +#{ statusCode: 200, + headers: #{ "content-type": "image/png" }, + body_base64: file.data } // base64 string → raw PNG bytes on the wire +``` + +Absent a `Content-Type` the platform defaults to `application/octet-stream`. + +### Non-JSON request bodies + +`ctx.request.body` is the parsed JSON for a JSON request. A non-JSON body is +no longer rejected: `application/x-www-form-urlencoded` arrives as an object +of string fields, `text/*` as the raw string, and any other content type as +a base64 string of the raw bytes. Read `ctx.request.content_type` (a +convenience mirror of the `content-type` header) to decide how to interpret +`ctx.request.body`. + ### Security note — `users::email_available` and account enumeration `users::email_available(email) -> bool` is the anonymous-safe way to