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

@@ -12,7 +12,7 @@ use chrono::{DateTime, Utc};
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
use picloud_shared::{
AdminUserId, ApiKeyId, App, AppDomain, AppId, AppRole, AppUser, DispatchMode, ExecutionLog,
HostKind, InstanceRole, PathKind, Route, Scope, Script, ScriptId,
HostKind, InstanceRole, PathKind, Route, Scope, Script, ScriptId, ScriptKind, ScriptSandbox,
};
use reqwest::{header, Method, RequestBuilder, StatusCode};
use serde::{Deserialize, Serialize};
@@ -171,10 +171,23 @@ impl Client {
}
/// `PUT /api/v1/admin/scripts/{id}` — matches the dashboard, which
/// uses PUT despite the field-level update semantics.
pub async fn scripts_update_source(&self, id: &str, source: &str) -> Result<Script> {
/// uses PUT despite the field-level update semantics. `cfg` carries
/// optional per-script runtime overrides (G3); unset fields are
/// omitted so they keep their stored value.
pub async fn scripts_update_source(
&self,
id: &str,
source: &str,
cfg: &ScriptConfig,
) -> Result<Script> {
let id = seg(id);
let body = UpdateScriptBody { source };
let body = UpdateScriptBody {
source,
timeout_seconds: cfg.timeout_seconds,
memory_limit_mb: cfg.memory_limit_mb,
kind: cfg.kind,
sandbox: cfg.sandbox,
};
let resp = self
.request(Method::PUT, &format!("/api/v1/admin/scripts/{id}"))
.json(&body)
@@ -222,15 +235,19 @@ impl Client {
}
/// `GET /api/v1/admin/scripts/{id}/logs?limit=N`
pub async fn logs_list(&self, script_id: &str, limit: u32) -> Result<Vec<ExecutionLog>> {
pub async fn logs_list(
&self,
script_id: &str,
limit: u32,
source: Option<&str>,
) -> Result<Vec<ExecutionLog>> {
let script_id = seg(script_id);
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/scripts/{script_id}/logs?limit={limit}"),
)
.send()
.await?;
let mut path = format!("/api/v1/admin/scripts/{script_id}/logs?limit={limit}");
if let Some(src) = source {
path.push_str("&source=");
path.push_str(&seg(src));
}
let resp = self.request(Method::GET, &path).send().await?;
decode(resp).await
}
@@ -709,6 +726,180 @@ impl Client {
.await?;
decode(resp).await
}
// --- App membership (G2) ----------------------------------------------
/// `GET /api/v1/admin/apps/{id_or_slug}/members`
pub async fn members_list(&self, app: &str) -> Result<Vec<AppMemberDto>> {
let app = seg(app);
let resp = self
.request(Method::GET, &format!("/api/v1/admin/apps/{app}/members"))
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/apps/{id_or_slug}/members`
pub async fn members_grant(
&self,
app: &str,
user_id: &str,
role: AppRole,
) -> Result<AppMemberDto> {
let app = seg(app);
let body = serde_json::json!({ "user_id": user_id, "role": role });
let resp = self
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/members"))
.json(&body)
.send()
.await?;
decode(resp).await
}
/// `PATCH /api/v1/admin/apps/{id_or_slug}/members/{user_id}`
pub async fn members_set_role(
&self,
app: &str,
user_id: &str,
role: AppRole,
) -> Result<AppMemberDto> {
let (app, user_id) = (seg(app), seg(user_id));
let body = serde_json::json!({ "role": role });
let resp = self
.request(
Method::PATCH,
&format!("/api/v1/admin/apps/{app}/members/{user_id}"),
)
.json(&body)
.send()
.await?;
decode(resp).await
}
/// `DELETE /api/v1/admin/apps/{id_or_slug}/members/{user_id}`
pub async fn members_remove(&self, app: &str, user_id: &str) -> Result<()> {
let (app, user_id) = (seg(app), seg(user_id));
let resp = self
.request(
Method::DELETE,
&format!("/api/v1/admin/apps/{app}/members/{user_id}"),
)
.send()
.await?;
decode_status(resp).await
}
// --- Files (G2, read-only admin surface) ------------------------------
/// `GET /api/v1/admin/apps/{id_or_slug}/files?collection=&limit=`
pub async fn files_list(
&self,
app: &str,
collection: &str,
limit: u32,
) -> Result<ListFilesResponse> {
let app = seg(app);
let collection = seg(collection);
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/apps/{app}/files?collection={collection}&limit={limit}"),
)
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/apps/{id_or_slug}/files/{collection}/{file_id}` —
/// streams the raw bytes (download). Returns the body verbatim.
pub async fn files_get_bytes(
&self,
app: &str,
collection: &str,
file_id: &str,
) -> Result<Vec<u8>> {
let (app, collection, file_id) = (seg(app), seg(collection), seg(file_id));
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/apps/{app}/files/{collection}/{file_id}"),
)
.send()
.await?;
if resp.status().is_success() {
Ok(resp.bytes().await.context("reading file bytes")?.to_vec())
} else {
Err(server_error(resp).await)
}
}
/// `DELETE /api/v1/admin/apps/{id_or_slug}/files/{collection}/{file_id}`
pub async fn files_delete(&self, app: &str, collection: &str, file_id: &str) -> Result<()> {
let (app, collection, file_id) = (seg(app), seg(collection), seg(file_id));
let resp = self
.request(
Method::DELETE,
&format!("/api/v1/admin/apps/{app}/files/{collection}/{file_id}"),
)
.send()
.await?;
decode_status(resp).await
}
// --- KV (G2, read-only admin surface) ---------------------------------
/// `GET /api/v1/admin/apps/{id_or_slug}/kv?collection=&limit=`
pub async fn kv_list(&self, app: &str, collection: &str, limit: u32) -> Result<KvListPageDto> {
let app = seg(app);
let collection = seg(collection);
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/apps/{app}/kv?collection={collection}&limit={limit}"),
)
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/apps/{id_or_slug}/kv/{collection}/{key}`
pub async fn kv_get(&self, app: &str, collection: &str, key: &str) -> Result<Value> {
let (app, collection, key) = (seg(app), seg(collection), seg(key));
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/apps/{app}/kv/{collection}/{key}"),
)
.send()
.await?;
let wrapped: KvGetResponse = decode(resp).await?;
Ok(wrapped.value)
}
// --- Queues (G2, read-only admin surface) -----------------------------
/// `GET /api/v1/admin/apps/{id_or_slug}/queues`
pub async fn queues_list(&self, app: &str) -> Result<Vec<QueueSummaryDto>> {
let app = seg(app);
let resp = self
.request(Method::GET, &format!("/api/v1/admin/apps/{app}/queues"))
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/apps/{id_or_slug}/queues/{queue_name}`
pub async fn queue_get(&self, app: &str, queue_name: &str) -> Result<QueueDetailDto> {
let (app, queue_name) = (seg(app), seg(queue_name));
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/apps/{app}/queues/{queue_name}"),
)
.send()
.await?;
decode(resp).await
}
}
/// `POST /api/v1/admin/auth/login` — sits outside the `Client` because
@@ -957,6 +1148,17 @@ pub struct SecretItemDto {
pub updated_at: DateTime<Utc>,
}
/// Per-script runtime config the CLI can now set (G3). All optional — an
/// unset field is omitted so the server applies its own default (and the
/// `PICLOUD_SANDBOX_MAX_*` admin ceilings still clamp overrides).
#[derive(Debug, Default, Clone)]
pub struct ScriptConfig {
pub timeout_seconds: Option<i32>,
pub memory_limit_mb: Option<i32>,
pub kind: Option<ScriptKind>,
pub sandbox: Option<ScriptSandbox>,
}
#[derive(Debug, Serialize)]
pub struct CreateScriptBody<'a> {
pub app_id: AppId,
@@ -964,11 +1166,104 @@ pub struct CreateScriptBody<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<&'a str>,
pub source: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout_seconds: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub memory_limit_mb: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<ScriptKind>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox: Option<ScriptSandbox>,
}
#[derive(Debug, Serialize)]
struct UpdateScriptBody<'a> {
source: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
timeout_seconds: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
memory_limit_mb: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
kind: Option<ScriptKind>,
#[serde(skip_serializing_if = "Option::is_none")]
sandbox: Option<ScriptSandbox>,
}
// --- G2 response DTOs (deserialize-only mirrors of the server shapes) ---
// `#[allow(dead_code)]` on a couple of fields below: these structs mirror
// the full server response shape (so the surface is documented and future
// columns are a one-line add), but the TSV/KvBlock renderers don't print
// every field today.
#[derive(Debug, Deserialize)]
pub struct AppMemberDto {
pub user_id: AdminUserId,
pub username: String,
#[allow(dead_code)]
pub email: Option<String>,
pub instance_role: InstanceRole,
pub is_active: bool,
pub role: AppRole,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct FileMetaDto {
pub id: String,
#[allow(dead_code)]
pub collection: String,
pub name: String,
pub content_type: String,
pub size: u64,
#[allow(dead_code)]
pub checksum: String,
#[allow(dead_code)]
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Deserialize)]
pub struct ListFilesResponse {
pub files: Vec<FileMetaDto>,
pub next_cursor: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct KvListPageDto {
pub keys: Vec<String>,
pub next_cursor: Option<String>,
}
#[derive(Debug, Deserialize)]
struct KvGetResponse {
value: Value,
}
#[derive(Debug, Deserialize)]
pub struct QueueSummaryDto {
pub queue_name: String,
pub total: u64,
pub pending: u64,
pub claimed: u64,
}
#[derive(Debug, Deserialize)]
pub struct QueueConsumerDto {
pub trigger_id: String,
pub script_id: ScriptId,
pub script_name: String,
pub visibility_timeout_secs: u32,
pub last_fired_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Deserialize)]
pub struct QueueDetailDto {
pub queue_name: String,
pub total: u64,
pub pending: u64,
pub claimed: u64,
pub consumer: Option<QueueConsumerDto>,
}
#[derive(Debug, Serialize)]