feat: E2E #2 (Stash) gap remediation + S6 hardening
Some checks failed
CI / Rust — fmt, clippy, test (push) Failing after 6m19s
CI / Dashboard — check (push) Successful in 9m48s

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:
MechaCat02
2026-06-13 15:01:04 +02:00
parent a91b134285
commit 51f14fa2b1
33 changed files with 2223 additions and 195 deletions

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

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

View File

@@ -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),
]);

View 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",
}
}

View File

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

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

View File

@@ -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")
};

View File

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