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

@@ -1,5 +1,62 @@
# PiCloud Changelog # 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: <uuid>)`.
### 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 <app>
<user_id> <role>` / `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 <app> <origins…>` (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 <slug>` (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) ## v1.1.9 — Durable Queues & Function Composition (unreleased)
Per-app durable queues + same-app function composition + caller- Per-app durable queues + same-app function composition + caller-

1
Cargo.lock generated
View File

@@ -2077,6 +2077,7 @@ version = "1.1.9"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
"base64",
"chrono", "chrono",
"lru", "lru",
"picloud-executor-core", "picloud-executor-core",

View File

@@ -358,12 +358,13 @@ impl Engine {
|m| m.into_inner().unwrap_or_default(), |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 { Ok(ExecResponse {
status_code, status_code,
headers, headers,
body, body,
body_base64,
logs, logs,
stats: ExecStats { stats: ExecStats {
duration_ms: u64::try_from(duration.as_millis()).unwrap_or(u64::MAX), 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())); 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 // SDK 1.1 additions — route-captured params, query string, prefix
// tail. Empty when not applicable so scripts can always read them. // tail. Empty when not applicable so scripts can always read them.
let mut params = Map::new(); let mut params = Map::new();
@@ -768,7 +783,9 @@ fn invocation_type_str(it: InvocationType) -> &'static str {
// Response parsing // 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. // Convention: a Map with a `statusCode` field is the structured shape.
// Anything else is treated as a 200 response with the value as body. // Anything else is treated as a 200 response with the value as body.
if value.is_map() { 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. // cheaply-aliased huge value can't OOM the node on materialization.
let body = dynamic_to_json_capped(&value, MAX_JSON_MATERIALIZE_BYTES) let body = dynamic_to_json_capped(&value, MAX_JSON_MATERIALIZE_BYTES)
.map_err(|e| ExecError::InvalidResponse(e.to_string()))?; .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 let status_dyn = map
.get("statusCode") .get("statusCode")
.ok_or_else(|| ExecError::InvalidResponse("missing statusCode".into()))?; .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") { // 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,
};
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) Some(b) => dynamic_to_json_capped(b, MAX_JSON_MATERIALIZE_BYTES)
.map_err(|e| ExecError::InvalidResponse(e.to_string()))?, .map_err(|e| ExecError::InvalidResponse(e.to_string()))?,
None => Json::Null, None => Json::Null,
}
}; };
Ok((status_code, headers, body)) 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 //! ```rhai
//! let run_id = workflow::start("nightly_report", #{ date: "2026-07-12" }); //! let run_id = workflow::start("nightly_report", #{ date: "2026-07-12" });
//! let run_id = workflow::start("cleanup"); // no input //! 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 //! Returns the run id as a string. The owning workflow resolves from
//! `cx.app_id` inside the service — never a script arg (the isolation //! `cx.app_id` inside the service — never a script arg (the isolation
//! boundary). The durable orchestrator advances the run asynchronously; the //! 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 std::sync::Arc;
use picloud_shared::{SdkCallCx, Services, WorkflowService}; use picloud_shared::{SdkCallCx, Services, WorkflowRunId, WorkflowRunView, WorkflowService};
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Module}; use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
use tokio::runtime::Handle as TokioHandle; 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>) { pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
let svc = services.workflow.clone(); 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()); engine.register_static_module("workflow", module.into());
} }
@@ -81,3 +96,61 @@ fn start_blocking(
})?; })?;
Ok(run_id.to_string()) 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 status_code: u16,
pub headers: BTreeMap<String, String>, pub headers: BTreeMap<String, String>,
pub body: serde_json::Value, 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 logs: Vec<LogEntry>,
pub stats: ExecStats, 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" })); 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] #[test]
fn ctx_exposes_request_data() { fn ctx_exposes_request_data() {
let src = r" let src = r"

View File

@@ -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;

View File

@@ -103,6 +103,25 @@ pub trait AppRepository: Send + Sync {
/// single transaction so a partial delete cannot be observed. /// single transaction so a partial delete cannot be observed.
async fn delete_cascade(&self, id: AppId) -> Result<(), ScriptRepositoryError>; async fn delete_cascade(&self, id: AppId) -> Result<(), ScriptRepositoryError>;
async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError>; async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError>;
/// 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<Vec<(AppId, Vec<String>)>, ScriptRepositoryError> {
Ok(Vec::new())
}
/// The CORS allow-list for one app (`[]` = CORS disabled).
async fn get_cors(&self, _id: AppId) -> Result<Vec<String>, ScriptRepositoryError> {
Ok(Vec::new())
}
/// Replace an app's CORS allow-list. `["*"]` allows any origin.
async fn set_cors(
&self,
_id: AppId,
_origins: Vec<String>,
) -> Result<(), ScriptRepositoryError> {
Ok(())
}
} }
pub struct PostgresAppRepository { pub struct PostgresAppRepository {
@@ -443,6 +462,32 @@ impl AppRepository for PostgresAppRepository {
.await?; .await?;
Ok(count.0) Ok(count.0)
} }
async fn cors_all(&self) -> Result<Vec<(AppId, Vec<String>)>, ScriptRepositoryError> {
let rows: Vec<(uuid::Uuid, sqlx::types::Json<Vec<String>>)> =
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<Vec<String>, ScriptRepositoryError> {
let row: Option<(sqlx::types::Json<Vec<String>>,)> =
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<String>) -> 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)] #[derive(sqlx::FromRow)]

View File

@@ -705,7 +705,14 @@ async fn resolve_app_id(apps: &dyn AppRepository, ident: &str) -> Result<AppId,
.await .await
.map_err(|e| ApplyError::Backend(e.to_string()))? .map_err(|e| ApplyError::Backend(e.to_string()))?
.map(|l| l.app.id) .map(|l| l.app.id)
.ok_or_else(|| ApplyError::AppNotFound(ident.to_string())) // `pic apply` reconciles the group tree but never creates an app — make
// the miss 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"
))
})
} }
fn map_authz(denied: AuthzDenied) -> ApplyError { fn map_authz(denied: AuthzDenied) -> ApplyError {

View File

@@ -3362,7 +3362,16 @@ impl ApplyService {
.await .await
.map_err(|e| ApplyError::Backend(e.to_string()))? .map_err(|e| ApplyError::Backend(e.to_string()))?
.map(|l| l.app) .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<GroupId, ApplyError> { async fn resolve_group(&self, ident: &str) -> Result<GroupId, ApplyError> {
@@ -5573,11 +5582,25 @@ fn reject_reserved_path(path: &str) -> Result<(), ApplyError> {
/// execute-by-id bypass / `invoke()`. Modules are exempt (never invoked /// execute-by-id bypass / `invoke()`. Modules are exempt (never invoked
/// directly); disabled endpoints are intentionally inert, so skip them. /// directly); disabled endpoints are intentionally inert, so skip them.
fn unreachable_endpoint_warnings(bundle: &Bundle) -> Vec<String> { fn unreachable_endpoint_warnings(bundle: &Bundle) -> Vec<String> {
// 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 let bound: HashSet<&str> = bundle
.routes .routes
.iter() .iter()
.map(|r| r.script.as_str()) .map(|r| r.script.as_str())
.chain(bundle.triggers.iter().map(BundleTrigger::script)) .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(); .collect();
bundle bundle
.scripts .scripts
@@ -6698,6 +6721,31 @@ mod tests {
lib.kind = ScriptKind::Module; lib.kind = ScriptKind::Module;
m.scripts = vec![lib]; m.scripts = vec![lib];
assert!(unreachable_endpoint_warnings(&m).is_empty()); 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] #[test]

View File

@@ -74,6 +74,10 @@ pub fn apps_router(state: AppsState) -> Router {
"/apps/{id_or_slug}/domains/{domain_id}", "/apps/{id_or_slug}/domains/{domain_id}",
delete(delete_domain), delete(delete_domain),
) )
.route(
"/apps/{id_or_slug}/cors",
get(get_cors_handler).put(set_cors_handler),
)
.with_state(state) .with_state(state)
} }
@@ -134,6 +138,13 @@ pub struct SlugCheckResponse {
pub reason: Option<String>, pub reason: Option<String>,
} }
/// 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<String>,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct CreateDomainRequest { pub struct CreateDomainRequest {
pub pattern: String, pub pattern: String,
@@ -513,6 +524,44 @@ async fn delete_domain(
Ok(StatusCode::NO_CONTENT) 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<AppsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<CorsConfig>, 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<AppsState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<CorsConfig>,
) -> Result<Json<CorsConfig>, 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 // Helpers
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@@ -602,6 +651,10 @@ pub async fn refresh_domain_cache(state: &AppsState) -> Result<(), AppsApiError>
}) })
.collect(); .collect();
state.domain_table.replace(compiled); 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(()) Ok(())
} }

View File

@@ -1681,6 +1681,7 @@ fn summarize(resp: &ExecResponse) -> ExecResponseSummary {
status_code: resp.status_code, status_code: resp.status_code,
headers: resp.headers.clone(), headers: resp.headers.clone(),
body: resp.body.clone(), body: resp.body.clone(),
body_base64: resp.body_base64.clone(),
} }
} }

View File

@@ -150,6 +150,12 @@ pub enum ComparisonOp {
/// `$in` — `= ANY($M::text[])` where the value list is bound as /// `$in` — `= ANY($M::text[])` where the value list is bound as
/// a TEXT[]. /// a TEXT[].
In, 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 { impl ComparisonOp {
@@ -169,6 +175,7 @@ impl ComparisonOp {
"$lt" => Ok(Self::Lt), "$lt" => Ok(Self::Lt),
"$lte" => Ok(Self::Lte), "$lte" => Ok(Self::Lte),
"$in" => Ok(Self::In), "$in" => Ok(Self::In),
"$contains" => Ok(Self::Contains),
other => Err(FilterParseError::UnsupportedOperator(format!( other => Err(FilterParseError::UnsupportedOperator(format!(
"docs::find: operator '{other}' is not supported in v1.1.2; planned for v1.2 advanced query" "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::Lt => "$lt",
ComparisonOp::Lte => "$lte", ComparisonOp::Lte => "$lte",
ComparisonOp::In => "$in", ComparisonOp::In => "$in",
ComparisonOp::Contains => "$contains",
} }
} }
@@ -465,6 +473,17 @@ mod tests {
// $in needs an array. // $in needs an array.
let f = parse(json!({ "tier": { "$in": ["gold", "platinum"] } })).unwrap(); let f = parse(json!({ "tier": { "$in": ["gold", "platinum"] } })).unwrap();
assert_eq!(f.conditions[0].op, ComparisonOp::In); 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] #[test]

View File

@@ -311,6 +311,20 @@ fn emit_condition<'a>(
qb: &mut QueryBuilder<'a, Postgres>, qb: &mut QueryBuilder<'a, Postgres>,
cond: &'a crate::docs_filter::FieldCondition, 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()); push_jsonb_path(qb, cond.path.segments());
match cond.op { match cond.op {
ComparisonOp::Eq => { ComparisonOp::Eq => {
@@ -358,6 +372,7 @@ fn emit_condition<'a>(
qb.push_bind(texts); qb.push_bind(texts);
qb.push(")"); 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(")"); 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 /// JSON scalar → TEXT for binding. `Value::Null` is preserved as
/// `None` so the binding lands as SQL NULL (handled specially above for /// `None` so the binding lands as SQL NULL (handled specially above for
/// `Eq` / `Ne`). Arrays + objects serialize to compact JSON; the user /// `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!({ "$sort": { "created_at": -1 }, "$limit": 5 }),
json!({ "tier": "gold", "$sort": { "created_at": 1 } }), json!({ "tier": "gold", "$sort": { "created_at": 1 } }),
json!({ "deleted_at": { "$ne": null } }), json!({ "deleted_at": { "$ne": null } }),
json!({ "tags": { "$contains": "rust" } }),
]; ];
for case in cases { for case in cases {
let sql = sql_for(case.clone()); let sql = sql_for(case.clone());
@@ -620,4 +648,16 @@ mod sql_shape_tests {
let sql = sql_for(json!({ "user.email": "a@b" })); let sql = sql_for(json!({ "user.email": "a@b" }));
assert!(sql.contains("jsonb_extract_path_text(data"), "sql: {sql}"); 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}");
}
} }

View File

@@ -452,6 +452,22 @@ mod tests {
}; };
arr.iter().any(|v| actual == json_text(v).as_deref()) 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::<serde_json::Value>(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); 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] #[tokio::test]
async fn find_one_explicit_limit_is_honoured() { async fn find_one_explicit_limit_is_honoured() {
// The service injects limit=1 ONLY when caller didn't set // The service injects limit=1 ONLY when caller didn't set

View File

@@ -56,6 +56,14 @@ pub fn app_users_router(state: AppUsersState) -> Router {
"/apps/{id_or_slug}/users/{user_id}/revoke-sessions", "/apps/{id_or_slug}/users/{user_id}/revoke-sessions",
post(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( .route(
"/apps/{id_or_slug}/invitations", "/apps/{id_or_slug}/invitations",
get(list_invitations_handler).post(create_invitation_handler), get(list_invitations_handler).post(create_invitation_handler),
@@ -100,6 +108,11 @@ pub struct PatchUserRequest {
pub display_name: Option<Option<String>>, pub display_name: Option<Option<String>>,
} }
#[derive(Debug, Deserialize)]
pub struct AddRoleRequest {
pub role: String,
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct ResetPasswordResponse { pub struct ResetPasswordResponse {
/// The one-shot raw token. Returned exactly once — the admin /// The one-shot raw token. Returned exactly once — the admin
@@ -304,6 +317,50 @@ async fn revoke_sessions(
Ok(Json(RevokeSessionsResponse { revoked })) 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<AppUsersState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
Json(input): Json<AddRoleRequest>,
) -> Result<Json<AppUser>, 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<AppUsersState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, user_id, role)): Path<(String, Uuid, String)>,
) -> Result<Json<AppUser>, 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( async fn list_invitations_handler(
State(s): State<AppUsersState>, State(s): State<AppUsersState>,
Extension(principal): Extension<Principal>, Extension(principal): Extension<Principal>,

View File

@@ -13,11 +13,15 @@
use std::sync::Arc; use std::sync::Arc;
use async_trait::async_trait; 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 sqlx::PgPool;
use crate::authz::{self, AuthzRepo, Capability}; 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 { pub struct WorkflowServiceImpl {
pool: PgPool, pool: PgPool,
@@ -84,4 +88,38 @@ impl WorkflowService for WorkflowServiceImpl {
.await .await
.map_err(|e| WorkflowError::Backend(e.to_string())) .map_err(|e| WorkflowError::Backend(e.to_string()))
} }
async fn run_status(
&self,
cx: &SdkCallCx,
run_id: WorkflowRunId,
) -> Result<Option<WorkflowRunView>, 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(),
}))
}
} }

View File

@@ -428,6 +428,7 @@ impl ExecutorClient for FakeExecutor {
status_code: 200, status_code: 200,
headers: std::collections::BTreeMap::new(), headers: std::collections::BTreeMap::new(),
body: json!({ "ran": req.script_name }), body: json!({ "ran": req.script_name }),
body_base64: None,
logs: vec![], logs: vec![],
stats: ExecStats::default(), stats: ExecStats::default(),
}) })
@@ -559,6 +560,7 @@ impl ExecutorClient for ScriptedExecutor {
status_code: 200, status_code: 200,
headers: std::collections::BTreeMap::new(), headers: std::collections::BTreeMap::new(),
body, body,
body_base64: None,
logs: vec![], logs: vec![],
stats: ExecStats::default(), stats: ExecStats::default(),
}) })

View File

@@ -22,6 +22,7 @@ uuid.workspace = true
chrono.workspace = true chrono.workspace = true
reqwest.workspace = true reqwest.workspace = true
rhai.workspace = true rhai.workspace = true
base64.workspace = true
tokio.workspace = true tokio.workspace = true
tokio-stream.workspace = true tokio-stream.workspace = true
urlencoding.workspace = true urlencoding.workspace = true

View File

@@ -14,6 +14,7 @@ use axum::{
routing::post, routing::post,
Extension, Json, Router, Extension, Json, Router,
}; };
use base64::Engine as _;
use chrono::Utc; use chrono::Utc;
use picloud_executor_core::{ use picloud_executor_core::{
build_execution_log, ExecError, ExecRequest, ExecResponse, InvocationType, build_execution_log, ExecError, ExecRequest, ExecResponse, InvocationType,
@@ -177,10 +178,75 @@ where
} }
#[allow(clippy::too_many_lines)] #[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<E, R>( async fn user_route_handler<E, R>(
State(state): State<DataPlaneState<E, R>>, State(state): State<DataPlaneState<E, R>>,
Extension(principal): Extension<Option<Principal>>, Extension(principal): Extension<Option<Principal>>,
request: Request, 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<E, R>(
state: &DataPlaneState<E, R>,
app_id: AppId,
request: Request,
principal: Option<Principal>,
) -> Result<Response, ApiError> ) -> Result<Response, ApiError>
where where
E: ExecutorClient + 'static, E: ExecutorClient + 'static,
@@ -203,19 +269,6 @@ where
.to_string(); .to_string();
let headers = request.headers().clone(); 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 let Some(matched) = state
.routes .routes
.match_request_for_app(app_id, &host, &method, &path) .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}"))), Err(e) => return Err(ApiError::BadRequest(format!("body read failed: {e}"))),
}; };
let body_json: Json_ = if body_bytes.is_empty() { let body_json = body_value_from_bytes(&body_bytes, content_type_of(&headers))?;
Json_::Null
} else {
serde_json::from_slice(&body_bytes)
.map_err(|e| ApiError::BadRequest(format!("invalid JSON body: {e}")))?
};
let header_map: BTreeMap<String, String> = headers let header_map: BTreeMap<String, String> = headers
.iter() .iter()
.filter_map(|(k, v)| { .filter_map(|(k, v)| {
@@ -275,7 +323,7 @@ where
match matched.matched.dispatch_mode { match matched.matched.dispatch_mode {
DispatchMode::Async => { DispatchMode::Async => {
handle_async_route( handle_async_route(
&state, state,
app_id, app_id,
matched.matched.route_id, matched.matched.route_id,
matched.matched.script_id, matched.matched.script_id,
@@ -294,7 +342,7 @@ where
} }
DispatchMode::Sync => { DispatchMode::Sync => {
handle_sync_route( handle_sync_route(
&state, state,
app_id, app_id,
matched.matched.route_id, matched.matched.route_id,
matched.matched.script_id, matched.matched.script_id,
@@ -490,9 +538,80 @@ where
Ok(response) 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<String> {
// `["*"]` 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 { fn http_response_from_summary(summary: picloud_shared::ExecResponseSummary) -> Response {
let status = let status =
StatusCode::from_u16(summary.status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); 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(); let mut http_headers = HeaderMap::new();
for (k, v) in summary.headers { for (k, v) in summary.headers {
if let (Ok(name), Ok(value)) = (k.parse::<HeaderName>(), v.parse::<HeaderValue>()) { if let (Ok(name), Ok(value)) = (k.parse::<HeaderName>(), v.parse::<HeaderValue>()) {
@@ -505,6 +624,59 @@ fn http_response_from_summary(summary: picloud_shared::ExecResponseSummary) -> R
(status, http_headers, Json(summary.body)).into_response() (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<String, String>,
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<u8>` 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::<HeaderName>(), v.parse::<HeaderValue>()) {
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. /// Map `InboxFailureKind` onto the design-notes §3 status-code table.
fn failure_to_response(kind: InboxFailureKind, message: &str) -> Response { fn failure_to_response(kind: InboxFailureKind, message: &str) -> Response {
let status = match kind { let status = match kind {
@@ -515,7 +687,15 @@ fn failure_to_response(kind: InboxFailureKind, message: &str) -> Response {
InboxFailureKind::OperationBudget => StatusCode::INSUFFICIENT_STORAGE, InboxFailureKind::OperationBudget => StatusCode::INSUFFICIENT_STORAGE,
InboxFailureKind::Platform => StatusCode::INTERNAL_SERVER_ERROR, 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) { if matches!(kind, InboxFailureKind::Overloaded) {
return (status, [(axum::http::header::RETRY_AFTER, "1")], body).into_response(); return (status, [(axum::http::header::RETRY_AFTER, "1")], body).into_response();
} }
@@ -585,6 +765,59 @@ fn parse_query_string(s: &str) -> BTreeMap<String, String> {
out 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<Json_, ApiError> {
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<String, Json_> = 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 // Marshalling
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@@ -604,12 +837,7 @@ fn build_exec_request(
} }
} }
let body_json: Json_ = if body.is_empty() { let body_json = body_value_from_bytes(body, content_type_of(headers))?;
Json_::Null
} else {
serde_json::from_slice(body)
.map_err(|e| ApiError::BadRequest(format!("invalid JSON body: {e}")))?
};
let execution_id = ExecutionId::new(); let execution_id = ExecutionId::new();
Ok(ExecRequest { Ok(ExecRequest {
@@ -651,6 +879,11 @@ fn exec_response_to_http(resp: ExecResponse) -> Response {
let status = let status =
StatusCode::from_u16(resp.status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); 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(); let mut http_headers = HeaderMap::new();
for (k, v) in resp.headers { for (k, v) in resp.headers {
if let (Ok(name), Ok(value)) = (k.parse::<HeaderName>(), v.parse::<HeaderValue>()) { if let (Ok(name), Ok(value)) = (k.parse::<HeaderName>(), v.parse::<HeaderValue>()) {
@@ -741,22 +974,12 @@ impl IntoResponse for ApiError {
// pool-exhaustion timing, and panic/backtrace // pool-exhaustion timing, and panic/backtrace
// fragments. They also reflect attacker-thrown // fragments. They also reflect attacker-thrown
// strings (`throw "..."`) back into operator-facing // strings (`throw "..."`) back into operator-facing
// JSON. This handler only ever serves anonymous // JSON. Both anonymous data-plane paths (execute-by-id
// data-plane callers (execute-by-id + user routes; // here + user routes via `failure_to_response`) scrub
// the authenticated script-test path lives in // through `scrub_runtime_detail`; the authenticated
// manager-core and keeps verbose errors), so log the // script-test path lives in manager-core and keeps
// full text server-side under a correlation id and // verbose errors.
// return only a stable generic message + the id. (StatusCode::BAD_GATEWAY, scrub_runtime_detail(detail))
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})"),
)
} }
ExecError::Overloaded { .. } => unreachable!("handled above"), ExecError::Overloaded { .. } => unreachable!("handled above"),
}, },
@@ -764,3 +987,205 @@ impl IntoResponse for ApiError {
(status, Json(serde_json::json!({ "error": message }))).into_response() (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);
}
}

View File

@@ -105,6 +105,7 @@ mod tests {
status_code: 200, status_code: 200,
headers: BTreeMap::new(), headers: BTreeMap::new(),
body: serde_json::json!({ "ok": true }), body: serde_json::json!({ "ok": true }),
body_base64: None,
}) })
} }

View File

@@ -5,6 +5,7 @@
//! Cached in memory; the manager rebuilds the table after each //! Cached in memory; the manager rebuilds the table after each
//! domain-claim CRUD operation (same pattern as `RouteTable`). //! domain-claim CRUD operation (same pattern as `RouteTable`).
use std::collections::HashMap;
use std::sync::RwLock; use std::sync::RwLock;
use picloud_shared::AppId; use picloud_shared::AppId;
@@ -22,6 +23,10 @@ pub struct CompiledAppDomain {
#[derive(Default)] #[derive(Default)]
pub struct AppDomainTable { pub struct AppDomainTable {
inner: RwLock<Vec<CompiledAppDomain>>, inner: RwLock<Vec<CompiledAppDomain>>,
/// 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<HashMap<AppId, Vec<String>>>,
} }
impl AppDomainTable { impl AppDomainTable {
@@ -37,6 +42,25 @@ impl AppDomainTable {
*guard = domains; *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<AppId, Vec<String>>) {
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<String> {
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 /// Resolve a request's `Host` header to an `AppId`. Most-specific
/// claim wins: exact > longest wildcard > shorter wildcard. Returns /// claim wins: exact > longest wildcard > shorter wildcard. Returns
/// `None` when no claim covers `host` (orchestrator should 404). /// `None` when no claim covers `host` (orchestrator should 404).

View File

@@ -954,6 +954,28 @@ impl Client {
decode_status(resp).await 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<CorsConfigDto> {
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<String>) -> Result<CorsConfigDto> {
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 ---------- // ---------- topics ----------
/// `GET /api/v1/admin/apps/{id_or_slug}/topics` /// `GET /api/v1/admin/apps/{id_or_slug}/topics`
@@ -1092,6 +1114,39 @@ impl Client {
decode(resp).await 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<AppUser> {
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<AppUser> {
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) ---------------------------------------------- // --- App membership (G2) ----------------------------------------------
/// `GET /api/v1/admin/apps/{id_or_slug}/members` /// `GET /api/v1/admin/apps/{id_or_slug}/members`
@@ -2204,6 +2259,12 @@ pub struct RevokeSessionsResponseDto {
pub revoked: u64, pub revoked: u64,
} }
/// F-030 per-app CORS config (mirrors `apps_api::CorsConfig`).
#[derive(Debug, Deserialize)]
pub struct CorsConfigDto {
pub allowed_origins: Vec<String>,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct DeadLetterCountDto { pub struct DeadLetterCountDto {
pub unresolved: i64, pub unresolved: i64,

View File

@@ -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<String>, 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(())
}

View File

@@ -4,6 +4,7 @@ pub mod admins;
pub mod api_keys; pub mod api_keys;
pub mod apply; pub mod apply;
pub mod apps; pub mod apps;
pub mod apps_cors;
pub mod apps_domains; pub mod apps_domains;
pub mod collections; pub mod collections;
pub mod config; pub mod config;

View File

@@ -1,41 +1,21 @@
//! `pic users {ls,show,reset-password,revoke-sessions}` — admin view of //! `pic users {ls,show,reset-password,revoke-sessions,add-role,rm-role}` —
//! an app's *end-users* (people who registered via `users::create` in a //! admin view of an app's *end-users* (people who registered via
//! script), distinct from `pic admins` (instance control-plane accounts) //! `users::create` in a script), distinct from `pic admins` (instance
//! and app membership/collaborators. //! 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. //! delete, and invitations are deferred to a follow-up.
use anyhow::Result; use anyhow::Result;
use picloud_shared::AppUser;
use crate::client::Client; use crate::client::Client;
use crate::config; use crate::config;
use crate::output::{KvBlock, OutputMode, Table}; use crate::output::{KvBlock, OutputMode, Table};
pub async fn ls(app: &str, limit: u32, mode: OutputMode) -> Result<()> { /// Render an end-user's identity + role set as a key/value block. Shared by
let creds = config::resolve()?; /// `show`, `add-role`, and `rm-role` so the role mutations echo the result.
let client = Client::from_creds(&creds)?; fn print_user(u: &AppUser, mode: OutputMode) {
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?;
let mut block = KvBlock::new(); let mut block = KvBlock::new();
block block
.field("id", u.id.to_string()) .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("created_at", u.created_at.to_rfc3339())
.field("updated_at", u.updated_at.to_rfc3339()); .field("updated_at", u.updated_at.to_rfc3339());
block.print(mode); 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(()) Ok(())
} }

View File

@@ -567,6 +567,26 @@ enum AppsCmd {
#[command(subcommand)] #[command(subcommand)]
cmd: DomainsCmd, 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<String>,
},
} }
#[derive(Subcommand)] #[derive(Subcommand)]
@@ -1327,6 +1347,24 @@ enum UsersCmd {
app: String, app: String,
user_id: 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)] #[derive(Subcommand)]
@@ -1663,6 +1701,16 @@ async fn main() -> ExitCode {
cmd: DomainsCmd::Rm { app, domain_id }, cmd: DomainsCmd::Rm { app, domain_id },
}, },
} => cmds::apps_domains::rm(&app, &domain_id).await, } => 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::Projects {
cmd: ProjectsCmd::Ls, cmd: ProjectsCmd::Ls,
} => cmds::projects::ls(mode).await, } => cmds::projects::ls(mode).await,
@@ -2119,6 +2167,12 @@ async fn main() -> ExitCode {
Cmd::Users { Cmd::Users {
cmd: UsersCmd::RevokeSessions { app, user_id }, cmd: UsersCmd::RevokeSessions { app, user_id },
} => cmds::users::revoke_sessions(&app, &user_id, mode).await, } => 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::DeadLetters {
cmd: DeadLettersCmd::Count { app }, cmd: DeadLettersCmd::Count { app },
} => cmds::dead_letters::count(&app, mode).await, } => cmds::dead_letters::count(&app, mode).await,

View File

@@ -503,6 +503,11 @@ pub async fn build_app(
}) })
.collect(); .collect();
app_domain_table.replace(compiled_domains); 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())); let resolver = Arc::new(RepoResolver::new(script_repo.clone()));
// Single global gate — overflow is rejected with 503 + Retry-After. // Single global gate — overflow is rejected with 503 + Retry-After.

View File

@@ -13,4 +13,9 @@ pub struct ExecResponseSummary {
pub status_code: u16, pub status_code: u16,
pub headers: BTreeMap<String, String>, pub headers: BTreeMap<String, String>,
pub body: serde_json::Value, 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<String>,
} }

View File

@@ -127,5 +127,6 @@ pub use vars::{NoopVarsService, VarsError, VarsService};
pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION}; pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION};
pub use workflow::{ pub use workflow::{
default_base_ms, NoopWorkflowService, OnError, RunStatus, StepStatus, WorkflowBackoff, default_base_ms, NoopWorkflowService, OnError, RunStatus, StepStatus, WorkflowBackoff,
WorkflowDefinition, WorkflowError, WorkflowRetry, WorkflowService, WorkflowStepDef, WorkflowDefinition, WorkflowError, WorkflowRetry, WorkflowRunView, WorkflowService,
WorkflowStepDef, WorkflowStepView,
}; };

View File

@@ -29,6 +29,42 @@ pub trait WorkflowService: Send + Sync {
name: &str, name: &str,
input: serde_json::Value, input: serde_json::Value,
) -> Result<WorkflowRunId, WorkflowError>; ) -> Result<WorkflowRunId, WorkflowError>;
/// 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<Option<WorkflowRunView>, 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<serde_json::Value>,
/// The failure reason (present once failed), else `None`.
pub error: Option<String>,
/// Per-step status, in step-name order.
pub steps: Vec<WorkflowStepView>,
}
/// One step's status within a [`WorkflowRunView`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowStepView {
pub name: String,
pub status: StepStatus,
} }
#[derive(Debug, Error)] #[derive(Debug, Error)]

View File

@@ -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 body shape. Returning a bare value (`42`, `#{ ok: true }`) is fine — it
becomes a `200` body as-is. 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 ### Security note — `users::email_available` and account enumeration
`users::email_available(email) -> bool` is the anonymous-safe way to `users::email_available(email) -> bool` is the anonymous-safe way to