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:
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user