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:
57
CHANGELOG.md
57
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: <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)
|
||||
|
||||
Per-app durable queues + same-app function composition + caller-
|
||||
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2077,6 +2077,7 @@ version = "1.1.9"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64",
|
||||
"chrono",
|
||||
"lru",
|
||||
"picloud-executor-core",
|
||||
|
||||
@@ -358,12 +358,13 @@ impl Engine {
|
||||
|m| m.into_inner().unwrap_or_default(),
|
||||
);
|
||||
|
||||
let (status_code, headers, body) = parse_response(value)?;
|
||||
let (status_code, headers, body, body_base64) = parse_response(value)?;
|
||||
|
||||
Ok(ExecResponse {
|
||||
status_code,
|
||||
headers,
|
||||
body,
|
||||
body_base64,
|
||||
logs,
|
||||
stats: ExecStats {
|
||||
duration_ms: u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
|
||||
@@ -531,6 +532,20 @@ fn build_ctx_map(req: &ExecRequest) -> Map {
|
||||
|
||||
request.insert("body".into(), json_to_dynamic(req.body.clone()));
|
||||
|
||||
// F-026 convenience: the request Content-Type (also present in `headers`).
|
||||
// Lets a script branch on how to read `body` — parsed JSON for a JSON
|
||||
// request, a raw string for `text/*`, or a base64 string for other binary
|
||||
// types — without a case-insensitive header lookup. `()` when absent.
|
||||
let content_type = req
|
||||
.headers
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
|
||||
.map(|(_, v)| v.clone());
|
||||
request.insert(
|
||||
"content_type".into(),
|
||||
content_type.map_or(Dynamic::UNIT, Into::into),
|
||||
);
|
||||
|
||||
// SDK 1.1 additions — route-captured params, query string, prefix
|
||||
// tail. Empty when not applicable so scripts can always read them.
|
||||
let mut params = Map::new();
|
||||
@@ -768,7 +783,9 @@ fn invocation_type_str(it: InvocationType) -> &'static str {
|
||||
// Response parsing
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
fn parse_response(value: Dynamic) -> Result<(u16, BTreeMap<String, String>, Json), ExecError> {
|
||||
type ParsedResponse = (u16, BTreeMap<String, String>, Json, Option<String>);
|
||||
|
||||
fn parse_response(value: Dynamic) -> Result<ParsedResponse, ExecError> {
|
||||
// Convention: a Map with a `statusCode` field is the structured shape.
|
||||
// Anything else is treated as a 200 response with the value as body.
|
||||
if value.is_map() {
|
||||
@@ -782,10 +799,10 @@ fn parse_response(value: Dynamic) -> Result<(u16, BTreeMap<String, String>, Json
|
||||
// cheaply-aliased huge value can't OOM the node on materialization.
|
||||
let body = dynamic_to_json_capped(&value, MAX_JSON_MATERIALIZE_BYTES)
|
||||
.map_err(|e| ExecError::InvalidResponse(e.to_string()))?;
|
||||
Ok((200, BTreeMap::new(), body))
|
||||
Ok((200, BTreeMap::new(), body, None))
|
||||
}
|
||||
|
||||
fn parse_structured_response(map: Map) -> Result<(u16, BTreeMap<String, String>, Json), ExecError> {
|
||||
fn parse_structured_response(map: Map) -> Result<ParsedResponse, ExecError> {
|
||||
let status_dyn = map
|
||||
.get("statusCode")
|
||||
.ok_or_else(|| ExecError::InvalidResponse("missing statusCode".into()))?;
|
||||
@@ -804,13 +821,29 @@ fn parse_structured_response(map: Map) -> Result<(u16, BTreeMap<String, String>,
|
||||
}
|
||||
}
|
||||
|
||||
let body = match map.get("body") {
|
||||
Some(b) => dynamic_to_json_capped(b, MAX_JSON_MATERIALIZE_BYTES)
|
||||
.map_err(|e| ExecError::InvalidResponse(e.to_string()))?,
|
||||
None => Json::Null,
|
||||
// F-026: a `body_base64` string field returns raw bytes with the script-set
|
||||
// `Content-Type`, instead of JSON-encoding `body`. When set, `body` is
|
||||
// ignored (the bytes win) so an author can't accidentally send both.
|
||||
let body_base64 = match map.get("body_base64") {
|
||||
Some(b) => Some(
|
||||
b.clone()
|
||||
.into_string()
|
||||
.map_err(|_| ExecError::InvalidResponse("body_base64 must be a string".into()))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok((status_code, headers, body))
|
||||
let body = if body_base64.is_some() {
|
||||
Json::Null
|
||||
} else {
|
||||
match map.get("body") {
|
||||
Some(b) => dynamic_to_json_capped(b, MAX_JSON_MATERIALIZE_BYTES)
|
||||
.map_err(|e| ExecError::InvalidResponse(e.to_string()))?,
|
||||
None => Json::Null,
|
||||
}
|
||||
};
|
||||
|
||||
Ok((status_code, headers, body, body_base64))
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
//! `workflow::` Rhai bridge — start a durable workflow run (v1.2 M5).
|
||||
//! `workflow::` Rhai bridge — start a durable workflow run + read its status.
|
||||
//!
|
||||
//! ```rhai
|
||||
//! let run_id = workflow::start("nightly_report", #{ date: "2026-07-12" });
|
||||
//! let run_id = workflow::start("cleanup"); // no input
|
||||
//! let st = workflow::run_status(run_id); // #{ status, output, error, steps } | ()
|
||||
//! ```
|
||||
//!
|
||||
//! Returns the run id as a string. The owning workflow resolves from
|
||||
//! `cx.app_id` inside the service — never a script arg (the isolation
|
||||
//! boundary). The durable orchestrator advances the run asynchronously; the
|
||||
//! script does not block on completion.
|
||||
//! script does not block on completion. `run_status` (F-038) lets the starter
|
||||
//! poll progress without the platform-admin API — `()` when no such run
|
||||
//! belongs to this app.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{SdkCallCx, Services, WorkflowService};
|
||||
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Module};
|
||||
use picloud_shared::{SdkCallCx, Services, WorkflowRunId, WorkflowRunView, WorkflowService};
|
||||
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
use tokio::runtime::Handle as TokioHandle;
|
||||
|
||||
use crate::sdk::bridge::{dynamic_to_json_capped, MAX_JSON_MATERIALIZE_BYTES};
|
||||
use crate::sdk::bridge::{dynamic_to_json_capped, json_to_dynamic, MAX_JSON_MATERIALIZE_BYTES};
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
let svc = services.workflow.clone();
|
||||
@@ -46,6 +49,18 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
);
|
||||
}
|
||||
|
||||
// `workflow::run_status(run_id)` — read-only poll (F-038). `()` if absent.
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"run_status",
|
||||
move |run_id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
run_status_blocking(&svc, &cx, run_id)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
engine.register_static_module("workflow", module.into());
|
||||
}
|
||||
|
||||
@@ -81,3 +96,61 @@ fn start_blocking(
|
||||
})?;
|
||||
Ok(run_id.to_string())
|
||||
}
|
||||
|
||||
fn run_status_blocking(
|
||||
svc: &Arc<dyn WorkflowService>,
|
||||
cx: &Arc<SdkCallCx>,
|
||||
run_id: &str,
|
||||
) -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let run_id: WorkflowRunId = run_id
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(WorkflowRunId::from)
|
||||
.map_err(|_| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("workflow::run_status: invalid run id {run_id:?}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
})?;
|
||||
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("workflow::run_status: no tokio runtime available: {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
})?;
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
let view = handle
|
||||
.block_on(async move { svc.run_status(&cx, run_id).await })
|
||||
.map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("workflow::run_status: {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
})?;
|
||||
// Absent run → `()` so a script can test `if status == ()`.
|
||||
Ok(view.map_or(Dynamic::UNIT, |v| view_to_dynamic(&v)))
|
||||
}
|
||||
|
||||
/// Convert a run view into the Rhai shape
|
||||
/// `#{ status, output, error, steps: #{ name: status } }`.
|
||||
fn view_to_dynamic(v: &WorkflowRunView) -> Dynamic {
|
||||
let mut m = Map::new();
|
||||
m.insert("status".into(), v.status.as_str().into());
|
||||
m.insert(
|
||||
"output".into(),
|
||||
v.output.clone().map_or(Dynamic::UNIT, json_to_dynamic),
|
||||
);
|
||||
m.insert(
|
||||
"error".into(),
|
||||
v.error.clone().map_or(Dynamic::UNIT, Into::into),
|
||||
);
|
||||
let mut steps = Map::new();
|
||||
for s in &v.steps {
|
||||
steps.insert(s.name.clone().into(), s.status.as_str().into());
|
||||
}
|
||||
m.insert("steps".into(), steps.into());
|
||||
Dynamic::from_map(m)
|
||||
}
|
||||
|
||||
@@ -123,6 +123,13 @@ pub struct ExecResponse {
|
||||
pub status_code: u16,
|
||||
pub headers: BTreeMap<String, String>,
|
||||
pub body: serde_json::Value,
|
||||
/// F-026 binary response. When a script returns an envelope with a
|
||||
/// `body_base64` field, the HTTP layer decodes it and returns the raw
|
||||
/// bytes with the script-set `Content-Type` (instead of JSON-encoding
|
||||
/// `body`). `None` ⇒ the normal JSON `body` path. `#[serde(default)]`
|
||||
/// keeps the cluster-mode wire format backward-compatible.
|
||||
#[serde(default)]
|
||||
pub body_base64: Option<String>,
|
||||
pub logs: Vec<LogEntry>,
|
||||
pub stats: ExecStats,
|
||||
}
|
||||
|
||||
@@ -93,6 +93,26 @@ fn returns_structured_response_when_status_code_present() {
|
||||
assert_eq!(resp.body, json!({ "ok": true, "msg": "created" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_base64_envelope_sets_binary_response() {
|
||||
// F-026: a `body_base64` field carries raw bytes; `body` is ignored.
|
||||
let src = r#"
|
||||
#{ statusCode: 200,
|
||||
headers: #{ "content-type": "image/png" },
|
||||
body: #{ ignored: true },
|
||||
body_base64: "aGVsbG8=" }
|
||||
"#;
|
||||
let resp = engine().execute(src, req(json!(null))).unwrap();
|
||||
assert_eq!(resp.status_code, 200);
|
||||
assert_eq!(resp.body_base64.as_deref(), Some("aGVsbG8="));
|
||||
// `body` is nulled out when body_base64 is present.
|
||||
assert_eq!(resp.body, json!(null));
|
||||
assert_eq!(
|
||||
resp.headers.get("content-type").map(String::as_str),
|
||||
Some("image/png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctx_exposes_request_data() {
|
||||
let src = r"
|
||||
|
||||
11
crates/manager-core/migrations/0077_apps_cors.sql
Normal file
11
crates/manager-core/migrations/0077_apps_cors.sql
Normal 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;
|
||||
@@ -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<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 {
|
||||
@@ -443,6 +462,32 @@ impl AppRepository for PostgresAppRepository {
|
||||
.await?;
|
||||
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)]
|
||||
|
||||
@@ -705,7 +705,14 @@ async fn resolve_app_id(apps: &dyn AppRepository, ident: &str) -> Result<AppId,
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
.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 {
|
||||
|
||||
@@ -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<GroupId, ApplyError> {
|
||||
@@ -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<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
|
||||
.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]
|
||||
|
||||
@@ -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<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)]
|
||||
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<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
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<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);
|
||||
}
|
||||
|
||||
#[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
|
||||
|
||||
@@ -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<Option<String>>,
|
||||
}
|
||||
|
||||
#[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<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(
|
||||
State(s): State<AppUsersState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
|
||||
@@ -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<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(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<E, R>(
|
||||
State(state): State<DataPlaneState<E, R>>,
|
||||
Extension(principal): Extension<Option<Principal>>,
|
||||
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>
|
||||
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<String, String> = 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<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 {
|
||||
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::<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()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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<String, String> {
|
||||
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
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -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::<HeaderName>(), v.parse::<HeaderValue>()) {
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ mod tests {
|
||||
status_code: 200,
|
||||
headers: BTreeMap::new(),
|
||||
body: serde_json::json!({ "ok": true }),
|
||||
body_base64: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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 {
|
||||
@@ -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<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
|
||||
/// claim wins: exact > longest wildcard > shorter wildcard. Returns
|
||||
/// `None` when no claim covers `host` (orchestrator should 404).
|
||||
|
||||
@@ -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<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 ----------
|
||||
|
||||
/// `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<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) ----------------------------------------------
|
||||
|
||||
/// `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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeadLetterCountDto {
|
||||
pub unresolved: i64,
|
||||
|
||||
41
crates/picloud-cli/src/cmds/apps_cors.rs
Normal file
41
crates/picloud-cli/src/cmds/apps_cors.rs
Normal 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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -13,4 +13,9 @@ pub struct ExecResponseSummary {
|
||||
pub status_code: u16,
|
||||
pub headers: BTreeMap<String, String>,
|
||||
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>,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -29,6 +29,42 @@ pub trait WorkflowService: Send + Sync {
|
||||
name: &str,
|
||||
input: serde_json::Value,
|
||||
) -> 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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user