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

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

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 apply;
pub mod apps;
pub mod apps_cors;
pub mod apps_domains;
pub mod collections;
pub mod config;

View File

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

View File

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