feat: E2E #2 (Stash) gap remediation + S6 hardening
Closes the gaps and the one security finding from the second end-to-end
CLI test (E2E_STASH_REPORT.md), plus the H1 boot-regression found while
re-reviewing those fixes.
Security
- S6: reserved-path validation (`check_reserved`) now case-folds before
comparing, so `/API/v2/x`, `/HEALTHZ`, `/Admin/x` are rejected like
their lowercase forms. Request-time matching stays case-sensitive.
- S10: "public route != public data" callout in sdk-shape.md (script_gate
skips authz when the principal is anonymous).
Observability / features
- G1: trigger executions now write `execution_logs`. Migration 0043 adds
a `source` column (CHECK mirrors ExecutionSource/OutboxSourceKind,
DEFAULT 'http' backfills history); a shared `build_execution_log` helper
in executor-core; dispatcher logging for outbox triggers + queue
consumers (skips sync-HTTP rows the orchestrator already logs). `pic
logs` gains a source column + `--source` filter.
- G5: dev-only in-memory email capture under PICLOUD_DEV_MODE with no SMTP
(email::send succeeds locally), readable at GET /api/v1/admin/dev/emails
(Owner/Admin only; route mounted only in capture mode).
- G6: generalized the Rhai in-place-mutation footgun note (trim/replace/
make_upper/make_lower/crop/truncate/pad return ()).
- G2/G3/G4 (CLI): `pic members`, `pic files`, `pic queues`, read-only
`pic kv` (+ new kv_api.rs); `pic deploy --timeout/--memory/--kind/
--sandbox`; first-class `pic triggers create-{docs,files,pubsub,queue,
email}` wrappers. All new client path segments percent-encoded via seg().
H1 regression fix (found in re-review)
- The S6 change also runs in `compile_routes`, which compiles every stored
route at boot and on each route CRUD. A single stored route the new
validation rejects (creatable while the S6 gap existed) made the whole
compile Err and aborted startup. `compile_routes` is now lenient: it
skips an un-compilable row with a warning instead of bricking boot
(route creation still validates separately). Migration 0044 sweeps
pre-existing reserved-path routes on upgrade (WHERE mirrors
check_reserved exactly). Added regression tests for both.
Verified: cargo fmt, clippy --all-targets --all-features -D warnings, the
schema_snapshot test, and the new S6/lenient-compile unit tests all pass;
boot-resilience and G1/G5 confirmed live.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
71
crates/picloud-cli/src/cmds/files.rs
Normal file
71
crates/picloud-cli/src/cmds/files.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
//! `pic files ls | get | rm` — operator-facing files inspection (G2).
|
||||
//!
|
||||
//! Wraps the read-only `/api/v1/admin/apps/{id}/files*` surface. There is
|
||||
//! no `set`/`upload` — blob writes go through scripts (`files::create`);
|
||||
//! the admin surface is inspect + delete only, matching the dashboard.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn ls(app: &str, collection: &str, limit: u32, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let page = client.files_list(app, collection, limit).await?;
|
||||
let mut table = Table::new(["id", "name", "content_type", "size", "updated_at"]);
|
||||
for f in page.files {
|
||||
table.row([
|
||||
f.id,
|
||||
f.name,
|
||||
f.content_type,
|
||||
f.size.to_string(),
|
||||
f.updated_at,
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
if page.next_cursor.is_some() {
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"(more results available — raise --limit to see them)"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download a file's bytes. With `--out <path>` writes to disk; otherwise
|
||||
/// streams raw bytes to stdout (pipe to a file or `xxd`).
|
||||
pub async fn get(app: &str, collection: &str, file_id: &str, out: Option<&Path>) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let bytes = client.files_get_bytes(app, collection, file_id).await?;
|
||||
match out {
|
||||
Some(path) => {
|
||||
std::fs::write(path, &bytes).with_context(|| format!("writing {}", path.display()))?;
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"Wrote {} bytes to {}",
|
||||
bytes.len(),
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
None => {
|
||||
std::io::stdout()
|
||||
.write_all(&bytes)
|
||||
.context("writing bytes to stdout")?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rm(app: &str, collection: &str, file_id: &str) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
client.files_delete(app, collection, file_id).await?;
|
||||
println!("Deleted file {file_id} from {collection}");
|
||||
Ok(())
|
||||
}
|
||||
42
crates/picloud-cli/src/cmds/kv.rs
Normal file
42
crates/picloud-cli/src/cmds/kv.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
//! `pic kv ls | get` — read-only KV inspection (G2).
|
||||
//!
|
||||
//! Wraps the read-only `/api/v1/admin/apps/{id}/kv*` surface. There is no
|
||||
//! `set`/`rm` on purpose: KV writes go through `kv::set` in scripts (which
|
||||
//! emit the change events triggers depend on); an admin write would bypass
|
||||
//! that, so the CLI stays read-only (matching the queues precedent).
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn ls(app: &str, collection: &str, limit: u32, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let page = client.kv_list(app, collection, limit).await?;
|
||||
let mut table = Table::new(["key"]);
|
||||
for k in page.keys {
|
||||
table.row([k]);
|
||||
}
|
||||
table.print(mode);
|
||||
if page.next_cursor.is_some() {
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"(more keys available — raise --limit to see them)"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get(app: &str, collection: &str, key: &str) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let value = client.kv_get(app, collection, key).await?;
|
||||
// Always emit the JSON value (pretty) so it pipes cleanly into jq.
|
||||
let pretty = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
|
||||
println!("{pretty}");
|
||||
Ok(())
|
||||
}
|
||||
@@ -12,10 +12,15 @@ use crate::client::Client;
|
||||
use crate::config;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn run(script_id: &str, limit: u32, mode: OutputMode) -> Result<()> {
|
||||
pub async fn run(
|
||||
script_id: &str,
|
||||
limit: u32,
|
||||
source: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let entries = client.logs_list(script_id, limit).await?;
|
||||
let entries = client.logs_list(script_id, limit, source).await?;
|
||||
match mode {
|
||||
OutputMode::Tsv => render_tsv(&entries),
|
||||
OutputMode::Json => render_json(&entries),
|
||||
@@ -24,11 +29,14 @@ pub async fn run(script_id: &str, limit: u32, mode: OutputMode) -> Result<()> {
|
||||
}
|
||||
|
||||
fn render_tsv(entries: &[ExecutionLog]) {
|
||||
let mut table = Table::new(["created_at", "status", "summary"]);
|
||||
// `source` is shown so background runs (queue/cron/invoke/…) are
|
||||
// distinguishable from HTTP at a glance — the whole point of G1.
|
||||
let mut table = Table::new(["created_at", "source", "status", "summary"]);
|
||||
for e in entries {
|
||||
let summary = summarize(&e.response_body, &e.script_logs);
|
||||
table.row([
|
||||
e.created_at.to_rfc3339(),
|
||||
e.source.as_str().to_string(),
|
||||
status_label(&e.status).to_string(),
|
||||
truncate(&summary, 120),
|
||||
]);
|
||||
|
||||
86
crates/picloud-cli/src/cmds/members.rs
Normal file
86
crates/picloud-cli/src/cmds/members.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
//! `pic members ls | add | set | rm` — manage app membership (G2).
|
||||
//!
|
||||
//! Wraps `/api/v1/admin/apps/{id}/members*`. All gated on
|
||||
//! `AppAdmin(app)` server-side; Editors/Viewers get a 403.
|
||||
|
||||
use anyhow::Result;
|
||||
use picloud_shared::AppRole;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config;
|
||||
use crate::output::{KvBlock, OutputMode, Table};
|
||||
|
||||
pub async fn ls(app: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let members = client.members_list(app).await?;
|
||||
let mut table = Table::new(["user_id", "username", "role", "instance_role", "active"]);
|
||||
for m in members {
|
||||
table.row([
|
||||
m.user_id.to_string(),
|
||||
m.username,
|
||||
app_role_str(m.role).to_string(),
|
||||
format!("{:?}", m.instance_role).to_lowercase(),
|
||||
m.is_active.to_string(),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add(app: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let m = client
|
||||
.members_grant(app, user_id, parse_role(role)?)
|
||||
.await?;
|
||||
print_member(&m, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set(app: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let m = client
|
||||
.members_set_role(app, user_id, parse_role(role)?)
|
||||
.await?;
|
||||
print_member(&m, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rm(app: &str, user_id: &str) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
client.members_remove(app, user_id).await?;
|
||||
println!("Removed {user_id} from {app}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_member(m: &crate::client::AppMemberDto, mode: OutputMode) {
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("user_id", m.user_id.to_string())
|
||||
.field("username", m.username.clone())
|
||||
.field("role", app_role_str(m.role))
|
||||
.field("created_at", m.created_at.to_rfc3339());
|
||||
block.print(mode);
|
||||
}
|
||||
|
||||
fn parse_role(s: &str) -> Result<AppRole> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"app_admin" | "admin" => Ok(AppRole::AppAdmin),
|
||||
"editor" => Ok(AppRole::Editor),
|
||||
"viewer" => Ok(AppRole::Viewer),
|
||||
other => Err(anyhow::anyhow!(
|
||||
"unknown role {other:?} (want app_admin | editor | viewer)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn app_role_str(r: AppRole) -> &'static str {
|
||||
match r {
|
||||
AppRole::AppAdmin => "app_admin",
|
||||
AppRole::Editor => "editor",
|
||||
AppRole::Viewer => "viewer",
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,13 @@ pub mod api_keys;
|
||||
pub mod apps;
|
||||
pub mod apps_domains;
|
||||
pub mod dead_letters;
|
||||
pub mod files;
|
||||
pub mod kv;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
pub mod logs;
|
||||
pub mod members;
|
||||
pub mod queues;
|
||||
pub mod routes;
|
||||
pub mod scripts;
|
||||
pub mod secrets;
|
||||
|
||||
62
crates/picloud-cli/src/cmds/queues.rs
Normal file
62
crates/picloud-cli/src/cmds/queues.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
//! `pic queues ls | show` — read-only queue inspection (G2).
|
||||
//!
|
||||
//! Wraps the read-only `/api/v1/admin/apps/{id}/queues*` surface (no
|
||||
//! purge/requeue — that is v1.2). Gated on `AppLogRead` server-side.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config;
|
||||
use crate::output::{KvBlock, OutputMode, Table};
|
||||
|
||||
pub async fn ls(app: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let queues = client.queues_list(app).await?;
|
||||
let mut table = Table::new(["queue", "total", "pending", "claimed"]);
|
||||
for q in queues {
|
||||
table.row([
|
||||
q.queue_name,
|
||||
q.total.to_string(),
|
||||
q.pending.to_string(),
|
||||
q.claimed.to_string(),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn show(app: &str, queue_name: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let q = client.queue_get(app, queue_name).await?;
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("queue", q.queue_name)
|
||||
.field("total", q.total.to_string())
|
||||
.field("pending", q.pending.to_string())
|
||||
.field("claimed", q.claimed.to_string());
|
||||
match q.consumer {
|
||||
Some(c) => {
|
||||
block
|
||||
.field("consumer_script", c.script_name)
|
||||
.field("consumer_script_id", c.script_id.to_string())
|
||||
.field("consumer_trigger_id", c.trigger_id)
|
||||
.field(
|
||||
"visibility_timeout_secs",
|
||||
c.visibility_timeout_secs.to_string(),
|
||||
)
|
||||
.field(
|
||||
"last_fired_at",
|
||||
c.last_fired_at
|
||||
.map(|t| t.to_rfc3339())
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
block.field("consumer", "(none registered)");
|
||||
}
|
||||
}
|
||||
block.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use anyhow::{anyhow, Context, Result};
|
||||
use picloud_shared::AppId;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::client::{Client, CreateScriptBody};
|
||||
use crate::client::{Client, CreateScriptBody, ScriptConfig};
|
||||
use crate::config;
|
||||
use crate::output::{KvBlock, OutputMode, Table};
|
||||
|
||||
@@ -62,6 +62,7 @@ pub async fn deploy(
|
||||
app_ident: &str,
|
||||
name_override: Option<&str>,
|
||||
description: Option<&str>,
|
||||
cfg: &ScriptConfig,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
@@ -90,7 +91,7 @@ pub async fn deploy(
|
||||
let existing = client.scripts_list_by_app(app_ident).await?;
|
||||
let (script, action) = if let Some(s) = existing.into_iter().find(|s| s.name == name) {
|
||||
let updated = client
|
||||
.scripts_update_source(&s.id.to_string(), &source)
|
||||
.scripts_update_source(&s.id.to_string(), &source, cfg)
|
||||
.await?;
|
||||
(updated, "updated")
|
||||
} else {
|
||||
@@ -99,6 +100,10 @@ pub async fn deploy(
|
||||
name: &name,
|
||||
description,
|
||||
source: &source,
|
||||
timeout_seconds: cfg.timeout_seconds,
|
||||
memory_limit_mb: cfg.memory_limit_mb,
|
||||
kind: cfg.kind,
|
||||
sandbox: cfg.sandbox,
|
||||
};
|
||||
(client.scripts_create(&body).await?, "created")
|
||||
};
|
||||
|
||||
@@ -124,6 +124,114 @@ pub async fn create_dead_letter(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// docs/files share KV's `{collection_glob, ops?}` shape.
|
||||
async fn create_collection_trigger(
|
||||
kind: &str,
|
||||
app: &str,
|
||||
script_id: &str,
|
||||
collection_glob: &str,
|
||||
ops: &[String],
|
||||
dispatch: &str,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let mut body = base_body(script_id, dispatch);
|
||||
body["collection_glob"] = json!(collection_glob);
|
||||
if !ops.is_empty() {
|
||||
body["ops"] = json!(ops);
|
||||
}
|
||||
let created = client.triggers_create(app, kind, &body).await?;
|
||||
print_created(&created, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_docs(
|
||||
app: &str,
|
||||
script_id: &str,
|
||||
collection_glob: &str,
|
||||
ops: &[String],
|
||||
dispatch: &str,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
create_collection_trigger("docs", app, script_id, collection_glob, ops, dispatch, mode).await
|
||||
}
|
||||
|
||||
pub async fn create_files(
|
||||
app: &str,
|
||||
script_id: &str,
|
||||
collection_glob: &str,
|
||||
ops: &[String],
|
||||
dispatch: &str,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
create_collection_trigger(
|
||||
"files",
|
||||
app,
|
||||
script_id,
|
||||
collection_glob,
|
||||
ops,
|
||||
dispatch,
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_pubsub(
|
||||
app: &str,
|
||||
script_id: &str,
|
||||
topic_pattern: &str,
|
||||
dispatch: &str,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let mut body = base_body(script_id, dispatch);
|
||||
body["topic_pattern"] = json!(topic_pattern);
|
||||
let created = client.triggers_create(app, "pubsub", &body).await?;
|
||||
print_created(&created, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_queue(
|
||||
app: &str,
|
||||
script_id: &str,
|
||||
queue_name: &str,
|
||||
visibility_timeout_secs: Option<u32>,
|
||||
dispatch: &str,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let mut body = base_body(script_id, dispatch);
|
||||
body["queue_name"] = json!(queue_name);
|
||||
if let Some(v) = visibility_timeout_secs {
|
||||
body["visibility_timeout_secs"] = json!(v);
|
||||
}
|
||||
let created = client.triggers_create(app, "queue", &body).await?;
|
||||
print_created(&created, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_email(
|
||||
app: &str,
|
||||
script_id: &str,
|
||||
inbound_secret: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
// Email triggers take no dispatch_mode (inbound webhook only) and an
|
||||
// optional shared HMAC secret the provider signs POSTs with.
|
||||
let mut body = json!({ "script_id": script_id });
|
||||
if let Some(secret) = inbound_secret {
|
||||
body["inbound_secret"] = json!(secret);
|
||||
}
|
||||
let created = client.triggers_create(app, "email", &body).await?;
|
||||
print_created(&created, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_from_json(app: &str, kind: &str, body: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
Reference in New Issue
Block a user