Compare commits
1 Commits
feat/proje
...
docs/devel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05ea29fbd0 |
14
README.md
14
README.md
@@ -28,6 +28,20 @@ cargo check --workspace
|
|||||||
cargo run -p picloud
|
cargo run -p picloud
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
The **Developer Guide** in [`docs/dev-guide/`](docs/dev-guide/) is the place to start using PiCloud —
|
||||||
|
quickstart, core concepts, full SDK / HTTP-API / CLI reference, five end-to-end example apps, and
|
||||||
|
deployment + security guides. Build and read it locally with [mdBook](https://rust-lang.github.io/mdBook/):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo install mdbook # once
|
||||||
|
mdbook serve docs/dev-guide # then open http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Architecture and contributor notes live alongside it in [`docs/`](docs/) (`sdk-shape.md`,
|
||||||
|
`stdlib-reference.md`, `versioning.md`, …) and in [`serverless_cloud_blueprint.md`](serverless_cloud_blueprint.md).
|
||||||
|
|
||||||
## Repository Layout
|
## Repository Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
//! Admin HTTP surface for the declarative reconcile engine.
|
|
||||||
//!
|
|
||||||
//! `POST /api/v1/admin/apps/{id}/plan` — diff a desired-state bundle
|
|
||||||
//! against the app's live state and return the plan. Read-only; requires
|
|
||||||
//! `AppRead`. The `apply` route (write path) lands in the next milestone.
|
|
||||||
|
|
||||||
use axum::{
|
|
||||||
extract::{Path, State},
|
|
||||||
http::StatusCode,
|
|
||||||
response::{IntoResponse, Response},
|
|
||||||
routing::post,
|
|
||||||
Extension, Json, Router,
|
|
||||||
};
|
|
||||||
use picloud_shared::{AppId, Principal};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
use crate::app_repo::AppRepository;
|
|
||||||
use crate::apply_service::{ApplyError, ApplyReport, ApplyService, Bundle, BundleTrigger, Plan};
|
|
||||||
use crate::authz::{require, AuthzDenied, Capability};
|
|
||||||
|
|
||||||
/// Build the apply/plan router. Mounted under `/api/v1/admin`.
|
|
||||||
pub fn apply_router(service: ApplyService) -> Router {
|
|
||||||
Router::new()
|
|
||||||
.route("/apps/{id}/plan", post(plan_handler))
|
|
||||||
.route("/apps/{id}/apply", post(apply_handler))
|
|
||||||
.with_state(service)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct ApplyRequest {
|
|
||||||
pub bundle: Bundle,
|
|
||||||
#[serde(default)]
|
|
||||||
pub prune: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn apply_handler(
|
|
||||||
State(svc): State<ApplyService>,
|
|
||||||
Extension(principal): Extension<Principal>,
|
|
||||||
Path(id_or_slug): Path<String>,
|
|
||||||
Json(req): Json<ApplyRequest>,
|
|
||||||
) -> Result<Json<ApplyReport>, ApplyError> {
|
|
||||||
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
|
||||||
// Read is always needed; write caps are required for the resource kinds
|
|
||||||
// the bundle touches — and for ALL kinds when `prune` is set, since
|
|
||||||
// pruning deletes resources whose bundle section is empty (and a script
|
|
||||||
// delete cascades its routes/triggers).
|
|
||||||
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
|
|
||||||
.await
|
|
||||||
.map_err(map_authz)?;
|
|
||||||
if req.prune || !req.bundle.scripts.is_empty() {
|
|
||||||
require(
|
|
||||||
svc.authz.as_ref(),
|
|
||||||
&principal,
|
|
||||||
Capability::AppWriteScript(app_id),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(map_authz)?;
|
|
||||||
}
|
|
||||||
if req.prune || !req.bundle.routes.is_empty() {
|
|
||||||
require(
|
|
||||||
svc.authz.as_ref(),
|
|
||||||
&principal,
|
|
||||||
Capability::AppWriteRoute(app_id),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(map_authz)?;
|
|
||||||
}
|
|
||||||
if req.prune || !req.bundle.triggers.is_empty() {
|
|
||||||
require(
|
|
||||||
svc.authz.as_ref(),
|
|
||||||
&principal,
|
|
||||||
Capability::AppManageTriggers(app_id),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(map_authz)?;
|
|
||||||
}
|
|
||||||
// Email triggers resolve and decrypt a stored secret by name server-side,
|
|
||||||
// which the secrets API guards with `AppSecretsRead`. Require it here too
|
|
||||||
// so apply can't bind a secret a principal couldn't otherwise read — the
|
|
||||||
// caps aren't strictly nested on the API-key scope path.
|
|
||||||
if req.bundle.triggers.iter().any(BundleTrigger::is_email) {
|
|
||||||
require(
|
|
||||||
svc.authz.as_ref(),
|
|
||||||
&principal,
|
|
||||||
Capability::AppSecretsRead(app_id),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(map_authz)?;
|
|
||||||
}
|
|
||||||
let report = svc
|
|
||||||
.apply(app_id, &req.bundle, req.prune, principal.user_id)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(report))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn plan_handler(
|
|
||||||
State(svc): State<ApplyService>,
|
|
||||||
Extension(principal): Extension<Principal>,
|
|
||||||
Path(id_or_slug): Path<String>,
|
|
||||||
Json(bundle): Json<Bundle>,
|
|
||||||
) -> Result<Json<Plan>, ApplyError> {
|
|
||||||
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
|
||||||
// NOTE: the returned `Plan` discloses live secret NAMES (not values). That
|
|
||||||
// is safe today only because `AppRead` and `AppSecretsRead` are co-granted
|
|
||||||
// at every tier (same `script:read` scope, both in the viewer role). If a
|
|
||||||
// future authz split puts `AppSecretsRead` on its own tier, this handler
|
|
||||||
// must additionally require it — otherwise it leaks names a principal
|
|
||||||
// couldn't enumerate via the secrets API.
|
|
||||||
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
|
|
||||||
.await
|
|
||||||
.map_err(map_authz)?;
|
|
||||||
let plan = svc.plan(app_id, &bundle).await?;
|
|
||||||
Ok(Json(plan))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve a slug-or-id path param to an `AppId`, mapping miss → 404.
|
|
||||||
/// Mirrors the `triggers_api` helper of the same shape.
|
|
||||||
async fn resolve_app_id(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ApplyError> {
|
|
||||||
crate::app_repo::resolve_app(apps, ident)
|
|
||||||
.await
|
|
||||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
|
||||||
.map(|l| l.app.id)
|
|
||||||
.ok_or_else(|| ApplyError::AppNotFound(ident.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_authz(denied: AuthzDenied) -> ApplyError {
|
|
||||||
match denied {
|
|
||||||
AuthzDenied::Denied => ApplyError::Forbidden,
|
|
||||||
AuthzDenied::Repo(e) => ApplyError::AuthzRepo(e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IntoResponse for ApplyError {
|
|
||||||
fn into_response(self) -> Response {
|
|
||||||
let (status, body) = match &self {
|
|
||||||
Self::AppNotFound(_) => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })),
|
|
||||||
Self::Invalid(_) => (
|
|
||||||
StatusCode::UNPROCESSABLE_ENTITY,
|
|
||||||
json!({ "error": self.to_string() }),
|
|
||||||
),
|
|
||||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
|
||||||
Self::AuthzRepo(e) => {
|
|
||||||
tracing::error!(error = %e, "apply authz repo error");
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
json!({ "error": "internal error" }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Self::Backend(e) => {
|
|
||||||
tracing::error!(error = %e, "apply backend error");
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
json!({ "error": "internal error" }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
(status, Json(body)).into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -23,8 +23,6 @@ pub mod app_user_repo;
|
|||||||
pub mod app_user_role_repo;
|
pub mod app_user_role_repo;
|
||||||
pub mod app_user_session_repo;
|
pub mod app_user_session_repo;
|
||||||
pub mod app_user_verification_repo;
|
pub mod app_user_verification_repo;
|
||||||
pub mod apply_api;
|
|
||||||
pub mod apply_service;
|
|
||||||
pub mod apps_api;
|
pub mod apps_api;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod auth_api;
|
pub mod auth_api;
|
||||||
@@ -130,8 +128,6 @@ pub use app_user_session_repo::{
|
|||||||
pub use app_user_verification_repo::{
|
pub use app_user_verification_repo::{
|
||||||
AppUserVerificationRepo, AppUserVerificationRepoError, PostgresAppUserVerificationRepo,
|
AppUserVerificationRepo, AppUserVerificationRepoError, PostgresAppUserVerificationRepo,
|
||||||
};
|
};
|
||||||
pub use apply_api::apply_router;
|
|
||||||
pub use apply_service::{ApplyError, ApplyService, Bundle, Plan};
|
|
||||||
pub use apps_api::{apps_router, AppsState};
|
pub use apps_api::{apps_router, AppsState};
|
||||||
pub use auth_api::auth_router;
|
pub use auth_api::auth_router;
|
||||||
pub use auth_bootstrap::{
|
pub use auth_bootstrap::{
|
||||||
|
|||||||
@@ -273,8 +273,42 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> {
|
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> {
|
||||||
|
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
|
||||||
|
.unwrap_or_else(|_| serde_json::json!({}));
|
||||||
let mut tx = self.pool.begin().await?;
|
let mut tx = self.pool.begin().await?;
|
||||||
let script = insert_script_tx(&mut tx, &input).await?;
|
let res = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||||
|
"INSERT INTO scripts ( \
|
||||||
|
app_id, name, description, source, kind, \
|
||||||
|
timeout_seconds, memory_limit_mb, sandbox \
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, COALESCE($6, 30), COALESCE($7, 256), $8) \
|
||||||
|
RETURNING {SCRIPT_SELECT_COLS}"
|
||||||
|
))
|
||||||
|
.bind(input.app_id.into_inner())
|
||||||
|
.bind(&input.name)
|
||||||
|
.bind(input.description.as_deref())
|
||||||
|
.bind(&input.source)
|
||||||
|
.bind(input.kind.as_str())
|
||||||
|
.bind(input.timeout_seconds)
|
||||||
|
.bind(input.memory_limit_mb)
|
||||||
|
.bind(sandbox_json)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let script: Script = match res {
|
||||||
|
Ok(row) => row.into(),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||||
|
return Err(ScriptRepositoryError::Conflict(format!(
|
||||||
|
"a script named {:?} already exists in this app",
|
||||||
|
input.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dep-graph: write any literal-path imports declared in the
|
||||||
|
// source. Unresolved names (the referenced module doesn't
|
||||||
|
// exist yet) are silently skipped — best-effort.
|
||||||
|
replace_imports_tx(&mut tx, script.id, script.app_id, &input.imports).await?;
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
Ok(script)
|
Ok(script)
|
||||||
}
|
}
|
||||||
@@ -284,8 +318,62 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
id: ScriptId,
|
id: ScriptId,
|
||||||
patch: ScriptPatch,
|
patch: ScriptPatch,
|
||||||
) -> Result<Script, ScriptRepositoryError> {
|
) -> Result<Script, ScriptRepositoryError> {
|
||||||
|
// COALESCE-based partial update: `NULL` parameters leave columns
|
||||||
|
// untouched. Description is double-Optioned so callers can
|
||||||
|
// explicitly set it to NULL (Some(None)) vs leave it alone (None).
|
||||||
|
// Sandbox is replaced wholesale when present; per-field merging
|
||||||
|
// happens in the API layer (clearer semantics for a "PUT a new
|
||||||
|
// sandbox config" call). app_id is immutable — moving a script
|
||||||
|
// to another app is a copy-and-delete, not an in-place edit.
|
||||||
|
let sandbox_json = patch
|
||||||
|
.sandbox
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| serde_json::to_value(s).unwrap_or_else(|_| serde_json::json!({})));
|
||||||
let mut tx = self.pool.begin().await?;
|
let mut tx = self.pool.begin().await?;
|
||||||
let script = update_script_tx(&mut tx, id, &patch).await?;
|
let res = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||||
|
"UPDATE scripts SET \
|
||||||
|
name = COALESCE($2, name), \
|
||||||
|
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||||
|
source = COALESCE($5, source), \
|
||||||
|
timeout_seconds = COALESCE($6, timeout_seconds), \
|
||||||
|
memory_limit_mb = COALESCE($7, memory_limit_mb), \
|
||||||
|
sandbox = COALESCE($8, sandbox), \
|
||||||
|
kind = COALESCE($9, kind), \
|
||||||
|
version = version + 1, \
|
||||||
|
updated_at = NOW() \
|
||||||
|
WHERE id = $1 \
|
||||||
|
RETURNING {SCRIPT_SELECT_COLS}"
|
||||||
|
))
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(patch.name.as_deref())
|
||||||
|
.bind(patch.description.is_some())
|
||||||
|
.bind(patch.description.as_ref().and_then(|d| d.as_deref()))
|
||||||
|
.bind(patch.source.as_deref())
|
||||||
|
.bind(patch.timeout_seconds)
|
||||||
|
.bind(patch.memory_limit_mb)
|
||||||
|
.bind(sandbox_json)
|
||||||
|
.bind(patch.kind.map(ScriptKind::as_str))
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let script: Script = match res {
|
||||||
|
Ok(Some(row)) => row.into(),
|
||||||
|
Ok(None) => return Err(ScriptRepositoryError::NotFound(id)),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||||
|
return Err(ScriptRepositoryError::Conflict(
|
||||||
|
"a script with that name already exists in this app".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Replace imports only when the caller has a fresh list (i.e.
|
||||||
|
// the source actually changed and the validator re-extracted
|
||||||
|
// imports). A name-only or description-only edit leaves the
|
||||||
|
// dep graph alone.
|
||||||
|
if let Some(imports) = patch.imports.as_deref() {
|
||||||
|
replace_imports_tx(&mut tx, script.id, script.app_id, imports).await?;
|
||||||
|
}
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
Ok(script)
|
Ok(script)
|
||||||
}
|
}
|
||||||
@@ -381,114 +469,6 @@ async fn replace_imports_tx(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert a script within an existing transaction — the declarative
|
|
||||||
/// `apply` engine composes scripts + routes + triggers into one tx.
|
|
||||||
/// Mirrors `create` minus the `begin`/`commit`.
|
|
||||||
pub(crate) async fn insert_script_tx(
|
|
||||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
|
||||||
input: &NewScript,
|
|
||||||
) -> Result<Script, ScriptRepositoryError> {
|
|
||||||
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
|
|
||||||
.unwrap_or_else(|_| serde_json::json!({}));
|
|
||||||
let res = sqlx::query_as::<_, ScriptRow>(&format!(
|
|
||||||
"INSERT INTO scripts ( \
|
|
||||||
app_id, name, description, source, kind, \
|
|
||||||
timeout_seconds, memory_limit_mb, sandbox \
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, COALESCE($6, 30), COALESCE($7, 256), $8) \
|
|
||||||
RETURNING {SCRIPT_SELECT_COLS}"
|
|
||||||
))
|
|
||||||
.bind(input.app_id.into_inner())
|
|
||||||
.bind(&input.name)
|
|
||||||
.bind(input.description.as_deref())
|
|
||||||
.bind(&input.source)
|
|
||||||
.bind(input.kind.as_str())
|
|
||||||
.bind(input.timeout_seconds)
|
|
||||||
.bind(input.memory_limit_mb)
|
|
||||||
.bind(sandbox_json)
|
|
||||||
.fetch_one(&mut **tx)
|
|
||||||
.await;
|
|
||||||
let script: Script = match res {
|
|
||||||
Ok(row) => row.into(),
|
|
||||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
|
||||||
return Err(ScriptRepositoryError::Conflict(format!(
|
|
||||||
"a script named {:?} already exists in this app",
|
|
||||||
input.name
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Err(e) => return Err(e.into()),
|
|
||||||
};
|
|
||||||
replace_imports_tx(tx, script.id, script.app_id, &input.imports).await?;
|
|
||||||
Ok(script)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update a script within an existing transaction. Mirrors `update`
|
|
||||||
/// minus the `begin`/`commit`.
|
|
||||||
pub(crate) async fn update_script_tx(
|
|
||||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
|
||||||
id: ScriptId,
|
|
||||||
patch: &ScriptPatch,
|
|
||||||
) -> Result<Script, ScriptRepositoryError> {
|
|
||||||
let sandbox_json = patch
|
|
||||||
.sandbox
|
|
||||||
.as_ref()
|
|
||||||
.map(|s| serde_json::to_value(s).unwrap_or_else(|_| serde_json::json!({})));
|
|
||||||
let res = sqlx::query_as::<_, ScriptRow>(&format!(
|
|
||||||
"UPDATE scripts SET \
|
|
||||||
name = COALESCE($2, name), \
|
|
||||||
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
|
||||||
source = COALESCE($5, source), \
|
|
||||||
timeout_seconds = COALESCE($6, timeout_seconds), \
|
|
||||||
memory_limit_mb = COALESCE($7, memory_limit_mb), \
|
|
||||||
sandbox = COALESCE($8, sandbox), \
|
|
||||||
kind = COALESCE($9, kind), \
|
|
||||||
version = version + 1, \
|
|
||||||
updated_at = NOW() \
|
|
||||||
WHERE id = $1 \
|
|
||||||
RETURNING {SCRIPT_SELECT_COLS}"
|
|
||||||
))
|
|
||||||
.bind(id.into_inner())
|
|
||||||
.bind(patch.name.as_deref())
|
|
||||||
.bind(patch.description.is_some())
|
|
||||||
.bind(patch.description.as_ref().and_then(|d| d.as_deref()))
|
|
||||||
.bind(patch.source.as_deref())
|
|
||||||
.bind(patch.timeout_seconds)
|
|
||||||
.bind(patch.memory_limit_mb)
|
|
||||||
.bind(sandbox_json)
|
|
||||||
.bind(patch.kind.map(ScriptKind::as_str))
|
|
||||||
.fetch_optional(&mut **tx)
|
|
||||||
.await;
|
|
||||||
let script: Script = match res {
|
|
||||||
Ok(Some(row)) => row.into(),
|
|
||||||
Ok(None) => return Err(ScriptRepositoryError::NotFound(id)),
|
|
||||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
|
||||||
return Err(ScriptRepositoryError::Conflict(
|
|
||||||
"a script with that name already exists in this app".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Err(e) => return Err(e.into()),
|
|
||||||
};
|
|
||||||
if let Some(imports) = patch.imports.as_deref() {
|
|
||||||
replace_imports_tx(tx, script.id, script.app_id, imports).await?;
|
|
||||||
}
|
|
||||||
Ok(script)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a script within an existing transaction (its routes/triggers
|
|
||||||
/// cascade via their FKs). Mirrors `delete` minus the pool.
|
|
||||||
pub(crate) async fn delete_script_tx(
|
|
||||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
|
||||||
id: ScriptId,
|
|
||||||
) -> Result<(), ScriptRepositoryError> {
|
|
||||||
let res = sqlx::query("DELETE FROM scripts WHERE id = $1")
|
|
||||||
.bind(id.into_inner())
|
|
||||||
.execute(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
if res.rows_affected() == 0 {
|
|
||||||
return Err(ScriptRepositoryError::NotFound(id));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Row shape mirroring the `scripts` table for sqlx FromRow.
|
/// Row shape mirroring the `scripts` table for sqlx FromRow.
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct ScriptRow {
|
struct ScriptRow {
|
||||||
|
|||||||
@@ -426,7 +426,7 @@ fn compile_route(r: &Route) -> Result<CompiledRoute, pattern::ParseError> {
|
|||||||
/// Validate that a new route's (host_kind, host) is consistent with at
|
/// Validate that a new route's (host_kind, host) is consistent with at
|
||||||
/// least one of the parent app's domain claims. `HostKind::Any` is
|
/// least one of the parent app's domain claims. `HostKind::Any` is
|
||||||
/// always permitted — it catches every host the app already owns.
|
/// always permitted — it catches every host the app already owns.
|
||||||
pub(crate) async fn validate_route_host_against_app(
|
async fn validate_route_host_against_app(
|
||||||
domains: &dyn AppDomainRepository,
|
domains: &dyn AppDomainRepository,
|
||||||
app_id: AppId,
|
app_id: AppId,
|
||||||
host_kind: HostKind,
|
host_kind: HostKind,
|
||||||
|
|||||||
@@ -111,10 +111,36 @@ impl RouteRepository for PostgresRouteRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError> {
|
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError> {
|
||||||
let mut tx = self.pool.begin().await?;
|
let res = sqlx::query_as::<_, RouteRow>(
|
||||||
let route = insert_route_tx(&mut tx, &input).await?;
|
"INSERT INTO routes ( \
|
||||||
tx.commit().await?;
|
app_id, script_id, host_kind, host, host_param_name, \
|
||||||
Ok(route)
|
path_kind, path, method, dispatch_mode \
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \
|
||||||
|
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
|
||||||
|
path_kind, path, method, dispatch_mode, created_at",
|
||||||
|
)
|
||||||
|
.bind(input.app_id.into_inner())
|
||||||
|
.bind(input.script_id.into_inner())
|
||||||
|
.bind(host_kind_str(input.host_kind))
|
||||||
|
.bind(&input.host)
|
||||||
|
.bind(input.host_param_name.as_deref())
|
||||||
|
.bind(path_kind_str(input.path_kind))
|
||||||
|
.bind(&input.path)
|
||||||
|
.bind(input.method.as_deref())
|
||||||
|
.bind(input.dispatch_mode.as_str())
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(row) => Ok(row.into()),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||||
|
ScriptRepositoryError::Conflict("a route with this binding already exists".into()),
|
||||||
|
),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
|
||||||
|
Err(ScriptRepositoryError::NotFound(input.script_id))
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete(&self, route_id: Uuid) -> Result<(), ScriptRepositoryError> {
|
async fn delete(&self, route_id: Uuid) -> Result<(), ScriptRepositoryError> {
|
||||||
@@ -163,56 +189,6 @@ const fn path_kind_str(k: PathKind) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert a route within an existing transaction (declarative apply
|
|
||||||
/// composes scripts + routes + triggers into one tx). Mirrors `create`
|
|
||||||
/// minus the `begin`/`commit`.
|
|
||||||
pub(crate) async fn insert_route_tx(
|
|
||||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
|
||||||
input: &NewRoute,
|
|
||||||
) -> Result<Route, ScriptRepositoryError> {
|
|
||||||
let res = sqlx::query_as::<_, RouteRow>(
|
|
||||||
"INSERT INTO routes ( \
|
|
||||||
app_id, script_id, host_kind, host, host_param_name, \
|
|
||||||
path_kind, path, method, dispatch_mode \
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \
|
|
||||||
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
|
|
||||||
path_kind, path, method, dispatch_mode, created_at",
|
|
||||||
)
|
|
||||||
.bind(input.app_id.into_inner())
|
|
||||||
.bind(input.script_id.into_inner())
|
|
||||||
.bind(host_kind_str(input.host_kind))
|
|
||||||
.bind(&input.host)
|
|
||||||
.bind(input.host_param_name.as_deref())
|
|
||||||
.bind(path_kind_str(input.path_kind))
|
|
||||||
.bind(&input.path)
|
|
||||||
.bind(input.method.as_deref())
|
|
||||||
.bind(input.dispatch_mode.as_str())
|
|
||||||
.fetch_one(&mut **tx)
|
|
||||||
.await;
|
|
||||||
match res {
|
|
||||||
Ok(row) => Ok(row.into()),
|
|
||||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
|
||||||
ScriptRepositoryError::Conflict("a route with this binding already exists".into()),
|
|
||||||
),
|
|
||||||
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
|
|
||||||
Err(ScriptRepositoryError::NotFound(input.script_id))
|
|
||||||
}
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a route by id within an existing transaction.
|
|
||||||
pub(crate) async fn delete_route_tx(
|
|
||||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
|
||||||
route_id: Uuid,
|
|
||||||
) -> Result<(), ScriptRepositoryError> {
|
|
||||||
sqlx::query("DELETE FROM routes WHERE id = $1")
|
|
||||||
.bind(route_id)
|
|
||||||
.execute(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct RouteRow {
|
struct RouteRow {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
|
|||||||
@@ -502,220 +502,6 @@ impl PostgresTriggerRepo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert a trigger (parent row + per-kind detail) within an existing
|
|
||||||
/// transaction — used by the declarative `apply` engine. Supports the
|
|
||||||
/// five settled kinds; `email`/`queue`/`dead_letter` have their own
|
|
||||||
/// create paths and are rejected here.
|
|
||||||
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
|
|
||||||
pub(crate) async fn insert_trigger_tx(
|
|
||||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
|
||||||
app_id: AppId,
|
|
||||||
script_id: ScriptId,
|
|
||||||
registered_by: AdminUserId,
|
|
||||||
dispatch_mode: TriggerDispatchMode,
|
|
||||||
retry_max_attempts: u32,
|
|
||||||
retry_backoff: BackoffShape,
|
|
||||||
retry_base_ms: u32,
|
|
||||||
details: &TriggerDetails,
|
|
||||||
) -> Result<TriggerId, TriggerRepoError> {
|
|
||||||
let kind = match details {
|
|
||||||
TriggerDetails::Kv { .. } => "kv",
|
|
||||||
TriggerDetails::Docs { .. } => "docs",
|
|
||||||
TriggerDetails::Files { .. } => "files",
|
|
||||||
TriggerDetails::Cron { .. } => "cron",
|
|
||||||
TriggerDetails::Pubsub { .. } => "pubsub",
|
|
||||||
TriggerDetails::Queue { .. } => "queue",
|
|
||||||
TriggerDetails::DeadLetter { .. } | TriggerDetails::Email { .. } => {
|
|
||||||
return Err(TriggerRepoError::Invalid(
|
|
||||||
"trigger kind not supported by declarative apply".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// Queue: enforce the one-consumer-per-(app_id, queue_name) invariant —
|
|
||||||
// the same advisory-lock + existence guard the interactive
|
|
||||||
// `create_queue_trigger` uses. Without this, a concurrent apply +
|
|
||||||
// interactive create on disjoint locks could double-register a queue
|
|
||||||
// consumer (there is no DB unique constraint backing the invariant).
|
|
||||||
if let TriggerDetails::Queue { queue_name, .. } = details {
|
|
||||||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
|
||||||
.bind(advisory_lock_key(app_id, queue_name))
|
|
||||||
.execute(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
let existing: Option<(Uuid,)> = sqlx::query_as(
|
|
||||||
"SELECT t.id FROM triggers t \
|
|
||||||
JOIN queue_trigger_details d ON d.trigger_id = t.id \
|
|
||||||
WHERE t.app_id = $1 AND t.kind = 'queue' AND d.queue_name = $2",
|
|
||||||
)
|
|
||||||
.bind(app_id.into_inner())
|
|
||||||
.bind(queue_name)
|
|
||||||
.fetch_optional(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
if existing.is_some() {
|
|
||||||
return Err(TriggerRepoError::Invalid(format!(
|
|
||||||
"queue '{queue_name}' already has a consumer trigger; remove the existing one first"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let row: (Uuid,) = sqlx::query_as(
|
|
||||||
"INSERT INTO triggers ( \
|
|
||||||
app_id, script_id, kind, enabled, dispatch_mode, \
|
|
||||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
|
||||||
registered_by_principal \
|
|
||||||
) VALUES ($1, $2, $3, TRUE, $4, $5, $6, $7, $8) RETURNING id",
|
|
||||||
)
|
|
||||||
.bind(app_id.into_inner())
|
|
||||||
.bind(script_id.into_inner())
|
|
||||||
.bind(kind)
|
|
||||||
.bind(dispatch_mode.as_str())
|
|
||||||
.bind(i32::try_from(retry_max_attempts).unwrap_or(3))
|
|
||||||
.bind(retry_backoff.as_str())
|
|
||||||
.bind(i32::try_from(retry_base_ms).unwrap_or(1000))
|
|
||||||
.bind(registered_by.into_inner())
|
|
||||||
.fetch_one(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
let tid = row.0;
|
|
||||||
|
|
||||||
match details {
|
|
||||||
TriggerDetails::Kv {
|
|
||||||
collection_glob,
|
|
||||||
ops,
|
|
||||||
} => {
|
|
||||||
let ops_str: Vec<String> = ops.iter().map(|o| o.as_str().to_string()).collect();
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO kv_trigger_details (trigger_id, collection_glob, ops) \
|
|
||||||
VALUES ($1, $2, $3)",
|
|
||||||
)
|
|
||||||
.bind(tid)
|
|
||||||
.bind(collection_glob)
|
|
||||||
.bind(&ops_str)
|
|
||||||
.execute(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
TriggerDetails::Docs {
|
|
||||||
collection_glob,
|
|
||||||
ops,
|
|
||||||
} => {
|
|
||||||
let ops_str: Vec<String> = ops.iter().map(|o| o.as_str().to_string()).collect();
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO docs_trigger_details (trigger_id, collection_glob, ops) \
|
|
||||||
VALUES ($1, $2, $3)",
|
|
||||||
)
|
|
||||||
.bind(tid)
|
|
||||||
.bind(collection_glob)
|
|
||||||
.bind(&ops_str)
|
|
||||||
.execute(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
TriggerDetails::Files {
|
|
||||||
collection_glob,
|
|
||||||
ops,
|
|
||||||
} => {
|
|
||||||
let ops_str: Vec<String> = ops.iter().map(|o| o.as_str().to_string()).collect();
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO files_trigger_details (trigger_id, collection_glob, ops) \
|
|
||||||
VALUES ($1, $2, $3)",
|
|
||||||
)
|
|
||||||
.bind(tid)
|
|
||||||
.bind(collection_glob)
|
|
||||||
.bind(&ops_str)
|
|
||||||
.execute(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
TriggerDetails::Cron {
|
|
||||||
schedule, timezone, ..
|
|
||||||
} => {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO cron_trigger_details (trigger_id, schedule, timezone) \
|
|
||||||
VALUES ($1, $2, $3)",
|
|
||||||
)
|
|
||||||
.bind(tid)
|
|
||||||
.bind(schedule)
|
|
||||||
.bind(timezone)
|
|
||||||
.execute(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
TriggerDetails::Pubsub { topic_pattern } => {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO pubsub_trigger_details (trigger_id, topic_pattern) VALUES ($1, $2)",
|
|
||||||
)
|
|
||||||
.bind(tid)
|
|
||||||
.bind(topic_pattern)
|
|
||||||
.execute(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
TriggerDetails::Queue {
|
|
||||||
queue_name,
|
|
||||||
visibility_timeout_secs,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO queue_trigger_details \
|
|
||||||
(trigger_id, queue_name, visibility_timeout_secs) \
|
|
||||||
VALUES ($1, $2, $3)",
|
|
||||||
)
|
|
||||||
.bind(tid)
|
|
||||||
.bind(queue_name)
|
|
||||||
.bind(i32::try_from(*visibility_timeout_secs).unwrap_or(30))
|
|
||||||
.execute(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
TriggerDetails::DeadLetter { .. } | TriggerDetails::Email { .. } => {
|
|
||||||
unreachable!("guarded above")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(tid.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert an email trigger within a transaction. The inbound HMAC secret
|
|
||||||
/// is sealed by the apply engine (resolved from the app's secret store);
|
|
||||||
/// this writes the ciphertext. Parent retry settings match the
|
|
||||||
/// interactive `create_email_trigger` path (async, 3, exponential, 1000).
|
|
||||||
pub(crate) async fn insert_email_trigger_tx(
|
|
||||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
|
||||||
app_id: AppId,
|
|
||||||
script_id: ScriptId,
|
|
||||||
registered_by: AdminUserId,
|
|
||||||
inbound_secret_encrypted: &[u8],
|
|
||||||
inbound_secret_nonce: &[u8],
|
|
||||||
) -> Result<TriggerId, TriggerRepoError> {
|
|
||||||
let row: (Uuid,) = sqlx::query_as(
|
|
||||||
"INSERT INTO triggers ( \
|
|
||||||
app_id, script_id, kind, enabled, dispatch_mode, \
|
|
||||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
|
||||||
registered_by_principal \
|
|
||||||
) VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3) RETURNING id",
|
|
||||||
)
|
|
||||||
.bind(app_id.into_inner())
|
|
||||||
.bind(script_id.into_inner())
|
|
||||||
.bind(registered_by.into_inner())
|
|
||||||
.fetch_one(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO email_trigger_details \
|
|
||||||
(trigger_id, inbound_secret_encrypted, inbound_secret_nonce) \
|
|
||||||
VALUES ($1, $2, $3)",
|
|
||||||
)
|
|
||||||
.bind(row.0)
|
|
||||||
.bind(inbound_secret_encrypted)
|
|
||||||
.bind(inbound_secret_nonce)
|
|
||||||
.execute(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
Ok(row.0.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a trigger by id within an existing transaction (its detail row
|
|
||||||
/// cascades via the FK). Used by `apply --prune`.
|
|
||||||
pub(crate) async fn delete_trigger_tx(
|
|
||||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
|
||||||
id: TriggerId,
|
|
||||||
) -> Result<(), TriggerRepoError> {
|
|
||||||
sqlx::query("DELETE FROM triggers WHERE id = $1")
|
|
||||||
.bind(id.into_inner())
|
|
||||||
.execute(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl TriggerRepo for PostgresTriggerRepo {
|
impl TriggerRepo for PostgresTriggerRepo {
|
||||||
async fn create_kv_trigger(
|
async fn create_kv_trigger(
|
||||||
|
|||||||
@@ -900,36 +900,6 @@ impl Client {
|
|||||||
.await?;
|
.await?;
|
||||||
decode(resp).await
|
decode(resp).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/v1/admin/apps/{id_or_slug}/plan` — diff a desired-state
|
|
||||||
/// bundle against the app's live state. Read-only.
|
|
||||||
pub async fn plan(&self, app: &str, bundle: &serde_json::Value) -> Result<PlanDto> {
|
|
||||||
let app = seg(app);
|
|
||||||
let resp = self
|
|
||||||
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/plan"))
|
|
||||||
.json(bundle)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
decode(resp).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `POST /api/v1/admin/apps/{id_or_slug}/apply` — reconcile the live
|
|
||||||
/// app to the bundle in one transaction.
|
|
||||||
pub async fn apply(
|
|
||||||
&self,
|
|
||||||
app: &str,
|
|
||||||
bundle: &serde_json::Value,
|
|
||||||
prune: bool,
|
|
||||||
) -> Result<ApplyReportDto> {
|
|
||||||
let app = seg(app);
|
|
||||||
let body = serde_json::json!({ "bundle": bundle, "prune": prune });
|
|
||||||
let resp = self
|
|
||||||
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/apply"))
|
|
||||||
.json(&body)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
decode(resp).await
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/v1/admin/auth/login` — sits outside the `Client` because
|
/// `POST /api/v1/admin/auth/login` — sits outside the `Client` because
|
||||||
@@ -954,50 +924,6 @@ pub async fn auth_login(url: &str, username: &str, password: &str) -> Result<Log
|
|||||||
|
|
||||||
// ---------- DTOs (CLI-local, wire-shape-matched) ----------
|
// ---------- DTOs (CLI-local, wire-shape-matched) ----------
|
||||||
|
|
||||||
/// Response of `POST .../plan`: per-resource diffs grouped by kind.
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct PlanDto {
|
|
||||||
#[serde(default)]
|
|
||||||
pub scripts: Vec<ChangeDto>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub routes: Vec<ChangeDto>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub triggers: Vec<ChangeDto>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub secrets: Vec<ChangeDto>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct ChangeDto {
|
|
||||||
pub op: String,
|
|
||||||
pub key: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub detail: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Response of `POST .../apply`: counts of what changed.
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
|
||||||
pub struct ApplyReportDto {
|
|
||||||
#[serde(default)]
|
|
||||||
pub scripts_created: u32,
|
|
||||||
#[serde(default)]
|
|
||||||
pub scripts_updated: u32,
|
|
||||||
#[serde(default)]
|
|
||||||
pub scripts_deleted: u32,
|
|
||||||
#[serde(default)]
|
|
||||||
pub routes_created: u32,
|
|
||||||
#[serde(default)]
|
|
||||||
pub routes_updated: u32,
|
|
||||||
#[serde(default)]
|
|
||||||
pub routes_deleted: u32,
|
|
||||||
#[serde(default)]
|
|
||||||
pub triggers_created: u32,
|
|
||||||
#[serde(default)]
|
|
||||||
pub triggers_deleted: u32,
|
|
||||||
#[serde(default)]
|
|
||||||
pub warnings: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct AuthMeDto {
|
pub struct AuthMeDto {
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
//! `pic apply [--file picloud.toml]` — reconcile the live app to the
|
|
||||||
//! manifest's desired state in one server-side transaction. Additive in
|
|
||||||
//! this milestone (creates + updates); pruning of stale resources lands
|
|
||||||
//! next.
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
|
|
||||||
use crate::client::Client;
|
|
||||||
use crate::cmds::plan::build_bundle;
|
|
||||||
use crate::config;
|
|
||||||
use crate::manifest::Manifest;
|
|
||||||
use crate::output::{KvBlock, OutputMode};
|
|
||||||
|
|
||||||
pub async fn run(manifest_path: &Path, prune: bool, mode: OutputMode) -> Result<()> {
|
|
||||||
let creds = config::resolve()?;
|
|
||||||
let client = Client::from_creds(&creds)?;
|
|
||||||
|
|
||||||
let manifest = Manifest::load(manifest_path)?;
|
|
||||||
let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
|
|
||||||
let bundle = build_bundle(&manifest, base_dir)?;
|
|
||||||
|
|
||||||
let report = client.apply(&manifest.app.slug, &bundle, prune).await?;
|
|
||||||
|
|
||||||
let mut block = KvBlock::new();
|
|
||||||
block
|
|
||||||
.field("app", manifest.app.slug.clone())
|
|
||||||
.field(
|
|
||||||
"scripts",
|
|
||||||
format!(
|
|
||||||
"+{} ~{} -{}",
|
|
||||||
report.scripts_created, report.scripts_updated, report.scripts_deleted
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.field(
|
|
||||||
"routes",
|
|
||||||
format!(
|
|
||||||
"+{} ~{} -{}",
|
|
||||||
report.routes_created, report.routes_updated, report.routes_deleted
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.field(
|
|
||||||
"triggers",
|
|
||||||
format!("+{} -{}", report.triggers_created, report.triggers_deleted),
|
|
||||||
);
|
|
||||||
for w in &report.warnings {
|
|
||||||
block.field("warning", w.clone());
|
|
||||||
}
|
|
||||||
block.print(mode);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
pub mod admins;
|
pub mod admins;
|
||||||
pub mod api_keys;
|
pub mod api_keys;
|
||||||
pub mod apply;
|
|
||||||
pub mod apps;
|
pub mod apps;
|
||||||
pub mod apps_domains;
|
pub mod apps_domains;
|
||||||
pub mod dead_letters;
|
pub mod dead_letters;
|
||||||
@@ -10,8 +9,6 @@ pub mod login;
|
|||||||
pub mod logout;
|
pub mod logout;
|
||||||
pub mod logs;
|
pub mod logs;
|
||||||
pub mod members;
|
pub mod members;
|
||||||
pub mod plan;
|
|
||||||
pub mod pull;
|
|
||||||
pub mod queues;
|
pub mod queues;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
pub mod scripts;
|
pub mod scripts;
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
//! `pic plan [--file picloud.toml]` — diff the manifest's desired state
|
|
||||||
//! against the live app and print the per-resource changes. Read-only:
|
|
||||||
//! builds a bundle (manifest + script sources) and POSTs it to the
|
|
||||||
//! server's plan endpoint, which computes the diff.
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use serde::Serialize;
|
|
||||||
use serde_json::{json, Map, Value};
|
|
||||||
|
|
||||||
use crate::client::{ChangeDto, Client, PlanDto};
|
|
||||||
use crate::config;
|
|
||||||
use crate::manifest::Manifest;
|
|
||||||
use crate::output::{OutputMode, Table};
|
|
||||||
|
|
||||||
pub async fn run(manifest_path: &Path, mode: OutputMode) -> Result<()> {
|
|
||||||
let creds = config::resolve()?;
|
|
||||||
let client = Client::from_creds(&creds)?;
|
|
||||||
|
|
||||||
let manifest = Manifest::load(manifest_path)?;
|
|
||||||
let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
|
|
||||||
let bundle = build_bundle(&manifest, base_dir)?;
|
|
||||||
|
|
||||||
let plan = client.plan(&manifest.app.slug, &bundle).await?;
|
|
||||||
render(&plan, mode);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Assemble the wire bundle: scripts carry inlined source (read from
|
|
||||||
/// their `file`), routes pass through, triggers flatten into a tagged
|
|
||||||
/// array, secrets are names only.
|
|
||||||
pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
|
||||||
let mut scripts = Vec::with_capacity(manifest.scripts.len());
|
|
||||||
for s in &manifest.scripts {
|
|
||||||
let source = std::fs::read_to_string(base_dir.join(&s.file))
|
|
||||||
.with_context(|| format!("reading script source {}", s.file))?;
|
|
||||||
let mut obj = Map::new();
|
|
||||||
obj.insert("name".into(), json!(s.name));
|
|
||||||
obj.insert("source".into(), json!(source));
|
|
||||||
obj.insert("kind".into(), serde_json::to_value(s.kind)?);
|
|
||||||
if let Some(d) = &s.description {
|
|
||||||
obj.insert("description".into(), json!(d));
|
|
||||||
}
|
|
||||||
if let Some(t) = s.timeout_seconds {
|
|
||||||
obj.insert("timeout_seconds".into(), json!(t));
|
|
||||||
}
|
|
||||||
if let Some(m) = s.memory_limit_mb {
|
|
||||||
obj.insert("memory_limit_mb".into(), json!(m));
|
|
||||||
}
|
|
||||||
if let Some(sb) = &s.sandbox {
|
|
||||||
obj.insert("sandbox".into(), serde_json::to_value(sb)?);
|
|
||||||
}
|
|
||||||
scripts.push(Value::Object(obj));
|
|
||||||
}
|
|
||||||
|
|
||||||
let routes = manifest
|
|
||||||
.routes
|
|
||||||
.iter()
|
|
||||||
.map(serde_json::to_value)
|
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
|
||||||
|
|
||||||
let t = &manifest.triggers;
|
|
||||||
let mut triggers = Vec::new();
|
|
||||||
for s in &t.kv {
|
|
||||||
triggers.push(tagged("kv", s)?);
|
|
||||||
}
|
|
||||||
for s in &t.docs {
|
|
||||||
triggers.push(tagged("docs", s)?);
|
|
||||||
}
|
|
||||||
for s in &t.files {
|
|
||||||
triggers.push(tagged("files", s)?);
|
|
||||||
}
|
|
||||||
for s in &t.cron {
|
|
||||||
triggers.push(tagged("cron", s)?);
|
|
||||||
}
|
|
||||||
for s in &t.pubsub {
|
|
||||||
triggers.push(tagged("pubsub", s)?);
|
|
||||||
}
|
|
||||||
for s in &t.email {
|
|
||||||
triggers.push(tagged("email", s)?);
|
|
||||||
}
|
|
||||||
for s in &t.queue {
|
|
||||||
triggers.push(tagged("queue", s)?);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(json!({
|
|
||||||
"scripts": scripts,
|
|
||||||
"routes": routes,
|
|
||||||
"triggers": triggers,
|
|
||||||
"secrets": manifest.secrets.names,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serialize a trigger spec and stamp its `kind` discriminator.
|
|
||||||
fn tagged(kind: &str, spec: impl Serialize) -> Result<Value> {
|
|
||||||
let mut v = serde_json::to_value(spec)?;
|
|
||||||
if let Value::Object(map) = &mut v {
|
|
||||||
map.insert("kind".into(), Value::String(kind.to_string()));
|
|
||||||
}
|
|
||||||
Ok(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render(plan: &PlanDto, mode: OutputMode) {
|
|
||||||
let mut table = Table::new(["kind", "op", "resource", "detail"]);
|
|
||||||
let groups: [(&str, &Vec<ChangeDto>); 4] = [
|
|
||||||
("script", &plan.scripts),
|
|
||||||
("route", &plan.routes),
|
|
||||||
("trigger", &plan.triggers),
|
|
||||||
("secret", &plan.secrets),
|
|
||||||
];
|
|
||||||
for (kind, changes) in groups {
|
|
||||||
for c in changes {
|
|
||||||
table.row([
|
|
||||||
kind.to_string(),
|
|
||||||
c.op.clone(),
|
|
||||||
c.key.clone(),
|
|
||||||
c.detail.clone().unwrap_or_default(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
table.print(mode);
|
|
||||||
}
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
//! `pic pull <app> [--dir .]` — export an app's current server state into
|
|
||||||
//! a `picloud.toml` manifest plus `scripts/<name>.rhai` source files, for
|
|
||||||
//! declarative management with `pic plan` / `pic apply`.
|
|
||||||
//!
|
|
||||||
//! Read-only: issues `GET`s only and writes local files. Every trigger
|
|
||||||
//! kind is exported except `email` — the server stores the *sealed secret
|
|
||||||
//! value*, not the secret name, so the manifest's `inbound_secret_ref`
|
|
||||||
//! can't be reconstructed (email triggers must be set up by hand).
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use picloud_shared::{DispatchMode, DocsEventOp, FilesEventOp, KvEventOp, ScriptId};
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use crate::client::Client;
|
|
||||||
use crate::config;
|
|
||||||
use crate::manifest::{
|
|
||||||
CronTriggerSpec, DocsTriggerSpec, FilesTriggerSpec, KvTriggerSpec, Manifest, ManifestApp,
|
|
||||||
ManifestRoute, ManifestScript, ManifestSecrets, ManifestTriggers, PubsubTriggerSpec,
|
|
||||||
QueueTriggerSpec, MANIFEST_FILE,
|
|
||||||
};
|
|
||||||
use crate::output::{KvBlock, OutputMode};
|
|
||||||
|
|
||||||
pub async fn run(app_ident: &str, dir: &Path, mode: OutputMode) -> Result<()> {
|
|
||||||
let creds = config::resolve()?;
|
|
||||||
let client = Client::from_creds(&creds)?;
|
|
||||||
|
|
||||||
// One GET per resource kind (routes are per-script, below).
|
|
||||||
let app = client.apps_get(app_ident).await?;
|
|
||||||
let scripts = client.scripts_list_by_app(app_ident).await?;
|
|
||||||
let triggers = client.triggers_list(app_ident).await?.triggers;
|
|
||||||
let secrets = client.secrets_list(app_ident).await?.secrets;
|
|
||||||
|
|
||||||
let name_by_id: HashMap<ScriptId, String> =
|
|
||||||
scripts.iter().map(|s| (s.id, s.name.clone())).collect();
|
|
||||||
|
|
||||||
// Routes: the admin surface lists them per script.
|
|
||||||
let mut routes = Vec::new();
|
|
||||||
for s in &scripts {
|
|
||||||
for r in client.routes_list_for_script(&s.id.to_string()).await? {
|
|
||||||
routes.push(ManifestRoute {
|
|
||||||
script: s.name.clone(),
|
|
||||||
method: r.method,
|
|
||||||
host_kind: r.host_kind,
|
|
||||||
host: r.host,
|
|
||||||
host_param_name: r.host_param_name,
|
|
||||||
path_kind: r.path_kind,
|
|
||||||
path: r.path,
|
|
||||||
dispatch_mode: r.dispatch_mode,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The server does not constrain script names to a filesystem-safe
|
|
||||||
// charset, so a name containing a path separator or `..` would let `pull`
|
|
||||||
// write outside the project dir. Validate ALL names up front, before any
|
|
||||||
// file is written, so a single bad name can't leave a half-written dir.
|
|
||||||
// Reject rather than sanitize: a silent rename would desync the manifest
|
|
||||||
// `name` from its `file`.
|
|
||||||
for s in &scripts {
|
|
||||||
if !is_safe_filename(&s.name) {
|
|
||||||
anyhow::bail!(
|
|
||||||
"script name {:?} is not filesystem-safe (contains a path \
|
|
||||||
separator, `..`, or a leading dot); cannot pull",
|
|
||||||
s.name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scripts: write each source next to the manifest and record a path ref.
|
|
||||||
let scripts_dir = dir.join("scripts");
|
|
||||||
std::fs::create_dir_all(&scripts_dir)
|
|
||||||
.with_context(|| format!("creating {}", scripts_dir.display()))?;
|
|
||||||
let mut manifest_scripts = Vec::with_capacity(scripts.len());
|
|
||||||
for s in &scripts {
|
|
||||||
let rel = format!("scripts/{}.rhai", s.name);
|
|
||||||
std::fs::write(dir.join(&rel), &s.source).with_context(|| format!("writing {rel}"))?;
|
|
||||||
manifest_scripts.push(ManifestScript {
|
|
||||||
name: s.name.clone(),
|
|
||||||
file: rel,
|
|
||||||
kind: s.kind,
|
|
||||||
description: s.description.clone(),
|
|
||||||
timeout_seconds: i32::try_from(s.timeout_seconds).ok(),
|
|
||||||
memory_limit_mb: i32::try_from(s.memory_limit_mb).ok(),
|
|
||||||
sandbox: if s.sandbox.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(s.sandbox)
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Triggers: map the five settled kinds; warn + skip the rest.
|
|
||||||
let mut manifest_triggers = ManifestTriggers::default();
|
|
||||||
let mut skipped: Vec<String> = Vec::new();
|
|
||||||
for t in &triggers {
|
|
||||||
let script = name_by_id
|
|
||||||
.get(&t.script_id)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| t.script_id.to_string());
|
|
||||||
let dispatch_mode = DispatchMode::from_wire(&t.dispatch_mode);
|
|
||||||
let retry_max_attempts = Some(t.retry_max_attempts);
|
|
||||||
match t.kind.as_str() {
|
|
||||||
"kv" => {
|
|
||||||
let d: CollectionDetails<KvEventOp> = decode_details(&t.details, &t.kind)?;
|
|
||||||
manifest_triggers.kv.push(KvTriggerSpec {
|
|
||||||
script,
|
|
||||||
collection_glob: d.collection_glob,
|
|
||||||
ops: d.ops,
|
|
||||||
dispatch_mode,
|
|
||||||
retry_max_attempts,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
"docs" => {
|
|
||||||
let d: CollectionDetails<DocsEventOp> = decode_details(&t.details, &t.kind)?;
|
|
||||||
manifest_triggers.docs.push(DocsTriggerSpec {
|
|
||||||
script,
|
|
||||||
collection_glob: d.collection_glob,
|
|
||||||
ops: d.ops,
|
|
||||||
dispatch_mode,
|
|
||||||
retry_max_attempts,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
"files" => {
|
|
||||||
let d: CollectionDetails<FilesEventOp> = decode_details(&t.details, &t.kind)?;
|
|
||||||
manifest_triggers.files.push(FilesTriggerSpec {
|
|
||||||
script,
|
|
||||||
collection_glob: d.collection_glob,
|
|
||||||
ops: d.ops,
|
|
||||||
dispatch_mode,
|
|
||||||
retry_max_attempts,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
"cron" => {
|
|
||||||
let d: CronDetails = decode_details(&t.details, &t.kind)?;
|
|
||||||
manifest_triggers.cron.push(CronTriggerSpec {
|
|
||||||
script,
|
|
||||||
schedule: d.schedule,
|
|
||||||
timezone: d.timezone,
|
|
||||||
dispatch_mode,
|
|
||||||
retry_max_attempts,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
"pubsub" => {
|
|
||||||
let d: PubsubDetails = decode_details(&t.details, &t.kind)?;
|
|
||||||
manifest_triggers.pubsub.push(PubsubTriggerSpec {
|
|
||||||
script,
|
|
||||||
topic_pattern: d.topic_pattern,
|
|
||||||
dispatch_mode,
|
|
||||||
retry_max_attempts,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
"queue" => {
|
|
||||||
let d: QueueDetails = decode_details(&t.details, &t.kind)?;
|
|
||||||
manifest_triggers.queue.push(QueueTriggerSpec {
|
|
||||||
script,
|
|
||||||
queue_name: d.queue_name,
|
|
||||||
visibility_timeout_secs: Some(d.visibility_timeout_secs),
|
|
||||||
dispatch_mode,
|
|
||||||
retry_max_attempts,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// `email` is skipped: the server stores the sealed secret value,
|
|
||||||
// not the secret *name*, so the manifest's `inbound_secret_ref`
|
|
||||||
// can't be reconstructed — set it up by hand.
|
|
||||||
other => skipped.push(format!("{other} ({})", t.id)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for s in &skipped {
|
|
||||||
eprintln!("warning: skipping {s} trigger — not yet representable in the manifest");
|
|
||||||
}
|
|
||||||
|
|
||||||
let manifest = Manifest {
|
|
||||||
app: ManifestApp {
|
|
||||||
slug: app.app.slug.clone(),
|
|
||||||
name: app.app.name.clone(),
|
|
||||||
description: app.app.description.clone(),
|
|
||||||
},
|
|
||||||
scripts: manifest_scripts,
|
|
||||||
routes,
|
|
||||||
triggers: manifest_triggers,
|
|
||||||
secrets: ManifestSecrets {
|
|
||||||
names: secrets.iter().map(|s| s.name.clone()).collect(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let manifest_path = dir.join(MANIFEST_FILE);
|
|
||||||
std::fs::write(&manifest_path, manifest.to_toml()?)
|
|
||||||
.with_context(|| format!("writing {}", manifest_path.display()))?;
|
|
||||||
|
|
||||||
let mut block = KvBlock::new();
|
|
||||||
block
|
|
||||||
.field("manifest", manifest_path.display().to_string())
|
|
||||||
.field("app", manifest.app.slug.clone())
|
|
||||||
.field("scripts", manifest.scripts.len().to_string())
|
|
||||||
.field("routes", manifest.routes.len().to_string())
|
|
||||||
.field("triggers", trigger_count(&manifest.triggers).to_string())
|
|
||||||
.field("secrets", manifest.secrets.names.len().to_string());
|
|
||||||
block.print(mode);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn trigger_count(t: &ManifestTriggers) -> usize {
|
|
||||||
t.kv.len() + t.docs.len() + t.files.len() + t.cron.len() + t.pubsub.len() + t.queue.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// True if `name` is safe to use as a single path component in `scripts/`.
|
|
||||||
/// Rejects empty names, path separators, `.`/`..`, and leading dots.
|
|
||||||
fn is_safe_filename(name: &str) -> bool {
|
|
||||||
!name.is_empty()
|
|
||||||
&& !name.starts_with('.')
|
|
||||||
&& !name.contains('/')
|
|
||||||
&& !name.contains('\\')
|
|
||||||
&& name != ".."
|
|
||||||
&& !name.contains('\0')
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deserialize a trigger's `details` JSON, attributing failures to the kind.
|
|
||||||
/// The server tags details with a `kind` field which these structs ignore.
|
|
||||||
fn decode_details<T: for<'de> Deserialize<'de>>(
|
|
||||||
details: &serde_json::Value,
|
|
||||||
kind: &str,
|
|
||||||
) -> Result<T> {
|
|
||||||
serde_json::from_value(details.clone())
|
|
||||||
.with_context(|| format!("decoding {kind} trigger details"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct CollectionDetails<Op> {
|
|
||||||
collection_glob: String,
|
|
||||||
#[serde(default = "Vec::new")]
|
|
||||||
ops: Vec<Op>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct CronDetails {
|
|
||||||
schedule: String,
|
|
||||||
#[serde(default = "default_timezone")]
|
|
||||||
timezone: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct PubsubDetails {
|
|
||||||
topic_pattern: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct QueueDetails {
|
|
||||||
queue_name: String,
|
|
||||||
visibility_timeout_secs: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_timezone() -> String {
|
|
||||||
"UTC".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::is_safe_filename;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_traversal_and_separators() {
|
|
||||||
for bad in [
|
|
||||||
"",
|
|
||||||
".",
|
|
||||||
"..",
|
|
||||||
"../etc/passwd",
|
|
||||||
"a/b",
|
|
||||||
"a\\b",
|
|
||||||
".hidden",
|
|
||||||
"with\0nul",
|
|
||||||
] {
|
|
||||||
assert!(!is_safe_filename(bad), "expected {bad:?} to be rejected");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn accepts_normal_names() {
|
|
||||||
for ok in ["create-post", "nightly_digest", "Greet", "x", "a.b"] {
|
|
||||||
assert!(is_safe_filename(ok), "expected {ok:?} to be accepted");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,6 @@ use clap::{Args, Parser, Subcommand, ValueEnum};
|
|||||||
mod client;
|
mod client;
|
||||||
mod cmds;
|
mod cmds;
|
||||||
mod config;
|
mod config;
|
||||||
mod manifest;
|
|
||||||
mod output;
|
mod output;
|
||||||
|
|
||||||
use crate::output::OutputMode;
|
use crate::output::OutputMode;
|
||||||
@@ -157,46 +156,6 @@ enum Cmd {
|
|||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
cmd: KvCmd,
|
cmd: KvCmd,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Reconcile the live app to a `picloud.toml` manifest in one
|
|
||||||
/// transaction (additive: creates + updates).
|
|
||||||
Apply(ApplyArgs),
|
|
||||||
|
|
||||||
/// Diff a `picloud.toml` manifest against the live app and print the
|
|
||||||
/// changes (create / update / no-op / delete). Read-only.
|
|
||||||
Plan(PlanArgs),
|
|
||||||
|
|
||||||
/// Export an app's current server state into a `picloud.toml` manifest
|
|
||||||
/// (+ `scripts/<name>.rhai` sources) for declarative management with
|
|
||||||
/// `pic plan` / `pic apply`.
|
|
||||||
Pull(PullArgs),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Args)]
|
|
||||||
struct ApplyArgs {
|
|
||||||
/// Path to the manifest.
|
|
||||||
#[arg(long, default_value = "picloud.toml")]
|
|
||||||
file: PathBuf,
|
|
||||||
/// Delete live scripts/routes/triggers absent from the manifest.
|
|
||||||
/// Secrets are never pruned.
|
|
||||||
#[arg(long)]
|
|
||||||
prune: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Args)]
|
|
||||||
struct PlanArgs {
|
|
||||||
/// Path to the manifest.
|
|
||||||
#[arg(long, default_value = "picloud.toml")]
|
|
||||||
file: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Args)]
|
|
||||||
struct PullArgs {
|
|
||||||
/// App slug or id to export.
|
|
||||||
app: String,
|
|
||||||
/// Directory to write `picloud.toml` + `scripts/` into.
|
|
||||||
#[arg(long, default_value = ".")]
|
|
||||||
dir: PathBuf,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
@@ -1086,9 +1045,6 @@ async fn main() -> ExitCode {
|
|||||||
}
|
}
|
||||||
Cmd::Logout => cmds::logout::run().await,
|
Cmd::Logout => cmds::logout::run().await,
|
||||||
Cmd::Whoami => cmds::whoami::run(mode).await,
|
Cmd::Whoami => cmds::whoami::run(mode).await,
|
||||||
Cmd::Apply(args) => cmds::apply::run(&args.file, args.prune, mode).await,
|
|
||||||
Cmd::Plan(args) => cmds::plan::run(&args.file, mode).await,
|
|
||||||
Cmd::Pull(args) => cmds::pull::run(&args.app, &args.dir, mode).await,
|
|
||||||
Cmd::Apps { cmd: AppsCmd::Ls } => cmds::apps::ls(mode).await,
|
Cmd::Apps { cmd: AppsCmd::Ls } => cmds::apps::ls(mode).await,
|
||||||
Cmd::Apps {
|
Cmd::Apps {
|
||||||
cmd:
|
cmd:
|
||||||
|
|||||||
@@ -1,362 +0,0 @@
|
|||||||
//! Declarative project manifest (`picloud.toml`).
|
|
||||||
//!
|
|
||||||
//! One manifest describes the desired state of a **single app** — its
|
|
||||||
//! scripts, routes, triggers, and the *names* of the secrets it expects
|
|
||||||
//! (values are pushed out-of-band via `pic secret set`, never committed).
|
|
||||||
//!
|
|
||||||
//! This is the foundation of the declarative project tool (`pic pull` /
|
|
||||||
//! `pic plan` / `pic apply`). The types deliberately reuse `picloud_shared`
|
|
||||||
//! enums (`HostKind`, `PathKind`, `DispatchMode`, `ScriptKind`,
|
|
||||||
//! `ScriptSandbox`, the event-op enums) so the manifest's wire shape stays
|
|
||||||
//! identical to the admin API — the CLI never depends on `manager-core`.
|
|
||||||
//!
|
|
||||||
//! All eight trigger kinds are representable except `dead_letter` (not
|
|
||||||
//! exposed declaratively). `email` triggers carry an `inbound_secret_ref`
|
|
||||||
//! (a secret name) resolved server-side at apply.
|
|
||||||
|
|
||||||
use std::fs;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use picloud_shared::{
|
|
||||||
DispatchMode, DocsEventOp, FilesEventOp, HostKind, KvEventOp, PathKind, ScriptKind,
|
|
||||||
ScriptSandbox,
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Conventional manifest filename at a project root.
|
|
||||||
pub const MANIFEST_FILE: &str = "picloud.toml";
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct Manifest {
|
|
||||||
pub app: ManifestApp,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub scripts: Vec<ManifestScript>,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub routes: Vec<ManifestRoute>,
|
|
||||||
#[serde(default, skip_serializing_if = "ManifestTriggers::is_empty")]
|
|
||||||
pub triggers: ManifestTriggers,
|
|
||||||
#[serde(default, skip_serializing_if = "ManifestSecrets::is_empty")]
|
|
||||||
pub secrets: ManifestSecrets,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Manifest {
|
|
||||||
/// Parse a manifest from TOML text.
|
|
||||||
pub fn parse(text: &str) -> Result<Self> {
|
|
||||||
toml::from_str(text).context("parsing manifest TOML")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load and parse the manifest at `path`.
|
|
||||||
pub fn load(path: &Path) -> Result<Self> {
|
|
||||||
let body =
|
|
||||||
fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
|
|
||||||
Self::parse(&body)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render to TOML text. Tables are emitted after scalars (the struct
|
|
||||||
/// field order already satisfies TOML's "values before tables" rule).
|
|
||||||
pub fn to_toml(&self) -> Result<String> {
|
|
||||||
toml::to_string_pretty(self).context("serializing manifest TOML")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct ManifestApp {
|
|
||||||
pub slug: String,
|
|
||||||
pub name: String,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub description: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct ManifestScript {
|
|
||||||
pub name: String,
|
|
||||||
/// Path to the `.rhai` source, relative to the manifest's directory.
|
|
||||||
pub file: String,
|
|
||||||
#[serde(default, skip_serializing_if = "is_endpoint")]
|
|
||||||
pub kind: ScriptKind,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub description: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub timeout_seconds: Option<i32>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub memory_limit_mb: Option<i32>,
|
|
||||||
/// Per-script sandbox overrides; omitted entirely when no knob is set.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub sandbox: Option<ScriptSandbox>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct ManifestRoute {
|
|
||||||
/// Name of the script this route binds to.
|
|
||||||
pub script: String,
|
|
||||||
/// HTTP method; omit for ANY.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub method: Option<String>,
|
|
||||||
pub host_kind: HostKind,
|
|
||||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|
||||||
pub host: String,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub host_param_name: Option<String>,
|
|
||||||
pub path_kind: PathKind,
|
|
||||||
pub path: String,
|
|
||||||
#[serde(default, skip_serializing_if = "is_sync")]
|
|
||||||
pub dispatch_mode: DispatchMode,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …).
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct ManifestTriggers {
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub kv: Vec<KvTriggerSpec>,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub docs: Vec<DocsTriggerSpec>,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub files: Vec<FilesTriggerSpec>,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub cron: Vec<CronTriggerSpec>,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub pubsub: Vec<PubsubTriggerSpec>,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub email: Vec<EmailTriggerSpec>,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub queue: Vec<QueueTriggerSpec>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ManifestTriggers {
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.kv.is_empty()
|
|
||||||
&& self.docs.is_empty()
|
|
||||||
&& self.files.is_empty()
|
|
||||||
&& self.cron.is_empty()
|
|
||||||
&& self.pubsub.is_empty()
|
|
||||||
&& self.email.is_empty()
|
|
||||||
&& self.queue.is_empty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct KvTriggerSpec {
|
|
||||||
pub script: String,
|
|
||||||
pub collection_glob: String,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub ops: Vec<KvEventOp>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub dispatch_mode: Option<DispatchMode>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub retry_max_attempts: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct DocsTriggerSpec {
|
|
||||||
pub script: String,
|
|
||||||
pub collection_glob: String,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub ops: Vec<DocsEventOp>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub dispatch_mode: Option<DispatchMode>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub retry_max_attempts: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct FilesTriggerSpec {
|
|
||||||
pub script: String,
|
|
||||||
pub collection_glob: String,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub ops: Vec<FilesEventOp>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub dispatch_mode: Option<DispatchMode>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub retry_max_attempts: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct CronTriggerSpec {
|
|
||||||
pub script: String,
|
|
||||||
/// 6-field cron expression (with seconds).
|
|
||||||
pub schedule: String,
|
|
||||||
#[serde(default = "default_timezone")]
|
|
||||||
pub timezone: String,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub dispatch_mode: Option<DispatchMode>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub retry_max_attempts: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct PubsubTriggerSpec {
|
|
||||||
pub script: String,
|
|
||||||
pub topic_pattern: String,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub dispatch_mode: Option<DispatchMode>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub retry_max_attempts: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct EmailTriggerSpec {
|
|
||||||
pub script: String,
|
|
||||||
/// Name of the secret (set via `pic secret set`) holding the inbound
|
|
||||||
/// HMAC value — resolved + sealed server-side at apply. Never the value.
|
|
||||||
pub inbound_secret_ref: String,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub dispatch_mode: Option<DispatchMode>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub retry_max_attempts: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct QueueTriggerSpec {
|
|
||||||
pub script: String,
|
|
||||||
pub queue_name: String,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub visibility_timeout_secs: Option<u32>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub dispatch_mode: Option<DispatchMode>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub retry_max_attempts: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `[secrets] names = [...]` — declares which secrets the app expects.
|
|
||||||
/// Values are never in the manifest; `pic secret set` pushes them.
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct ManifestSecrets {
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub names: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ManifestSecrets {
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.names.is_empty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- serde skip/default helpers ----
|
|
||||||
|
|
||||||
fn is_endpoint(kind: &ScriptKind) -> bool {
|
|
||||||
*kind == ScriptKind::Endpoint
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_sync(mode: &DispatchMode) -> bool {
|
|
||||||
*mode == DispatchMode::Sync
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_timezone() -> String {
|
|
||||||
"UTC".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn sample() -> Manifest {
|
|
||||||
Manifest {
|
|
||||||
app: ManifestApp {
|
|
||||||
slug: "blog".into(),
|
|
||||||
name: "My Blog".into(),
|
|
||||||
description: Some("demo".into()),
|
|
||||||
},
|
|
||||||
scripts: vec![
|
|
||||||
ManifestScript {
|
|
||||||
name: "create-post".into(),
|
|
||||||
file: "scripts/create-post.rhai".into(),
|
|
||||||
kind: ScriptKind::Endpoint,
|
|
||||||
description: None,
|
|
||||||
timeout_seconds: Some(10),
|
|
||||||
memory_limit_mb: Some(256),
|
|
||||||
sandbox: None,
|
|
||||||
},
|
|
||||||
ManifestScript {
|
|
||||||
name: "lib".into(),
|
|
||||||
file: "scripts/lib.rhai".into(),
|
|
||||||
kind: ScriptKind::Module,
|
|
||||||
description: None,
|
|
||||||
timeout_seconds: None,
|
|
||||||
memory_limit_mb: None,
|
|
||||||
sandbox: Some(ScriptSandbox {
|
|
||||||
max_operations: Some(5_000_000),
|
|
||||||
..ScriptSandbox::empty()
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
routes: vec![ManifestRoute {
|
|
||||||
script: "create-post".into(),
|
|
||||||
method: Some("POST".into()),
|
|
||||||
host_kind: HostKind::Any,
|
|
||||||
host: String::new(),
|
|
||||||
host_param_name: None,
|
|
||||||
path_kind: PathKind::Exact,
|
|
||||||
path: "/posts".into(),
|
|
||||||
dispatch_mode: DispatchMode::Sync,
|
|
||||||
}],
|
|
||||||
triggers: ManifestTriggers {
|
|
||||||
cron: vec![CronTriggerSpec {
|
|
||||||
script: "create-post".into(),
|
|
||||||
schedule: "0 6 * * * *".into(),
|
|
||||||
timezone: "UTC".into(),
|
|
||||||
dispatch_mode: None,
|
|
||||||
retry_max_attempts: None,
|
|
||||||
}],
|
|
||||||
kv: vec![KvTriggerSpec {
|
|
||||||
script: "create-post".into(),
|
|
||||||
collection_glob: "users".into(),
|
|
||||||
ops: vec![KvEventOp::Insert, KvEventOp::Update],
|
|
||||||
dispatch_mode: Some(DispatchMode::Async),
|
|
||||||
retry_max_attempts: Some(5),
|
|
||||||
}],
|
|
||||||
..ManifestTriggers::default()
|
|
||||||
},
|
|
||||||
secrets: ManifestSecrets {
|
|
||||||
names: vec!["STRIPE_KEY".into()],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn round_trips_through_toml() {
|
|
||||||
let m = sample();
|
|
||||||
let text = m.to_toml().expect("serialize");
|
|
||||||
let back = Manifest::parse(&text).expect("parse");
|
|
||||||
assert_eq!(m, back, "manifest must survive a TOML round-trip");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn omits_defaulted_fields() {
|
|
||||||
let text = sample().to_toml().unwrap();
|
|
||||||
// Endpoint kind + sync dispatch are defaults → not emitted.
|
|
||||||
assert!(
|
|
||||||
!text.contains("kind = \"endpoint\""),
|
|
||||||
"default kind should be omitted:\n{text}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!text.contains("dispatch_mode = \"sync\""),
|
|
||||||
"default route dispatch should be omitted:\n{text}"
|
|
||||||
);
|
|
||||||
// Module kind IS non-default → emitted.
|
|
||||||
assert!(text.contains("kind = \"module\""), "got:\n{text}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_optional_sections_omitted() {
|
|
||||||
let m = Manifest {
|
|
||||||
app: ManifestApp {
|
|
||||||
slug: "x".into(),
|
|
||||||
name: "X".into(),
|
|
||||||
description: None,
|
|
||||||
},
|
|
||||||
scripts: vec![],
|
|
||||||
routes: vec![],
|
|
||||||
triggers: ManifestTriggers::default(),
|
|
||||||
secrets: ManifestSecrets::default(),
|
|
||||||
};
|
|
||||||
let text = m.to_toml().unwrap();
|
|
||||||
assert!(!text.contains("[[scripts]]"), "got:\n{text}");
|
|
||||||
assert!(!text.contains("triggers"), "got:\n{text}");
|
|
||||||
assert!(!text.contains("secrets"), "got:\n{text}");
|
|
||||||
// Still round-trips.
|
|
||||||
assert_eq!(m, Manifest::parse(&text).unwrap());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
//! `pic apply` journey: apply a manifest to an empty app (atomic create),
|
|
||||||
//! re-apply is an idempotent no-op, and a bundle containing any invalid
|
|
||||||
//! resource applies nothing (all-or-nothing).
|
|
||||||
|
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
use crate::common;
|
|
||||||
use crate::common::cleanup::AppGuard;
|
|
||||||
|
|
||||||
fn manifest_dir() -> TempDir {
|
|
||||||
let dir = TempDir::new().expect("tempdir");
|
|
||||||
fs::create_dir_all(dir.path().join("scripts")).expect("scripts dir");
|
|
||||||
dir
|
|
||||||
}
|
|
||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
||||||
#[test]
|
|
||||||
fn apply_creates_then_noop() {
|
|
||||||
let Some(fx) = common::fixture_or_skip() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let env = common::admin_env(fx);
|
|
||||||
let slug = common::unique_slug("apply");
|
|
||||||
common::pic_as(&env)
|
|
||||||
.args(["apps", "create", &slug])
|
|
||||||
.assert()
|
|
||||||
.success();
|
|
||||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
|
||||||
|
|
||||||
let dir = manifest_dir();
|
|
||||||
fs::write(
|
|
||||||
dir.path().join("scripts/greet.rhai"),
|
|
||||||
"let body = #{ ok: true }; body",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let manifest = format!(
|
|
||||||
"[app]\nslug = \"{slug}\"\nname = \"Apply Test\"\n\n\
|
|
||||||
[[scripts]]\nname = \"greet\"\nfile = \"scripts/greet.rhai\"\n\n\
|
|
||||||
[[routes]]\nscript = \"greet\"\nmethod = \"POST\"\n\
|
|
||||||
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/greet\"\n\n\
|
|
||||||
[[triggers.cron]]\nscript = \"greet\"\nschedule = \"0 0 * * * *\"\ntimezone = \"UTC\"\n"
|
|
||||||
);
|
|
||||||
let manifest_path = dir.path().join("picloud.toml");
|
|
||||||
fs::write(&manifest_path, &manifest).unwrap();
|
|
||||||
|
|
||||||
// First apply: creates script + route + trigger.
|
|
||||||
let out = common::pic_as(&env)
|
|
||||||
.args(["apply", "--file"])
|
|
||||||
.arg(&manifest_path)
|
|
||||||
.output()
|
|
||||||
.expect("apply");
|
|
||||||
assert!(
|
|
||||||
out.status.success(),
|
|
||||||
"apply failed: {}",
|
|
||||||
String::from_utf8_lossy(&out.stderr)
|
|
||||||
);
|
|
||||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
|
||||||
assert!(
|
|
||||||
stdout.contains("+1"),
|
|
||||||
"expected creations in report:\n{stdout}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// The resources now exist.
|
|
||||||
let s = String::from_utf8(
|
|
||||||
common::pic_as(&env)
|
|
||||||
.args(["scripts", "ls", "--app", &slug])
|
|
||||||
.output()
|
|
||||||
.unwrap()
|
|
||||||
.stdout,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(s.contains("greet"), "script not created:\n{s}");
|
|
||||||
|
|
||||||
// Plan is now clean (apply reached desired state).
|
|
||||||
let p = String::from_utf8(
|
|
||||||
common::pic_as(&env)
|
|
||||||
.args(["plan", "--file"])
|
|
||||||
.arg(&manifest_path)
|
|
||||||
.output()
|
|
||||||
.unwrap()
|
|
||||||
.stdout,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
!p.contains("create") && !p.contains("update"),
|
|
||||||
"expected clean plan after apply:\n{p}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Re-apply: idempotent — nothing created/updated.
|
|
||||||
let r = String::from_utf8(
|
|
||||||
common::pic_as(&env)
|
|
||||||
.args(["apply", "--file"])
|
|
||||||
.arg(&manifest_path)
|
|
||||||
.output()
|
|
||||||
.unwrap()
|
|
||||||
.stdout,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(!r.contains("+1"), "re-apply should be a no-op:\n{r}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
||||||
#[test]
|
|
||||||
fn apply_rejects_bad_bundle_atomically() {
|
|
||||||
let Some(fx) = common::fixture_or_skip() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let env = common::admin_env(fx);
|
|
||||||
let slug = common::unique_slug("apply-atomic");
|
|
||||||
common::pic_as(&env)
|
|
||||||
.args(["apps", "create", &slug])
|
|
||||||
.assert()
|
|
||||||
.success();
|
|
||||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
|
||||||
|
|
||||||
let dir = manifest_dir();
|
|
||||||
fs::write(dir.path().join("scripts/good.rhai"), "let x = 1; x").unwrap();
|
|
||||||
// Invalid Rhai — fails validation, so the whole apply must abort.
|
|
||||||
fs::write(dir.path().join("scripts/bad.rhai"), "let x = ;").unwrap();
|
|
||||||
let manifest = format!(
|
|
||||||
"[app]\nslug = \"{slug}\"\nname = \"Atomic Test\"\n\n\
|
|
||||||
[[scripts]]\nname = \"good\"\nfile = \"scripts/good.rhai\"\n\n\
|
|
||||||
[[scripts]]\nname = \"bad\"\nfile = \"scripts/bad.rhai\"\n"
|
|
||||||
);
|
|
||||||
let manifest_path = dir.path().join("picloud.toml");
|
|
||||||
fs::write(&manifest_path, &manifest).unwrap();
|
|
||||||
|
|
||||||
let out = common::pic_as(&env)
|
|
||||||
.args(["apply", "--file"])
|
|
||||||
.arg(&manifest_path)
|
|
||||||
.output()
|
|
||||||
.expect("apply");
|
|
||||||
assert!(
|
|
||||||
!out.status.success(),
|
|
||||||
"apply with an invalid script should fail"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Atomic: the valid script must NOT have been created.
|
|
||||||
let s = String::from_utf8(
|
|
||||||
common::pic_as(&env)
|
|
||||||
.args(["scripts", "ls", "--app", &slug])
|
|
||||||
.output()
|
|
||||||
.unwrap()
|
|
||||||
.stdout,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
!s.contains("good"),
|
|
||||||
"a failed apply must leave nothing behind:\n{s}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -15,17 +15,12 @@ mod common;
|
|||||||
|
|
||||||
mod admins;
|
mod admins;
|
||||||
mod api_keys;
|
mod api_keys;
|
||||||
mod apply;
|
|
||||||
mod apps;
|
mod apps;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod dead_letters;
|
mod dead_letters;
|
||||||
mod email_queue;
|
|
||||||
mod invoke;
|
mod invoke;
|
||||||
mod logs;
|
mod logs;
|
||||||
mod output;
|
mod output;
|
||||||
mod plan;
|
|
||||||
mod prune;
|
|
||||||
mod pull;
|
|
||||||
mod roles;
|
mod roles;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod scripts;
|
mod scripts;
|
||||||
|
|||||||
@@ -1,222 +0,0 @@
|
|||||||
//! M5: `pic apply` creates email + queue triggers. The email trigger's
|
|
||||||
//! inbound secret is referenced by name (pushed via `pic secret set`) and
|
|
||||||
//! resolved + re-sealed server-side — never written into the manifest.
|
|
||||||
|
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
use crate::common;
|
|
||||||
use crate::common::cleanup::AppGuard;
|
|
||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
||||||
#[test]
|
|
||||||
fn apply_email_and_queue_triggers() {
|
|
||||||
let Some(fx) = common::fixture_or_skip() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let env = common::admin_env(fx);
|
|
||||||
let slug = common::unique_slug("m5");
|
|
||||||
common::pic_as(&env)
|
|
||||||
.args(["apps", "create", &slug])
|
|
||||||
.assert()
|
|
||||||
.success();
|
|
||||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
|
||||||
|
|
||||||
// The email trigger references this secret by name; push its value
|
|
||||||
// out-of-band first.
|
|
||||||
common::pic_as(&env)
|
|
||||||
.args(["secrets", "set", "--app", &slug, "email-hmac"])
|
|
||||||
.write_stdin("super-secret-hmac")
|
|
||||||
.assert()
|
|
||||||
.success();
|
|
||||||
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
|
||||||
fs::write(dir.path().join("scripts/handler.rhai"), "let x = 1; x").unwrap();
|
|
||||||
let manifest = format!(
|
|
||||||
"[app]\nslug = \"{slug}\"\nname = \"M5\"\n\n\
|
|
||||||
[secrets]\nnames = [\"email-hmac\"]\n\n\
|
|
||||||
[[scripts]]\nname = \"handler\"\nfile = \"scripts/handler.rhai\"\n\n\
|
|
||||||
[[triggers.queue]]\nscript = \"handler\"\nqueue_name = \"jobs\"\n\n\
|
|
||||||
[[triggers.email]]\nscript = \"handler\"\ninbound_secret_ref = \"email-hmac\"\n"
|
|
||||||
);
|
|
||||||
let manifest_path = dir.path().join("picloud.toml");
|
|
||||||
fs::write(&manifest_path, &manifest).unwrap();
|
|
||||||
|
|
||||||
let out = common::pic_as(&env)
|
|
||||||
.args(["apply", "--file"])
|
|
||||||
.arg(&manifest_path)
|
|
||||||
.output()
|
|
||||||
.expect("apply");
|
|
||||||
assert!(
|
|
||||||
out.status.success(),
|
|
||||||
"apply failed: {}",
|
|
||||||
String::from_utf8_lossy(&out.stderr)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Both triggers exist.
|
|
||||||
let s = String::from_utf8(
|
|
||||||
common::pic_as(&env)
|
|
||||||
.args(["triggers", "ls", "--app", &slug])
|
|
||||||
.output()
|
|
||||||
.unwrap()
|
|
||||||
.stdout,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
s.lines().any(|l| l.contains("queue")),
|
|
||||||
"queue trigger missing:\n{s}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
s.lines().any(|l| l.contains("email")),
|
|
||||||
"email trigger missing:\n{s}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Re-apply is a no-op (both triggers match by identity).
|
|
||||||
let r = String::from_utf8(
|
|
||||||
common::pic_as(&env)
|
|
||||||
.args(["apply", "--file"])
|
|
||||||
.arg(&manifest_path)
|
|
||||||
.output()
|
|
||||||
.unwrap()
|
|
||||||
.stdout,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(!r.contains("+1"), "re-apply should be a no-op:\n{r}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
||||||
#[test]
|
|
||||||
fn prune_refuses_to_orphan_email_trigger() {
|
|
||||||
// `pull` can't represent email triggers, so a manifest that omits the
|
|
||||||
// script owning one would, under `--prune`, cascade-delete the trigger
|
|
||||||
// (and its sealed secret) when the script is dropped. Apply must refuse.
|
|
||||||
let Some(fx) = common::fixture_or_skip() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let env = common::admin_env(fx);
|
|
||||||
let slug = common::unique_slug("m5-orphan");
|
|
||||||
common::pic_as(&env)
|
|
||||||
.args(["apps", "create", &slug])
|
|
||||||
.assert()
|
|
||||||
.success();
|
|
||||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
|
||||||
|
|
||||||
common::pic_as(&env)
|
|
||||||
.args(["secrets", "set", "--app", &slug, "email-hmac"])
|
|
||||||
.write_stdin("super-secret-hmac")
|
|
||||||
.assert()
|
|
||||||
.success();
|
|
||||||
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
|
||||||
fs::write(dir.path().join("scripts/handler.rhai"), "let x = 1; x").unwrap();
|
|
||||||
let manifest_path = dir.path().join("picloud.toml");
|
|
||||||
|
|
||||||
// v1: a script with an email trigger.
|
|
||||||
let v1 = format!(
|
|
||||||
"[app]\nslug = \"{slug}\"\nname = \"M5\"\n\n\
|
|
||||||
[secrets]\nnames = [\"email-hmac\"]\n\n\
|
|
||||||
[[scripts]]\nname = \"handler\"\nfile = \"scripts/handler.rhai\"\n\n\
|
|
||||||
[[triggers.email]]\nscript = \"handler\"\ninbound_secret_ref = \"email-hmac\"\n"
|
|
||||||
);
|
|
||||||
fs::write(&manifest_path, &v1).unwrap();
|
|
||||||
common::pic_as(&env)
|
|
||||||
.args(["apply", "--file"])
|
|
||||||
.arg(&manifest_path)
|
|
||||||
.assert()
|
|
||||||
.success();
|
|
||||||
|
|
||||||
// v2: drop the script (and, implicitly, its un-representable email
|
|
||||||
// trigger). A prune apply must REFUSE rather than cascade-destroy it.
|
|
||||||
let v2 = format!("[app]\nslug = \"{slug}\"\nname = \"M5\"\n");
|
|
||||||
fs::write(&manifest_path, &v2).unwrap();
|
|
||||||
let out = common::pic_as(&env)
|
|
||||||
.args(["apply", "--file"])
|
|
||||||
.arg(&manifest_path)
|
|
||||||
.arg("--prune")
|
|
||||||
.output()
|
|
||||||
.expect("apply --prune");
|
|
||||||
assert!(
|
|
||||||
!out.status.success(),
|
|
||||||
"prune must refuse to orphan an email trigger"
|
|
||||||
);
|
|
||||||
|
|
||||||
// The script and its email trigger both survive the refused apply.
|
|
||||||
let scripts = String::from_utf8(
|
|
||||||
common::pic_as(&env)
|
|
||||||
.args(["scripts", "ls", "--app", &slug])
|
|
||||||
.output()
|
|
||||||
.unwrap()
|
|
||||||
.stdout,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
scripts.contains("handler"),
|
|
||||||
"script must survive:\n{scripts}"
|
|
||||||
);
|
|
||||||
let triggers = String::from_utf8(
|
|
||||||
common::pic_as(&env)
|
|
||||||
.args(["triggers", "ls", "--app", &slug])
|
|
||||||
.output()
|
|
||||||
.unwrap()
|
|
||||||
.stdout,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
triggers.lines().any(|l| l.contains("email")),
|
|
||||||
"email trigger must survive:\n{triggers}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
||||||
#[test]
|
|
||||||
fn apply_email_unset_secret_fails() {
|
|
||||||
let Some(fx) = common::fixture_or_skip() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let env = common::admin_env(fx);
|
|
||||||
let slug = common::unique_slug("m5-nosecret");
|
|
||||||
common::pic_as(&env)
|
|
||||||
.args(["apps", "create", &slug])
|
|
||||||
.assert()
|
|
||||||
.success();
|
|
||||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
|
||||||
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
|
||||||
fs::write(dir.path().join("scripts/handler.rhai"), "let x = 1; x").unwrap();
|
|
||||||
let manifest = format!(
|
|
||||||
"[app]\nslug = \"{slug}\"\nname = \"M5\"\n\n\
|
|
||||||
[[scripts]]\nname = \"handler\"\nfile = \"scripts/handler.rhai\"\n\n\
|
|
||||||
[[triggers.email]]\nscript = \"handler\"\ninbound_secret_ref = \"never-set\"\n"
|
|
||||||
);
|
|
||||||
let manifest_path = dir.path().join("picloud.toml");
|
|
||||||
fs::write(&manifest_path, &manifest).unwrap();
|
|
||||||
|
|
||||||
// The referenced secret was never set → apply must fail atomically.
|
|
||||||
let out = common::pic_as(&env)
|
|
||||||
.args(["apply", "--file"])
|
|
||||||
.arg(&manifest_path)
|
|
||||||
.output()
|
|
||||||
.expect("apply");
|
|
||||||
assert!(
|
|
||||||
!out.status.success(),
|
|
||||||
"apply must fail when an email secret is unset"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Atomic: neither the script nor the email trigger was created.
|
|
||||||
let s = String::from_utf8(
|
|
||||||
common::pic_as(&env)
|
|
||||||
.args(["scripts", "ls", "--app", &slug])
|
|
||||||
.output()
|
|
||||||
.unwrap()
|
|
||||||
.stdout,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
!s.contains("handler"),
|
|
||||||
"failed apply must leave nothing behind:\n{s}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
//! `pic plan` journey: a freshly-pulled manifest must diff to all-no-op
|
|
||||||
//! (pull→plan is idempotent), and editing a script source must surface
|
|
||||||
//! as an update.
|
|
||||||
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
use crate::common;
|
|
||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
||||||
#[test]
|
|
||||||
fn plan_roundtrips_then_detects_change() {
|
|
||||||
let Some(fx) = common::fixture_or_skip() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let env = common::admin_env(fx);
|
|
||||||
let (script_id, guard) = common::deploy_fixture(&env, "plan", "hello.rhai");
|
|
||||||
let app = guard.slug().to_string();
|
|
||||||
|
|
||||||
common::pic_as(&env)
|
|
||||||
.args([
|
|
||||||
"routes", "create", "--script", &script_id, "--path", "/p", "--method", "GET",
|
|
||||||
])
|
|
||||||
.assert()
|
|
||||||
.success();
|
|
||||||
|
|
||||||
// Pull the live state, then plan it back — must be a clean no-op.
|
|
||||||
let dir = TempDir::new().expect("tempdir");
|
|
||||||
common::pic_as(&env)
|
|
||||||
.args(["pull", &app, "--dir"])
|
|
||||||
.arg(dir.path())
|
|
||||||
.assert()
|
|
||||||
.success();
|
|
||||||
let manifest = dir.path().join("picloud.toml");
|
|
||||||
|
|
||||||
let out = common::pic_as(&env)
|
|
||||||
.args(["plan", "--file"])
|
|
||||||
.arg(&manifest)
|
|
||||||
.output()
|
|
||||||
.expect("plan");
|
|
||||||
assert!(
|
|
||||||
out.status.success(),
|
|
||||||
"plan failed: {}",
|
|
||||||
String::from_utf8_lossy(&out.stderr)
|
|
||||||
);
|
|
||||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
|
||||||
let hello = stdout
|
|
||||||
.lines()
|
|
||||||
.find(|l| l.contains("hello"))
|
|
||||||
.unwrap_or_else(|| panic!("no hello row in plan:\n{stdout}"));
|
|
||||||
assert!(
|
|
||||||
hello.contains("noop"),
|
|
||||||
"expected hello no-op, got:\n{stdout}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!stdout.contains("create") && !stdout.contains("delete"),
|
|
||||||
"fresh pull should diff clean, got:\n{stdout}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Edit the script source on disk → plan must report an update.
|
|
||||||
std::fs::write(
|
|
||||||
dir.path().join("scripts/hello.rhai"),
|
|
||||||
"let body = #{ ok: false }; body",
|
|
||||||
)
|
|
||||||
.expect("rewrite source");
|
|
||||||
let out = common::pic_as(&env)
|
|
||||||
.args(["plan", "--file"])
|
|
||||||
.arg(&manifest)
|
|
||||||
.output()
|
|
||||||
.expect("plan after edit");
|
|
||||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
|
||||||
let hello = stdout
|
|
||||||
.lines()
|
|
||||||
.find(|l| l.contains("hello"))
|
|
||||||
.unwrap_or_else(|| panic!("no hello row in plan:\n{stdout}"));
|
|
||||||
assert!(
|
|
||||||
hello.contains("update"),
|
|
||||||
"expected hello update after source edit, got:\n{stdout}"
|
|
||||||
);
|
|
||||||
|
|
||||||
drop(guard);
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
//! `pic apply --prune` journey: a resource dropped from the manifest
|
|
||||||
//! survives a plain (additive) apply but is deleted with `--prune`.
|
|
||||||
|
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
use crate::common;
|
|
||||||
use crate::common::cleanup::AppGuard;
|
|
||||||
|
|
||||||
fn scripts_ls(env: &common::TestEnv, slug: &str) -> String {
|
|
||||||
String::from_utf8(
|
|
||||||
common::pic_as(env)
|
|
||||||
.args(["scripts", "ls", "--app", slug])
|
|
||||||
.output()
|
|
||||||
.unwrap()
|
|
||||||
.stdout,
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
||||||
#[test]
|
|
||||||
fn prune_deletes_stale_resources() {
|
|
||||||
let Some(fx) = common::fixture_or_skip() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let env = common::admin_env(fx);
|
|
||||||
let slug = common::unique_slug("prune");
|
|
||||||
common::pic_as(&env)
|
|
||||||
.args(["apps", "create", &slug])
|
|
||||||
.assert()
|
|
||||||
.success();
|
|
||||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
|
||||||
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
|
||||||
fs::write(dir.path().join("scripts/keep.rhai"), "let x = 1; x").unwrap();
|
|
||||||
fs::write(dir.path().join("scripts/drop.rhai"), "let y = 2; y").unwrap();
|
|
||||||
let manifest_path = dir.path().join("picloud.toml");
|
|
||||||
|
|
||||||
// v1: two scripts + a route on `drop`.
|
|
||||||
let v1 = format!(
|
|
||||||
"[app]\nslug = \"{slug}\"\nname = \"Prune Test\"\n\n\
|
|
||||||
[[scripts]]\nname = \"keep\"\nfile = \"scripts/keep.rhai\"\n\n\
|
|
||||||
[[scripts]]\nname = \"drop\"\nfile = \"scripts/drop.rhai\"\n\n\
|
|
||||||
[[routes]]\nscript = \"drop\"\nmethod = \"GET\"\n\
|
|
||||||
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/drop\"\n"
|
|
||||||
);
|
|
||||||
fs::write(&manifest_path, &v1).unwrap();
|
|
||||||
common::pic_as(&env)
|
|
||||||
.args(["apply", "--file"])
|
|
||||||
.arg(&manifest_path)
|
|
||||||
.assert()
|
|
||||||
.success();
|
|
||||||
|
|
||||||
// v2: drop `drop` and its route.
|
|
||||||
let v2 = format!(
|
|
||||||
"[app]\nslug = \"{slug}\"\nname = \"Prune Test\"\n\n\
|
|
||||||
[[scripts]]\nname = \"keep\"\nfile = \"scripts/keep.rhai\"\n"
|
|
||||||
);
|
|
||||||
fs::write(&manifest_path, &v2).unwrap();
|
|
||||||
|
|
||||||
// Plain apply is additive — `drop` survives.
|
|
||||||
common::pic_as(&env)
|
|
||||||
.args(["apply", "--file"])
|
|
||||||
.arg(&manifest_path)
|
|
||||||
.assert()
|
|
||||||
.success();
|
|
||||||
assert!(
|
|
||||||
scripts_ls(&env, &slug).contains("drop"),
|
|
||||||
"additive apply must not delete"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Prune apply removes `drop` and its route.
|
|
||||||
let out = common::pic_as(&env)
|
|
||||||
.args(["apply", "--file"])
|
|
||||||
.arg(&manifest_path)
|
|
||||||
.arg("--prune")
|
|
||||||
.output()
|
|
||||||
.expect("apply --prune");
|
|
||||||
assert!(
|
|
||||||
out.status.success(),
|
|
||||||
"prune failed: {}",
|
|
||||||
String::from_utf8_lossy(&out.stderr)
|
|
||||||
);
|
|
||||||
let report = String::from_utf8(out.stdout).unwrap();
|
|
||||||
assert!(
|
|
||||||
report.contains("-1"),
|
|
||||||
"expected deletions in report:\n{report}"
|
|
||||||
);
|
|
||||||
|
|
||||||
let s = scripts_ls(&env, &slug);
|
|
||||||
assert!(!s.contains("drop"), "prune should delete `drop`:\n{s}");
|
|
||||||
assert!(s.contains("keep"), "prune must keep `keep`:\n{s}");
|
|
||||||
|
|
||||||
// Plan is clean after prune.
|
|
||||||
let p = String::from_utf8(
|
|
||||||
common::pic_as(&env)
|
|
||||||
.args(["plan", "--file"])
|
|
||||||
.arg(&manifest_path)
|
|
||||||
.output()
|
|
||||||
.unwrap()
|
|
||||||
.stdout,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
!p.contains("delete"),
|
|
||||||
"plan should be clean after prune:\n{p}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
//! `pic pull` journey: stand up an app with a script, route, cron trigger,
|
|
||||||
//! and a secret, then export it and assert the manifest + script file.
|
|
||||||
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
use crate::common;
|
|
||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
||||||
#[test]
|
|
||||||
fn pull_exports_manifest_and_sources() {
|
|
||||||
let Some(fx) = common::fixture_or_skip() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let env = common::admin_env(fx);
|
|
||||||
|
|
||||||
// App + script "hello" (deploy derives the name from the file stem).
|
|
||||||
let (script_id, guard) = common::deploy_fixture(&env, "pull", "hello.rhai");
|
|
||||||
let app = guard.slug().to_string();
|
|
||||||
|
|
||||||
// Route → script.
|
|
||||||
common::pic_as(&env)
|
|
||||||
.args([
|
|
||||||
"routes", "create", "--script", &script_id, "--path", "/hook", "--method", "POST",
|
|
||||||
])
|
|
||||||
.assert()
|
|
||||||
.success();
|
|
||||||
|
|
||||||
// Cron trigger → script.
|
|
||||||
common::pic_as(&env)
|
|
||||||
.args([
|
|
||||||
"triggers",
|
|
||||||
"create-cron",
|
|
||||||
"--app",
|
|
||||||
&app,
|
|
||||||
"--script",
|
|
||||||
&script_id,
|
|
||||||
"--schedule",
|
|
||||||
"0 0 * * * *",
|
|
||||||
])
|
|
||||||
.assert()
|
|
||||||
.success();
|
|
||||||
|
|
||||||
// Secret (name only ends up in the manifest; value stays server-side).
|
|
||||||
common::pic_as(&env)
|
|
||||||
.args(["secrets", "set", "--app", &app, "api_key"])
|
|
||||||
.write_stdin("xyzzy")
|
|
||||||
.assert()
|
|
||||||
.success();
|
|
||||||
|
|
||||||
// Pull into a scratch dir.
|
|
||||||
let out_dir = TempDir::new().expect("pull tempdir");
|
|
||||||
common::pic_as(&env)
|
|
||||||
.args(["pull", &app, "--dir"])
|
|
||||||
.arg(out_dir.path())
|
|
||||||
.assert()
|
|
||||||
.success();
|
|
||||||
|
|
||||||
// Manifest exists and captures every resource.
|
|
||||||
let manifest = std::fs::read_to_string(out_dir.path().join("picloud.toml"))
|
|
||||||
.expect("picloud.toml should be written");
|
|
||||||
assert!(
|
|
||||||
manifest.contains(&format!("slug = \"{app}\"")),
|
|
||||||
"manifest missing app slug:\n{manifest}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
manifest.contains("name = \"hello\"") && manifest.contains("scripts/hello.rhai"),
|
|
||||||
"manifest missing script entry:\n{manifest}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
manifest.contains("[[routes]]") && manifest.contains("path = \"/hook\""),
|
|
||||||
"manifest missing route:\n{manifest}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
manifest.contains("[[triggers.cron]]") && manifest.contains("schedule = \"0 0 * * * *\""),
|
|
||||||
"manifest missing cron trigger:\n{manifest}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
manifest.contains("api_key"),
|
|
||||||
"manifest missing secret name:\n{manifest}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Script source was written out faithfully.
|
|
||||||
let src = std::fs::read_to_string(out_dir.path().join("scripts/hello.rhai"))
|
|
||||||
.expect("scripts/hello.rhai should be written");
|
|
||||||
assert!(
|
|
||||||
src.contains("hello from pic"),
|
|
||||||
"exported source mismatch:\n{src}"
|
|
||||||
);
|
|
||||||
|
|
||||||
drop(guard);
|
|
||||||
}
|
|
||||||
@@ -10,13 +10,13 @@ use axum::middleware::from_fn_with_state;
|
|||||||
use axum::{routing::get, Json, Router};
|
use axum::{routing::get, Json, Router};
|
||||||
use picloud_executor_core::{Engine, Limits};
|
use picloud_executor_core::{Engine, Limits};
|
||||||
use picloud_manager_core::{
|
use picloud_manager_core::{
|
||||||
admin_router, admins_router, api_keys_router, app_members_router, apply_router, apps_api,
|
admin_router, admins_router, api_keys_router, app_members_router, apps_api, apps_router,
|
||||||
apps_router, attach_principal_if_present, auth_router, compile_routes, dead_letters_router,
|
attach_principal_if_present, auth_router, compile_routes, dead_letters_router,
|
||||||
dev_emails_router, email_inbound_router, files_admin_router, kv_admin_router, migrations,
|
dev_emails_router, email_inbound_router, files_admin_router, kv_admin_router, migrations,
|
||||||
require_authenticated, route_admin_router, secrets_router, topics_router, triggers_router,
|
require_authenticated, route_admin_router, secrets_router, topics_router, triggers_router,
|
||||||
AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState, AdminUserRepository,
|
AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState, AdminUserRepository,
|
||||||
AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository,
|
AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository,
|
||||||
AppMembersState, AppRepository, ApplyService, AppsState, AuthState, AuthzRepo, DeadLetterRepo,
|
AppMembersState, AppRepository, AppsState, AuthState, AuthzRepo, DeadLetterRepo,
|
||||||
DeadLettersState, DevEmailState, Dispatcher, DocsServiceImpl, EmailInboundState,
|
DeadLettersState, DevEmailState, Dispatcher, DocsServiceImpl, EmailInboundState,
|
||||||
EmailServiceImpl, FilesAdminState, FilesConfig, FilesServiceImpl, FsFilesRepo, HttpConfig,
|
EmailServiceImpl, FilesAdminState, FilesConfig, FilesServiceImpl, FsFilesRepo, HttpConfig,
|
||||||
HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter,
|
HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter,
|
||||||
@@ -42,8 +42,8 @@ use picloud_orchestrator_core::{
|
|||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
DeadLetterService, DocsService, EmailService, ExecutionLogSink, FilesService, HttpService,
|
DeadLetterService, DocsService, EmailService, ExecutionLogSink, FilesService, HttpService,
|
||||||
InboxResolver, KvService, MasterKey, OutboxWriter, PubsubService, RealtimeAuthority,
|
InboxResolver, KvService, MasterKey, OutboxWriter, PubsubService, RealtimeAuthority,
|
||||||
RealtimeBroadcaster, SecretsService, ServiceEventEmitter, Services, UsersService, API_VERSION,
|
RealtimeBroadcaster, ScriptValidator, SecretsService, ServiceEventEmitter, Services,
|
||||||
PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION,
|
UsersService, API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION,
|
||||||
};
|
};
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
@@ -399,7 +399,7 @@ pub async fn build_app(
|
|||||||
logs: log_repo,
|
logs: log_repo,
|
||||||
apps: apps_repo.clone(),
|
apps: apps_repo.clone(),
|
||||||
authz: authz.clone(),
|
authz: authz.clone(),
|
||||||
validator: engine.clone(),
|
validator: engine as Arc<dyn ScriptValidator>,
|
||||||
sandbox_ceiling: SandboxCeiling::from_env(),
|
sandbox_ceiling: SandboxCeiling::from_env(),
|
||||||
};
|
};
|
||||||
let route_admin = RouteAdminState {
|
let route_admin = RouteAdminState {
|
||||||
@@ -414,7 +414,7 @@ pub async fn build_app(
|
|||||||
resolver,
|
resolver,
|
||||||
log_sink,
|
log_sink,
|
||||||
app_domains: app_domain_table.clone(),
|
app_domains: app_domain_table.clone(),
|
||||||
routes: route_table.clone(),
|
routes: route_table,
|
||||||
inbox: inbox_registry,
|
inbox: inbox_registry,
|
||||||
outbox: outbox_writer,
|
outbox: outbox_writer,
|
||||||
};
|
};
|
||||||
@@ -440,7 +440,7 @@ pub async fn build_app(
|
|||||||
// v1.1.4: cron scheduler. Polls cron_trigger_details on a tick and
|
// v1.1.4: cron scheduler. Polls cron_trigger_details on a tick and
|
||||||
// enqueues due triggers into the outbox; the dispatcher above
|
// enqueues due triggers into the outbox; the dispatcher above
|
||||||
// delivers them like any other async trigger.
|
// delivers them like any other async trigger.
|
||||||
picloud_manager_core::spawn_cron_scheduler(pool.clone(), trigger_config.cron_tick_interval_ms);
|
picloud_manager_core::spawn_cron_scheduler(pool, trigger_config.cron_tick_interval_ms);
|
||||||
// v1.1.6: GC empty realtime broadcast channels (one-shot subscribers)
|
// v1.1.6: GC empty realtime broadcast channels (one-shot subscribers)
|
||||||
// and sweep orphaned `*.tmp.*` blobs left by crashed file writes.
|
// and sweep orphaned `*.tmp.*` blobs left by crashed file writes.
|
||||||
spawn_realtime_gc(broadcaster_concrete, DEFAULT_GC_INTERVAL_SECS);
|
spawn_realtime_gc(broadcaster_concrete, DEFAULT_GC_INTERVAL_SECS);
|
||||||
@@ -453,23 +453,6 @@ pub async fn build_app(
|
|||||||
config: trigger_config,
|
config: trigger_config,
|
||||||
master_key: master_key.clone(),
|
master_key: master_key.clone(),
|
||||||
};
|
};
|
||||||
// Declarative reconcile engine (pic plan / apply). Trait-object repos
|
|
||||||
// for the read/diff path; shares the same handles as the CRUD routers.
|
|
||||||
let apply_service = ApplyService {
|
|
||||||
pool: pool.clone(),
|
|
||||||
scripts: script_repo.clone(),
|
|
||||||
routes: route_repo.clone(),
|
|
||||||
triggers: trigger_repo.clone(),
|
|
||||||
secrets: secrets_repo.clone(),
|
|
||||||
apps: apps_repo.clone(),
|
|
||||||
domains: domains_repo.clone(),
|
|
||||||
authz: authz.clone(),
|
|
||||||
validator: engine.clone(),
|
|
||||||
sandbox_ceiling: SandboxCeiling::from_env(),
|
|
||||||
trigger_config,
|
|
||||||
route_table: route_table.clone(),
|
|
||||||
master_key: master_key.clone(),
|
|
||||||
};
|
|
||||||
// v1.1.9: keep a clone for the queues-api state (built later).
|
// v1.1.9: keep a clone for the queues-api state (built later).
|
||||||
let trigger_repo_for_queues = trigger_repo.clone();
|
let trigger_repo_for_queues = trigger_repo.clone();
|
||||||
// v1.1.7 public inbound-email receiver. Outside the admin auth layer
|
// v1.1.7 public inbound-email receiver. Outside the admin auth layer
|
||||||
@@ -579,7 +562,6 @@ pub async fn build_app(
|
|||||||
))
|
))
|
||||||
.merge(api_keys_router(api_keys_state))
|
.merge(api_keys_router(api_keys_state))
|
||||||
.merge(triggers_router(triggers_state))
|
.merge(triggers_router(triggers_state))
|
||||||
.merge(apply_router(apply_service))
|
|
||||||
.merge(picloud_manager_core::queues_api::queues_router(
|
.merge(picloud_manager_core::queues_api::queues_router(
|
||||||
picloud_manager_core::queues_api::QueuesState {
|
picloud_manager_core::queues_api::QueuesState {
|
||||||
queues: queue_repo.clone(),
|
queues: queue_repo.clone(),
|
||||||
|
|||||||
@@ -1,849 +0,0 @@
|
|||||||
# Groups, Projects & the Declarative Project Tool
|
|
||||||
|
|
||||||
> **Status:** Draft — design discussion captured 2026-06-18, revised 2026-06-20 with resolutions
|
|
||||||
> grounded in precedent (GitLab, Kustomize, Helm, Terraform, Kubernetes Server-Side Apply, Pulumi)
|
|
||||||
> and corrected against the codebase after three independent review passes (consistency, gaps,
|
|
||||||
> feasibility). Not yet scheduled into a phase.
|
|
||||||
>
|
|
||||||
> **Scope:** turns the imperative `pic` CLI into a declarative, file-based project tool, and
|
|
||||||
> introduces a server-side **groups** hierarchy so apps can share and inherit config/scripts
|
|
||||||
> without duplication.
|
|
||||||
>
|
|
||||||
> **Blueprint impact:** this reverses the §11.5 *snapshot-copy, not live-link* stance — top-down
|
|
||||||
> hierarchical inheritance (group → app) is now in scope, implemented as a *materialized, auto-
|
|
||||||
> invalidated* view (§5.1). Fold the outcome back into the blueprint before it drifts.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Motivation
|
|
||||||
|
|
||||||
Today `pic` is **imperative**: every command (`pic deploy`, `pic routes create`, `pic triggers
|
|
||||||
create-cron`, …) maps 1:1 to an admin API call. There is no memory of desired state, no project
|
|
||||||
file, and no way to express "these N apps are the staging/prod/tenant variants of one thing."
|
|
||||||
|
|
||||||
Two gaps follow:
|
|
||||||
|
|
||||||
1. **No declarative project layer.** Developers re-run commands by hand; nothing reconciles a repo
|
|
||||||
to an instance.
|
|
||||||
2. **No server-side notion of a project/group.** If we model environments as separate apps, the
|
|
||||||
*shared* base (config, scripts) is duplicated across every deployed app. The server sees N
|
|
||||||
unrelated apps.
|
|
||||||
|
|
||||||
This document designs both halves: a **declarative manifest + project tool**, and a **groups
|
|
||||||
hierarchy** on the server that the project tool projects onto the filesystem.
|
|
||||||
|
|
||||||
`manager-core` is the single writer to Postgres. We lean on that for two concrete things — an
|
|
||||||
**atomic desired-state write** (one DB transaction per apply, §4.2) and a **server-computed diff**
|
|
||||||
(§4.2) — *not* for continuous reconciliation, which we deliberately decline (drift handling is
|
|
||||||
detect-and-surface, §4.2). This is a narrower and more honest claim than "we can reconcile live
|
|
||||||
state": most comparable tools cannot do an all-or-nothing apply because their effects are
|
|
||||||
un-undoable cloud resources; ours are Postgres rows, so we can — within the boundary described in
|
|
||||||
§4.2.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Vocabulary
|
|
||||||
|
|
||||||
| Term | Meaning |
|
|
||||||
|---|---|
|
|
||||||
| **Group** | Server-side hierarchy node. Single-parent tree. Owns shared **code/config** definitions and members/roles. Nestable. |
|
|
||||||
| **App** | A deployable unit. The data-isolation boundary (`app_id`). Lives under a group. |
|
|
||||||
| **Environment** | A deployment variant (staging/production/…). **An environment *is* an app.** Also a *scope dimension* on config (§3). |
|
|
||||||
| **Tenant** | Modeled like an environment — a scope dimension / overlay axis, **not** a second parent (§5.4). |
|
|
||||||
| **Project** | *Not* a server entity. A CLI view over a repo-managed **subtree** of groups. |
|
|
||||||
| **Definition** | Entities that can be group-owned: **scripts/modules, vars, secret-refs only**. Routes, triggers, collections, topics are **app-scoped** (§3, §5.1). |
|
|
||||||
| **Data** | KV/docs/files collections + pub/sub topics. **Always app-owned** (`app_id`). The isolation boundary never moves. |
|
|
||||||
| **Manifest** | TOML file describing desired state for one node (group base) + per-env overlays. |
|
|
||||||
| **Effective view** | The materialized, per-app resolved set of definitions, served to the runtime and recomputed on any ancestor change (§5.1). |
|
|
||||||
| **Attach point** | Where a local working tree's root binds into the server group tree. Its ceiling — you cannot apply above it. |
|
|
||||||
|
|
||||||
Relationship: **the base + per-env-overlay "project" is the degenerate one-group subtree** of the
|
|
||||||
general groups model. Nesting generalizes it to multi-group subtrees. Nothing about the
|
|
||||||
single-project model is lost.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Configuration resolution
|
|
||||||
|
|
||||||
This is the single rule the whole design hangs off; everything else (env vars, secrets, `enabled`,
|
|
||||||
overrides) resolves through it. **All three precedent systems converge on the same shape, so we
|
|
||||||
adopt it wholesale:**
|
|
||||||
|
|
||||||
> ⚠️ **Net-new, not an extension (verified against the codebase).** PiCloud has **no env-agnostic
|
|
||||||
> config / `vars` layer today** — this resolution engine and the `vars` table are greenfield. Only
|
|
||||||
> `secrets` exists, and as a value-bearing table, not a ref. Read §3 as *new* infrastructure, not a
|
|
||||||
> modification of something present.
|
|
||||||
|
|
||||||
> **Resolution = sparse, per-field merge; environment is a *pre-filter*, not a precedence tier;
|
|
||||||
> proximity wins across levels.**
|
|
||||||
|
|
||||||
To resolve a key for an app in environment `E`:
|
|
||||||
|
|
||||||
1. **Filter by environment first, per level.** A value scoped `@E` is eligible; a value scoped `*`
|
|
||||||
(env-agnostic) is the fallback. At a *single* level, `@E` beats `*`. Environment scope decides
|
|
||||||
*eligibility*, it is **not** a competing precedence rank.
|
|
||||||
*Evidence:* GitLab CI/CD variables use `environment_scope` exactly this way — a `production`-scoped
|
|
||||||
row simply isn't visible to a `staging` job ([GitLab CI/CD variables](https://docs.gitlab.com/ci/variables/)).
|
|
||||||
2. **Then nearest level wins.** Walk up `acme → team-a → blog → app`; the closest level that defines
|
|
||||||
the (filtered) key wins. GitLab states this verbatim: *"if the same variable name exists in a
|
|
||||||
group and its subgroups, the job uses the value from the closest subgroup."*
|
|
||||||
3. **Merge granularity:** maps/vars deep-merge **per key** (set `title`, still inherit `region`);
|
|
||||||
entities (scripts) replace **by identity** (name); deletion is an explicit tombstone.
|
|
||||||
*Evidence:* Kustomize strategic-merge patches are sparse and deep-merge maps but **replace lists
|
|
||||||
unless keyed** ([Kustomize patches](https://kubectl.docs.kubernetes.io/references/kustomize/kustomization/patches/));
|
|
||||||
Helm deep-merges nested maps and uses `null` to delete a defaulted key
|
|
||||||
([Helm values](https://helm.sh/docs/chart_template_guide/values_files/)).
|
|
||||||
|
|
||||||
This one rule resolves several issues at once: it makes **group config environment-scopable** (a
|
|
||||||
group sets `db_url@production` and `db_url@staging`; descendants inherit the right one — no per-leaf
|
|
||||||
duplication), it defines **merge granularity** (maps per-key, entities per-identity), and it makes
|
|
||||||
**`enabled` just another sparse field** (§4.3).
|
|
||||||
|
|
||||||
### 3.1 The env-scope manifest syntax
|
|
||||||
|
|
||||||
```toml
|
|
||||||
# group manifest (e.g. team-a/picloud.toml)
|
|
||||||
[vars]
|
|
||||||
region = "eu" # env-agnostic (scope *)
|
|
||||||
|
|
||||||
[vars.production] # scope @production
|
|
||||||
db_url = "postgres://prod/..."
|
|
||||||
[vars.staging]
|
|
||||||
db_url = "postgres://staging/..."
|
|
||||||
|
|
||||||
[secrets]
|
|
||||||
names = ["stripe_key"] # name-only; values pushed per env via CLI (§4.6)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 The one deliberately-novel precedence call
|
|
||||||
|
|
||||||
With `db_url@production` at the root **and** a plain `db_url` at the leaf, the **leaf's env-agnostic
|
|
||||||
value wins** for a production app — proximity beats farther-level env-specificity. The research was
|
|
||||||
explicit that **no major system lets env-specificity override proximity across levels**; GitLab
|
|
||||||
structurally avoids the question by treating env as a per-level filter (above). So **proximity-first
|
|
||||||
is the evidence-backed default**, and we document it as a chosen rule: *inner scope shadows outer,
|
|
||||||
like lexical scoping.* To avoid this becoming a debugging nightmare at depth, `pic config
|
|
||||||
--effective --explain` must show **why** a value resolved (which level/scope it came from).
|
|
||||||
|
|
||||||
> **Residual risk (carried, not solved):** multi-level + environment scopes is genuinely novel
|
|
||||||
> territory — GitLab is two-level (group→project). Proximity-first at arbitrary depth is consistent
|
|
||||||
> and defensible but not battle-tested. The `--explain` tooling is a hard requirement, not a nicety.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Manifest & apply
|
|
||||||
|
|
||||||
### 4.1 Manifest & layering
|
|
||||||
|
|
||||||
- **TOML, data not code.** PiCloud runs untrusted scripts; the deployment descriptor stays inert,
|
|
||||||
diffable, server-validatable, and dashboard-renderable. Turing-completeness belongs in the Rhai
|
|
||||||
script, never the manifest.
|
|
||||||
- **Base + overlays.** A node's `picloud.toml` holds the shared base; `picloud.<env>.toml` overlays
|
|
||||||
add per-env vars/secrets/slug. Shared config is written once; overlays only carry deltas (the
|
|
||||||
Kustomize base+overlay model — overlays are sparse patches, not replacements).
|
|
||||||
- **Routes are top-level**, referencing scripts by name (one place to see all routing).
|
|
||||||
- **`pull` is first-class** — see §4.6 for its inheritance/masking semantics.
|
|
||||||
|
|
||||||
### 4.2 The apply engine
|
|
||||||
|
|
||||||
The IaC research reshaped this section materially; the relevant precedents are cited inline.
|
|
||||||
|
|
||||||
**Plan/apply split with a bound plan artifact.** `pic plan` computes a reviewable diff
|
|
||||||
(`create / update / delete / replace`), the server persists it as a row, and `apply` executes
|
|
||||||
*exactly that stored plan* — **detecting and refusing if state moved underneath it** (state
|
|
||||||
version/serial check, cheap given the single writer). *Evidence:* Terraform's saved plan
|
|
||||||
*"reliably perform[s] an exact set of pre-approved changes, even if the configuration or state has
|
|
||||||
changed in the minutes since"* ([Terraform run](https://developer.hashicorp.com/terraform/cloud-docs/run)).
|
|
||||||
The "state moved" check covers **both** the per-node **content** version **and** the **tree-structure**
|
|
||||||
version (§6): a reparent or a new app created under the subtree between `plan` and `apply` changes the
|
|
||||||
true blast radius, so the bound plan must refuse if *either* counter moved. Both are **net-new** — the
|
|
||||||
existing `scripts.version` column is an unconditional write counter, **not** a compare-and-set guard,
|
|
||||||
and must not be mistaken for one.
|
|
||||||
|
|
||||||
**Atomicity — the corrected position.** No major IaC tool does transactional apply, *because their
|
|
||||||
effects are un-undoable cloud resources* (you cannot un-create a VM). **That constraint does not
|
|
||||||
bind us:** a PiCloud manifest apply is **almost entirely Postgres row writes** (script source,
|
|
||||||
route/trigger defs, config). Therefore:
|
|
||||||
|
|
||||||
- **The desired-state write is a single DB transaction** — all-or-nothing across the subtree. This
|
|
||||||
is achievable *because* our substrate is one transactional store (unlike Terraform's un-undoable
|
|
||||||
cloud resources). It removes the downstream-breakage hazard of an earlier draft (which proposed
|
|
||||||
per-node, stop-on-error apply — **now superseded**): there is no intermediate state where a parent
|
|
||||||
committed and a child did not.
|
|
||||||
- **Propagation is forward-convergent, not transactional.** The post-commit effective-view refresh +
|
|
||||||
cache invalidation (§5.1) live *outside* the transaction. So "atomic" means
|
|
||||||
**atomic-for-desired-state, eventually-consistent-for-effect**, with a bounded window where the DB
|
|
||||||
says new and a cache says old.
|
|
||||||
|
|
||||||
> **Invariant to enforce — and it does NOT hold today (verified).** The single-transaction model
|
|
||||||
> requires apply to write **only Postgres**, but the *current* admin write paths violate that: route
|
|
||||||
> and domain writes prime in-process caches **synchronously, interleaved with the write**
|
|
||||||
> (`route_admin.rs:234`, `apps_api.rs:396`), and files fsync to disk. So moving to "commit the
|
|
||||||
> transaction, *then* refresh views" is a deliberate **restructuring of manager-core**, not a
|
|
||||||
> description of the status quo. Additionally, **domains** are a non-DB effect (Caddy/filesystem, out
|
|
||||||
> of band) — they must be **excluded from the transactional core** and handled by the convergence
|
|
||||||
> model below. Treat "apply writes only Postgres" as an invariant to *establish and guard*, not one
|
|
||||||
> we already have.
|
|
||||||
|
|
||||||
**Idempotent, convergent recovery (for the non-transactional propagation and any future side
|
|
||||||
effects).** Operations are idempotent upserts keyed by stable identity; "re-apply converges" is the
|
|
||||||
recovery story; "rollback" means revert the declaration and re-apply forward. Per-node status
|
|
||||||
(applied/pending/failed/drifted) is recorded so a partial propagation failure is observable and
|
|
||||||
re-runnable. *Evidence:* every surveyed tool (Terraform, ArgoCD, Flux, Pulumi) chose idempotent
|
|
||||||
forward-only convergence over distributed rollback.
|
|
||||||
|
|
||||||
**Drift model — upgraded from pure last-write-wins.** An earlier draft chose model "(c)":
|
|
||||||
last-write-wins, just log. That is *exactly* the pre-SSA `kubectl apply` footgun — KEP-555 lists the
|
|
||||||
scenario verbatim: *"User does an apply, then `kubectl edit`, then applies again: surprise!"*
|
|
||||||
Kubernetes Server-Side Apply exists to convert that silent clobber into an explicit conflict
|
|
||||||
([K8s SSA](https://kubernetes.io/docs/reference/using-api/server-side-apply/)). Concrete risk for us:
|
|
||||||
CI runs `pic apply` and silently re-enables a route an operator killed in an emergency. Resolution:
|
|
||||||
|
|
||||||
- **Keep (c)'s simplicity for ordinary fields** (last-write-wins, change reported).
|
|
||||||
- **For the security-relevant subset only** — `enabled` and a secret's *reference/existence in
|
|
||||||
desired state* — track a "changed out-of-band" bit. If that subset was changed outside the manifest
|
|
||||||
(e.g. an operator disabled a route in the dashboard), `pic plan` surfaces it as a **labeled
|
|
||||||
conflict** and `apply` **refuses without `--force`**. This is a scoped version of SSA's field
|
|
||||||
ownership; we deliberately avoid full per-field managers (object bloat, complexity).
|
|
||||||
- **Secret *values* are explicitly out of scope of the conflict/state-version machinery.** Values
|
|
||||||
are never in the manifest or the plan (§4.6), so a routine `pic secret set` between `plan` and
|
|
||||||
`apply` is the *supported* workflow and does **not** trip the state-version refusal — the
|
|
||||||
state-version check covers manifest-managed *desired state* (definitions, including secret
|
|
||||||
*references* and `enabled`), not value rotation.
|
|
||||||
- Out-of-band changes render as a **distinct labeled diff** in `plan`, separate from intended
|
|
||||||
changes. *Evidence:* Terraform/Pulumi both make read-only drift detection default and label drift
|
|
||||||
distinctly ([Pulumi drift](https://www.pulumi.com/docs/iac/operations/stack-management/drift/)).
|
|
||||||
|
|
||||||
> **Residual risk:** the conflict bit reintroduces some of the complexity (c) was chosen to avoid.
|
|
||||||
> The cruder fallback, if even that is too much: keep pure (c) but add a non-revertible
|
|
||||||
> **operational lock** flag an operator sets in an emergency that `apply` won't touch. Either closes
|
|
||||||
> the silent-revert hole; neither is free.
|
|
||||||
|
|
||||||
**Gating high-stakes applies: trigger ≠ authorization.** Any CI trigger may `plan`, but applying to
|
|
||||||
a confirm-required env is a separate, default-off, explicitly-authorized step. A blanket `--yes`
|
|
||||||
covers ordinary confirms; **confirm-required envs require an explicit per-env `--approve <env>`**, so
|
|
||||||
CI must opt in per environment. "Override a gate" is its own audited capability (maps onto
|
|
||||||
`manager-core::authz::can`). *Evidence:* Terraform Cloud parks runs in *Needs Confirmation* and
|
|
||||||
separates the *apply runs* permission from *manage policy overrides*
|
|
||||||
([TFC run states](https://developer.hashicorp.com/terraform/cloud-docs/run/states)).
|
|
||||||
|
|
||||||
**Concurrency.** Start with a **coarse per-instance (or per-root-group) apply lock** — one apply at a
|
|
||||||
time — which is trivially correct for the single-node MVP and makes "last-commit-wins" hold. Refine
|
|
||||||
*later* to **per-blast-radius advisory locks** (lock the affected apps in `app_id` order so
|
|
||||||
overlapping radii serialize while disjoint ones proceed; queue triggers already use this advisory-lock
|
|
||||||
primitive) only if contention appears. Don't build the fine-grained version speculatively.
|
|
||||||
|
|
||||||
**Blast radius — defined and bounded.** The blast radius is **the set of descendant apps whose
|
|
||||||
materialized effective view would actually change** — a *diff*, not "all descendants." Per changed
|
|
||||||
definition at node N it is `subtree(N)` minus apps that override that key nearer. `pic plan`
|
|
||||||
enumerates it for small radii; for large ones it **summarizes (count + sample)** and a threshold
|
|
||||||
triggers extra confirmation (`"this changes 4,213 apps — confirm"`). Because only scripts/vars/secret-
|
|
||||||
refs inherit (§5.1), blast radius applies to *those* changes; an app-scoped change (a route or
|
|
||||||
trigger) has blast radius = that single app. Root-level changes are accepted as expensive, rare, and
|
|
||||||
high-confirmation; the computation is bounded by `subtree(N)` size, and the confirmed radius is
|
|
||||||
re-validated at apply against the tree-structure version (above) so it can't go stale.
|
|
||||||
|
|
||||||
**In-flight executions.** An apply that disables or replaces a script affects **new invocations
|
|
||||||
immediately** (via the `enabled` re-check + view invalidation; pending trigger outbox rows are dropped
|
|
||||||
at fire-time, §4.3 — so no separate outbox purge is needed). **In-flight *running* executions run to
|
|
||||||
completion**, and note (verified) the executor has **no external-cancel path today**: executions are
|
|
||||||
`spawn_blocking` Rhai calls interruptible only by their operation budget or a pre-set wall-clock
|
|
||||||
deadline self-checked in `engine.on_progress`. A true must-stop-now **kill-switch** is therefore a
|
|
||||||
*net-new* capability — buildable by having that same `on_progress` hook also poll a per-execution
|
|
||||||
cancel flag — **gated by an admin capability (`authz::can`) and audited**, scheduled as a later item,
|
|
||||||
not phase 1. Killing mid-run also risks partial side effects, so it stays the explicit extreme, never
|
|
||||||
default apply behavior.
|
|
||||||
|
|
||||||
**Apply flow (end to end):**
|
|
||||||
|
|
||||||
```
|
|
||||||
CLI builds bundle (manifests + script sources) for the subtree
|
|
||||||
→ server computes plan + blast radius (incl. descendant apps in OTHER repos)
|
|
||||||
→ server persists plan artifact; checks state version
|
|
||||||
→ dev reviews; confirm/approve per env policy
|
|
||||||
→ server applies desired state in ONE DB transaction (all-or-nothing)
|
|
||||||
→ server refreshes effective views + bumps generation + invalidates caches (convergent)
|
|
||||||
→ server returns change report; CLI logs created / updated / re-enabled / pruned / conflicts
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 The three-state `enabled` lifecycle
|
|
||||||
|
|
||||||
`enabled` is a real platform feature (DB + server runtime + UI badge/toggle), not CLI sugar, and — by
|
|
||||||
§3 — it is **just another sparse, proximity-resolved field**.
|
|
||||||
|
|
||||||
| State | Meaning | Pruned? |
|
|
||||||
|---|---|---|
|
|
||||||
| declared, `enabled = true` (or omitted) | deployed, active | kept |
|
|
||||||
| declared, `enabled = false` | deployed but **inert** (route short-circuits, trigger doesn't fire, script not invocable) | **kept** — still desired state |
|
|
||||||
| absent from merged manifest | stale | **deleted** by prune / `--prune` |
|
|
||||||
|
|
||||||
- Default `true`; last-write-wins on merge; a base `enabled = false` is inherited until an overlay
|
|
||||||
(env *or* a nearer group/app) explicitly sets `enabled = true`. Overriding a *different* field does
|
|
||||||
**not** implicitly re-enable.
|
|
||||||
- **Across the group axis (resolved):** a descendant disables an inherited (group-owned) script via a
|
|
||||||
**sparse override** — an override row that sets only `enabled = false`, inheriting the source. A
|
|
||||||
descendant can re-enable a parent-disabled entity because nearest-level wins. This is the same
|
|
||||||
sparse-field mechanism as everything else (Kustomize patches are sparse — you specify only what
|
|
||||||
changes).
|
|
||||||
- **Base = the superset across envs.** Per-env you may toggle `enabled` in *either* direction
|
|
||||||
(disable a base-active entity, or re-enable a base-disabled one — proximity wins, §3), but you
|
|
||||||
**cannot *remove* a base entity** in one env; true removal requires the entity not be in the base.
|
|
||||||
An entity unique to one env goes in that overlay; an entity present everywhere but off in staging
|
|
||||||
goes in base with `enabled = false` in the staging overlay.
|
|
||||||
- **Disabled = invisible.** External callers hitting a disabled route get **404** (indistinguishable
|
|
||||||
from absent — no info leak).
|
|
||||||
- **Schema note:** the `triggers` table **already** has `enabled` (+ `dispatch_mode`, retry columns)
|
|
||||||
and it is **honored at match/schedule time** (`trigger_repo.rs`, `cron_scheduler.rs` — verified).
|
|
||||||
New work is `enabled` on **scripts** and **routes** only, plus runtime honoring in the matcher /
|
|
||||||
invoker.
|
|
||||||
- **Outbox fire-time gap (verified):** the dispatcher does **not** re-check `enabled` on an
|
|
||||||
already-enqueued outbox row (`dispatcher.rs:699`), so disabling a trigger stops *new* matches while
|
|
||||||
a pending item still fires. **Fix:** add an `enabled` re-check (trigger *and* script) at fire time
|
|
||||||
in `resolve_trigger`, so a pending outbox row for a now-disabled trigger/script is dropped when it
|
|
||||||
comes up — closing the gap cheaply, with no cache or kill-switch involvement. This is the home for
|
|
||||||
the §5.1 security-disable guarantee on the trigger path.
|
|
||||||
- **Provenance caveat (accepted):** a single boolean carries no "manual vs manifest" provenance, so a
|
|
||||||
disabled entity looks the same however it got there — which is *why* the §4.2 conflict bit on the
|
|
||||||
`enabled`/secrets subset exists, to stop the next apply silently reverting an operational disable.
|
|
||||||
|
|
||||||
### 4.4 Identity & naming
|
|
||||||
|
|
||||||
- **Kebab everywhere.** One canonical identifier regex: `^[a-z0-9][a-z0-9-]{0,62}$` for project
|
|
||||||
names, env names, script names/slugs, trigger names — unified with the existing app-slug rule.
|
|
||||||
- **Scripts:** unique `name`/`slug` per app = merge/upsert key.
|
|
||||||
- **Routes:** identity = the triple **`<method> <host> <path>`**, e.g.
|
|
||||||
`ANY *.beta.example.com /hello/:name`. `dispatch_mode`, `host_param_name`, etc. are *attributes*
|
|
||||||
overridable without changing identity. The CLI infers `host_kind`/`path_kind` from the pattern
|
|
||||||
syntax (`*`, `{name}`, `:name`, exact), with an explicit `kind` key as override (mirrors the UI).
|
|
||||||
Needs a normalization rule (default method `ANY`, default host `*`, case-folding) so manifest ↔
|
|
||||||
server match exactly.
|
|
||||||
- **Slugs are instance-global, derived from the path.** Two identifiers coexist:
|
|
||||||
- **path** = `acme/team-a/blog` — hierarchical, group-scoped, display/organization/RBAC.
|
|
||||||
- **slug** = flat, instance-global, the deployment key.
|
|
||||||
The derived default is **`{flattened-path}-{env}`** (e.g. `team-a-blog-staging`) — unique by
|
|
||||||
construction in the multi-group world, unlike a bare `{leaf}-{env}` which collides whenever two
|
|
||||||
groups reuse a leaf name. On >63-char overflow, truncate + short hash suffix. Explicit override
|
|
||||||
allowed; the path never *is* the slug, it only *seeds* it.
|
|
||||||
|
|
||||||
### 4.5 Triggers
|
|
||||||
|
|
||||||
Triggers are **app-scoped, not group-inherited** (§5.1 explains why). Two senses of "app" are in play
|
|
||||||
and must not be conflated: a trigger is declared once in the **leaf (logical app)** base — an
|
|
||||||
authoring convenience — and **materializes into per-`app_id` rows**, one per env-app, where `app_id`
|
|
||||||
is the server-app isolation boundary (§5.2). It is never group-owned.
|
|
||||||
|
|
||||||
Two distinct constraints:
|
|
||||||
|
|
||||||
- **`name`** (explicit, kebab) = the merge/identity key + upsert target. Unique per app.
|
|
||||||
*(Triggers have no name column today — new.)*
|
|
||||||
- **Semantic uniqueness** = a *post-merge validation*: no two triggers may share their kind-specific
|
|
||||||
semantic key. Checked after merging, so overriding a base trigger per-env (reuse `name`, change a
|
|
||||||
field) is fine; two differently-named triggers with identical effect is an error.
|
|
||||||
|
|
||||||
| Kind | Semantic key | Note |
|
|
||||||
|---|---|---|
|
|
||||||
| kv / docs / files | `(script, collection_glob, ops)` | **canonicalize `ops`** (sort + dedupe) |
|
|
||||||
| cron | `(script, schedule, timezone)` | exact TEXT match |
|
|
||||||
| pubsub | `(script, topic_pattern)` | |
|
|
||||||
| dead-letter | `(script, source_filter, trigger_id_filter, script_id_filter)` | NULL = literal value (not wildcard) for equality |
|
|
||||||
| email | `(script)` | no filters — one per script |
|
|
||||||
| **queue** | `(queue_name)` — **not** script-scoped | already server-enforced (advisory lock: one consumer per `(app_id, queue_name)`) |
|
|
||||||
|
|
||||||
- **Matching vs identity:** `[]`/`NULL`/globs mean **"any/wildcard" at dispatch time** (unchanged
|
|
||||||
runtime matching) but are compared as **literal structural values for dedup**. We dedup on
|
|
||||||
**structural identity, never on overlap/subsumption** — `ops = []` and `ops = ["insert"]` are *not*
|
|
||||||
duplicates. Overlapping triggers coexist (multiple triggers firing on one event is already how
|
|
||||||
PiCloud works).
|
|
||||||
- **Name backfill** for existing nameless rows: `{kind}-{entity}-{n}`, where `{entity}` is the kind's
|
|
||||||
identity token (collection / queue / topic; for cron/email/dead-letter fall back to `{kind}-{n}`),
|
|
||||||
sanitized to the kebab regex, with `-{n}` (discovery order) guaranteeing per-app uniqueness.
|
|
||||||
|
|
||||||
> **Ergonomic debt (accepted, watch it):** because triggers don't inherit, 100 tenant apps each
|
|
||||||
> needing the same 5 triggers = 500 declarations. The fix is group trigger/route **templates** that
|
|
||||||
> fan out per descendant (a *template/instantiation* mechanism, not inheritance) — deferred, but it
|
|
||||||
> bites early if tenant cardinality is high. Pressure-test against real tenant counts before
|
|
||||||
> committing to the narrow-inheritance choice (§5.1).
|
|
||||||
|
|
||||||
### 4.6 Secrets & `pull`
|
|
||||||
|
|
||||||
- **Name-only in the manifest; value pushed via CLI** (`pic secret set`, reads stdin). Unanimous
|
|
||||||
across every comparable tool — never commit secret values.
|
|
||||||
- **Env-scoped** like any var (`[secrets]` names declared once; values set per env).
|
|
||||||
- **Warn (don't block) if a referenced secret is not set.** This requires an app dev to see that a
|
|
||||||
group secret **exists / is set** (a boolean) without reading its value — an accepted, explicit
|
|
||||||
authz boundary. GitLab surfaces inherited masked variable *keys* the same way.
|
|
||||||
- **Email's `inbound_secret` is a reference**, not inline — same rule; the server already encrypts it
|
|
||||||
at rest.
|
|
||||||
- **`pull` exports own-rows only** (this node's overrides), **never effective/inherited state.** A
|
|
||||||
separate read-only **`pic config --effective`** shows the inherited result with **masked secrets
|
|
||||||
rendered as `<set>` / `***`, never plaintext.** This makes pull-under-masking safe *by
|
|
||||||
construction* — you cannot pull a secret you cannot read, and you cannot accidentally duplicate
|
|
||||||
inherited config into a leaf. *Evidence:* GitLab shows inherited group variables in a project
|
|
||||||
read-only and separate from project-own variables.
|
|
||||||
- Flat pull for a new project; "smart" delta-pull (own-vs-effective diff) is server-computed since an
|
|
||||||
app dev's checkout lacks ancestor manifests.
|
|
||||||
|
|
||||||
### 4.7 Apply-time warnings
|
|
||||||
|
|
||||||
- Enabled route/trigger pointing at a **disabled script**.
|
|
||||||
- An **endpoint** script deployed with **no route and no trigger** (unreachable). Modules are exempt.
|
|
||||||
- Sandbox override exceeding the admin **ceiling**.
|
|
||||||
- Referenced secret not set.
|
|
||||||
- An out-of-band change to the `enabled`/secrets subset (surfaced as a conflict, §4.2).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Groups & inheritance
|
|
||||||
|
|
||||||
### 5.1 What inherits, and the runtime model
|
|
||||||
|
|
||||||
- **GitLab-like, nested, single-parent tree.** Single parent keeps inheritance acyclic and
|
|
||||||
resolution deterministic (no diamond precedence).
|
|
||||||
- **Inherit code/config — narrowly. Inherit data — no.** Group-inheritable = **scripts/modules,
|
|
||||||
vars, secret-refs only.** Routes, triggers, collections, topics, files are **app-scoped.**
|
|
||||||
- *Why narrow:* routes and triggers are **bindings**, not pure code/config. A group-owned trigger
|
|
||||||
has no app data to watch (triggers fire on app-owned collections/topics/queues); a group-owned
|
|
||||||
route has no host (routing is Host→app first). Inheriting them is incoherent. There are two
|
|
||||||
distinct sharing mechanisms and an earlier draft conflated them: the **leaf base+overlay** shares
|
|
||||||
routes/triggers across *one app's environments*; **group inheritance** shares *code/config across
|
|
||||||
many apps*. *Evidence:* Serverless/SAM keep `events:` per-function and share logic via *layers* —
|
|
||||||
bindings local, code shared.
|
|
||||||
- *Why data stays app-owned:* a group script executes in the *inheriting app's* context, so its
|
|
||||||
`cx.app_id` still scopes data to that app. Group-level *collections/topics* would break `app_id`
|
|
||||||
as the isolation boundary — that is the v1.3 cross-app data-sharing problem and stays **out**.
|
|
||||||
- **Runtime model: a materialized effective view + versioned cache (not per-request live-resolve).**
|
|
||||||
An earlier draft said "live-resolve," which is underspecified and would fight the existing cache.
|
|
||||||
The real model:
|
|
||||||
- manager-core **resolves-at-write into a materialized per-app effective view** (§3 rule applied:
|
|
||||||
sparse merge, env filter, proximity, CoW, `enabled`).
|
|
||||||
- The orchestrator/executor serve from that view, **keyed by `app_id` + a generation/version**. The
|
|
||||||
app_id-keyed *shape* survives (today's route cache is already an `app_id`→routes map), **but the
|
|
||||||
substance is net-new (verified):** there is no generation counter anywhere today, the route cache
|
|
||||||
is rebuilt whole-table on every write rather than per-app, and script *bodies* are live-resolved
|
|
||||||
per request. Read "serve from it" as *extend the keying*, not *reuse the mechanism* — and note
|
|
||||||
that fanning out today's full-rescan invalidation to thousands of descendants would be a
|
|
||||||
regression, so per-app incremental invalidation is part of the build.
|
|
||||||
- On any write to a node, manager-core (single writer, knows the tree) **recomputes descendants'
|
|
||||||
views + bumps the generation + invalidates caches.**
|
|
||||||
- The materialized view is a **derived cache, not a second source of truth** — canonical config
|
|
||||||
still lives once at the owning node, so this does **not** reintroduce duplication. This dissolves
|
|
||||||
the long-running "snapshot vs. live" tension: no duplication *and* propagation *and* a fast hot
|
|
||||||
path.
|
|
||||||
|
|
||||||
> **Residual risk (relocated, not solved):** cache invalidation is now a **correctness/security
|
|
||||||
> requirement** — disabling a script for a security reason must stop it running *everywhere* within
|
|
||||||
> bounded time. This is the same class as the existing PrincipalCache revocation lag. Require
|
|
||||||
> **synchronous invalidation for the security-relevant subset** (`enabled=false`, secret rotation)
|
|
||||||
> and accept bounded eventual staleness elsewhere; the hard SLA at fan-out to thousands of
|
|
||||||
> descendants is genuinely unsolved.
|
|
||||||
|
|
||||||
### 5.2 Schema impact
|
|
||||||
|
|
||||||
- Inheritable definition kinds get a polymorphic owner (`owner_kind ∈ {group, app}` + `owner_id`):
|
|
||||||
**scripts** (modify the existing table — and note its `app_id` FK is `ON DELETE RESTRICT`, not
|
|
||||||
CASCADE, so a pruning apply needs explicit ordering), plus **`vars` and `secret-refs`, which are
|
|
||||||
net-new tables** (they do not exist today — §3). So this is *one table modified + two invented*,
|
|
||||||
not "three tables touched." **Routes, triggers, collections, topics, files stay strictly
|
|
||||||
`app_id`-owned**, so the runtime isolation boundary stays fixed. (The CLAUDE.md `app_id NOT NULL …
|
|
||||||
CASCADE` rule is itself not universal today — scripts already use RESTRICT.)
|
|
||||||
- New: a `groups` table (single-parent, `parent_id`), group `membership`/roles, an `owner_project`
|
|
||||||
column on group nodes (§7), and the materialized effective-view store keyed by `app_id` +
|
|
||||||
generation (§5.1). Env-scoped values carry an `environment_scope` column (`*` or a specific env).
|
|
||||||
|
|
||||||
### 5.3 RBAC
|
|
||||||
|
|
||||||
- **Hierarchy-aware capabilities.** `authz::can(principal, cap, on=node)` resolves by walking
|
|
||||||
ancestors and taking the highest effective role. Instance → group(s) → app.
|
|
||||||
- **Inherited membership** (GitLab-style): a group admin is implicitly admin of every subgroup/app
|
|
||||||
beneath it.
|
|
||||||
- **Masked group secrets:** a group secret is *used by* an app at runtime but *not human-readable* by
|
|
||||||
the app's developers. Two orthogonal gates: **runtime resolution** (engine injects plaintext) vs
|
|
||||||
**human-read authz** (admin API returns a value only to a principal with rights at the *owning
|
|
||||||
group*). An app-scoped admin call never returns group secrets; runtime injection bypasses the human
|
|
||||||
gate. App devs may see a group secret **exists** (for the unset-warning, §4.6) but not its value.
|
|
||||||
**An app can run with config its own developers cannot see.**
|
|
||||||
|
|
||||||
> **Crypto caveat (verified):** secrets today are AES-GCM sealed with AAD `secret:{app_id}:{name}`,
|
|
||||||
> and the decrypt path hard-codes `cx.app_id` (migration 0042). A **group-owned** secret is *not
|
|
||||||
> expressible* under this scheme — there is no group identity in the AAD. It needs a new AAD
|
|
||||||
> identity (e.g. `secret:group:{group_id}:{name}`) **and** an owner-aware decrypt path that resolves
|
|
||||||
> whether the inherited secret is group- or app-owned. This is the single hardest correctness detail
|
|
||||||
> of group secrets and gates phasing step 3.
|
|
||||||
|
|
||||||
### 5.4 Tenants & single-parent
|
|
||||||
|
|
||||||
Single-parent forbids an app combining two *sibling* groups' configs — which seems to threaten the
|
|
||||||
multi-tenant use case (shared-platform base + per-tenant overlay). It does not, because **the
|
|
||||||
shared base is an *ancestor*, not a sibling:** model tenants as leaf apps under a `tenants/`
|
|
||||||
subgroup that inherits the platform base up the chain (tenant leaf → `tenants` group → platform
|
|
||||||
group). The "two parents" intuition is satisfied by the *chain*. Better still, **a tenant is a scope
|
|
||||||
dimension like environment** (§3) — the same env-scope machinery generalizes to a `tenant` scope, so
|
|
||||||
multi-tenant needs no new hierarchy primitive. *Evidence:* GitLab is single-parent and serves
|
|
||||||
multi-tenant teams via subgroups; "combine two siblings" is handled by promoting shared config to a
|
|
||||||
common ancestor.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.5 Module & import resolution under inheritance
|
|
||||||
|
|
||||||
A group-owned script may `import` modules, and inheritance makes "which module?" ambiguous (an
|
|
||||||
inherited script importing a module a leaf has shadowed could bind to app-dev code it never saw — a
|
|
||||||
trust inversion). The rule:
|
|
||||||
|
|
||||||
- **Lexical by default.** An inherited script's imports resolve against the module set visible at the
|
|
||||||
**script's own defining node** (walking up from there), **not** the inheriting app's effective
|
|
||||||
view. A leaf cannot shadow a module an inherited group script depends on, and a group script
|
|
||||||
behaves identically across every app that inherits it — preserving both **determinism** and the
|
|
||||||
**trust boundary** (a security-authored shared script is tamper-proof from below). Ordinary lexical
|
|
||||||
scoping; "sealed by default", like non-`open` classes in Kotlin / `final` in Java.
|
|
||||||
- **"Defining node" of a CoW-overridden script = the node that authored *that body*.** An
|
|
||||||
inherited-unchanged script's defining node is the ancestor that owns it (imports sealed to the
|
|
||||||
ancestor's modules). A script a leaf *overrides* (CoW, §3) has the **leaf** as its defining node, so
|
|
||||||
the override's imports resolve from the leaf — which is *not* a trust inversion, because the app
|
|
||||||
owner wrote that body and is trusted within their own app. Consequence (intended): the same module
|
|
||||||
name can resolve to **two different bodies** in one app, depending on the importing script's defining
|
|
||||||
node.
|
|
||||||
- **Explicit extension points for opt-in polymorphism.** A module is marked an extension point in the
|
|
||||||
**manifest** (an `[extension_points]` declaration — *not* in Rhai source, keeping code inert and
|
|
||||||
giving the `plan` checker something to read). Such a module is one descendants are *expected* to
|
|
||||||
provide or override; **only** these resolve against the inheriting app's effective view. Controlled
|
|
||||||
template-method customization (a shared `render` whose `theme` module each tenant supplies) without
|
|
||||||
the blanket trust inversion of dynamic resolution. When a name is declared at more than one level —
|
|
||||||
or is concrete on one path and an extension point on another — **the nearest declaration's kind
|
|
||||||
wins** (proximity, §3); its default body (if any) is the inherited fallback.
|
|
||||||
- **Apply-time checks:** a dangling import (inherited script → missing module) is a `plan` error; an
|
|
||||||
extension point with no provider in a given app is an error for that app (a hard failure, joining
|
|
||||||
§4.7).
|
|
||||||
|
|
||||||
> **Residual (verified):** executor-core's `PicloudModuleResolver` is app-scoped today and ignores the
|
|
||||||
> importing script's origin (`module_resolver.rs` passes `_source` unused). Rhai *does* expose that
|
|
||||||
> origin, so the lexical-vs-dynamic split is expressible — but it requires re-keying the resolver cache
|
|
||||||
> by owner identity and adding per-import policy (sealed vs. extension point), i.e. a real
|
|
||||||
> resolver+cache redesign, not a parameter tweak. Lands with phasing step 4.
|
|
||||||
|
|
||||||
### 5.6 Tree lifecycle: delete, reparent, rename
|
|
||||||
|
|
||||||
Structural mutations of the group tree, which the rest of the design depends on staying acyclic and
|
|
||||||
non-orphaning:
|
|
||||||
|
|
||||||
- **Delete = RESTRICT, never implicit CASCADE.** Deleting a non-empty group is refused — an implicit
|
|
||||||
cascade would destroy descendant apps and their isolated data. CLI `--recursive` expands a delete
|
|
||||||
into *ordered, explicit, confirmed* child deletions; the DB FK stays RESTRICT. Corollary: a
|
|
||||||
referenced ancestor **cannot vanish while it has descendants**, so cross-repo read-only references
|
|
||||||
(§7) can't be orphaned by deletion — the RESTRICT protects them automatically. **App *data* is
|
|
||||||
destroyed only on explicit opt-in:** an app delete refuses unless `--purge-data`, which then removes
|
|
||||||
its KV/docs rows *and* its files blob tree under `PICLOUD_FILES_ROOT/<app_id>/` — a non-DB,
|
|
||||||
non-undoable effect run outside the transaction and logged. So `--recursive` group delete requires
|
|
||||||
`--purge-data` to touch any descendant app's data; without it, a non-empty app blocks the delete.
|
|
||||||
- **Reparent / rename: the slug is frozen at creation.** The path only *seeds* the derived slug
|
|
||||||
(§4.4); a move or rename updates the **display path** but never rewrites the **instance-global
|
|
||||||
slug** — the deployment key stays stable, external references don't break. After a move the slug no
|
|
||||||
longer mirrors the path (cosmetic, accepted).
|
|
||||||
- **Reparent recomputes descendant effective views** (it changes the resolution chain — the same
|
|
||||||
fan-out invalidation as a node write, §5.1) and is **doubly capability-gated**: group-admin at
|
|
||||||
*both* the source and destination parent (you remove from one ancestor's domain and add to
|
|
||||||
another's). Because it changes the resolution chain, a reparent is **validated like a plan and
|
|
||||||
refused (unless forced)** if the recompute would orphan a sparse `enabled`-override (now shadowing
|
|
||||||
nothing) or leave an extension point with no provider (§5.5) — a structural move must not silently
|
|
||||||
produce a state `apply` would have rejected.
|
|
||||||
- **Cycle guard, under the apply lock.** Reparent runs an **ancestor-walk check in manager-core**
|
|
||||||
(walk from the destination up to root; reject if it reaches the node being moved). A Postgres
|
|
||||||
`CHECK` can't express this; the guard is what guarantees §9's "resolution always terminates."
|
|
||||||
Single-parent + this guard = acyclic. **All structural mutations (reparent/rename/delete) take the
|
|
||||||
same coarse apply-lock (§4.2)**, so the ancestor-walk + `parent_id` write run serialized — two
|
|
||||||
concurrent reparents can't race into a cycle, and a reparent's view-recompute can't collide with an
|
|
||||||
overlapping apply on the materialized-view store.
|
|
||||||
|
|
||||||
## 6. The CLI ↔ server projection
|
|
||||||
|
|
||||||
- **Directories = groups** (the hierarchy axis). Single-parent falls out of the filesystem for free.
|
|
||||||
- **Overlay files = environments/apps** (the deployment-variant axis) — *not* subdirectories,
|
|
||||||
because envs share scripts/structure and only diverge on vars/secrets/slug; files structurally
|
|
||||||
prevent per-env script drift.
|
|
||||||
- **`scripts/` at every level cascades** up the tree; nearer overrides farther by name (CoW).
|
|
||||||
- **A leaf group = one logical app; its environments = the actual server apps.** Multiple distinct
|
|
||||||
apps = multiple sibling leaf groups. **Intermediate groups may also bear apps** (a dir may have
|
|
||||||
both subdirs and overlay files) — allowed, no special-casing.
|
|
||||||
- **Environment registry lives at the app-bearing node**, but **confirm-policy is inheritable** (set
|
|
||||||
"production always confirms" once at root; it flows down via the §3 mechanism). Leaves may declare
|
|
||||||
independent environment sets; a tree-wide `pic apply --env production` simply skips a leaf that has
|
|
||||||
no `production`.
|
|
||||||
- **Attach point:** the local root manifest declares where it binds into the server tree
|
|
||||||
(`parent_group = "acme"` or instance root). Ancestors above it are inherited/referenced but **not
|
|
||||||
present locally** — which makes the sparse checkout enforce the RBAC masking for free; effective
|
|
||||||
config needs a server round-trip (`pic config --effective`).
|
|
||||||
- **Stable IDs in gitignored `.picloud/`** (group IDs, instance URL, token ref) so a directory
|
|
||||||
rename/move maps to a server **reparent**, not delete+create.
|
|
||||||
- **Local/server structural divergence is detected, not silently fought.** Alongside the per-node
|
|
||||||
content version (§4.2), each node carries a **per-subtree structure version** (covering its own
|
|
||||||
parentage/subtree — **not one global counter**, so a structural edit in one team's subtree never
|
|
||||||
force-refuses an unrelated repo's plan). `pic plan` compares the local parent-by-ID (from
|
|
||||||
`.picloud/`) against the server's; on a structural mismatch (someone reparented server-side, or a
|
|
||||||
dir moved locally) it **refuses**, requiring an explicit `--adopt-server-structure` or
|
|
||||||
`--force-local-structure` (Terraform's detect-and-refuse on stale state). The content-version check
|
|
||||||
alone would miss a pure structural move; reparent/rename/delete each bump the affected subtree's
|
|
||||||
structure version.
|
|
||||||
- **Mono-repo** = attach at instance root. **Per-team repo** = attach at a subgroup, contain only its
|
|
||||||
slice. Same model, different attach depth.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Ownership of shared nodes
|
|
||||||
|
|
||||||
**Single-owner-per-node, ceilinged by the attach point.**
|
|
||||||
|
|
||||||
1. Each group node is owned by exactly one **project-root** (the repo that *manages* it — contains
|
|
||||||
its manifest as something it applies, not merely references). The server records `owner_project`.
|
|
||||||
2. **Your attach point is your ceiling** — you cannot apply to anything above your local root.
|
|
||||||
3. **First apply claims; transfer is explicit and capability-gated.** A second repo applying to an
|
|
||||||
owned node is rejected (`owned by project X; use --takeover`); takeover needs group-admin
|
|
||||||
capability. This stops one team silently clobbering org-wide config — whose blast radius (via the
|
|
||||||
effective-view fan-out) is the whole subtree.
|
|
||||||
4. **Ownership ⟂ RBAC.** Ownership = *which manifest is authoritative*; RBAC = *whether this
|
|
||||||
principal may*. The owner still needs group-admin capability to apply.
|
|
||||||
5. A node with **no project claim is UI/API-owned** — the dashboard is its source of truth and no
|
|
||||||
manifest fights it. Every node is either manifest-owned (one repo) or UI-owned.
|
|
||||||
|
|
||||||
**Corollary:** don't co-own a node — split config downward. Shared config lives *higher* (owned by a
|
|
||||||
platform/shared repo attaching at root); team-specific bits go into subgroups each team owns.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Diagrams
|
|
||||||
|
|
||||||
### 8.1 Server ownership & containment
|
|
||||||
|
|
||||||
Only scripts/vars/secret-refs are group-ownable (polymorphic owner); routes/triggers/data are always
|
|
||||||
app-owned via `app_id`.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
INST([Instance])
|
|
||||||
INST --> RG["Group: acme (root)"]
|
|
||||||
RG --> SGA["Group: team-a"]
|
|
||||||
RG --> SGB["Group: team-b"]
|
|
||||||
SGA --> LGA["Group: blog (leaf)"]
|
|
||||||
SGA --> LGB["Group: shop (leaf)"]
|
|
||||||
LGA --> APP1["App: blog-staging"]
|
|
||||||
LGA --> APP2["App: blog-production"]
|
|
||||||
|
|
||||||
RG -.->|"owns (shared)"| D0["scripts, vars, secret-refs"]
|
|
||||||
SGA -.->|owns| D1["team-a scripts, vars"]
|
|
||||||
APP2 -.->|"owns / overrides"| D2["app scripts, vars, secrets"]
|
|
||||||
|
|
||||||
APP1 ==>|app_id| C1[("routes, triggers, KV/Docs/Files, Topics")]
|
|
||||||
APP2 ==>|app_id| C2[("routes, triggers, KV/Docs/Files, Topics")]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.2 Entity identity & cardinalities
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
erDiagram
|
|
||||||
GROUP ||--o{ GROUP : "parent-of (single parent)"
|
|
||||||
GROUP ||--o{ APP : contains
|
|
||||||
GROUP ||--o{ DEFINITION : "owns (scripts/vars/secret-refs)"
|
|
||||||
APP ||--o{ DEFINITION : "owns (override)"
|
|
||||||
APP ||--o{ APPSCOPED : "owns (routes/triggers/data, app_id)"
|
|
||||||
GROUP ||--o{ MEMBERSHIP : "role (inherited down)"
|
|
||||||
APP ||--o{ MEMBERSHIP : "role"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.3 Config resolution (sparse merge, env filter, proximity-first)
|
|
||||||
|
|
||||||
Effective value for one app+env, resolved by §3. Env-scope filters per level; nearest level wins;
|
|
||||||
maps merge per key.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TB
|
|
||||||
I["Instance defaults"] --> RG["Group acme<br/>secret stripe_key (ref)<br/>script auth.rhai<br/>db_url@production"]
|
|
||||||
RG --> SG["Group team-a<br/>var region = eu"]
|
|
||||||
SG --> LG["Group blog<br/>script render.rhai<br/>var title = Blog"]
|
|
||||||
LG --> EV["Env overlay: production<br/>var title = Blog PROD (override)<br/>secret stripe_key = prod value"]
|
|
||||||
EV --> EFF[["Materialized effective view (app=blog-production):<br/>auth.rhai (acme), render.rhai (blog)<br/>title = Blog PROD (leaf overlay)<br/>region = eu (team-a)<br/>db_url = prod (acme @production filter)<br/>stripe_key = prod"]]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.4 Filesystem ↔ server mapping
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph LR
|
|
||||||
subgraph FS["Git working tree"]
|
|
||||||
direction TB
|
|
||||||
R["acme/<br/>picloud.toml<br/>scripts/"]
|
|
||||||
R --> TA["team-a/<br/>picloud.toml<br/>scripts/"]
|
|
||||||
TA --> BL["blog/<br/>picloud.toml (base)<br/>picloud.staging.toml<br/>picloud.production.toml<br/>scripts/render.rhai"]
|
|
||||||
LINK[".picloud/ (gitignored)<br/>group IDs, instance URL, token ref"]
|
|
||||||
end
|
|
||||||
subgraph SRV["PiCloud server"]
|
|
||||||
direction TB
|
|
||||||
G0["Group acme"] --> G1["Group team-a"]
|
|
||||||
G1 --> G2["Group blog"]
|
|
||||||
G2 --> A1["App blog-staging"]
|
|
||||||
G2 --> A2["App blog-production"]
|
|
||||||
end
|
|
||||||
R -.->|defines| G0
|
|
||||||
TA -.->|defines| G1
|
|
||||||
BL -.->|"base defines"| G2
|
|
||||||
BL -.->|"staging.toml"| A1
|
|
||||||
BL -.->|"production.toml"| A2
|
|
||||||
LINK -.->|"stable IDs"| SRV
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.5 Multi-repo subtree views & single-owner ownership
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
subgraph Server["Server group tree (authoritative)"]
|
|
||||||
acme["acme (root)"]
|
|
||||||
acme --> ta["team-a"]
|
|
||||||
acme --> tb["team-b"]
|
|
||||||
ta --> blog["blog"]
|
|
||||||
tb --> shop["shop"]
|
|
||||||
end
|
|
||||||
subgraph PR["Repo: platform"]
|
|
||||||
pr["manages acme"]
|
|
||||||
end
|
|
||||||
subgraph AR["Repo: team-a"]
|
|
||||||
ar["attaches at acme<br/>manages team-a + blog"]
|
|
||||||
end
|
|
||||||
pr ==>|owns| acme
|
|
||||||
ar ==>|owns| ta
|
|
||||||
ar ==>|owns| blog
|
|
||||||
ar -.->|"reference, read-only"| acme
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.6 Apply pipeline (bound plan → DB-atomic write → convergent propagation)
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
actor Dev
|
|
||||||
participant CLI as pic CLI
|
|
||||||
participant Mgr as manager-core (single writer)
|
|
||||||
participant DB as Postgres
|
|
||||||
participant View as effective views + caches
|
|
||||||
Dev->>CLI: pic plan --env production
|
|
||||||
CLI->>CLI: read manifests + .rhai sources, build subtree bundle
|
|
||||||
CLI->>Mgr: send bundle (whole subtree)
|
|
||||||
Mgr->>DB: read state + version of affected nodes
|
|
||||||
Mgr->>Mgr: diff + blast radius + persist plan artifact
|
|
||||||
Mgr-->>CLI: PLAN (changes, N descendant apps incl. other repos, conflicts)
|
|
||||||
CLI-->>Dev: show plan; confirm/approve per env policy
|
|
||||||
Dev->>CLI: pic apply (executes stored plan)
|
|
||||||
CLI->>Mgr: apply (plan id)
|
|
||||||
Mgr->>DB: refuse if content or structure version moved; else ONE transaction (all-or-nothing)
|
|
||||||
Mgr->>View: recompute effective views + bump generation + invalidate
|
|
||||||
Mgr-->>CLI: change report (created/updated/re-enabled/pruned/conflicts)
|
|
||||||
CLI-->>Dev: log changes
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.7 RBAC: masked group secrets
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
GA["Group admin (team-a)"] -->|"sets + can read"| GS["Group secret: stripe_key<br/>owned by team-a, encrypted at rest"]
|
|
||||||
AD["App developer (blog)"] -- "cannot read value (sees exists)" --x GS
|
|
||||||
AD -->|"can edit"| AS["App script source + app vars"]
|
|
||||||
GS ==>|"runtime injects plaintext"| EX["Executor: running app script"]
|
|
||||||
AS --> EX
|
|
||||||
GATE["Two gates:<br/>human-read authz vs runtime resolution"]
|
|
||||||
GATE -.-> GS
|
|
||||||
GATE -.-> EX
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.8 The three-state `enabled` lifecycle
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
stateDiagram-v2
|
|
||||||
[*] --> Active: declared (enabled=true/omitted)
|
|
||||||
Active --> Disabled: set enabled=false (manifest or UI toggle)
|
|
||||||
Disabled --> Active: set enabled=true (manifest or UI toggle)
|
|
||||||
Active --> Pruned: removed from manifest + prune/--prune
|
|
||||||
Disabled --> Pruned: removed from manifest + prune/--prune
|
|
||||||
Pruned --> [*]
|
|
||||||
note right of Disabled
|
|
||||||
Still desired state, NOT pruned.
|
|
||||||
Route 404s, trigger inert, script not invocable.
|
|
||||||
Out-of-band toggle on this field is conflict-guarded (4.2).
|
|
||||||
end note
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Adoption & backfill
|
|
||||||
|
|
||||||
Groups land onto a live instance with existing flat apps, so a migration is a prerequisite, not an
|
|
||||||
afterthought:
|
|
||||||
|
|
||||||
- Create a **root group** (and/or a per-owner personal namespace, GitLab-style) and **reparent every
|
|
||||||
existing app** under it. Every app must have a parent from day one so resolution always terminates.
|
|
||||||
- Existing apps have no group-owned definitions, so their effective view = their own rows — the
|
|
||||||
materialized-view store can be backfilled trivially (identity resolution).
|
|
||||||
- The trigger `name` backfill (§4.5) runs in the same migration window.
|
|
||||||
- Existing app slugs are already instance-global, so no slug rewrite is needed; the path is new
|
|
||||||
metadata layered on top.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Open questions & residual risks
|
|
||||||
|
|
||||||
Resolved items now live inline next to their topic. What genuinely remains:
|
|
||||||
|
|
||||||
- **Effective-view invalidation SLA (§5.1)** — the security-staleness guarantee at fan-out to many
|
|
||||||
descendants is unsolved; synchronous-for-security + eventual-elsewhere is the proposed shape, not a
|
|
||||||
proven one. Highest-risk open item.
|
|
||||||
- **Conflict bit vs. operational lock (§4.2)** — *decided:* phase 1 ships the `enabled` / secret-
|
|
||||||
reference **conflict bit**; the operational-lock flag is the documented fallback if the bit proves
|
|
||||||
too heavy. (Was listed as undecided; resolved here to match §11 phase 1.)
|
|
||||||
- **Multi-level env-scope precedence (§3.2)** — *decided default:* proximity-first with env as a
|
|
||||||
per-level filter. The open part is only *validation at depth*, which is why `pic config --effective
|
|
||||||
--explain` is a **phase-3 hard requirement** (when multi-level resolution first ships), not a
|
|
||||||
precondition to adopting the rule.
|
|
||||||
- **Inherited-membership revocation lag (§5.3)** — revoking a group admin must drop implicit admin on
|
|
||||||
every descendant app, but §5.1's synchronous-invalidation subset covers only `enabled`/secrets, not
|
|
||||||
**role revocation** — leaving an unbounded window. New residual risk; should get the same
|
|
||||||
synchronous-for-security bound, lands with phase 3's authz.
|
|
||||||
- **External execution cancel (§4.2 kill-switch)** — the executor has **no external-cancel path
|
|
||||||
today** (`spawn_blocking` + self-checked deadline, verified); the kill-switch is a net-new capability
|
|
||||||
(a cancel flag polled in `on_progress`), deferred past phase 1. Until it exists, the strongest stop
|
|
||||||
is op-budget/deadline + the dispatcher fire-time `enabled` re-check (§4.3) for the trigger path.
|
|
||||||
- **Narrow-inheritance vs. trigger/route templates (§4.5, §5.1)** — the per-app binding tax bites
|
|
||||||
early at high tenant cardinality. Decide whether templates are truly deferrable for your target.
|
|
||||||
- **`pull --factor`** — auto-extract a shared base by diffing two pulled envs (later nicety).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Suggested phasing
|
|
||||||
|
|
||||||
1. **Declarative project tool, single-app (no groups yet).** `init`, `pull`/`config --effective`,
|
|
||||||
manifest parse/validate, `plan` (bound artifact), `apply` (**atomic desired-state write — requires
|
|
||||||
the manager-core post-commit-refresh restructuring of §4.2, domains/files excluded from the
|
|
||||||
transactional core**), `prune`, secrets push, link state, env-scoped config. Adds `enabled` to
|
|
||||||
scripts/routes + the three-state runtime + the dispatcher fire-time `enabled` re-check (§4.3) +
|
|
||||||
trigger `name` column/backfill + the `enabled`/secrets conflict bit + the net-new content +
|
|
||||||
tree-structure version counters + a coarse per-instance apply lock; in-flight executions finish (no
|
|
||||||
kill).
|
|
||||||
2. **Groups as pure org/RBAC/UI container.** Nested groups (single-parent, `parent_id`, **delete =
|
|
||||||
RESTRICT**, reparent/rename with the **ancestor-walk cycle guard** + **slug-freeze** +
|
|
||||||
**tree-structure version**, §5.6), inherited membership, hierarchy-aware `can`, UI grouping, the §9
|
|
||||||
backfill. No shared resources yet — cheap, no data-plane schema change.
|
|
||||||
3. **Group-inherited config** (vars, secret-refs, env-scoped). The net-new `vars`/`secret-refs`
|
|
||||||
tables + polymorphic owner; the group-secret AAD scheme (§5.3 caveat); masked group secrets; the
|
|
||||||
effective-view resolver + materialization + invalidation; **`config --effective --explain`** (hard
|
|
||||||
requirement, since multi-level resolution first ships here).
|
|
||||||
4. **Group-inherited scripts/modules.** CoW overrides; the **scope-aware module/import resolver +
|
|
||||||
extension points** (§5.5); cache-invalidation fan-out hardening; versioning/pinning if needed.
|
|
||||||
5. **Project tool maps onto groups.** Nested manifests, attach point, single-owner, server-computed
|
|
||||||
tree plan, per-env approval gating.
|
|
||||||
6. **(Much later) group-level collections/topics** — the v1.3 cross-app data-sharing problem, with a
|
|
||||||
real shared-scope authz model. Optionally, trigger/route **templates** (§4.5) if cardinality
|
|
||||||
demands.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Contracts still to draft
|
|
||||||
|
|
||||||
- The **apply bundle / plan artifact / change-report** wire contract (what the CLI ships, what the
|
|
||||||
server persists and returns), including the conflict and blast-radius shapes.
|
|
||||||
- The **effective-view resolver** (the read primitive) — the §3 rule made executable, plus the
|
|
||||||
materialization + invalidation protocol (§5.1).
|
|
||||||
- The **full manifest schema** spelling every block (scripts, routes, the 8 trigger kinds, storage
|
|
||||||
config, env-scoped vars, secret-refs, domains, `[project.environments]` + confirm policy).
|
|
||||||
2
docs/dev-guide/.gitignore
vendored
Normal file
2
docs/dev-guide/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# mdBook build output — generated by `mdbook build`, not source.
|
||||||
|
book/
|
||||||
23
docs/dev-guide/book.toml
Normal file
23
docs/dev-guide/book.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[book]
|
||||||
|
title = "PiCloud Developer Guide"
|
||||||
|
description = "How to build and run serverless apps on PiCloud — write Rhai scripts, get HTTP endpoints."
|
||||||
|
authors = ["PiCloud"]
|
||||||
|
language = "en"
|
||||||
|
src = "src"
|
||||||
|
|
||||||
|
[output.html]
|
||||||
|
default-theme = "navy"
|
||||||
|
preferred-dark-theme = "navy"
|
||||||
|
smart-punctuation = true
|
||||||
|
git-repository-url = ""
|
||||||
|
edit-url-template = ""
|
||||||
|
|
||||||
|
[output.html.fold]
|
||||||
|
enable = true
|
||||||
|
level = 1
|
||||||
|
|
||||||
|
[output.html.search]
|
||||||
|
enable = true
|
||||||
|
limit-results = 30
|
||||||
|
use-boolean-and = true
|
||||||
|
boost-title = 2
|
||||||
66
docs/dev-guide/src/SUMMARY.md
Normal file
66
docs/dev-guide/src/SUMMARY.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Summary
|
||||||
|
|
||||||
|
[Introduction](introduction.md)
|
||||||
|
|
||||||
|
# Guide
|
||||||
|
|
||||||
|
- [Quickstart](guide/quickstart.md)
|
||||||
|
- [Core concepts](guide/concepts.md)
|
||||||
|
- [Writing scripts](guide/writing-scripts.md)
|
||||||
|
|
||||||
|
# Tutorials
|
||||||
|
|
||||||
|
- [Overview & feature matrix](examples/index.md)
|
||||||
|
- [URL shortener](examples/url-shortener.md)
|
||||||
|
- [Webhook receiver](examples/webhook-receiver.md)
|
||||||
|
- [TODO API with auth](examples/todo-api.md)
|
||||||
|
- [Scheduled report](examples/scheduled-report.md)
|
||||||
|
- [File-upload service](examples/file-upload.md)
|
||||||
|
|
||||||
|
# SDK reference
|
||||||
|
|
||||||
|
- [Overview](reference/sdk/overview.md)
|
||||||
|
- [The execution context & events](reference/sdk/ctx-and-events.md)
|
||||||
|
- [Storage: kv, docs, files](reference/sdk/storage.md)
|
||||||
|
- [Messaging: pubsub, queue](reference/sdk/messaging.md)
|
||||||
|
- [Outbound HTTP](reference/sdk/http.md)
|
||||||
|
- [Email](reference/sdk/email.md)
|
||||||
|
- [Users & auth](reference/sdk/users.md)
|
||||||
|
- [Secrets](reference/sdk/secrets.md)
|
||||||
|
- [Composition: invoke, retry, dead_letters](reference/sdk/composition.md)
|
||||||
|
- [Standard library](reference/sdk/stdlib.md)
|
||||||
|
|
||||||
|
# HTTP API reference
|
||||||
|
|
||||||
|
- [Overview & authentication](reference/rest-api/overview.md)
|
||||||
|
- [Apps & domains](reference/rest-api/apps.md)
|
||||||
|
- [Scripts, routes & logs](reference/rest-api/scripts.md)
|
||||||
|
- [Triggers](reference/rest-api/triggers.md)
|
||||||
|
- [Topics & realtime](reference/rest-api/topics.md)
|
||||||
|
- [Secrets, KV & files](reference/rest-api/data-admin.md)
|
||||||
|
- [Queues & dead-letters](reference/rest-api/queues.md)
|
||||||
|
- [App users](reference/rest-api/app-users.md)
|
||||||
|
- [Members, admins & API keys](reference/rest-api/access.md)
|
||||||
|
|
||||||
|
# CLI reference
|
||||||
|
|
||||||
|
- [The `pic` CLI](reference/cli/pic.md)
|
||||||
|
- [Server admin commands](reference/cli/server-admin.md)
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
|
||||||
|
- [Environment variables](reference/config/env-vars.md)
|
||||||
|
- [Capabilities & roles](reference/config/capabilities.md)
|
||||||
|
|
||||||
|
# Deployment
|
||||||
|
|
||||||
|
- [Docker Compose](deploy/docker-compose.md)
|
||||||
|
- [Running the bare binary](deploy/bare-binary.md)
|
||||||
|
- [Caddy & TLS](deploy/caddy-tls.md)
|
||||||
|
- [Production checklist](deploy/production-checklist.md)
|
||||||
|
|
||||||
|
# Operations
|
||||||
|
|
||||||
|
- [Security](operations/security.md)
|
||||||
|
- [Best practices](operations/best-practices.md)
|
||||||
|
- [Troubleshooting](operations/troubleshooting.md)
|
||||||
82
docs/dev-guide/src/deploy/bare-binary.md
Normal file
82
docs/dev-guide/src/deploy/bare-binary.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# Running the bare binary
|
||||||
|
|
||||||
|
You can run `picloud` directly against a Postgres you provide — handy for development against the
|
||||||
|
source, debugging, or a setup where you manage the database and proxy yourself.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Rust 1.92+** (pinned in `rust-toolchain.toml`).
|
||||||
|
- **PostgreSQL 15+** reachable via `DATABASE_URL`. The easiest Postgres is the compose one:
|
||||||
|
`docker compose up -d postgres` (publishes `127.0.0.1:15432`).
|
||||||
|
|
||||||
|
## Build and run
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build -p picloud # or --release
|
||||||
|
|
||||||
|
export DATABASE_URL="postgres://picloud:picloud@localhost:15432/picloud"
|
||||||
|
export PICLOUD_BIND="0.0.0.0:18080" # 8080 is a common conflict — pick a free port
|
||||||
|
export PICLOUD_PUBLIC_BASE_URL="http://localhost:18080"
|
||||||
|
# Dev master key (local only):
|
||||||
|
export PICLOUD_DEV_MODE=true
|
||||||
|
export PICLOUD_DEV_INSECURE_KEY="i-understand-this-is-insecure"
|
||||||
|
# Bootstrap admin (only needed on a fresh DB):
|
||||||
|
export PICLOUD_ADMIN_USERNAME="admin"
|
||||||
|
export PICLOUD_ADMIN_PASSWORD="change-me"
|
||||||
|
|
||||||
|
cargo run -p picloud
|
||||||
|
```
|
||||||
|
|
||||||
|
It applies migrations, seeds the bootstrap admin + `hello` example on a fresh DB, and listens on
|
||||||
|
`PICLOUD_BIND`. Verify:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl localhost:18080/healthz # ok
|
||||||
|
curl localhost:18080/version
|
||||||
|
```
|
||||||
|
|
||||||
|
**Minimal requirements:** just `DATABASE_URL` and a master key — either `PICLOUD_SECRET_KEY` (base64 of
|
||||||
|
32 bytes) *or* the dev-mode pair above. `PICLOUD_DEV_MODE=true` **alone** aborts at startup; you must
|
||||||
|
also set `PICLOUD_DEV_INSECURE_KEY`. The bootstrap admin vars are only consulted when `admin_users` is
|
||||||
|
empty. Full list: [Environment variables](../reference/config/env-vars.md).
|
||||||
|
|
||||||
|
## A real master key
|
||||||
|
|
||||||
|
For anything beyond throwaway local use, generate a proper key instead of dev mode:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export PICLOUD_SECRET_KEY="$(head -c 32 /dev/urandom | base64)"
|
||||||
|
# (then DON'T set PICLOUD_DEV_MODE / PICLOUD_DEV_INSECURE_KEY)
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep this stable — see the [rotation caveat](../operations/security.md#secrets-and-the-master-key).
|
||||||
|
|
||||||
|
## Running the dashboard in dev
|
||||||
|
|
||||||
|
The SvelteKit dashboard has its own Vite dev server with hot reload:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd dashboard
|
||||||
|
npm install
|
||||||
|
npm run dev # serves on http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Port footgun.** Vite proxies `/api` and `/healthz` to **`http://127.0.0.1:18080`** by default — not
|
||||||
|
> 8080. So either run `picloud` on `18080` (as above), or point Vite elsewhere:
|
||||||
|
> `PICLOUD_API=http://localhost:9000 npm run dev`. The dashboard dev port is `5173`
|
||||||
|
> (`PICLOUD_DASHBOARD_PORT` to change).
|
||||||
|
|
||||||
|
For a production-style static build, `npm run build` emits a static SPA (served by Caddy in the
|
||||||
|
compose stack). You usually don't need the Vite dev server unless you're hacking on the dashboard
|
||||||
|
itself — the [compose stack](docker-compose.md) already serves a built dashboard at `/admin`.
|
||||||
|
|
||||||
|
## The `pic` CLI
|
||||||
|
|
||||||
|
Build it alongside: `cargo build -p picloud-cli` → `target/debug/pic`. Point it at your instance:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
printf "$PICLOUD_ADMIN_PASSWORD" | pic login --url http://localhost:18080 --username admin --password-stdin
|
||||||
|
pic whoami
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [CLI reference](../reference/cli/pic.md).
|
||||||
71
docs/dev-guide/src/deploy/caddy-tls.md
Normal file
71
docs/dev-guide/src/deploy/caddy-tls.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Caddy & TLS
|
||||||
|
|
||||||
|
Caddy is the single entry point in front of PiCloud. The same topology serves dev and production — only
|
||||||
|
the upstream targets and TLS settings change.
|
||||||
|
|
||||||
|
## Routing topology
|
||||||
|
|
||||||
|
Caddy routes by path prefix (from `caddy/Caddyfile`):
|
||||||
|
|
||||||
|
| Path | Goes to |
|
||||||
|
|---|---|
|
||||||
|
| `/healthz`, `/version` | picloud (liveness + version) |
|
||||||
|
| `/api/v1/admin/*` | picloud (control plane) |
|
||||||
|
| `/api/v1/execute/*` | picloud (execute-by-id) |
|
||||||
|
| `/api/*` (anything else) | **404** — reserved for future API versions |
|
||||||
|
| `/admin/*` | the dashboard SPA |
|
||||||
|
| **everything else** | picloud's **user-route matcher** — your scripts' routes |
|
||||||
|
|
||||||
|
That final catch-all is what makes arbitrary user paths like `/hello` or `/r/abc123` work: Caddy
|
||||||
|
forwards them to picloud, which matches them against the [route table](../guide/concepts.md#routes-and-domains)
|
||||||
|
(or returns a JSON `404`). This is why your routes can't use the reserved prefixes `/api/`, `/admin/`,
|
||||||
|
`/healthz`, `/version` — Caddy would never forward them to the matcher.
|
||||||
|
|
||||||
|
## Security headers & body limit
|
||||||
|
|
||||||
|
The Caddyfile adds defense-in-depth headers and a hard body cap:
|
||||||
|
|
||||||
|
- `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer` on **every** response.
|
||||||
|
- A strict CSP, `X-Frame-Options: DENY`, `Cache-Control: no-store`, and a locked-down
|
||||||
|
`Permissions-Policy` on the **dashboard** and **admin API**.
|
||||||
|
- A **12 MB request-body ceiling** at the proxy (just above the orchestrator's 10 MiB user-route read).
|
||||||
|
- **User-route responses get no CSP on purpose** — your scripts own their own response headers. The
|
||||||
|
`?` (set-if-missing) operator means a script's own header wins over the default.
|
||||||
|
|
||||||
|
## Production: HTTPS with Let's Encrypt
|
||||||
|
|
||||||
|
Layer the production overlay, which swaps in `caddy/Caddyfile.prod` and exposes 80/443:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export PICLOUD_DOMAIN="picloud.example.com"
|
||||||
|
export PICLOUD_ADMIN_EMAIL="you@example.com"
|
||||||
|
export POSTGRES_PASSWORD="$(head -c 24 /dev/urandom | base64)"
|
||||||
|
export PICLOUD_SECRET_KEY="$(head -c 32 /dev/urandom | base64)"
|
||||||
|
export PICLOUD_ADMIN_USERNAME="admin"
|
||||||
|
export PICLOUD_ADMIN_PASSWORD="…"
|
||||||
|
export PICLOUD_PUBLIC_BASE_URL="https://picloud.example.com"
|
||||||
|
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Caddy obtains and renews a certificate for `PICLOUD_DOMAIN` automatically (HTTP/TLS-ALPN challenge — the
|
||||||
|
host must be reachable on 80/443 from the internet). The prod overlay also:
|
||||||
|
|
||||||
|
- **removes the published Postgres port** (DB reachable only inside the compose network);
|
||||||
|
- adds **HSTS** to every response (on top of the dev headers);
|
||||||
|
- sets `restart: unless-stopped` on all services.
|
||||||
|
|
||||||
|
The prod Caddyfile reads `PICLOUD_DOMAIN` and `PICLOUD_ADMIN_EMAIL` from the environment, so set them
|
||||||
|
before `up`.
|
||||||
|
|
||||||
|
## Custom domains for your apps
|
||||||
|
|
||||||
|
PiCloud's app [domains](../reference/rest-api/apps.md#domains) are matched by picloud *after* Caddy
|
||||||
|
forwards the request — they're independent of Caddy's own host config. For multi-tenant setups
|
||||||
|
(`{tenant}.example.com`), point a wildcard DNS record + Caddy cert at the box, then let each app claim
|
||||||
|
its host pattern. The most-specific app claim wins; unclaimed hosts get a `404 no app claims host`.
|
||||||
|
|
||||||
|
## Adding a future API version
|
||||||
|
|
||||||
|
When `/api/v2/...` ships, add a `handle /api/v2/admin/* { … }` block before the catch-all `/api/*`
|
||||||
|
404, mirroring the v1 block. The v1 routes stay live through the deprecation window.
|
||||||
88
docs/dev-guide/src/deploy/docker-compose.md
Normal file
88
docs/dev-guide/src/deploy/docker-compose.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Deploy with Docker Compose
|
||||||
|
|
||||||
|
The default stack runs the whole system — Postgres, the `picloud` binary, the dashboard, and a Caddy
|
||||||
|
reverse proxy — behind one port. It's the recommended way to run PiCloud for local dev and single-node
|
||||||
|
production.
|
||||||
|
|
||||||
|
## The stack
|
||||||
|
|
||||||
|
`docker-compose.yml` defines four services:
|
||||||
|
|
||||||
|
| Service | Role | Exposed |
|
||||||
|
|---|---|---|
|
||||||
|
| `postgres` | the database | host `127.0.0.1:15432` (dev convenience; removed in prod) |
|
||||||
|
| `picloud` | the all-in-one binary | internal `:8080` only |
|
||||||
|
| `dashboard` | the SvelteKit SPA | internal `:80` only |
|
||||||
|
| `caddy` | reverse proxy / entry point | host `:${PICLOUD_HOST_PORT:-8000}` → `:80` |
|
||||||
|
|
||||||
|
Only Caddy is published. It routes by path: `/healthz`, `/version`, `/api/v1/admin/*`,
|
||||||
|
`/api/v1/execute/*` → picloud; `/admin/*` → dashboard; everything else → picloud's user-route matcher.
|
||||||
|
See [Caddy & TLS](caddy-tls.md).
|
||||||
|
|
||||||
|
## First run
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp .env.example .env
|
||||||
|
# edit .env — at minimum set PICLOUD_ADMIN_USERNAME and PICLOUD_ADMIN_PASSWORD
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
On first boot picloud runs migrations, seeds the bootstrap admin from `PICLOUD_ADMIN_USERNAME` /
|
||||||
|
`PICLOUD_ADMIN_PASSWORD` (as instance `owner`), and seeds a `hello` example into the `default` app.
|
||||||
|
Then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl localhost:8000/healthz # ok
|
||||||
|
curl localhost:8000/version # {"product":"1.1.9","sdk":"1.10",...}
|
||||||
|
curl localhost:8000/hello # {"message":"Hello, world!"}
|
||||||
|
open http://localhost:8000/admin
|
||||||
|
```
|
||||||
|
|
||||||
|
> **The compose file makes `PICLOUD_ADMIN_USERNAME`/`PICLOUD_ADMIN_PASSWORD` mandatory** (it uses
|
||||||
|
> `${VAR:?…}`), so `docker compose up` errors out if they're unset — even though the binary itself only
|
||||||
|
> needs them on a fresh DB. This is intentional: it stops you from shipping with no admin.
|
||||||
|
|
||||||
|
## The dev `.env`
|
||||||
|
|
||||||
|
The shipped `.env.example` runs in **dev mode**: it sets `PICLOUD_DEV_MODE=true`, and
|
||||||
|
`docker-compose.override.yml` (gitignored, applied automatically) supplies
|
||||||
|
`PICLOUD_DEV_INSECURE_KEY=i-understand-this-is-insecure` so the stack boots with a deterministic,
|
||||||
|
world-known master key. **This is for local dev only** — at-rest encryption is meaningless with a public
|
||||||
|
key. For anything real, set a true `PICLOUD_SECRET_KEY` and don't use dev mode (see
|
||||||
|
[Production checklist](production-checklist.md)).
|
||||||
|
|
||||||
|
Key `.env` settings:
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `PICLOUD_HOST_PORT` | host port Caddy listens on (default 8000) |
|
||||||
|
| `POSTGRES_PASSWORD` | DB password — **change for prod** |
|
||||||
|
| `PICLOUD_POSTGRES_HOST_PORT` | host port for Postgres (dev only; default 15432) |
|
||||||
|
| `PICLOUD_PUBLIC_BASE_URL` | the URL users actually reach (rendered in the dashboard, `/version`) |
|
||||||
|
| `PICLOUD_ADMIN_USERNAME` / `PICLOUD_ADMIN_PASSWORD` | bootstrap admin |
|
||||||
|
| `RUST_LOG` | log verbosity |
|
||||||
|
|
||||||
|
The full variable list is in [Environment variables](../reference/config/env-vars.md).
|
||||||
|
|
||||||
|
## Everyday operations
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose ps # service health
|
||||||
|
docker compose logs -f picloud # tail the server
|
||||||
|
docker compose exec postgres psql -U picloud picloud # poke the DB (dev)
|
||||||
|
docker compose down # stop (keeps data)
|
||||||
|
docker compose down -v # stop and WIPE Postgres + Caddy volumes
|
||||||
|
docker compose up -d --build # rebuild after a code change
|
||||||
|
```
|
||||||
|
|
||||||
|
Data lives in the `postgres_data` volume (and, for `files`, inside the picloud container's
|
||||||
|
`PICLOUD_FILES_ROOT` — mount a volume there if you use `files` in production). Recover a locked-out
|
||||||
|
admin with [`picloud admin reset-password`](../reference/cli/server-admin.md):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose exec picloud picloud admin reset-password admin
|
||||||
|
```
|
||||||
|
|
||||||
|
For production (real domain, HTTPS, no published DB port), layer the prod overlay — see
|
||||||
|
[Caddy & TLS](caddy-tls.md) and the [Production checklist](production-checklist.md). To run the binary
|
||||||
|
without Docker, see [Running the bare binary](bare-binary.md).
|
||||||
77
docs/dev-guide/src/deploy/production-checklist.md
Normal file
77
docs/dev-guide/src/deploy/production-checklist.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Production checklist
|
||||||
|
|
||||||
|
Before exposing PiCloud to real traffic, walk this list. Items marked **critical** can compromise the
|
||||||
|
whole instance if skipped.
|
||||||
|
|
||||||
|
## Secrets & keys
|
||||||
|
|
||||||
|
- [ ] **Critical: set a real `PICLOUD_SECRET_KEY`** (base64 of 32 random bytes) and **do not** use
|
||||||
|
`PICLOUD_DEV_MODE` / `PICLOUD_DEV_INSECURE_KEY`. The dev key is world-known — every `secrets`
|
||||||
|
value and realtime key would be "encrypted" with a public value.
|
||||||
|
```sh
|
||||||
|
export PICLOUD_SECRET_KEY="$(head -c 32 /dev/urandom | base64)"
|
||||||
|
```
|
||||||
|
- [ ] **Store the key in a secret manager**, not in a committed `.env`. If you lose it, every encrypted
|
||||||
|
secret becomes undecryptable. **Rotating it orphans existing ciphertext** — there's no auto
|
||||||
|
re-encryption; rotate deliberately and re-`set` your secrets. See
|
||||||
|
[Security → master key](../operations/security.md#secrets-and-the-master-key).
|
||||||
|
- [ ] **Critical: change `POSTGRES_PASSWORD`** from the dev default.
|
||||||
|
- [ ] Prefer `PICLOUD_ADMIN_PASSWORD_HASH` (a pre-computed Argon2id PHC string) over a raw
|
||||||
|
`PICLOUD_ADMIN_PASSWORD`, so the plaintext never lands in env/compose.
|
||||||
|
|
||||||
|
## Network & TLS
|
||||||
|
|
||||||
|
- [ ] Deploy with the **prod overlay** for automatic HTTPS:
|
||||||
|
`docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d`. See
|
||||||
|
[Caddy & TLS](caddy-tls.md).
|
||||||
|
- [ ] Set `PICLOUD_DOMAIN`, `PICLOUD_ADMIN_EMAIL`, and `PICLOUD_PUBLIC_BASE_URL` (to your `https://`
|
||||||
|
origin).
|
||||||
|
- [ ] Confirm **Postgres is not publicly exposed** (the prod overlay removes the host port mapping).
|
||||||
|
- [ ] **Critical: do not set `PICLOUD_HTTP_ALLOW_PRIVATE`** — leaving the SSRF guard on stops scripts
|
||||||
|
probing your internal network via `http::*`.
|
||||||
|
|
||||||
|
## Data & durability
|
||||||
|
|
||||||
|
- [ ] Back up the **Postgres volume** *and* the **`files` blob storage** (`PICLOUD_FILES_ROOT`)
|
||||||
|
together — file metadata and bytes live in different places.
|
||||||
|
- [ ] Mount a persistent volume for `PICLOUD_FILES_ROOT` if you use the `files` SDK (otherwise blobs
|
||||||
|
live inside the container and vanish on `down`).
|
||||||
|
- [ ] Set retention to taste: `PICLOUD_DEAD_LETTER_RETENTION_DAYS`,
|
||||||
|
`PICLOUD_ABANDONED_EXECUTIONS_RETENTION_DAYS`.
|
||||||
|
|
||||||
|
## Capacity & limits
|
||||||
|
|
||||||
|
- [ ] Tune `PICLOUD_MAX_CONCURRENT_EXECUTIONS` and `PICLOUD_DB_MAX_CONNECTIONS` together to your
|
||||||
|
hardware. Past the concurrency cap, data-plane requests get `503 Retry-After: 1` — make sure
|
||||||
|
clients handle it.
|
||||||
|
- [ ] Review the [sandbox ceilings](../reference/config/env-vars.md#sandbox-ceilings)
|
||||||
|
(`PICLOUD_SANDBOX_MAX_*`) — the defaults are conservative; raise only deliberately.
|
||||||
|
- [ ] Review the [size caps](../reference/config/env-vars.md#data-plane-size-caps) for `kv`, `docs`,
|
||||||
|
`queue`, `pubsub`, `files`.
|
||||||
|
|
||||||
|
## Email
|
||||||
|
|
||||||
|
- [ ] Configure `PICLOUD_SMTP_*` if any script (or the `users` flows) sends mail — otherwise
|
||||||
|
`email::send` throws `NotConfigured` (the dev sink does **not** exist outside dev mode).
|
||||||
|
|
||||||
|
## Application hygiene
|
||||||
|
|
||||||
|
- [ ] Remember the [data plane is public](../operations/security.md#the-data-plane-is-public): every
|
||||||
|
route runs with full app authority. Audit each public route — does it expose data it shouldn't?
|
||||||
|
- [ ] Put auth (`users::verify`, a shared secret, etc.) on every route that needs it; the platform
|
||||||
|
won't.
|
||||||
|
- [ ] Scope **API keys** narrowly (least-privilege scopes, bind to one app, set `expires_at`).
|
||||||
|
- [ ] Use **per-app members** with the lowest role that works (`viewer`/`editor`/`app_admin`) rather
|
||||||
|
than handing out instance `admin`.
|
||||||
|
- [ ] Prefer **`async` dispatch** for slow or fire-and-forget work so the request path stays fast.
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
- [ ] Set `RUST_LOG` sensibly (`info,picloud=info` in prod).
|
||||||
|
- [ ] Alert on the unresolved **dead-letter** count (`pic dead-letters count --app …` /
|
||||||
|
`GET …/dead_letters/count`).
|
||||||
|
- [ ] Remember that `log::` output is captured for **async/triggered** runs, not synchronous HTTP runs
|
||||||
|
([why](../guide/writing-scripts.md#logging)).
|
||||||
|
|
||||||
|
When all boxes are checked, you're running on a real key, behind HTTPS, with a private database,
|
||||||
|
backed up, and with auth on the routes that need it.
|
||||||
126
docs/dev-guide/src/examples/file-upload.md
Normal file
126
docs/dev-guide/src/examples/file-upload.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# Tutorial: File-upload service
|
||||||
|
|
||||||
|
Accept file uploads, store them as blobs, announce each upload on a [pubsub](../reference/sdk/messaging.md#pubsub)
|
||||||
|
topic, and stream those announcements to a browser over [SSE](../reference/rest-api/topics.md). This
|
||||||
|
exercises [`files`](../reference/sdk/storage.md#files), [`base64`](../reference/sdk/stdlib.md#base64),
|
||||||
|
`pubsub::publish_durable`, and a realtime topic — and surfaces two real platform constraints about
|
||||||
|
binary I/O.
|
||||||
|
|
||||||
|
> **Two things to know up front (both verified below):**
|
||||||
|
> 1. **There are no raw request bytes** — `ctx.request.body` is JSON. So uploads arrive as
|
||||||
|
> **base64 inside a JSON field**, which the script decodes.
|
||||||
|
> 2. **Script responses are always JSON** — a script *cannot* stream raw bytes back through its
|
||||||
|
> response (a blob body serializes to a hex JSON string). So downloads either return **base64 in
|
||||||
|
> JSON** (shown here) or use the **admin files endpoint**, which serves true raw bytes.
|
||||||
|
|
||||||
|
## 1. App, host, topic
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pic apps create vault --name "File Vault"
|
||||||
|
pic apps domains add vault vault.localhost
|
||||||
|
# register a public, externally-subscribable topic (note: --app is a flag, name is positional)
|
||||||
|
pic topics create --app vault file.uploaded --external --auth-mode public
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Upload
|
||||||
|
|
||||||
|
`upload.rhai` — decode the base64 body, store the blob, publish:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let b = ctx.request.body;
|
||||||
|
if type_of(b) != "map" || !b.contains("b64") {
|
||||||
|
return #{ statusCode: 400, body: #{ error: "expected JSON { name, content_type, b64 }" } };
|
||||||
|
}
|
||||||
|
let bytes = base64::decode(b.b64); // Blob
|
||||||
|
let id = files::collection("uploads").create(#{
|
||||||
|
name: b.name, content_type: b.content_type, data: bytes
|
||||||
|
});
|
||||||
|
pubsub::publish_durable("file.uploaded", #{ id: id, name: b.name, size: bytes.len() });
|
||||||
|
return #{ statusCode: 201, body: #{ id: id, size: bytes.len() } };
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Download (base64 in JSON)
|
||||||
|
|
||||||
|
`download.rhai`:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let id = ctx.request.params.id;
|
||||||
|
let meta = files::collection("uploads").head(id); // metadata, () if missing
|
||||||
|
if meta == () { return #{ statusCode: 404, body: #{ error: "not found" } }; }
|
||||||
|
let bytes = files::collection("uploads").get(id); // the Blob
|
||||||
|
return #{ statusCode: 200, body: #{
|
||||||
|
name: meta.name, content_type: meta.content_type, b64: base64::encode(bytes)
|
||||||
|
} };
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Deploy and route
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pic scripts deploy upload.rhai --app vault --name upload
|
||||||
|
pic scripts deploy download.rhai --app vault --name download
|
||||||
|
UP=$(pic --output json scripts ls --app vault | jq -r '.[]|select(.name=="upload").id')
|
||||||
|
DL=$(pic --output json scripts ls --app vault | jq -r '.[]|select(.name=="download").id')
|
||||||
|
pic routes create --script $UP --path /files --method POST
|
||||||
|
pic routes create --script $DL --path '/files/:id/data' --path-kind param --method GET
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Upload and download
|
||||||
|
|
||||||
|
```sh
|
||||||
|
H='Host: vault.localhost'
|
||||||
|
B64=$(printf 'hello pics' | base64) # aGVsbG8gcGljcw==
|
||||||
|
|
||||||
|
curl -s -X POST http://localhost:18080/files -H "$H" -H 'Content-Type: application/json' \
|
||||||
|
-d "{\"name\":\"greeting.txt\",\"content_type\":\"text/plain\",\"b64\":\"$B64\"}"
|
||||||
|
# {"id":"98698d33-…","size":10}
|
||||||
|
|
||||||
|
curl -s http://localhost:18080/files/<id>/data -H "$H"
|
||||||
|
# {"b64":"aGVsbG8gcGljcw==","content_type":"text/plain","name":"greeting.txt"}
|
||||||
|
# → base64-decode "b64" on the client to recover the bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
Need true raw bytes (e.g. to `<img src>` an upload)? Use the authenticated admin endpoint, which sets
|
||||||
|
`Content-Type`/`Content-Disposition` and streams the bytes:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl http://localhost:18080/api/v1/admin/apps/$(pic --output json apps show vault | jq -r .id)/files/uploads/<id> \
|
||||||
|
-H "Authorization: Bearer $TOKEN" -o greeting.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
…or the CLI: `pic files get --app vault --collection uploads --id <id> --out greeting.txt`.
|
||||||
|
|
||||||
|
## 6. Watch uploads live (SSE)
|
||||||
|
|
||||||
|
Subscribe to the topic; every upload pushes an event:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -N http://localhost:18080/realtime/topics/file.uploaded -H 'Host: vault.localhost'
|
||||||
|
```
|
||||||
|
|
||||||
|
Upload another file from a second terminal and the subscriber prints:
|
||||||
|
|
||||||
|
```text
|
||||||
|
data: {"message":{"id":"1313221c-…","name":"c.txt","size":10},
|
||||||
|
"published_at":"2026-…Z","topic":"file.uploaded"}
|
||||||
|
```
|
||||||
|
|
||||||
|
The published payload is under `message`. In a browser that's
|
||||||
|
`new EventSource('https://your-host/realtime/topics/file.uploaded')`. For non-public topics, mint a
|
||||||
|
subscriber token (`pubsub::subscriber_token`) or use an app-user session — see
|
||||||
|
[Topics & realtime](../reference/rest-api/topics.md).
|
||||||
|
|
||||||
|
## Notes, constraints & next steps
|
||||||
|
|
||||||
|
- **Size caps.** Per-file blobs cap at `PICLOUD_FILES_MAX_FILE_SIZE_BYTES` (100 MiB), but the upload
|
||||||
|
also passes through the request-body limit (~10 MiB) **and** base64 inflates by ~33%. For large
|
||||||
|
files you'll want chunking or a different ingest path; this base64-in-JSON pattern suits modest
|
||||||
|
files (avatars, attachments, docs).
|
||||||
|
- **Blobs in / metadata out.** `files::...head(id)` returns metadata only (name, content_type, size,
|
||||||
|
checksum) — cheap; `...get(id)` returns the bytes.
|
||||||
|
- **Storage location.** Bytes live on disk under `PICLOUD_FILES_ROOT`; metadata in Postgres. Back both
|
||||||
|
up together.
|
||||||
|
- **`files` triggers.** A blob create/update/delete can fire a [`files` trigger](../reference/rest-api/triggers.md)
|
||||||
|
(`ctx.event.files`, metadata only) — e.g. to generate a thumbnail or scan an upload.
|
||||||
|
|
||||||
|
Next: validate `content_type` and reject disallowed types; generate thumbnails in a `files`-trigger
|
||||||
|
consumer; or gate uploads behind the [auth pattern from the TODO tutorial](todo-api.md).
|
||||||
59
docs/dev-guide/src/examples/index.md
Normal file
59
docs/dev-guide/src/examples/index.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Tutorials — overview & feature matrix
|
||||||
|
|
||||||
|
Five complete, copy-pasteable example apps. Each was built and run against a real instance while
|
||||||
|
writing this guide — the commands and outputs are what actually happened. Work through them in any
|
||||||
|
order; together they touch most of the platform.
|
||||||
|
|
||||||
|
Every tutorial assumes the [Quickstart](../guide/quickstart.md) stack is running and you're logged in
|
||||||
|
with `pic`. Each creates its own [app](../guide/concepts.md#apps) and claims a `*.localhost` host, so
|
||||||
|
you address it with a `Host:` header (`curl -H 'Host: links.localhost' …`).
|
||||||
|
|
||||||
|
> **A note on ports.** These tutorials use `http://localhost:18080` (a bare binary — see
|
||||||
|
> [Running the bare binary](../deploy/bare-binary.md)). If you followed the Quickstart's **Docker
|
||||||
|
> Compose** stack, substitute **`8000`** for `18080` everywhere (including the `short_url` the URL
|
||||||
|
> shortener builds). Same behavior, different port.
|
||||||
|
|
||||||
|
## Which tutorial shows which feature
|
||||||
|
|
||||||
|
| | [URL shortener](url-shortener.md) | [Webhook receiver](webhook-receiver.md) | [TODO API](todo-api.md) | [Scheduled report](scheduled-report.md) | [File upload](file-upload.md) |
|
||||||
|
|---|:--:|:--:|:--:|:--:|:--:|
|
||||||
|
| `kv` | ● | ● | | ● | |
|
||||||
|
| `docs` | | | ● | | |
|
||||||
|
| `files` | | | | | ● |
|
||||||
|
| `secrets` | | ● | | | |
|
||||||
|
| `http` (outbound) | | ● | | | |
|
||||||
|
| `email` | | | | ● | |
|
||||||
|
| `users` (auth) | | | ● | | |
|
||||||
|
| `pubsub` + SSE | | | | | ● |
|
||||||
|
| `queue` | | ● | | | |
|
||||||
|
| `invoke`/`retry` | | ● | | | |
|
||||||
|
| dead-letters | | ● | | | |
|
||||||
|
| stdlib (`random`/`base64`/`time`) | ● | | | ● | ● |
|
||||||
|
| route params (`:id`) | ● | | ● | | ● |
|
||||||
|
| async dispatch (`202`) | | ● | | | |
|
||||||
|
| cron trigger | | | | ● | |
|
||||||
|
| queue trigger | | ● | | | |
|
||||||
|
| modules (`import`) | | | | ● | |
|
||||||
|
|
||||||
|
## The five apps
|
||||||
|
|
||||||
|
1. **[URL shortener](url-shortener.md)** — `POST /shorten` + `GET /r/:code`. The gentlest start: KV,
|
||||||
|
a param route, a random code, a `302` redirect. *(~10 min.)*
|
||||||
|
2. **[Webhook receiver](webhook-receiver.md)** — authenticate with a shared secret, accept with `202`,
|
||||||
|
process on a durable queue with retries, forward over HTTP, handle failures as dead-letters. The
|
||||||
|
most feature-dense tutorial. *(~25 min.)*
|
||||||
|
3. **[TODO API with auth](todo-api.md)** — real multi-user signup/login built from the `users` SDK,
|
||||||
|
per-user data in `docs`. Shows that PiCloud has no built-in auth endpoints — you compose them.
|
||||||
|
*(~20 min.)*
|
||||||
|
4. **[Scheduled report](scheduled-report.md)** — a cron trigger that aggregates data, formats it via an
|
||||||
|
imported module, and emails a summary (verified against the dev mail sink). *(~15 min.)*
|
||||||
|
5. **[File-upload service](file-upload.md)** — store blobs, announce uploads on a pubsub topic, stream
|
||||||
|
them to a browser via SSE. Clears up how binary I/O works (base64-in/JSON-out). *(~20 min.)*
|
||||||
|
|
||||||
|
## Patterns they share
|
||||||
|
|
||||||
|
- **One app per project**, each claiming its own host — that's the isolation boundary.
|
||||||
|
- **Deploy from a file** with `pic scripts deploy file.rhai --app <slug>`, then bind routes.
|
||||||
|
- **Inspect with the admin side** — `pic kv get`, `pic logs`, `pic dead-letters ls`, the dashboard.
|
||||||
|
- **Read the "Notes, constraints & next steps"** box at the end of each — that's where the real-world
|
||||||
|
caveats and security gotchas live.
|
||||||
124
docs/dev-guide/src/examples/scheduled-report.md
Normal file
124
docs/dev-guide/src/examples/scheduled-report.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# Tutorial: Scheduled report
|
||||||
|
|
||||||
|
Run a job on a schedule that aggregates data and emails a summary. This exercises a
|
||||||
|
[`cron` trigger](../reference/rest-api/triggers.md), a reusable [`module`](../guide/writing-scripts.md#modules-and-imports)
|
||||||
|
script imported with `import`, [`kv`](../reference/sdk/storage.md#kv) reads, and
|
||||||
|
[`email::send_html`](../reference/sdk/email.md) — verified here against the **dev email sink** so you
|
||||||
|
need no real SMTP server.
|
||||||
|
|
||||||
|
## 1. App
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pic apps create reports --name "Scheduled Reports"
|
||||||
|
```
|
||||||
|
|
||||||
|
(No domain needed — a cron-triggered script isn't reached over HTTP.)
|
||||||
|
|
||||||
|
## 2. A shared formatting module
|
||||||
|
|
||||||
|
`report_fmt.rhai` (`kind: module` — only `fn`/`const`, no top-level statements):
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
fn summary(n) {
|
||||||
|
`Signups so far: ${n}`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. The report worker
|
||||||
|
|
||||||
|
`reporter.rhai` reads a metric, formats it via the module, and emails it. It handles both a real cron
|
||||||
|
firing (`ctx.event.cron`) and a manual test run (no event):
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
import "report_fmt" as fmt;
|
||||||
|
|
||||||
|
let hits = kv::collection("metrics").get("signups");
|
||||||
|
if hits == () { hits = 0; }
|
||||||
|
|
||||||
|
let when = if "event" in ctx && ctx.event.source == "cron" {
|
||||||
|
ctx.event.cron.scheduled_at // RFC 3339 string of the scheduled tick
|
||||||
|
} else {
|
||||||
|
time::now()
|
||||||
|
};
|
||||||
|
|
||||||
|
let line = fmt::summary(hits);
|
||||||
|
email::send_html(#{
|
||||||
|
to: "ops@example.com",
|
||||||
|
from: "reports@reports.app",
|
||||||
|
subject: `Report @ ${when}`,
|
||||||
|
text: line, // plain-text fallback
|
||||||
|
html: `<p>${line}</p>`
|
||||||
|
});
|
||||||
|
log::info("report sent", #{ when: when, line: line });
|
||||||
|
return #{ statusCode: 200, body: #{ sent: true, line: line } };
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Deploy
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pic scripts deploy report_fmt.rhai --app reports --kind module --name report_fmt
|
||||||
|
pic scripts deploy reporter.rhai --app reports --name reporter
|
||||||
|
RP=$(pic --output json scripts ls --app reports | jq -r '.[]|select(.name=="reporter").id')
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Test it once, by hand
|
||||||
|
|
||||||
|
You can run any script directly by id (no route needed) with `execute`. There's no cron event this way,
|
||||||
|
so the worker falls back to `time::now()`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -s -X POST http://localhost:18080/api/v1/execute/$RP -H 'Content-Type: application/json' -d '{}'
|
||||||
|
# {"line":"Signups so far: 0","sent":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
Because the dev stack has no SMTP relay, the mail went to the **in-memory dev sink**. Read it back
|
||||||
|
(instance owner/admin only; this route exists only in dev mode):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -s http://localhost:18080/api/v1/admin/dev/emails -H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
[{"captured_at":"2026-…Z","from":"reports@reports.app","to":["ops@example.com"],
|
||||||
|
"raw":"From: reports@reports.app\r\nSubject: Report @ 2026-…Z\r\n…multipart/alternative…"}]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Put it on a schedule
|
||||||
|
|
||||||
|
Cron expressions are **6 fields (with seconds)**. This fires every 10 seconds — handy for a demo:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pic triggers create-cron --app reports --script $RP --schedule '*/10 * * * * *'
|
||||||
|
```
|
||||||
|
|
||||||
|
Within ~10 seconds it fires; confirm via the execution log (source `cron`), where — because cron runs
|
||||||
|
are asynchronous — the `log::` output is captured:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pic logs $RP --source cron --limit 3
|
||||||
|
# … status=success source=cron … (the "report sent" log entry is recorded)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then replace it with a real schedule and timezone, e.g. 8 AM daily in Berlin, and remove the demo one:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pic triggers create-cron --app reports --script $RP --schedule '0 0 8 * * *' --timezone Europe/Berlin
|
||||||
|
pic triggers ls --app reports
|
||||||
|
pic triggers rm --app reports <demo_trigger_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes, constraints & next steps
|
||||||
|
|
||||||
|
- **Module fns don't see `ctx`.** An imported module gets no access to the caller's scope — pass what
|
||||||
|
it needs as arguments (here, `fmt::summary(hits)`). Module fns *can* call SDK namespaces.
|
||||||
|
- **Cron resolution is seconds, 6 fields.** `*/10 * * * * *` = every 10s; `0 0 8 * * *` = 08:00:00
|
||||||
|
daily. Set `--timezone` (IANA name) or it runs in UTC.
|
||||||
|
- **Real SMTP.** Configure `PICLOUD_SMTP_*` ([env vars](../reference/config/env-vars.md)) for actual
|
||||||
|
delivery; without it (outside dev mode) `email::send_html` throws `NotConfigured`.
|
||||||
|
- **Where does the metric come from?** Here it's a `kv` key you'd increment elsewhere in your app (the
|
||||||
|
[quickstart counter](../guide/quickstart.md#step-5-write-your-first-script) pattern). A report often
|
||||||
|
aggregates [`docs`](../reference/sdk/storage.md#docs) instead — `docs.find(...)` then fold.
|
||||||
|
- **Don't over-fire.** Each tick is a full execution counted against your concurrency budget; a
|
||||||
|
10-second cron in production is rarely what you want.
|
||||||
|
|
||||||
|
Next: have the report run per user (loop `users::list`, email each), or push results to a dashboard
|
||||||
|
over [pubsub + SSE](file-upload.md) instead of email.
|
||||||
158
docs/dev-guide/src/examples/todo-api.md
Normal file
158
docs/dev-guide/src/examples/todo-api.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
# Tutorial: TODO API with auth
|
||||||
|
|
||||||
|
Build a multi-user TODO API where each user signs up, logs in, and sees only their own items. This is
|
||||||
|
the tutorial that shows how **app-user authentication** actually works in PiCloud: there is no built-in
|
||||||
|
`/signup` or `/login` — you compose them from the [`users`](../reference/sdk/users.md) SDK and bind
|
||||||
|
them to your own routes. Data lives in [`docs`](../reference/sdk/storage.md#docs).
|
||||||
|
|
||||||
|
> **The key idea:** PiCloud gives you `users::create`, `users::login`, `users::verify` — the
|
||||||
|
> *primitives*. The endpoints (`/auth/signup`, the bearer-token check on protected routes) are yours to
|
||||||
|
> write. This is more work than a turnkey auth box, but it means auth is *your* code with no hidden
|
||||||
|
> behavior. See [Concepts → authentication](../guide/concepts.md#authentication-three-kinds-of-identity).
|
||||||
|
|
||||||
|
## 1. App and host
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pic apps create todo --name "TODO API"
|
||||||
|
pic apps domains add todo todo.localhost
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Signup
|
||||||
|
|
||||||
|
`signup.rhai` — create the user, then log them straight in:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let b = ctx.request.body;
|
||||||
|
if type_of(b) != "map" || !b.contains("email") || !b.contains("password") {
|
||||||
|
return #{ statusCode: 400, body: #{ error: "email and password required" } };
|
||||||
|
}
|
||||||
|
if !users::email_available(b.email) { // anonymous-safe pre-check
|
||||||
|
return #{ statusCode: 409, body: #{ error: "email already registered" } };
|
||||||
|
}
|
||||||
|
let display = if b.contains("display_name") { b.display_name } else { () };
|
||||||
|
users::create(#{ email: b.email, password: b.password, display_name: display });
|
||||||
|
let token = users::login(b.email, b.password); // mint a session token
|
||||||
|
return #{ statusCode: 201, body: #{ token: token } };
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Login
|
||||||
|
|
||||||
|
`login.rhai`:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let b = ctx.request.body;
|
||||||
|
let token = users::login(b.email, b.password); // () on bad credentials
|
||||||
|
if token == () { return #{ statusCode: 401, body: #{ error: "invalid credentials" } }; }
|
||||||
|
return #{ statusCode: 200, body: #{ token: token } };
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. The TODO endpoints (one script, branches on method)
|
||||||
|
|
||||||
|
A single `todos.rhai` handles `POST /todos`, `GET /todos`, and `DELETE /todos/:id`. Each begins by
|
||||||
|
turning the `Authorization: Bearer …` header into a user with `users::verify`:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
// --- authenticate ---
|
||||||
|
let hdr = if "authorization" in ctx.request.headers { ctx.request.headers["authorization"] } else { "" };
|
||||||
|
let token = if hdr.starts_with("Bearer ") { hdr.sub_string(7) } else { hdr };
|
||||||
|
let me = users::verify(token); // () if invalid/expired
|
||||||
|
if me == () { return #{ statusCode: 401, body: #{ error: "unauthorized" } }; }
|
||||||
|
|
||||||
|
// --- handle the request ---
|
||||||
|
let todos = docs::collection("todos");
|
||||||
|
let method = ctx.request.method;
|
||||||
|
|
||||||
|
if method == "POST" {
|
||||||
|
let id = todos.create(#{ owner: me.id, text: ctx.request.body.text, done: false });
|
||||||
|
return #{ statusCode: 201, body: #{ id: id } };
|
||||||
|
}
|
||||||
|
if method == "GET" {
|
||||||
|
let mine = todos.find(#{ owner: me.id }); // only this user's docs
|
||||||
|
return #{ statusCode: 200, body: #{ todos: mine } };
|
||||||
|
}
|
||||||
|
if method == "DELETE" {
|
||||||
|
let id = ctx.request.params.id;
|
||||||
|
let row = todos.get(id);
|
||||||
|
// 404 if missing OR not owned by the caller — never reveal another user's item
|
||||||
|
if row == () || row.data.owner != me.id { return #{ statusCode: 404, body: #{ error: "not found" } }; }
|
||||||
|
todos.delete(id);
|
||||||
|
return #{ statusCode: 204, body: () };
|
||||||
|
}
|
||||||
|
return #{ statusCode: 405, body: #{ error: "method not allowed" } };
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Deploy and bind
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pic scripts deploy signup.rhai --app todo --name signup
|
||||||
|
pic scripts deploy login.rhai --app todo --name login
|
||||||
|
pic scripts deploy todos.rhai --app todo --name todos
|
||||||
|
|
||||||
|
SU=$(pic --output json scripts ls --app todo | jq -r '.[]|select(.name=="signup").id')
|
||||||
|
LI=$(pic --output json scripts ls --app todo | jq -r '.[]|select(.name=="login").id')
|
||||||
|
TD=$(pic --output json scripts ls --app todo | jq -r '.[]|select(.name=="todos").id')
|
||||||
|
|
||||||
|
pic routes create --script $SU --path /auth/signup --method POST
|
||||||
|
pic routes create --script $LI --path /auth/login --method POST
|
||||||
|
pic routes create --script $TD --path /todos --method POST
|
||||||
|
pic routes create --script $TD --path /todos --method GET
|
||||||
|
pic routes create --script $TD --path '/todos/:id' --path-kind param --method DELETE
|
||||||
|
```
|
||||||
|
|
||||||
|
(Binding several routes to one script is normal — the script branches on `ctx.request.method`.)
|
||||||
|
|
||||||
|
## 6. Use it
|
||||||
|
|
||||||
|
```sh
|
||||||
|
H='Host: todo.localhost'
|
||||||
|
# sign up → get a token
|
||||||
|
curl -s -X POST http://localhost:18080/auth/signup -H "$H" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"email":"ada@example.com","password":"lovelace99","display_name":"Ada"}'
|
||||||
|
# {"token":"JCMgEH6i_DLtNggsEE9KspGNaUHIYvBcV0p4jG9fKZ8"}
|
||||||
|
|
||||||
|
TOK=… # paste the token
|
||||||
|
|
||||||
|
# unauthenticated request is rejected
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:18080/todos -H "$H" # 401
|
||||||
|
|
||||||
|
# create a couple of todos
|
||||||
|
curl -s -X POST http://localhost:18080/todos -H "$H" -H "Authorization: Bearer $TOK" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"text":"write docs"}'
|
||||||
|
# {"id":"391bc9df-…"}
|
||||||
|
|
||||||
|
# list them — each is a docs envelope (your fields under `data`)
|
||||||
|
curl -s http://localhost:18080/todos -H "$H" -H "Authorization: Bearer $TOK"
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{"todos":[
|
||||||
|
{"id":"0816b607-…","data":{"done":false,"owner":"8c7716bc-…","text":"ship it"},
|
||||||
|
"created_at":"2026-…Z","updated_at":"2026-…Z"},
|
||||||
|
{"id":"391bc9df-…","data":{"done":false,"owner":"8c7716bc-…","text":"write docs"},
|
||||||
|
"created_at":"2026-…Z","updated_at":"2026-…Z"}
|
||||||
|
]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Operators can see registered users (but never their passwords) from the admin side:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pic users ls --app todo
|
||||||
|
# id … email ada@example.com … display_name Ada … last_login_at … created_at …
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes, constraints & next steps
|
||||||
|
|
||||||
|
- **`find_by_email` is privileged.** Anonymous (public) scripts can't call it — it's an
|
||||||
|
anti-enumeration guard. `email_available` *is* anonymous-safe (a signup form needs it) but isn't
|
||||||
|
throttled, so rate-limit it if abuse matters. [Security →](../operations/security.md#user-enumeration)
|
||||||
|
- **Ownership checks are yours.** The platform scopes data to the *app*, not to a *user*. "Only my
|
||||||
|
todos" is enforced by the `owner` field + the `row.data.owner != me.id` check — there's no automatic
|
||||||
|
per-user row security. Get this right on every protected handler.
|
||||||
|
- **`docs.update` replaces `data` wholesale.** To toggle `done`, read the doc, set the field, and
|
||||||
|
`update` with the full map.
|
||||||
|
- **Sessions slide.** `users::verify` extends the session TTL on each call
|
||||||
|
(`PICLOUD_APP_USER_SESSION_TTL_HOURS`). Use `users::logout(token)` to end one.
|
||||||
|
- **Add roles** with `users::add_role(me.id, "pro")` and gate features on `users::has_role(...)`.
|
||||||
|
|
||||||
|
Next: add email verification (`users::send_verification_email` + a `/auth/verify` route) and password
|
||||||
|
reset — the [users SDK reference](../reference/sdk/users.md#email-tied-flows) lists the flow. The
|
||||||
|
[scheduled-report tutorial](scheduled-report.md) shows how to email users on a cron.
|
||||||
118
docs/dev-guide/src/examples/url-shortener.md
Normal file
118
docs/dev-guide/src/examples/url-shortener.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# Tutorial: URL shortener
|
||||||
|
|
||||||
|
Build a classic link shortener: `POST /shorten` stores a long URL under a random code; `GET /r/:code`
|
||||||
|
redirects to it. You'll use [`kv`](../reference/sdk/storage.md#kv) storage, a
|
||||||
|
[`:param` route](../guide/concepts.md#routes-and-domains), the [`random`](../reference/sdk/stdlib.md#random)
|
||||||
|
stdlib, and a `302` [response envelope](../guide/writing-scripts.md#the-response-envelope-in-detail).
|
||||||
|
|
||||||
|
**Prereqs:** the [Quickstart](../guide/quickstart.md) stack running, and you're logged in with `pic`.
|
||||||
|
We use `http://localhost:18080`; adjust the port to yours.
|
||||||
|
|
||||||
|
## 1. Create the app and claim a host
|
||||||
|
|
||||||
|
Each tutorial gets its own [app](../guide/concepts.md#apps) for isolation. Locally we claim a
|
||||||
|
`*.localhost` host and pass it with a `Host:` header.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pic apps create links --name "Link Shortener"
|
||||||
|
pic apps domains add links links.localhost
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. The "shorten" script
|
||||||
|
|
||||||
|
`shorten.rhai`:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
// POST /shorten body: { "url": "https://..." } -> { code, short_url }
|
||||||
|
let body = ctx.request.body;
|
||||||
|
if type_of(body) != "map" || !body.contains("url") {
|
||||||
|
return #{ statusCode: 400, body: #{ error: "json body with a 'url' field required" } };
|
||||||
|
}
|
||||||
|
let links = kv::collection("links");
|
||||||
|
let code = random::string(6); // 6 alphanumeric chars
|
||||||
|
links.set(code, body.url);
|
||||||
|
return #{
|
||||||
|
statusCode: 201,
|
||||||
|
body: #{ code: code, short_url: `http://links.localhost:18080/r/${code}` }
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. The "redirect" script
|
||||||
|
|
||||||
|
`redirect.rhai`:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
// GET /r/:code -> 302 to the stored URL, or 404
|
||||||
|
let code = ctx.request.params.code;
|
||||||
|
let target = kv::collection("links").get(code); // () when absent
|
||||||
|
if target == () {
|
||||||
|
return #{ statusCode: 404, body: #{ error: "unknown code" } };
|
||||||
|
}
|
||||||
|
return #{ statusCode: 302, headers: #{ "Location": target }, body: () };
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Deploy and bind routes
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pic scripts deploy shorten.rhai --app links --name shorten
|
||||||
|
pic scripts deploy redirect.rhai --app links --name redirect
|
||||||
|
|
||||||
|
SH=$(pic --output json scripts ls --app links | jq -r '.[]|select(.name=="shorten").id')
|
||||||
|
RD=$(pic --output json scripts ls --app links | jq -r '.[]|select(.name=="redirect").id')
|
||||||
|
|
||||||
|
pic routes create --script $SH --path /shorten --method POST
|
||||||
|
pic routes create --script $RD --path '/r/:code' --path-kind param --method GET
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Try it
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST http://localhost:18080/shorten -H 'Host: links.localhost' \
|
||||||
|
-H 'Content-Type: application/json' -d '{"url":"https://example.com/some/long/path"}'
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{"code":"pE4wVV","short_url":"http://links.localhost:18080/r/pE4wVV"}
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -i http://localhost:18080/r/pE4wVV -H 'Host: links.localhost'
|
||||||
|
```
|
||||||
|
```text
|
||||||
|
HTTP/1.1 302 Found
|
||||||
|
location: https://example.com/some/long/path
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -i http://localhost:18080/r/nope -H 'Host: links.localhost'
|
||||||
|
# HTTP/1.1 404 Not Found
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Look inside
|
||||||
|
|
||||||
|
Every stored link is just a KV entry — inspect them straight from the admin side:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pic kv ls --app links --collection links
|
||||||
|
pic kv get --app links --collection links pE4wVV
|
||||||
|
# "https://example.com/some/long/path"
|
||||||
|
```
|
||||||
|
|
||||||
|
(`pic kv get` prints the stored value as JSON — a string value comes back quoted.)
|
||||||
|
|
||||||
|
## Notes, constraints & next steps
|
||||||
|
|
||||||
|
- **Collisions.** `random::string(6)` over 62 symbols is only ~36 bits (6 × log₂62) — fine for a
|
||||||
|
demo, but at scale two codes will eventually collide and the second `set` would silently overwrite
|
||||||
|
the first. For production, check `links.has(code)` and regenerate on collision, use more characters,
|
||||||
|
or use `random::uuid()`.
|
||||||
|
- **Size cap.** A stored value can't exceed `PICLOUD_KV_MAX_VALUE_BYTES` (256 KiB) — irrelevant for
|
||||||
|
URLs, but good to know ([overview](../reference/sdk/overview.md#size-caps)).
|
||||||
|
- **Validate input.** We checked the body is a map with a `url`. A real version should also validate
|
||||||
|
the URL scheme to avoid storing `javascript:` links you later redirect to.
|
||||||
|
- **Open redirect.** Redirecting to arbitrary user-supplied URLs is an
|
||||||
|
[open-redirect](../operations/security.md) vector if codes are guessable and the links are
|
||||||
|
attacker-controlled — fine here, but think about it for auth flows.
|
||||||
|
|
||||||
|
Next: add per-link click counts (another `kv` collection, like the quickstart counter), or move to
|
||||||
|
[`docs`](../reference/sdk/storage.md#docs) if you want to store metadata (owner, created-at, hits) per
|
||||||
|
link and query it — which is exactly what the [TODO API tutorial](todo-api.md) does.
|
||||||
148
docs/dev-guide/src/examples/webhook-receiver.md
Normal file
148
docs/dev-guide/src/examples/webhook-receiver.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# Tutorial: Webhook receiver
|
||||||
|
|
||||||
|
Receive webhooks reliably: authenticate the caller with a shared secret, accept fast with `202`, do the
|
||||||
|
slow work on a durable [queue](../reference/sdk/messaging.md#queue), forward downstream over
|
||||||
|
[`http`](../reference/sdk/http.md) with [`retry`](../reference/sdk/composition.md#retry), and let
|
||||||
|
failures land in the [dead-letter](../reference/sdk/composition.md#dead-letters) queue for replay.
|
||||||
|
|
||||||
|
This exercises [`secrets`](../reference/sdk/secrets.md), [async dispatch](../guide/concepts.md#dispatch-modes-sync-vs-async),
|
||||||
|
`queue::enqueue`, a `queue` trigger, `http` + `retry`, and dead-letters.
|
||||||
|
|
||||||
|
> **Why a shared secret, not HMAC?** The 1.1.9 script SDK has **no hashing/HMAC primitive** — `base64`
|
||||||
|
> and `hex` exist, but not `sha256`/`hmac`. So a script can't verify a provider's HMAC signature
|
||||||
|
> itself. (The platform *does* HMAC-verify built-in [inbound-email triggers](../reference/rest-api/triggers.md)
|
||||||
|
> server-side.) For script-level webhooks, use a shared-secret header, which is what we do here.
|
||||||
|
|
||||||
|
## 1. App, host, and the secret
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pic apps create hooks --name "Webhook Receiver"
|
||||||
|
pic apps domains add hooks hooks.localhost
|
||||||
|
printf 'shhh-secret-token' | pic secrets set --app hooks webhook_token
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. The receiver (sync, returns 202)
|
||||||
|
|
||||||
|
`receiver.rhai` — verify the secret, enqueue, accept:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
// POST /ingest header: x-webhook-token: <secret> body: the event JSON
|
||||||
|
let expected = secrets::get("webhook_token");
|
||||||
|
let got = if "x-webhook-token" in ctx.request.headers { ctx.request.headers["x-webhook-token"] } else { "" };
|
||||||
|
if got != expected {
|
||||||
|
return #{ statusCode: 401, body: #{ error: "bad or missing webhook token" } };
|
||||||
|
}
|
||||||
|
queue::enqueue("deliveries", ctx.request.body, #{ max_attempts: 2 });
|
||||||
|
return #{ statusCode: 202, body: #{ accepted: true } };
|
||||||
|
```
|
||||||
|
|
||||||
|
We verify and enqueue synchronously (so a bad token gets a real `401`), then return `202` — the heavy
|
||||||
|
lifting happens off the request path.
|
||||||
|
|
||||||
|
## 3. The consumer (runs per queued message)
|
||||||
|
|
||||||
|
`consumer.rhai` — note the event shape: the payload is `ctx.event.queue.message`, and the retry count
|
||||||
|
is `ctx.event.queue.attempt`:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let msg = ctx.event.queue.message;
|
||||||
|
log::info("processing delivery", #{ id: msg.id, attempt: ctx.event.queue.attempt });
|
||||||
|
|
||||||
|
if msg.fail == true {
|
||||||
|
throw "simulated downstream failure"; // forces retries → dead-letter (for the demo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward downstream, retrying transient failures.
|
||||||
|
let policy = retry::policy(#{ max_attempts: 3, backoff: "exponential", base_ms: 200 });
|
||||||
|
let resp = retry::run(policy, || http::get("https://example.com"));
|
||||||
|
|
||||||
|
kv::collection("delivered").set(msg.id, `${resp.status}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Deploy, route, and register the queue trigger
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pic scripts deploy receiver.rhai --app hooks --name receiver
|
||||||
|
pic scripts deploy consumer.rhai --app hooks --name consumer
|
||||||
|
|
||||||
|
RCV=$(pic --output json scripts ls --app hooks | jq -r '.[]|select(.name=="receiver").id')
|
||||||
|
CON=$(pic --output json scripts ls --app hooks | jq -r '.[]|select(.name=="consumer").id')
|
||||||
|
|
||||||
|
pic routes create --script $RCV --path /ingest --method POST
|
||||||
|
|
||||||
|
# Register the consumer on the "deliveries" queue. The per-kind wrapper `pic triggers create-queue`
|
||||||
|
# works too; we use the JSON form here only to set a short retry backoff so the dead-letter demo is
|
||||||
|
# quick (the wrapper doesn't expose retry knobs):
|
||||||
|
pic triggers create-from-json --app hooks --kind queue \
|
||||||
|
--body "{\"script_id\":\"$CON\",\"queue_name\":\"deliveries\",\"retry_max_attempts\":1,\"retry_base_ms\":300}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Send some webhooks
|
||||||
|
|
||||||
|
```sh
|
||||||
|
B=http://localhost:18080/ingest
|
||||||
|
# bad token → 401
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' -X POST $B -H 'Host: hooks.localhost' \
|
||||||
|
-H 'Content-Type: application/json' -d '{"id":"d1"}' # 401
|
||||||
|
|
||||||
|
# good token, succeeds → 202, processed in the background
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' -X POST $B -H 'Host: hooks.localhost' \
|
||||||
|
-H 'x-webhook-token: shhh-secret-token' -H 'Content-Type: application/json' \
|
||||||
|
-d '{"id":"d3","event":"order.created","fail":false}' # 202
|
||||||
|
|
||||||
|
# good token, fails downstream → 202 now, dead-letter after retries
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' -X POST $B -H 'Host: hooks.localhost' \
|
||||||
|
-H 'x-webhook-token: shhh-secret-token' -H 'Content-Type: application/json' \
|
||||||
|
-d '{"id":"d4","fail":true}' # 202
|
||||||
|
```
|
||||||
|
|
||||||
|
After a moment, the happy delivery is recorded and its background run's `log::` output is captured
|
||||||
|
(async/triggered runs persist logs — sync HTTP runs don't):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pic kv get --app hooks --collection delivered d3 # "200"
|
||||||
|
pic logs $CON --source queue --limit 5 # shows the "processing delivery" entries
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Dead-letters {#dead-letters}
|
||||||
|
|
||||||
|
The `fail` delivery used up its delivery attempts and became a dead-letter. We enqueued it with
|
||||||
|
`max_attempts: 2`, so the dead-letter row shows `attempts 2`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pic dead-letters count --app hooks # 1
|
||||||
|
pic dead-letters ls --app hooks --unresolved
|
||||||
|
# id … attempts 2 … last_error "script runtime error: Runtime error: simulated downstream failure …"
|
||||||
|
```
|
||||||
|
|
||||||
|
Re-enqueue it (e.g. after fixing the downstream), or close it out:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pic dead-letters replay --app hooks <dl_id> # Replayed dead-letter <id>
|
||||||
|
pic dead-letters resolve --app hooks <dl_id> --reason ignored # close without replay
|
||||||
|
```
|
||||||
|
|
||||||
|
`replay` marks the row `replayed` and puts the original message back on the queue. `resolve` takes one
|
||||||
|
of a **fixed set** of reasons — `replayed`, `ignored`, `handled_by_script`, `handler_failed` — not
|
||||||
|
free text. You can also automate this: register a [`dead_letter` trigger](../reference/rest-api/triggers.md)
|
||||||
|
that runs a script (with `ctx.event.dead_letter`) to alert or call
|
||||||
|
[`dead_letters::replay`/`resolve`](../reference/sdk/composition.md#dead-letters).
|
||||||
|
|
||||||
|
## Notes, constraints & next steps
|
||||||
|
|
||||||
|
- **Idempotency.** A queued message can run more than once (a retry after a partial success, or a
|
||||||
|
visibility-timeout expiry). Use `ctx.event.queue.attempt` and a dedup key (`if
|
||||||
|
kv::collection("done").has(msg.id) { return; }`). See
|
||||||
|
[Best practices](../operations/best-practices.md#idempotent-consumers).
|
||||||
|
- **SSRF.** `http::*` blocks private/loopback targets — `http::get("http://localhost…")` throws
|
||||||
|
`http: blocked by SSRF policy: loopback`. Forwarding to your own internal services from a script
|
||||||
|
requires a routable address (or, in dev only, `PICLOUD_HTTP_ALLOW_PRIVATE=true`).
|
||||||
|
[Security →](../operations/security.md#ssrf)
|
||||||
|
- **Size cap.** A queued message can't exceed `PICLOUD_QUEUE_MAX_PAYLOAD_BYTES` (256 KiB).
|
||||||
|
- **Two retry layers.** The in-script `retry::run` retries *within one execution*; the trigger's
|
||||||
|
`retry_max_attempts` retries the *whole execution*. Pick one as your primary strategy to avoid
|
||||||
|
multiplicative delays.
|
||||||
|
|
||||||
|
Next: swap the shared-secret check for the platform's [inbound-email trigger](../reference/rest-api/triggers.md)
|
||||||
|
if you're ingesting mail, or fan a single event out to several consumers with
|
||||||
|
[`pubsub`](../reference/sdk/messaging.md#pubsub).
|
||||||
177
docs/dev-guide/src/guide/concepts.md
Normal file
177
docs/dev-guide/src/guide/concepts.md
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
# Core concepts
|
||||||
|
|
||||||
|
This chapter is the mental model. Read it once and the rest of the guide clicks into place.
|
||||||
|
|
||||||
|
## The request lifecycle
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
request ──▶│ 1. Caddy proxy 2. resolve Host → app 3. match the route │
|
||||||
|
│ (most-specific (method+path │
|
||||||
|
│ domain claim wins) in that app) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│ found a script
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 4. run the script in a sandbox, with `ctx` populated │
|
||||||
|
│ 5. script calls the SDK (kv, http, …) — all scoped to the app │
|
||||||
|
│ 6. script returns a response envelope → HTTP response │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Two phases matter most: **Host → app**, then **route within that app**. A request whose `Host` no app
|
||||||
|
claims gets `404 {"error":"no app claims host …"}`. A request that hits a claimed host but matches no
|
||||||
|
route gets `404 {"error":"no route matches …"}`. (The built-in `default` app claims `localhost`, which
|
||||||
|
is why the quickstart's `curl localhost:8000/...` just works.)
|
||||||
|
|
||||||
|
## Apps
|
||||||
|
|
||||||
|
An **app** is a tenant: an isolated namespace that owns scripts, routes, domain claims, and *all* data
|
||||||
|
(KV, docs, files, secrets, users, queues, topics). Isolation is the headline guarantee:
|
||||||
|
|
||||||
|
> **A script can only ever touch its own app's data.** Every SDK call derives the app from the
|
||||||
|
> execution context on the server — never from anything the script passes. There is no API for a
|
||||||
|
> script to name another app. This is enforced in the platform, not by convention.
|
||||||
|
|
||||||
|
You create apps from the dashboard (Apps → New), the CLI (`pic apps create <slug>`), or HTTP
|
||||||
|
(`POST /api/v1/admin/apps`). An app has a URL-safe **slug** (`^[a-z0-9][a-z0-9-]{0,62}$`) and a
|
||||||
|
display name. The `default` app exists on every install and is where the seeded example lives.
|
||||||
|
|
||||||
|
Cross-app data sharing does not exist in 1.1.9 — isolation is strict by design. (It is on the roadmap
|
||||||
|
for a later release.)
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
A **script** is a Rhai program belonging to one app. There are two **kinds**:
|
||||||
|
|
||||||
|
| Kind | Runs on | Can bind routes/triggers? | Top-level statements? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `endpoint` (default) | HTTP requests, events, `invoke()`, `/execute/{id}` | yes | yes |
|
||||||
|
| `module` | never directly — only `import`ed by other scripts | no | no — only `fn` and `const` |
|
||||||
|
|
||||||
|
A **module** is a library. Another script in the *same app* pulls it in with
|
||||||
|
`import "modulename" as alias;` and calls `alias::some_fn(...)`. Cross-app imports are impossible (the
|
||||||
|
import name carries no app). Modules are how you share helpers without copy-paste. See
|
||||||
|
[Writing scripts](writing-scripts.md#modules-and-imports).
|
||||||
|
|
||||||
|
Each script carries sandbox settings — a wall-clock `timeout_seconds`, a `memory_limit_mb`, and
|
||||||
|
optional fine-grained [sandbox overrides](writing-scripts.md#sandbox-limits). New scripts default to a
|
||||||
|
30-second timeout and 256 MB.
|
||||||
|
|
||||||
|
## Routes and domains
|
||||||
|
|
||||||
|
A **route** binds an incoming HTTP request to a script. A route has:
|
||||||
|
|
||||||
|
- a **host** match: `any` (the default, matches whatever host the app claims), `strict` (one exact
|
||||||
|
host), or `wildcard` (`*.example.com`, optionally capturing the subdomain into a param);
|
||||||
|
- a **path** match of one of three **path kinds**:
|
||||||
|
- `exact` — `/webhook` matches only `/webhook`;
|
||||||
|
- `param` — `/users/:id` matches `/users/42` and exposes `ctx.request.params.id == "42"`;
|
||||||
|
- `prefix` — `/files/*` matches `/files/a/b/c` and exposes the tail as `ctx.request.rest`;
|
||||||
|
- an optional **method** (`GET`, `POST`, …); omit it to match any method;
|
||||||
|
- a **dispatch mode** (below).
|
||||||
|
|
||||||
|
> **Param syntax is deliberately split.** Route paths use `:name` (`/users/:id`). Domain patterns use
|
||||||
|
> `{name}` (`{tenant}.example.com`). Never mix them. [More →](writing-scripts.md).
|
||||||
|
|
||||||
|
**Domains.** Beyond the `default` app, an app's routes only match once the app **claims** the request's
|
||||||
|
`Host`. Claim patterns are exact (`api.example.com`), wildcard (`*.example.com`), or parameterized
|
||||||
|
(`{tenant}.example.com`). The most specific claim wins. Locally you can claim something like
|
||||||
|
`myapp.localhost` and test with `curl -H 'Host: myapp.localhost' localhost:8000/...` — that's the
|
||||||
|
pattern the [tutorials](../examples/index.md) use. The port is stripped before matching.
|
||||||
|
|
||||||
|
**Reserved prefixes.** PiCloud refuses to create routes under `/api/`, `/admin/`, `/healthz`, or
|
||||||
|
`/version` — those belong to the platform. Pick any other path.
|
||||||
|
|
||||||
|
## The response envelope
|
||||||
|
|
||||||
|
An `endpoint` script's return value becomes the HTTP response. The rule
|
||||||
|
([`engine.rs`](../reference/sdk/ctx-and-events.md)):
|
||||||
|
|
||||||
|
- **Return a map containing a `statusCode` key** → that's the structured envelope:
|
||||||
|
```rhai
|
||||||
|
return #{
|
||||||
|
statusCode: 201,
|
||||||
|
headers: #{ "Content-Type": "application/json", "Location": "/things/7" },
|
||||||
|
body: #{ id: 7 }
|
||||||
|
};
|
||||||
|
```
|
||||||
|
`statusCode` is a required integer; `headers` (string→string) and `body` are optional. A `body` that
|
||||||
|
is a map/array is serialized to JSON.
|
||||||
|
- **Return anything else** (a map without `statusCode`, a string, a number, an array) → PiCloud wraps
|
||||||
|
it in a **`200 OK`** with that value as the body.
|
||||||
|
|
||||||
|
So `return #{ ok: true };` and `return #{ statusCode: 200, body: #{ ok: true } };` both produce
|
||||||
|
`200 {"ok":true}`. Use the full envelope when you need a non-200 status, custom headers, or redirects.
|
||||||
|
|
||||||
|
## Dispatch modes: sync vs async
|
||||||
|
|
||||||
|
Every route (and trigger) has a **dispatch mode**:
|
||||||
|
|
||||||
|
- **`sync`** (default for routes) — the caller waits; the script's envelope is the HTTP response.
|
||||||
|
- **`async`** — PiCloud immediately returns **`202 Accepted`** with an `execution_id`, and runs the
|
||||||
|
script in the background via the dispatcher. The caller never sees the script's return value.
|
||||||
|
|
||||||
|
Use `async` for fire-and-forget work (webhooks you don't need to answer in detail, fan-out, slow jobs)
|
||||||
|
so the client isn't blocked. Triggers default to `async`. [More on choosing →](../operations/best-practices.md#sync-vs-async).
|
||||||
|
|
||||||
|
## Triggers and events
|
||||||
|
|
||||||
|
A **trigger** fires a script on an *event* rather than an HTTP request. PiCloud has eight trigger
|
||||||
|
kinds:
|
||||||
|
|
||||||
|
| Kind | Fires when… | `ctx.event` carries |
|
||||||
|
|---|---|---|
|
||||||
|
| `kv` | a key is set/deleted in matching collections | `op`, `kv.{collection,key,value}` |
|
||||||
|
| `docs` | a document is created/updated/deleted | `op`, `docs.{collection,id,data,prev_data}` |
|
||||||
|
| `files` | a blob is created/updated/deleted | `op`, file metadata (never the bytes) |
|
||||||
|
| `pubsub` | a message is published to a matching topic | `pubsub.{topic,payload}` |
|
||||||
|
| `queue` | a message is claimed off a named queue | `queue.{…}`, attempt count |
|
||||||
|
| `cron` | a schedule ticks | `cron.{schedule,timezone,scheduled_at,fired_at}` |
|
||||||
|
| `email` | mail arrives at the inbound webhook | the parsed message |
|
||||||
|
| `dead_letter` | another trigger exhausts its retries | the failed event + error |
|
||||||
|
|
||||||
|
The triggered script reads `ctx.event` (absent on plain HTTP invocations, so you can test
|
||||||
|
`if "event" in ctx`). Triggers are created per-kind — see [Triggers](../reference/rest-api/triggers.md)
|
||||||
|
and the [`ctx.event` reference](../reference/sdk/ctx-and-events.md#events).
|
||||||
|
|
||||||
|
## Authentication: three kinds of identity
|
||||||
|
|
||||||
|
Keep these straight — they are easy to confuse:
|
||||||
|
|
||||||
|
| Identity | Who | Used for | Managed by |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Admin user** | you / your team | the control plane (dashboard, CLI, admin API) | `pic admins`, bootstrap env vars |
|
||||||
|
| **API key** | you / a CI job | the control plane, non-interactively | `pic api-keys`, dashboard profile |
|
||||||
|
| **App user** | *your app's* end-users | whatever *your* scripts decide | the `users` SDK, in your scripts |
|
||||||
|
|
||||||
|
An **admin user** or **API key** is a *principal* on the control plane, with an **instance role**
|
||||||
|
(`owner` > `admin` > `member`) and, for members, per-app **app roles** (`app_admin` > `editor` >
|
||||||
|
`viewer`). See [Capabilities & roles](../reference/config/capabilities.md).
|
||||||
|
|
||||||
|
> **There is no built-in end-user signup/login HTTP endpoint.** App-user authentication is the
|
||||||
|
> [`users` SDK](../reference/sdk/users.md) — you compose `users::create`, `users::login`,
|
||||||
|
> `users::verify` into *your own* routes. This trips people up; the
|
||||||
|
> [TODO API tutorial](../examples/todo-api.md) shows the full pattern.
|
||||||
|
|
||||||
|
## The data plane is unauthenticated by default
|
||||||
|
|
||||||
|
Routes you create are **public**. A request to your route runs the script with **full app authority** —
|
||||||
|
it can read every secret, every KV key, every user in the app. PiCloud does *not* put auth in front of
|
||||||
|
your routes for you. If a route must be restricted, your script enforces it (check a token, call
|
||||||
|
`users::verify`, compare an HMAC). This is the single most important security point —
|
||||||
|
see [Security](../operations/security.md).
|
||||||
|
|
||||||
|
## The five version surfaces
|
||||||
|
|
||||||
|
`GET /version` returns five independent numbers. They move independently on purpose:
|
||||||
|
|
||||||
|
| Surface | Example | What it tracks | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `product` | `1.1.9` | the release build | semver of the whole platform |
|
||||||
|
| `sdk` | `1.10` | the script-visible API | **`major.minor`** — `1.10` is the *tenth minor*, not `1.1.0`. Read it at runtime as `ctx.sdk_version`. A minor bump only *adds*; existing scripts keep working. |
|
||||||
|
| `api` | `1` | the HTTP API major | appears in URLs as `/api/v1/...` |
|
||||||
|
| `schema` | `44` | the DB migration number | monotonic, forward-only |
|
||||||
|
| `wire` | `1` | inter-node protocol | reserved; cluster mode is a future release |
|
||||||
|
|
||||||
|
The recurring mistake is reading `sdk: "1.10"` as "version 1.1.0". It isn't. It's SDK 1, minor 10.
|
||||||
170
docs/dev-guide/src/guide/quickstart.md
Normal file
170
docs/dev-guide/src/guide/quickstart.md
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
# Quickstart
|
||||||
|
|
||||||
|
This gets you from nothing to a live, custom HTTP endpoint — backed by persistent storage — in about
|
||||||
|
ten minutes. We'll use the built-in **default app** so there's zero setup friction; later you'll learn
|
||||||
|
to create your own [apps](concepts.md#apps) with their own [domains](concepts.md#routes-and-domains).
|
||||||
|
|
||||||
|
> Every command and response below was produced by running it. If yours differ, check
|
||||||
|
> [Troubleshooting](../operations/troubleshooting.md).
|
||||||
|
|
||||||
|
## Step 1 — Boot the stack
|
||||||
|
|
||||||
|
The fastest path is Docker Compose, which brings up Postgres, PiCloud, the dashboard, and a Caddy
|
||||||
|
reverse proxy behind one port.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone <your-picloud-checkout> picloud && cd picloud
|
||||||
|
cp .env.example .env # then edit: set PICLOUD_ADMIN_USERNAME / PICLOUD_ADMIN_PASSWORD
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
The default `.env` runs in **dev mode** (a deterministic, insecure master key — fine for local, never
|
||||||
|
for production) and publishes Caddy on host port **8000**. The dashboard is at
|
||||||
|
`http://localhost:8000/admin`.
|
||||||
|
|
||||||
|
> **Prefer running the binary directly?** See [Running the bare binary](../deploy/bare-binary.md).
|
||||||
|
> The only difference is the port. This guide uses `http://localhost:8000`; export it once so you can
|
||||||
|
> paste the rest verbatim:
|
||||||
|
>
|
||||||
|
> ```sh
|
||||||
|
> export PICLOUD=http://localhost:8000
|
||||||
|
> ```
|
||||||
|
|
||||||
|
## Step 2 — Confirm it's alive
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl $PICLOUD/healthz
|
||||||
|
# ok
|
||||||
|
|
||||||
|
curl $PICLOUD/version
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{"api":1,"product":"1.1.9","public_base_url":"http://localhost:8000","schema":44,"sdk":"1.10","wire":1}
|
||||||
|
```
|
||||||
|
|
||||||
|
That `sdk` field — `"1.10"` — is the SDK version your scripts target. It is a `major.minor` string (the
|
||||||
|
tenth minor of SDK v1), **not** the product release `1.1.9`. See
|
||||||
|
[the five version surfaces](concepts.md#the-five-version-surfaces).
|
||||||
|
|
||||||
|
## Step 3 — Log in
|
||||||
|
|
||||||
|
You authenticate once and use a **bearer token** for every control-plane call. Three equivalent ways:
|
||||||
|
|
||||||
|
**Dashboard:** open `http://localhost:8000/admin`, enter your admin username/password.
|
||||||
|
|
||||||
|
**CLI** (recommended for the rest of this guide):
|
||||||
|
```sh
|
||||||
|
printf 'YOUR_PASSWORD' | pic login --url $PICLOUD --username admin --password-stdin
|
||||||
|
# Logged in as admin (owner) at http://localhost:8000
|
||||||
|
pic whoami
|
||||||
|
```
|
||||||
|
|
||||||
|
**Raw HTTP:**
|
||||||
|
```sh
|
||||||
|
TOKEN=$(curl -s -X POST $PICLOUD/api/v1/admin/auth/login \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"username":"admin","password":"YOUR_PASSWORD"}' | jq -r .token)
|
||||||
|
```
|
||||||
|
The login response is:
|
||||||
|
```json
|
||||||
|
{"user":{"id":"…","username":"admin","instance_role":"owner","email":null},
|
||||||
|
"token":"V-f-0ey3eEcFKBWH5_6GLCqBkih08bhWtY-5GHCxZ04",
|
||||||
|
"expires_at":"2026-06-18T19:49:39Z"}
|
||||||
|
```
|
||||||
|
Pass it as `Authorization: Bearer $TOKEN` on admin requests. (The `pic` CLI stores it for you in
|
||||||
|
`~/.config/picloud/credentials`.)
|
||||||
|
|
||||||
|
## Step 4 — Meet the seeded "hello" script
|
||||||
|
|
||||||
|
A fresh install seeds one example script into the default app, bound to `GET /hello`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl $PICLOUD/hello
|
||||||
|
# {"message":"Hello, world!"}
|
||||||
|
|
||||||
|
curl $PICLOUD/hello -H 'Content-Type: application/json' -d '{"name":"Fabi"}'
|
||||||
|
# {"message":"Hello, Fabi!"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Open it in the dashboard (Apps → Default → Scripts → hello) to see its source. It's a good template:
|
||||||
|
it reads `ctx.request.body` and returns a [response envelope](concepts.md#the-response-envelope).
|
||||||
|
|
||||||
|
## Step 5 — Write your first script
|
||||||
|
|
||||||
|
Create a file `counter.rhai`:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
// Count visits, persisted in KV across requests and restarts.
|
||||||
|
let hits = kv::collection("counters");
|
||||||
|
let n = hits.get("home"); // () if the key has never been set
|
||||||
|
if n == () { n = 0; }
|
||||||
|
n += 1;
|
||||||
|
hits.set("home", n);
|
||||||
|
return #{ statusCode: 200, body: #{ count: n } };
|
||||||
|
```
|
||||||
|
|
||||||
|
Deploy it to the default app:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pic scripts deploy counter.rhai --app default --name counter
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{"action":"created","id":"3502852c-030b-4e39-82f6-121220095da7","name":"counter","version":"1"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Grab that `id` — call it `$SID`.
|
||||||
|
|
||||||
|
> Doing it in the dashboard instead? Apps → Default → Scripts → **New script**, paste the source,
|
||||||
|
> save. Doing it over raw HTTP? `POST /api/v1/admin/scripts` with
|
||||||
|
> `{"app_id":"…","name":"counter","source":"…"}` — see [Scripts](../reference/rest-api/scripts.md).
|
||||||
|
|
||||||
|
## Step 6 — Bind a route
|
||||||
|
|
||||||
|
A script does nothing until a [route](concepts.md#routes-and-domains) points at it.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pic routes create --script $SID --path /count --method GET
|
||||||
|
# Created route 9df71836-… (GET * /count)
|
||||||
|
```
|
||||||
|
|
||||||
|
Now hit it — the count persists between calls:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl $PICLOUD/count # {"count":1}
|
||||||
|
curl $PICLOUD/count # {"count":2}
|
||||||
|
curl $PICLOUD/count # {"count":3}
|
||||||
|
```
|
||||||
|
|
||||||
|
You just wrote a stateful endpoint with no database wiring, no migrations, no deploy pipeline.
|
||||||
|
|
||||||
|
## Step 7 — Look behind the curtain
|
||||||
|
|
||||||
|
Read what your script stored, straight from KV:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pic kv get --app default --collection counters home
|
||||||
|
# 3
|
||||||
|
```
|
||||||
|
|
||||||
|
And see every execution, with timing and status:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pic logs $SID --limit 3
|
||||||
|
```
|
||||||
|
```text
|
||||||
|
created_at source status summary
|
||||||
|
2026-06-17T19:54:01.265901+00:00 http success -
|
||||||
|
2026-06-17T19:54:01.161625+00:00 http success -
|
||||||
|
```
|
||||||
|
|
||||||
|
The dashboard shows the same under the script's **Executions** tab, including each run's `log::` output.
|
||||||
|
|
||||||
|
## Where to next
|
||||||
|
|
||||||
|
You now understand the core loop: **write a script → bind a route → call it → inspect**. From here:
|
||||||
|
|
||||||
|
- **Understand the model** → [Core concepts](concepts.md): apps, isolation, dispatch, events, versions.
|
||||||
|
- **Write better scripts** → [Writing scripts](writing-scripts.md): the `ctx` object, the envelope,
|
||||||
|
error handling, logging, sandbox limits.
|
||||||
|
- **Build something real** → the [tutorials](../examples/index.md): a URL shortener, a webhook
|
||||||
|
receiver, an authenticated TODO API, a scheduled report, and a file-upload service.
|
||||||
206
docs/dev-guide/src/guide/writing-scripts.md
Normal file
206
docs/dev-guide/src/guide/writing-scripts.md
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
# Writing scripts
|
||||||
|
|
||||||
|
Scripts are written in [Rhai](https://rhai.rs/book/) — a small, embeddable language with Rust-flavored
|
||||||
|
syntax, dynamic typing, maps (`#{ ... }`), arrays, and closures (`|x| ...`). This chapter covers what's
|
||||||
|
specific to writing scripts *for PiCloud*: the `ctx` object, the response envelope, errors, logging,
|
||||||
|
modules, and the sandbox. For the SDK calls themselves (`kv`, `http`, …) see the
|
||||||
|
[SDK reference](../reference/sdk/overview.md).
|
||||||
|
|
||||||
|
## The shape of a script
|
||||||
|
|
||||||
|
An `endpoint` script runs top to bottom; whatever it `return`s (or the value of its last expression)
|
||||||
|
becomes the [response envelope](concepts.md#the-response-envelope).
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
// Read input from ctx, do work, return a response.
|
||||||
|
let body = ctx.request.body;
|
||||||
|
let name = if type_of(body) == "map" && body.contains("name") { body.name } else { "world" };
|
||||||
|
return #{ statusCode: 200, body: #{ message: `Hello, ${name}!` } };
|
||||||
|
```
|
||||||
|
|
||||||
|
There's no `main`, no handler signature, no framework. The whole file *is* the handler.
|
||||||
|
|
||||||
|
## `ctx` — the execution context
|
||||||
|
|
||||||
|
Every run gets a global `ctx` map. The fields:
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `ctx.sdk_version` | string | e.g. `"1.10"` — for feature detection |
|
||||||
|
| `ctx.execution_id` | string (UUID) | unique per run |
|
||||||
|
| `ctx.script_id` | string (UUID) | the running script |
|
||||||
|
| `ctx.script_name` | string | |
|
||||||
|
| `ctx.request_id` | string (UUID) | correlation id, also in logs |
|
||||||
|
| `ctx.invocation_type` | string | `"http"`, `"function"` (via `invoke()`), or `"scheduled"` |
|
||||||
|
| `ctx.request` | map | the request, see below |
|
||||||
|
| `ctx.event` | map | **present only for triggered runs** — see [events](../reference/sdk/ctx-and-events.md#events) |
|
||||||
|
|
||||||
|
`ctx.request`:
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `ctx.request.path` | string | e.g. `"/users/42"` |
|
||||||
|
| `ctx.request.method` | string | uppercased, e.g. `"GET"` |
|
||||||
|
| `ctx.request.headers` | map | **lowercased** keys → values: `ctx.request.headers["authorization"]` |
|
||||||
|
| `ctx.request.body` | dynamic | **already JSON-parsed** when the body is JSON; a string otherwise; `()` if empty |
|
||||||
|
| `ctx.request.params` | map | captures from `:name` path segments; empty if none |
|
||||||
|
| `ctx.request.query` | map | query-string params; empty if none |
|
||||||
|
| `ctx.request.rest` | string | the tail captured by a `prefix` (`/*`) route; empty otherwise |
|
||||||
|
|
||||||
|
> **`ctx.request.body` is parsed for you.** If the client sends `Content-Type: application/json`, the
|
||||||
|
> body arrives as a Rhai map/array/scalar — you do **not** call `json::parse` on it. Only parse raw
|
||||||
|
> strings you get from elsewhere. Header keys are always lowercase. Test for presence before indexing:
|
||||||
|
> `if "x-api-key" in ctx.request.headers { ... }`.
|
||||||
|
|
||||||
|
> **There are no raw request bytes in `ctx`.** The body is JSON (or a string). To accept a binary
|
||||||
|
> upload, have the client base64-encode it inside a JSON field and `base64::decode` it in the script —
|
||||||
|
> see the [file-upload tutorial](../examples/file-upload.md).
|
||||||
|
|
||||||
|
## The response envelope, in detail
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
// Full control: status, headers, body.
|
||||||
|
return #{
|
||||||
|
statusCode: 302,
|
||||||
|
headers: #{ "Location": "https://example.com/target" },
|
||||||
|
body: ()
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- `statusCode` — required integer if the map is to be treated as an envelope. Omit it and the *whole
|
||||||
|
map* becomes a `200` JSON body instead.
|
||||||
|
- `headers` — a `string → string` map. Values are stringified.
|
||||||
|
- `body` — **always serialized as JSON.** A map/array becomes a JSON object/array; a string becomes a
|
||||||
|
JSON *string* (so `body: "pong"` is sent as `"pong"`, with quotes); `()` becomes `null`.
|
||||||
|
|
||||||
|
Shortcut: returning any non-envelope value yields `200 OK` with that value as the JSON body. `return #{
|
||||||
|
ok: true };` ⇒ `200 {"ok":true}`; `return "pong";` ⇒ `200 "pong"`.
|
||||||
|
|
||||||
|
> **Responses are always JSON.** The response body is JSON-encoded regardless of any `Content-Type`
|
||||||
|
> header you set (the default content type is `application/json`; you can override the *header*, but
|
||||||
|
> the *bytes* stay JSON). In 1.1.9 a script route therefore **cannot serve raw HTML, plain text, or
|
||||||
|
> binary** — to return a file's bytes, send base64 in JSON or use the
|
||||||
|
> [admin files endpoint](../reference/rest-api/data-admin.md#files). See the
|
||||||
|
> [file-upload tutorial](../examples/file-upload.md).
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
A script that **`throw`**s aborts and produces an HTTP **502**:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
if ctx.request.body == () { throw "body required"; }
|
||||||
|
```
|
||||||
|
```text
|
||||||
|
HTTP/1.1 502 Bad Gateway
|
||||||
|
{"error":"Runtime error: body required (line 1, position 30)"}
|
||||||
|
```
|
||||||
|
|
||||||
|
To return a *clean* client error instead of a 502, return an envelope with the status you want:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
if ctx.request.body == () {
|
||||||
|
return #{ statusCode: 400, body: #{ error: "body required" } };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
SDK calls follow a consistent convention so you can decide whether to guard or let it bubble:
|
||||||
|
|
||||||
|
- **They throw** on real failures (DB down, payload over a size cap, authorization denied).
|
||||||
|
- **They return `()`** for "not found" (`kv::...get` of a missing key, `users::login` with bad
|
||||||
|
credentials, etc.). Test with `== ()`.
|
||||||
|
- **They return a `bool`** for predicates (`...has(k)`, `...delete(k)` → was-it-present).
|
||||||
|
|
||||||
|
Wrap risky calls in `try { ... } catch (e) { ... }` when you want to handle failure yourself.
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
`log::<level>(message)` or `log::<level>(message, #{ structured: "data" })`, at four levels:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
log::info("charge succeeded", #{ order: id, cents: amount });
|
||||||
|
log::warn("retrying upstream");
|
||||||
|
log::error("gave up", #{ attempts: 3 });
|
||||||
|
log::trace("entered branch A");
|
||||||
|
```
|
||||||
|
|
||||||
|
There is **no `log::debug`** (`debug` is a reserved word in Rhai) — use `log::trace`.
|
||||||
|
|
||||||
|
> **Where do these show up?** Log entries are buffered during the run and persisted to the execution
|
||||||
|
> log **for asynchronous and triggered executions** (async routes, cron/queue/kv/… triggers) — view
|
||||||
|
> them with `pic logs <script_id>` or the dashboard's **Executions** tab. **Synchronous HTTP
|
||||||
|
> executions do not persist the buffered `log::` output** (the row records timing, status, and code
|
||||||
|
> only). So `log::` is most useful for background work; for sync endpoints, fold diagnostics into the
|
||||||
|
> response during development. This is a deliberate hot-path optimization, not a bug.
|
||||||
|
|
||||||
|
## Modules and imports
|
||||||
|
|
||||||
|
Factor shared logic into a **module** script (`kind: "module"`) — it may contain only `fn` and `const`
|
||||||
|
declarations, no top-level statements:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
// module script named "money"
|
||||||
|
const CURRENCY = "EUR";
|
||||||
|
fn format(cents) {
|
||||||
|
`${CURRENCY} ${cents / 100}.${cents % 100}`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Another script *in the same app* imports it by name:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
import "money" as money;
|
||||||
|
return #{ statusCode: 200, body: #{ price: money::format(4999) } };
|
||||||
|
```
|
||||||
|
|
||||||
|
Deploy a module with `pic scripts deploy money.rhai --app myapp --kind module`. Cross-app imports are
|
||||||
|
impossible — the import name carries no app, and resolution is scoped to the caller's app. Module names
|
||||||
|
can't shadow SDK namespaces (`kv`, `http`, `log`, …). The [scheduled-report
|
||||||
|
tutorial](../examples/scheduled-report.md) uses a module.
|
||||||
|
|
||||||
|
## Sandbox limits
|
||||||
|
|
||||||
|
Every run is bounded. If a script exceeds a limit it's terminated and the request fails (a timeout is
|
||||||
|
`504`; an operation-budget overrun is `507`).
|
||||||
|
|
||||||
|
| Limit | Default (what scripts get) | What it bounds |
|
||||||
|
|---|---|---|
|
||||||
|
| `timeout_seconds` | 30 (max 300) | wall-clock time |
|
||||||
|
| `memory_limit_mb` | 256 (max 2048) | memory |
|
||||||
|
| `max_operations` | 1,000,000 | total Rhai operations (a CPU proxy) |
|
||||||
|
| `max_string_size` | 64 KiB | longest string built |
|
||||||
|
| `max_array_size` | 10,000 | longest array |
|
||||||
|
| `max_map_size` | 10,000 | largest map |
|
||||||
|
| `max_call_levels` | 64 | call-stack depth |
|
||||||
|
| `max_expr_depth` | 64 | expression nesting |
|
||||||
|
|
||||||
|
You can *lower* (or raise, within ceilings) these per script. Via the CLI:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pic scripts deploy heavy.rhai --app myapp --timeout 60 --sandbox max_operations=5000000
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-knob overrides are clamped to admin-configured **ceilings** (defaults: 10M operations, 1 MiB
|
||||||
|
strings, 100k array/map, 128 call/expr levels; tunable with `PICLOUD_SANDBOX_MAX_*` — see
|
||||||
|
[env vars](../reference/config/env-vars.md)). An override above the ceiling is rejected at deploy time,
|
||||||
|
so a typo can't silently create an unrestricted script.
|
||||||
|
|
||||||
|
Separately, the whole instance caps **concurrent** executions (`PICLOUD_MAX_CONCURRENT_EXECUTIONS`,
|
||||||
|
default 32); past that, new data-plane requests get `503` with `Retry-After: 1` immediately — there's
|
||||||
|
no queue. Plan for it; see [Best practices](../operations/best-practices.md).
|
||||||
|
|
||||||
|
## Quick reference: idioms
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
// Default a missing body field
|
||||||
|
let n = if "count" in ctx.request.body { ctx.request.body.count } else { 0 };
|
||||||
|
|
||||||
|
// Read a header (always lowercase key)
|
||||||
|
let auth = if "authorization" in ctx.request.headers { ctx.request.headers["authorization"] } else { "" };
|
||||||
|
|
||||||
|
// 404 cleanly
|
||||||
|
let row = kv::collection("things").get(id);
|
||||||
|
if row == () { return #{ statusCode: 404, body: #{ error: "not found" } }; }
|
||||||
|
|
||||||
|
// Redirect
|
||||||
|
return #{ statusCode: 302, headers: #{ "Location": target }, body: () };
|
||||||
|
```
|
||||||
124
docs/dev-guide/src/introduction.md
Normal file
124
docs/dev-guide/src/introduction.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# PiCloud Developer Guide
|
||||||
|
|
||||||
|
**PiCloud is a self-hosted, event-driven serverless platform.** You write small scripts in
|
||||||
|
[Rhai](https://rhai.rs) (an embedded scripting language with Rust-like syntax), upload them, and bind
|
||||||
|
them to URLs. PiCloud runs each script in a sandbox when a request arrives — no servers to manage, no
|
||||||
|
containers to build, no cold-start orchestration to think about. It is built to run comfortably on a
|
||||||
|
single box (a home server, a VPS, a Raspberry Pi) and scales to a cluster later without a rewrite.
|
||||||
|
|
||||||
|
If you have ever used a "functions" product in a public cloud, the mental model is the same — but the
|
||||||
|
whole thing is one binary plus a Postgres database that *you* own.
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP request ┌──────────────────────────┐
|
||||||
|
│ │ your Rhai script │
|
||||||
|
▼ │ │
|
||||||
|
┌───────────┐ resolve Host → app, ┌──────▶│ let body = ctx.request │
|
||||||
|
│ Caddy │──▶ then match the route ─┘ │ kv::collection("x")... │
|
||||||
|
│ (proxy) │ (orchestrator) │ return #{ statusCode } │
|
||||||
|
└───────────┘ └──────────────────────────┘
|
||||||
|
│ │
|
||||||
|
│ sandboxed run (executor) ◀─────┘
|
||||||
|
▼
|
||||||
|
HTTP response ◀── the response envelope your script returned
|
||||||
|
```
|
||||||
|
|
||||||
|
## What you can build
|
||||||
|
|
||||||
|
A script can do a lot more than return JSON. Through the **SDK** it can store data, send mail, call
|
||||||
|
other services, react to events, and run on a schedule:
|
||||||
|
|
||||||
|
| Capability | SDK namespace | Backed by |
|
||||||
|
|---|---|---|
|
||||||
|
| Key/value storage | `kv` | Postgres (JSONB) |
|
||||||
|
| Document storage with queries | `docs` | Postgres (JSONB) |
|
||||||
|
| Blob / file storage | `files` | Filesystem + Postgres metadata |
|
||||||
|
| Outbound HTTP requests | `http` | reqwest, with an SSRF guard |
|
||||||
|
| Send & receive email | `email` | SMTP relay / inbound webhook |
|
||||||
|
| End-user accounts & login | `users` | Postgres + Argon2id |
|
||||||
|
| Pub/sub & realtime (SSE) | `pubsub` | Postgres `LISTEN/NOTIFY` |
|
||||||
|
| Durable job queues | `queue` | Postgres |
|
||||||
|
| Encrypted secrets | `secrets` | Postgres (AES-256-GCM) |
|
||||||
|
| Call other scripts | `invoke` | in-process re-entry |
|
||||||
|
| Retry with backoff | `retry` | — |
|
||||||
|
| Scheduled / event triggers | (triggers, not an SDK call) | dispatcher |
|
||||||
|
|
||||||
|
Plus a **standard library** for the everyday glue: `json`, `base64`, `hex`, `url`, `regex`, `random`,
|
||||||
|
`time`. See the [SDK reference](reference/sdk/overview.md) for the full surface.
|
||||||
|
|
||||||
|
## Three ways to drive PiCloud
|
||||||
|
|
||||||
|
Everything below is just a client of the same HTTP control plane — pick whichever fits:
|
||||||
|
|
||||||
|
1. **The dashboard** — a web UI at `/admin` with a code editor, route binder, log viewer, and
|
||||||
|
management screens for every resource. Best for exploring and for editing scripts.
|
||||||
|
2. **The `pic` CLI** — a developer command-line client. Best for scripting deploys, CI, and quick
|
||||||
|
inspection. See the [CLI reference](reference/cli/pic.md).
|
||||||
|
3. **Plain HTTP** (`curl`, any language) — the underlying REST API. Best when you want no extra
|
||||||
|
tooling, or to integrate from your own code. See the [HTTP API reference](reference/rest-api/overview.md).
|
||||||
|
|
||||||
|
This guide shows all three side by side throughout.
|
||||||
|
|
||||||
|
## Where to start
|
||||||
|
|
||||||
|
> **New here?** → [Quickstart](guide/quickstart.md) (zero to a live endpoint in ~10 minutes) →
|
||||||
|
> [Core concepts](guide/concepts.md) → pick a [tutorial](examples/index.md).
|
||||||
|
>
|
||||||
|
> **Want to build something concrete?** → the [tutorials](examples/index.md) are five complete,
|
||||||
|
> copy-pasteable example apps, each exercising a different slice of the platform.
|
||||||
|
>
|
||||||
|
> **Looking something up?** → the [SDK](reference/sdk/overview.md),
|
||||||
|
> [HTTP API](reference/rest-api/overview.md), and [CLI](reference/cli/pic.md) references are
|
||||||
|
> organized for lookup. Use the search box (top-left) for anything specific.
|
||||||
|
>
|
||||||
|
> **Running it in production?** → [Deployment](deploy/docker-compose.md) and the
|
||||||
|
> [security chapter](operations/security.md).
|
||||||
|
|
||||||
|
## A note on versions
|
||||||
|
|
||||||
|
This guide documents **PiCloud product `1.1.9`**. PiCloud tracks five independent version numbers, all
|
||||||
|
returned by [`GET /version`](reference/rest-api/overview.md#get-version):
|
||||||
|
|
||||||
|
| Surface | Value | Means |
|
||||||
|
|---|---|---|
|
||||||
|
| `product` | `1.1.9` | the release you're running |
|
||||||
|
| `sdk` | `1.10` | the script-visible SDK, in **`major.minor`** form — this is the *tenth minor* of SDK v1, **not** "1.1.0" |
|
||||||
|
| `api` | `1` | the HTTP API major version, in the URL as `/api/v1/...` |
|
||||||
|
| `schema` | `44` | the database migration number |
|
||||||
|
| `wire` | `1` | the inter-node protocol (reserved; cluster mode is a future release) |
|
||||||
|
|
||||||
|
Your scripts can read the SDK version at runtime as `ctx.sdk_version` (the string `"1.10"`). Do not
|
||||||
|
confuse `sdk` with `product`. [More on versioning →](guide/concepts.md#the-five-version-surfaces)
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
These terms recur throughout the guide:
|
||||||
|
|
||||||
|
- **App** — a tenant/namespace. Owns scripts, routes, domains, and all data. Apps are isolated from
|
||||||
|
each other; a script can only ever touch its own app's data. See [Core concepts](guide/concepts.md#apps).
|
||||||
|
- **Script** — a Rhai program. Two kinds: an **endpoint** (runs on requests/events) or a **module**
|
||||||
|
(a library of `fn`/`const` that other scripts `import`).
|
||||||
|
- **Route** — a binding from an HTTP method + host + path to a script. One script can have many routes.
|
||||||
|
- **Trigger** — a binding that fires a script on an *event* instead of an HTTP request: a KV/docs/files
|
||||||
|
mutation, a published message, a queue message, a cron tick, or inbound email.
|
||||||
|
- **Event** — the thing that fired a triggered script; surfaced to the script as `ctx.event`.
|
||||||
|
- **Dispatch mode** — `sync` (the caller waits for the script's response) or `async` (the platform
|
||||||
|
returns `202 Accepted` immediately and runs the script in the background).
|
||||||
|
- **Collection** — a named bucket inside a storage service. The identity of a stored item is the tuple
|
||||||
|
`(app, collection, key)`. Collections are mandatory.
|
||||||
|
- **Response envelope** — the value an endpoint script returns to shape the HTTP response: a map with
|
||||||
|
`statusCode`, optional `headers`, and `body`.
|
||||||
|
- **Principal** — the authenticated identity behind a control-plane request: an **admin user** or an
|
||||||
|
**API key**. Distinct from an **app user** (an end-user of *your* app, managed by the `users` SDK).
|
||||||
|
- **Instance role** — an admin's platform-wide role: `owner`, `admin`, or `member`.
|
||||||
|
- **App role** — a member's per-app role: `app_admin`, `editor`, or `viewer`.
|
||||||
|
- **Dead letter** — a record of a triggered execution that exhausted its retries; you can inspect and
|
||||||
|
replay it.
|
||||||
|
|
||||||
|
## How to read the code examples
|
||||||
|
|
||||||
|
- Rhai snippets are shown as ```rhai``` blocks; they are the *body* of a script.
|
||||||
|
- HTTP examples use `curl` against a local dev instance on port `18080` (the dashboard-fronted stack
|
||||||
|
uses `8000` — adjust to your setup; see [Deployment](deploy/docker-compose.md)).
|
||||||
|
- CLI examples use the `pic` binary.
|
||||||
|
- Wherever a guide says a request "returns X", that output was produced by actually running it.
|
||||||
99
docs/dev-guide/src/operations/best-practices.md
Normal file
99
docs/dev-guide/src/operations/best-practices.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# Best practices
|
||||||
|
|
||||||
|
Patterns that hold up as your scripts move from demo to production.
|
||||||
|
|
||||||
|
## Sync vs async dispatch {#sync-vs-async}
|
||||||
|
|
||||||
|
- Use **`sync`** when the caller needs the result *now* and the work is fast (a lookup, a small
|
||||||
|
computation, a redirect).
|
||||||
|
- Use **`async`** (route or trigger) for anything slow or fire-and-forget — webhooks you don't need to
|
||||||
|
answer in detail, fan-out, outbound calls, report generation. The client gets `202` immediately and
|
||||||
|
isn't blocked by your processing.
|
||||||
|
- Don't do slow work (a chain of `http` calls, big aggregations) on a `sync` route — you'll hold the
|
||||||
|
connection and burn a concurrency permit. Enqueue it and return `202`.
|
||||||
|
|
||||||
|
## Idempotent consumers {#idempotent-consumers}
|
||||||
|
|
||||||
|
Queue messages and triggers can fire **more than once** (a retry after a partial success, a
|
||||||
|
visibility-timeout expiry). Make consumers idempotent:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let msg = ctx.event.queue.message;
|
||||||
|
let done = kv::collection("processed");
|
||||||
|
if done.has(msg.id) { return; } // already handled — skip
|
||||||
|
// ... do the work ...
|
||||||
|
done.set(msg.id, time::now_ms());
|
||||||
|
```
|
||||||
|
|
||||||
|
`ctx.event.queue.attempt` (and `ctx.event.dead_letter.attempts`) tell you when you're on a retry.
|
||||||
|
|
||||||
|
## Retries & dead-letters
|
||||||
|
|
||||||
|
- Wrap genuinely transient calls (`http`, `invoke`) in [`retry::run`](../reference/sdk/composition.md#retry)
|
||||||
|
with a bounded `max_attempts` and exponential backoff. Use `retry::on_codes` so you only retry the
|
||||||
|
failures worth retrying (e.g. `502/503/504`), not deterministic ones (`400`).
|
||||||
|
- Don't stack retries blindly: the trigger's own `retry_max_attempts` *and* an in-script `retry::run`
|
||||||
|
multiply. Pick one layer as primary.
|
||||||
|
- Let exhausted work become a **dead-letter**, then alert on the count and replay after a fix — rather
|
||||||
|
than retrying forever. A [`dead_letter` trigger](../reference/rest-api/triggers.md) can automate the
|
||||||
|
alert/replay.
|
||||||
|
|
||||||
|
## Data modeling
|
||||||
|
|
||||||
|
- **`kv`** for simple keyed values, counters, flags, caches. **`docs`** when you need to *find by
|
||||||
|
field*. **`files`** for blobs. **`secrets`** for credentials.
|
||||||
|
- Pick **collection names** deliberately — `(app, collection, key)` is the identity; one collection per
|
||||||
|
logical entity (`users`, `orders`) keeps listing and triggers clean.
|
||||||
|
- `docs.update` **replaces** the whole `data` map — read-modify-write to change one field.
|
||||||
|
- Respect the [size caps](../reference/config/env-vars.md#data-plane-size-caps) (256 KiB for
|
||||||
|
kv/docs/queue/pubsub). If a value is growing toward the cap, you're probably modeling it wrong (split
|
||||||
|
it, or use `files`).
|
||||||
|
|
||||||
|
## Pagination
|
||||||
|
|
||||||
|
List endpoints (`kv.list`, `docs.list`, admin lists) are **keyset/cursor** paginated. Loop until
|
||||||
|
`next_cursor` is `()`:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let c = docs::collection("orders");
|
||||||
|
let cursor = ();
|
||||||
|
loop {
|
||||||
|
let page = c.list(#{ cursor: cursor, limit: 100 });
|
||||||
|
for doc in page.docs { /* ... */ }
|
||||||
|
cursor = page.next_cursor;
|
||||||
|
if cursor == () { break; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Don't assume one `list()` returns everything.
|
||||||
|
|
||||||
|
## Script organization
|
||||||
|
|
||||||
|
- Factor shared logic into a **`module`** and `import` it — don't copy-paste across endpoints.
|
||||||
|
- One script can serve **several routes** and branch on `ctx.request.method` / `params` (see the
|
||||||
|
[TODO API](../examples/todo-api.md)); or split per concern. Either is fine — optimize for readability.
|
||||||
|
- Validate `ctx.request.body` shape early and return a clean `400`, rather than letting a missing field
|
||||||
|
throw a `502`.
|
||||||
|
|
||||||
|
## Logging & debugging
|
||||||
|
|
||||||
|
- `log::` output is persisted for **async/triggered** runs — use it freely there and read it with
|
||||||
|
`pic logs <id> --source <kind>`.
|
||||||
|
- For **synchronous HTTP** runs, `log::` is **not** persisted (a hot-path optimization). During
|
||||||
|
development, fold diagnostics into the response, or test the logic via an async path / `execute`.
|
||||||
|
- Filter logs by `--source` (`http`, `queue`, `cron`, …) to separate request handling from background
|
||||||
|
work.
|
||||||
|
|
||||||
|
## Concurrency & limits
|
||||||
|
|
||||||
|
- The instance caps concurrent executions (`PICLOUD_MAX_CONCURRENT_EXECUTIONS`); past it, callers get
|
||||||
|
`503 Retry-After: 1`. Build clients that honor `Retry-After`, and keep sync handlers quick so permits
|
||||||
|
free up fast.
|
||||||
|
- Lower a script's `timeout_seconds`/sandbox limits if it should be cheap — it fails fast instead of
|
||||||
|
hogging a permit.
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
- Read `ctx.sdk_version` if you want to feature-detect, but remember it's `major.minor` (`"1.10"`).
|
||||||
|
Minor bumps only *add*; your scripts keep working across them.
|
||||||
|
- Don't hardcode assumptions about the `product` version into scripts.
|
||||||
105
docs/dev-guide/src/operations/security.md
Normal file
105
docs/dev-guide/src/operations/security.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Security
|
||||||
|
|
||||||
|
PiCloud gives you isolation and sandboxing by default, but several things are *your* responsibility.
|
||||||
|
Read this once before you put a public route online.
|
||||||
|
|
||||||
|
## The data plane is public {#the-data-plane-is-public}
|
||||||
|
|
||||||
|
**This is the most important point in the whole guide.** The routes you create are public, and a
|
||||||
|
request to one runs your script with **full authority over its app's data** — it can read every secret,
|
||||||
|
every KV key, every document, every user. PiCloud puts **no authentication in front of your routes**.
|
||||||
|
|
||||||
|
The [capability/role model](../reference/config/capabilities.md) governs the *control plane* (the admin
|
||||||
|
API) — not your routes. So:
|
||||||
|
|
||||||
|
- If a route must be restricted, **the script enforces it** — check a bearer token with
|
||||||
|
`users::verify`, compare a shared secret, etc. (see the [TODO API](../examples/todo-api.md) and
|
||||||
|
[webhook](../examples/webhook-receiver.md) tutorials).
|
||||||
|
- Treat anything a public script can read as reachable by anyone who can hit the route. Don't put a
|
||||||
|
secret in a collection a public script reads unless you mean to expose it.
|
||||||
|
- A script that calls `users::find_by_email` from an *unauthenticated* context is refused — but that's
|
||||||
|
one specific guard, not a general one.
|
||||||
|
|
||||||
|
## Cross-app isolation
|
||||||
|
|
||||||
|
Apps are hard-isolated: every SDK call derives the app from the server-side execution context, and
|
||||||
|
there is **no `app_id` argument anywhere in the SDK**. A script cannot name or reach another app's
|
||||||
|
data, period. This is enforced in the platform, not by convention — you can rely on it. Cross-app
|
||||||
|
sharing does not exist in 1.1.9.
|
||||||
|
|
||||||
|
## Secrets and the master key {#secrets-and-the-master-key}
|
||||||
|
|
||||||
|
- Store credentials in [`secrets`](../reference/sdk/secrets.md), not `kv` — secret values are encrypted
|
||||||
|
at rest (AES-256-GCM) and never returned by the admin API/dashboard/CLI (names only).
|
||||||
|
- Set them out-of-band (`pic secrets set` reads the value from stdin) rather than hardcoding in source.
|
||||||
|
- **Critical: the master key.** Secrets are encrypted under `PICLOUD_SECRET_KEY`. **Rotating it makes
|
||||||
|
existing secrets undecryptable** (`secrets::get` returns `()`); there's no automatic re-encryption in
|
||||||
|
1.1.9. Treat the key as durable, store it in a secret manager, and if you must rotate, re-`set` every
|
||||||
|
secret afterward.
|
||||||
|
- **Never** run production with `PICLOUD_DEV_MODE` + `PICLOUD_DEV_INSECURE_KEY` — that "key" is
|
||||||
|
world-known, so at-rest encryption is worthless.
|
||||||
|
|
||||||
|
## SSRF — outbound HTTP {#ssrf}
|
||||||
|
|
||||||
|
`http::*` resolves the target and **blocks private, loopback, and link-local addresses** by default, so
|
||||||
|
a script taking a user-supplied URL can't be tricked into scanning your internal network. A blocked
|
||||||
|
call throws, e.g.:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http: blocked by SSRF policy: loopback
|
||||||
|
```
|
||||||
|
|
||||||
|
The escape hatch `PICLOUD_HTTP_ALLOW_PRIVATE=true` disables this — **dev/test only, never production**.
|
||||||
|
If you need a script to reach an internal service in prod, give it a routable address, not a private
|
||||||
|
one.
|
||||||
|
|
||||||
|
## User enumeration {#user-enumeration}
|
||||||
|
|
||||||
|
The [`users`](../reference/sdk/users.md) SDK is built to resist account enumeration:
|
||||||
|
|
||||||
|
- `users::find_by_email` **requires an authenticated principal** — anonymous/public scripts can't use
|
||||||
|
it to probe who's registered.
|
||||||
|
- `users::email_available` *is* anonymous-safe (a signup form legitimately needs it), but it is **not
|
||||||
|
throttled**. If enumeration via the signup form is a concern, rate-limit it yourself (e.g. a `kv`
|
||||||
|
counter keyed by IP).
|
||||||
|
|
||||||
|
## Input validation & injection
|
||||||
|
|
||||||
|
- `ctx.request.body` is parsed JSON — validate its **shape** before use (`type_of`, `contains`) so a
|
||||||
|
missing field throws a clean `400`, not a confusing `502`.
|
||||||
|
- There's no SQL to inject (you don't write SQL), but be careful with **open redirects** (validate URLs
|
||||||
|
before issuing a `302`) and with reflecting user input into emails or downstream HTTP.
|
||||||
|
- Storing user-supplied HTML and serving it later is *not* a vector here, because
|
||||||
|
[script responses are always JSON](../guide/writing-scripts.md#the-response-envelope-in-detail) — but
|
||||||
|
if you base64-ship content for a client to render, the usual XSS rules apply on the client.
|
||||||
|
|
||||||
|
## Webhook authenticity
|
||||||
|
|
||||||
|
The 1.1.9 script SDK has **no HMAC/hashing primitive**, so a script cannot verify a provider's HMAC
|
||||||
|
signature itself. Options:
|
||||||
|
|
||||||
|
- Use a **shared-secret header** compared against a stored secret (the
|
||||||
|
[webhook tutorial](../examples/webhook-receiver.md) approach).
|
||||||
|
- For **inbound email**, use the built-in [email trigger](../reference/rest-api/triggers.md) with an
|
||||||
|
`inbound_secret` — the platform does the HMAC verification server-side.
|
||||||
|
|
||||||
|
## API keys & least privilege
|
||||||
|
|
||||||
|
- Mint [API keys](../reference/rest-api/access.md#api-keys) with the **narrowest scopes** that work,
|
||||||
|
**bind them to one app** (`--app`), and set `expires_at`. A bound key can't hold `instance:admin`.
|
||||||
|
- The raw token is shown once — store it in your secret manager.
|
||||||
|
- Revoking a key (or deactivating an admin) takes effect immediately.
|
||||||
|
- Prefer **per-app members** with `viewer`/`editor` over handing out instance `admin`.
|
||||||
|
|
||||||
|
## Platform hardening (already done for you)
|
||||||
|
|
||||||
|
- Caddy adds CSP, `X-Frame-Options`, `nosniff`, `Referrer-Policy`, and (in prod) HSTS to the dashboard
|
||||||
|
and admin API, plus a 12 MB request-body ceiling. User-route responses deliberately get no CSP — your
|
||||||
|
scripts own their headers.
|
||||||
|
- Login is rate-limited per IP and per username; passwords use Argon2id; sessions and API keys are
|
||||||
|
stored as hashes.
|
||||||
|
- Scripts run in a [sandbox](../guide/writing-scripts.md#sandbox-limits) with operation, memory, and
|
||||||
|
wall-clock limits, and the whole instance caps concurrency.
|
||||||
|
|
||||||
|
See the [Production checklist](../deploy/production-checklist.md) for the deployment-time version of
|
||||||
|
this page.
|
||||||
106
docs/dev-guide/src/operations/troubleshooting.md
Normal file
106
docs/dev-guide/src/operations/troubleshooting.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# Troubleshooting
|
||||||
|
|
||||||
|
Symptoms you'll actually hit, and what they mean. All error strings below are real responses from a
|
||||||
|
running instance.
|
||||||
|
|
||||||
|
## Routing
|
||||||
|
|
||||||
|
**`404 {"error":"no app claims host \"…\""}`**
|
||||||
|
No app claims the request's `Host`. The `default` app claims only `localhost`. For another app, claim
|
||||||
|
the host first (`pic apps domains add <app> <host>`) and send a matching `Host:` header. Locally:
|
||||||
|
`pic apps domains add myapp myapp.localhost` then `curl -H 'Host: myapp.localhost' …`.
|
||||||
|
|
||||||
|
**`404 {"error":"no route matches GET /…"}`**
|
||||||
|
The host resolved to an app, but no route matches that method+path in *that app*. Check
|
||||||
|
`pic routes ls <script_id>`, and that the method matches (a route with `method: null` matches any). Use
|
||||||
|
`pic routes match --app <app> <url>` to see what would match.
|
||||||
|
|
||||||
|
**My route returns 404 but I just created it.**
|
||||||
|
Are you hitting the right app's host? Routes are app-scoped. Also: routes under `/api/`, `/admin/`,
|
||||||
|
`/healthz`, `/version` are rejected at creation — Caddy never forwards those to the matcher.
|
||||||
|
|
||||||
|
## Script errors
|
||||||
|
|
||||||
|
**`502 {"error":"Runtime error: … (line N, position M)"}`**
|
||||||
|
Your script `throw`ew (or hit a runtime error). To return a clean client error instead, return an
|
||||||
|
envelope: `return #{ statusCode: 400, body: #{ error: "…" } };`.
|
||||||
|
|
||||||
|
**`422 invalid script: … 'public' is a reserved keyword`** (or `loop`, `fn`, …)
|
||||||
|
Rhai reserves more words than you'd expect (`public`, `loop`, `debug`, …). Rename the variable. Note
|
||||||
|
there's no `log::debug` — use `log::trace`.
|
||||||
|
|
||||||
|
**`Unknown property 'x' - a getter is not registered …`**
|
||||||
|
You accessed `.x` on a value that isn't a map (or whose field is named differently). Most often a
|
||||||
|
wrong [`ctx.event`](../reference/sdk/ctx-and-events.md#events) path — e.g. the queue payload is
|
||||||
|
`ctx.event.queue.message`, **not** `.payload`; the attempt count is `.attempt`, not `.attempts`.
|
||||||
|
|
||||||
|
**`504 Gateway Timeout`** — the script exceeded `timeout_seconds`.
|
||||||
|
**`507`** — it blew the operation budget (`max_operations`). Both mean: do less, or raise the limit
|
||||||
|
(within [ceilings](../reference/config/env-vars.md#sandbox-ceilings)) at deploy time.
|
||||||
|
|
||||||
|
**My `log::info` output isn't in the logs.**
|
||||||
|
Synchronous HTTP runs don't persist `log::` output — only async/triggered runs do. Not a bug; see
|
||||||
|
[Writing scripts → Logging](../guide/writing-scripts.md#logging).
|
||||||
|
|
||||||
|
## SDK calls
|
||||||
|
|
||||||
|
**`http: blocked by SSRF policy: loopback`** (or `private`)
|
||||||
|
`http::*` blocks private/loopback targets by default. You're trying to reach `localhost`/a private IP.
|
||||||
|
Use a public address, or (dev only) `PICLOUD_HTTP_ALLOW_PRIVATE=true`. [More →](security.md#ssrf).
|
||||||
|
|
||||||
|
**`email::send` throws `NotConfigured`.**
|
||||||
|
No SMTP relay configured. Set `PICLOUD_SMTP_*`, or in dev mode use the sink at
|
||||||
|
`GET /api/v1/admin/dev/emails`.
|
||||||
|
|
||||||
|
**A `get` returns `()` unexpectedly.**
|
||||||
|
`()` means "absent". For `secrets::get` it can also mean decryption failed — did the
|
||||||
|
`PICLOUD_SECRET_KEY` change? ([master-key caveat](security.md#secrets-and-the-master-key)).
|
||||||
|
|
||||||
|
**A `set`/`enqueue`/`create` throws about size.**
|
||||||
|
You exceeded a [size cap](../reference/config/env-vars.md#data-plane-size-caps) (256 KiB for
|
||||||
|
kv/docs/queue/pubsub; 100 MiB for files). Remember base64 inflates ~33%.
|
||||||
|
|
||||||
|
## API & auth
|
||||||
|
|
||||||
|
**`401 Unauthorized` on an admin call.** Missing/invalid/expired bearer token. Re-`pic login`, or check
|
||||||
|
the `Authorization: Bearer …` header. **`403 Forbidden`** means you're authenticated but lack the
|
||||||
|
[capability](../reference/config/capabilities.md) — wrong role for that action.
|
||||||
|
|
||||||
|
**`422 unknown scope: app:read`** (minting an API key)
|
||||||
|
That's not a real scope. The seven valid ones: `script:read`, `script:write`, `route:write`,
|
||||||
|
`domain:manage`, `log:read`, `app:admin`, `instance:admin`.
|
||||||
|
|
||||||
|
**`422 invalid resolution: …`** (resolving a dead-letter)
|
||||||
|
Reasons are a fixed set: `ignored`, `handled_by_script`, `handler_failed` (and `replayed`, set by
|
||||||
|
`replay`). Not free text.
|
||||||
|
|
||||||
|
**`Failed to parse the request body as JSON …`**
|
||||||
|
Malformed JSON, or (a classic) unescaped quotes when hand-building a body in the shell. Deploy scripts
|
||||||
|
from a `.rhai` file with `pic scripts deploy` instead of inlining source in a JSON string.
|
||||||
|
|
||||||
|
## Platform
|
||||||
|
|
||||||
|
**`503` with `Retry-After: 1` on a route.**
|
||||||
|
The instance hit its concurrency cap (`PICLOUD_MAX_CONCURRENT_EXECUTIONS`, default 32). There's no
|
||||||
|
queue — back off and retry. Long-term: raise the cap (and `PICLOUD_DB_MAX_CONNECTIONS` with it), or
|
||||||
|
move slow work to `async`.
|
||||||
|
|
||||||
|
**Dashboard dev server can't reach the API.**
|
||||||
|
Vite proxies `/api` to `http://127.0.0.1:18080` by default — run `picloud` on `18080` or set
|
||||||
|
`PICLOUD_API`. [More →](../deploy/bare-binary.md#running-the-dashboard-in-dev).
|
||||||
|
|
||||||
|
**Server won't start.**
|
||||||
|
- Missing `DATABASE_URL` → it aborts. Set it.
|
||||||
|
- `PICLOUD_DEV_MODE=true` *alone* aborts — also set `PICLOUD_DEV_INSECURE_KEY=i-understand-this-is-insecure`,
|
||||||
|
or provide a real `PICLOUD_SECRET_KEY`.
|
||||||
|
- `docker compose up` errors on unset `PICLOUD_ADMIN_USERNAME`/`PICLOUD_ADMIN_PASSWORD` (compose makes
|
||||||
|
them mandatory).
|
||||||
|
|
||||||
|
**Locked out — forgot the admin password.**
|
||||||
|
`docker compose exec picloud picloud admin reset-password <username>` (or run the binary's subcommand
|
||||||
|
directly). [More →](../reference/cli/server-admin.md).
|
||||||
|
|
||||||
|
## Still stuck?
|
||||||
|
|
||||||
|
Check the server logs (`docker compose logs -f picloud` or stdout), bump `RUST_LOG=debug,picloud=debug`,
|
||||||
|
and read the execution log for the script (`pic logs <id>` / the dashboard **Executions** tab).
|
||||||
97
docs/dev-guide/src/reference/cli/pic.md
Normal file
97
docs/dev-guide/src/reference/cli/pic.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# The `pic` CLI
|
||||||
|
|
||||||
|
`pic` is the PiCloud command-line client — a thin, scriptable wrapper over the same
|
||||||
|
[HTTP API](../rest-api/overview.md) the dashboard uses. It's the most ergonomic way to deploy scripts,
|
||||||
|
inspect logs, and automate from CI.
|
||||||
|
|
||||||
|
## Install & authenticate
|
||||||
|
|
||||||
|
`pic` is the `picloud-cli` crate. Build it from the workspace:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build -p picloud-cli --release # binary at target/release/pic
|
||||||
|
```
|
||||||
|
|
||||||
|
Authenticate once; the token is saved to `~/.config/picloud/credentials`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# interactive (prompts for password)
|
||||||
|
pic login --url http://localhost:8000 --username admin
|
||||||
|
|
||||||
|
# non-interactive (CI): password from stdin
|
||||||
|
printf "$ADMIN_PASSWORD" | pic login --url http://localhost:8000 --username admin --password-stdin
|
||||||
|
|
||||||
|
# or authenticate with a long-lived API key instead of a session
|
||||||
|
printf 'pic_…' | pic login --url http://localhost:8000 --token -
|
||||||
|
```
|
||||||
|
|
||||||
|
`PICLOUD_URL` and `PICLOUD_TOKEN` environment variables work too (handy in CI).
|
||||||
|
|
||||||
|
## Output format
|
||||||
|
|
||||||
|
Global `--output` flag: `tsv` (default — pipe-friendly, columnar) or `json` (`jq`-ready).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pic whoami # tsv table
|
||||||
|
pic --output json apps ls # JSON array
|
||||||
|
```
|
||||||
|
|
||||||
|
## Command map
|
||||||
|
|
||||||
|
| Group | Commands |
|
||||||
|
|---|---|
|
||||||
|
| Auth | `login`, `logout`, `whoami` |
|
||||||
|
| Apps | `apps ls`, `apps create <slug>`, `apps show <id>`, `apps delete <id> [--force]`, `apps domains {ls,add,rm}` |
|
||||||
|
| Scripts | `scripts ls [--app]`, `scripts deploy <file> --app <slug>`, `scripts invoke <id>`, `scripts delete <id>` (also top-level `deploy`, `invoke`) |
|
||||||
|
| Routes | `routes ls <script_id>`, `routes create --script … --path …`, `routes rm <id>`, `routes check`, `routes match` |
|
||||||
|
| Logs | `logs <script_id> [--limit] [--source]` |
|
||||||
|
| Triggers | `triggers ls`, `triggers create-{kv,cron,docs,files,pubsub,queue,email,dead-letter}`, `triggers create-from-json`, `triggers rm` |
|
||||||
|
| Topics | `topics {ls,create,update,rm}` |
|
||||||
|
| Secrets | `secrets {ls,set,rm}` (value via stdin) |
|
||||||
|
| Queues | `queues {ls,show}` |
|
||||||
|
| KV | `kv {ls,get}` |
|
||||||
|
| Files | `files {ls,get,rm}` |
|
||||||
|
| Dead-letters | `dead-letters {count,ls,show,replay,resolve}` |
|
||||||
|
| App users | `users {ls,show,reset-password,revoke-sessions}` |
|
||||||
|
| Members | `members {ls,add,set,rm}` |
|
||||||
|
| Admins (instance) | `admins {ls,create,show,set,rm}` |
|
||||||
|
| API keys | `api-keys {mint,ls,rm}` |
|
||||||
|
|
||||||
|
Each resource page in the [HTTP API reference](../rest-api/overview.md) lists the matching `pic`
|
||||||
|
commands. Run `pic <group> --help` for full flags.
|
||||||
|
|
||||||
|
## Worked examples
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Deploy a script and bind a route
|
||||||
|
pic scripts deploy ./shorten.rhai --app links --name shorten
|
||||||
|
SID=$(pic --output json scripts ls --app links | jq -r '.[]|select(.name=="shorten").id')
|
||||||
|
pic routes create --script $SID --path /shorten --method POST
|
||||||
|
|
||||||
|
# A parameterized route, async dispatch
|
||||||
|
pic routes create --script $SID --path '/r/:code' --path-kind param --method GET
|
||||||
|
pic routes create --script $SID --path /ingest --method POST --dispatch async
|
||||||
|
|
||||||
|
# Module script, sandbox override, custom timeout
|
||||||
|
pic scripts deploy ./fmt.rhai --app links --kind module
|
||||||
|
pic scripts deploy ./heavy.rhai --app links --timeout 60 --sandbox max_operations=5000000
|
||||||
|
|
||||||
|
# Secrets (value never hits shell history)
|
||||||
|
printf 'sk_live_…' | pic secrets set --app links stripe_key
|
||||||
|
|
||||||
|
# Mint a scoped, app-bound, expiring API key
|
||||||
|
pic api-keys mint deploy-bot --scope script:write --scope route:write --app links --expires 30d
|
||||||
|
|
||||||
|
# Tail background-execution logs
|
||||||
|
pic logs $SID --source queue --limit 20
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes & gotchas
|
||||||
|
|
||||||
|
- Passwords and API-key values are **never** accepted inline (they'd leak into shell history / `ps`) —
|
||||||
|
always stdin or interactive prompt.
|
||||||
|
- `routes create` defaults: host `*` (any), path-kind `exact`, dispatch `sync`. Trigger `create-*`
|
||||||
|
wrappers default dispatch to `async`.
|
||||||
|
- `--app` accepts a slug or a UUID anywhere.
|
||||||
|
- `pic` only ever talks to the HTTP API — it has no special powers the API doesn't. If something works
|
||||||
|
in `pic` it works in `curl`, and vice versa.
|
||||||
42
docs/dev-guide/src/reference/cli/server-admin.md
Normal file
42
docs/dev-guide/src/reference/cli/server-admin.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Server admin commands
|
||||||
|
|
||||||
|
Separate from the `pic` developer CLI, the **`picloud` server binary** carries a tiny set of
|
||||||
|
operator/recovery subcommands you run directly on the host (no auth — they touch the database through
|
||||||
|
`DATABASE_URL`). These are for out-of-band situations where you can't log in.
|
||||||
|
|
||||||
|
## Bootstrap the first admin (env vars)
|
||||||
|
|
||||||
|
On a **fresh install** (empty `admin_users` table), the server seeds the first admin — always as
|
||||||
|
`owner` — from environment variables:
|
||||||
|
|
||||||
|
| Variable | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `PICLOUD_ADMIN_USERNAME` | required |
|
||||||
|
| `PICLOUD_ADMIN_PASSWORD_HASH` | preferred — a pre-computed Argon2id PHC string, so the raw password never lands in env/compose |
|
||||||
|
| `PICLOUD_ADMIN_PASSWORD` | fallback — raw password, hashed on first boot |
|
||||||
|
|
||||||
|
If both the hash and the raw password are set, the hash wins (and a warning is logged). **These are
|
||||||
|
read only when no admin exists yet** — on a database that already has an admin, they're ignored (with a
|
||||||
|
warning). After that, manage admins via [`pic admins`](../rest-api/access.md#admin-users-instance) or
|
||||||
|
the recovery command below.
|
||||||
|
|
||||||
|
## `picloud admin reset-password`
|
||||||
|
|
||||||
|
Reset (and reactivate) an admin's password without logging in — the escape hatch when you've locked
|
||||||
|
yourself out:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# interactive: prompts for a new password on stdin
|
||||||
|
picloud admin reset-password admin
|
||||||
|
|
||||||
|
# non-interactive: supply a pre-computed Argon2id PHC hash
|
||||||
|
picloud admin reset-password admin --password-hash '$argon2id$v=19$m=…'
|
||||||
|
```
|
||||||
|
|
||||||
|
It re-activates a deactivated admin and **drops all of that admin's sessions** (forcing re-login). It
|
||||||
|
needs the same `DATABASE_URL` (and master key) the server normally runs with. Run it on the host, e.g.
|
||||||
|
inside the container: `docker compose exec picloud picloud admin reset-password admin`.
|
||||||
|
|
||||||
|
> This is the *only* subcommand on the server binary besides running the server itself. Everything
|
||||||
|
> else — creating more admins, changing roles, minting keys — goes through the authenticated API
|
||||||
|
> (`pic` or `curl`).
|
||||||
73
docs/dev-guide/src/reference/config/capabilities.md
Normal file
73
docs/dev-guide/src/reference/config/capabilities.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Capabilities & roles
|
||||||
|
|
||||||
|
Every control-plane action requires a **capability**. A request's principal is granted capabilities by
|
||||||
|
its **instance role** and (for members) its per-app **app roles**. This page is the authoritative
|
||||||
|
mapping.
|
||||||
|
|
||||||
|
## Instance roles
|
||||||
|
|
||||||
|
| Role | Grants |
|
||||||
|
|---|---|
|
||||||
|
| `owner` | **everything** — every capability on every app, plus instance settings |
|
||||||
|
| `admin` | every capability on every app (implicit `app_admin` everywhere) + instance user management; **not** owner-only instance settings |
|
||||||
|
| `member` | **no** instance authority; only the capabilities its app roles grant, on apps it's a member of |
|
||||||
|
|
||||||
|
So owners and admins never need an explicit app membership. Members start with nothing until granted an
|
||||||
|
app role.
|
||||||
|
|
||||||
|
## App roles
|
||||||
|
|
||||||
|
App roles form a strict chain — each includes the one before it: **viewer ⊂ editor ⊂ app_admin**.
|
||||||
|
|
||||||
|
| App role | Adds |
|
||||||
|
|---|---|
|
||||||
|
| `viewer` | read scripts, routes, logs, KV, docs, files, secrets (names), app-users |
|
||||||
|
| `editor` | + write scripts & routes; write KV/docs/files; publish pubsub; enqueue; write secrets; send email; manage app-users; `invoke` |
|
||||||
|
| `app_admin` | + manage domains, triggers, topics, dead-letters; admin app-user actions; app settings & delete |
|
||||||
|
|
||||||
|
## Capability reference
|
||||||
|
|
||||||
|
Instance-level:
|
||||||
|
|
||||||
|
| Capability | Required role |
|
||||||
|
|---|---|
|
||||||
|
| `InstanceCreateApp` | owner / admin |
|
||||||
|
| `InstanceManageUsers` | owner / admin |
|
||||||
|
| `InstanceManageSettings` | owner only |
|
||||||
|
|
||||||
|
App-level (each is parameterized by the target app; a member needs the listed app role *on that app*):
|
||||||
|
|
||||||
|
| Capability | viewer | editor | app_admin | Used by |
|
||||||
|
|---|:--:|:--:|:--:|---|
|
||||||
|
| `AppRead` | ✓ | ✓ | ✓ | read app/scripts/routes |
|
||||||
|
| `AppLogRead` | ✓ | ✓ | ✓ | read execution logs |
|
||||||
|
| `AppKvRead` / `AppDocsRead` / `AppFilesRead` / `AppSecretsRead` / `AppUsersRead` | ✓ | ✓ | ✓ | admin data views |
|
||||||
|
| `AppWriteScript` | | ✓ | ✓ | create/update scripts |
|
||||||
|
| `AppWriteRoute` | | ✓ | ✓ | manage routes |
|
||||||
|
| `AppKvWrite` / `AppDocsWrite` / `AppFilesWrite` | | ✓ | ✓ | (data-plane writes) |
|
||||||
|
| `AppHttpRequest` | | ✓ | ✓ | outbound `http` |
|
||||||
|
| `AppPubsubPublish` | | ✓ | ✓ | `pubsub::publish_durable` |
|
||||||
|
| `AppQueueEnqueue` | | ✓ | ✓ | `queue::enqueue` |
|
||||||
|
| `AppSecretsWrite` | | ✓ | ✓ | set/delete secrets |
|
||||||
|
| `AppEmailSend` | | ✓ | ✓ | `email::send` |
|
||||||
|
| `AppUsersWrite` | | ✓ | ✓ | manage app-users |
|
||||||
|
| `AppInvoke` | | ✓ | ✓ | `invoke()` |
|
||||||
|
| `AppManageDomains` | | | ✓ | domain claims |
|
||||||
|
| `AppManageTriggers` | | | ✓ | triggers |
|
||||||
|
| `AppTopicManage` | | | ✓ | topics |
|
||||||
|
| `AppDeadLetterManage` | | | ✓ | dead-letters |
|
||||||
|
| `AppUsersAdmin` | | | ✓ | invitations, resets |
|
||||||
|
| `AppAdmin` | | | ✓ | app settings, delete, members |
|
||||||
|
|
||||||
|
## API-key scopes
|
||||||
|
|
||||||
|
An [API key](../rest-api/access.md#api-keys) carries a subset of capabilities as **scopes**. The seven
|
||||||
|
valid scopes: `script:read`, `script:write`, `route:write`, `domain:manage`, `log:read`, `app:admin`,
|
||||||
|
`instance:admin`. A key bound to one app cannot hold `instance:admin`.
|
||||||
|
|
||||||
|
## A crucial caveat: the data plane runs with full app authority
|
||||||
|
|
||||||
|
The capability model above governs the **control plane** (the admin API). It does **not** gate your
|
||||||
|
scripts' routes. A public route runs its script with **full authority over its own app's data** — it
|
||||||
|
can read every secret and every record. PiCloud puts no auth in front of your routes. If a route must
|
||||||
|
be restricted, the script enforces it. See [Security](../../operations/security.md#the-data-plane-is-public).
|
||||||
151
docs/dev-guide/src/reference/config/env-vars.md
Normal file
151
docs/dev-guide/src/reference/config/env-vars.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# Environment variables
|
||||||
|
|
||||||
|
The `picloud` server is configured entirely through environment variables. This is the complete list,
|
||||||
|
grouped by area. Only `DATABASE_URL` and a master key are strictly required to boot.
|
||||||
|
|
||||||
|
## Required to boot
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `DATABASE_URL` | — | **Required.** Postgres connection string. |
|
||||||
|
| `PICLOUD_SECRET_KEY` | — | Master encryption key, base64 of 32 bytes. Required **unless** dev mode is acknowledged (below). Encrypts secrets and realtime keys at rest. |
|
||||||
|
|
||||||
|
On a **fresh** database you also need the [bootstrap admin](#bootstrap-admin) vars. After that, only
|
||||||
|
the two above are required.
|
||||||
|
|
||||||
|
## Dev mode
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `PICLOUD_DEV_MODE` | `false` | Enables local-dev conveniences (in-memory email sink). **Never in production.** |
|
||||||
|
| `PICLOUD_DEV_INSECURE_KEY` | — | Set to the literal `i-understand-this-is-insecure` to boot without `PICLOUD_SECRET_KEY`, using a deterministic, world-known dev key. `PICLOUD_DEV_MODE=true` **alone aborts** — you must also set this. Never in production. |
|
||||||
|
|
||||||
|
## Network & process
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `PICLOUD_BIND` | `0.0.0.0:8080` | HTTP listen address. (In Compose, picloud binds 8080 *inside* the network; Caddy publishes 8000.) |
|
||||||
|
| `PICLOUD_PUBLIC_BASE_URL` | `http://localhost:8000` | Public origin reported by `/version` and used to render URLs. |
|
||||||
|
| `PICLOUD_DB_MAX_CONNECTIONS` | `32` | Postgres pool size (matched to the execution cap). |
|
||||||
|
| `PICLOUD_MAX_CONCURRENT_EXECUTIONS` | `32` | Global cap on simultaneous script runs. Overflow → `503` + `Retry-After: 1`, no queue. |
|
||||||
|
| `PICLOUD_SESSION_TTL_HOURS` | `24` | Admin session sliding-window lifetime. |
|
||||||
|
| `RUST_LOG` | `info` | `tracing` filter, e.g. `info,picloud=debug`. |
|
||||||
|
| `PICLOUD_CONFIG_DIR` | platform default | Where the `pic` CLI stores credentials. |
|
||||||
|
|
||||||
|
## Bootstrap admin {#bootstrap-admin}
|
||||||
|
|
||||||
|
Read only on a fresh install (empty `admin_users`); ignored afterward. See
|
||||||
|
[Server admin commands](../cli/server-admin.md).
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `PICLOUD_ADMIN_USERNAME` | first admin's username (seeded as `owner`) |
|
||||||
|
| `PICLOUD_ADMIN_PASSWORD_HASH` | preferred: Argon2id PHC string |
|
||||||
|
| `PICLOUD_ADMIN_PASSWORD` | fallback: raw password |
|
||||||
|
|
||||||
|
## Data-plane size caps
|
||||||
|
|
||||||
|
Exceeding any cap throws in the script. All in bytes.
|
||||||
|
|
||||||
|
| Variable | Default | Caps |
|
||||||
|
|---|---|---|
|
||||||
|
| `PICLOUD_KV_MAX_VALUE_BYTES` | `262144` (256 KiB) | `kv::...set` value |
|
||||||
|
| `PICLOUD_DOCS_MAX_VALUE_BYTES` | `262144` | `docs` document |
|
||||||
|
| `PICLOUD_PUBSUB_MAX_MESSAGE_BYTES` | `262144` | `pubsub::publish_durable` message |
|
||||||
|
| `PICLOUD_QUEUE_MAX_PAYLOAD_BYTES` | `262144` | `queue::enqueue` message |
|
||||||
|
| `PICLOUD_SECRET_MAX_VALUE_BYTES` | `262144` | `secrets::set` value |
|
||||||
|
| `PICLOUD_FILES_MAX_FILE_SIZE_BYTES` | `104857600` (100 MiB) | `files::...create/update` blob |
|
||||||
|
| `PICLOUD_EMAIL_MAX_MESSAGE_BYTES` | — | inbound/outbound email size |
|
||||||
|
| `PICLOUD_EMAIL_MAX_RECIPIENTS` | — | recipients per send |
|
||||||
|
| `PICLOUD_HTTP_MAX_REQUEST_BODY_BYTES` | — | outbound `http` request body |
|
||||||
|
| `PICLOUD_HTTP_MAX_RESPONSE_BODY_BYTES` | — | outbound `http` response body read |
|
||||||
|
|
||||||
|
## Sandbox ceilings
|
||||||
|
|
||||||
|
Upper bounds on per-script [sandbox overrides](../../guide/writing-scripts.md#sandbox-limits). An
|
||||||
|
override above the ceiling is rejected at deploy time. Defaults are the conservative built-ins.
|
||||||
|
|
||||||
|
| Variable | Default ceiling |
|
||||||
|
|---|---|
|
||||||
|
| `PICLOUD_SANDBOX_MAX_OPERATIONS` | 10,000,000 |
|
||||||
|
| `PICLOUD_SANDBOX_MAX_STRING_SIZE` | 1 MiB |
|
||||||
|
| `PICLOUD_SANDBOX_MAX_ARRAY_SIZE` | 100,000 |
|
||||||
|
| `PICLOUD_SANDBOX_MAX_MAP_SIZE` | 100,000 |
|
||||||
|
| `PICLOUD_SANDBOX_MAX_CALL_LEVELS` | 128 |
|
||||||
|
| `PICLOUD_SANDBOX_MAX_EXPR_DEPTH` | 128 |
|
||||||
|
|
||||||
|
Two related platform-level depth bounds (not per-script overridable):
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `PICLOUD_MAX_TRIGGER_DEPTH` | `8` | max trigger fan-out / `invoke` re-entry depth |
|
||||||
|
| `PICLOUD_MODULE_IMPORT_DEPTH_MAX` | `8` | max `import` chain depth |
|
||||||
|
|
||||||
|
## Outbound HTTP (`http` SDK)
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `PICLOUD_HTTP_ALLOW_PRIVATE` | `false` | **Dev/test only.** `true` disables the SSRF deny-list (allows requests to private/loopback IPs). Never production. |
|
||||||
|
|
||||||
|
## Email / SMTP
|
||||||
|
|
||||||
|
Configure a relay to make `email::send` work; without it, sends throw `NotConfigured` (or hit the dev
|
||||||
|
sink in dev mode).
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `PICLOUD_SMTP_HOST` / `PICLOUD_SMTP_PORT` | relay address |
|
||||||
|
| `PICLOUD_SMTP_USER` / `PICLOUD_SMTP_PASSWORD` | relay auth |
|
||||||
|
| `PICLOUD_SMTP_TLS` | TLS mode |
|
||||||
|
| `PICLOUD_SMTP_TIMEOUT_SECS` | send timeout |
|
||||||
|
|
||||||
|
## Triggers, dispatcher & background workers
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `PICLOUD_DISPATCHER_ASYNC_EXEC_TIMEOUT_SEC` | `120` | per-message executor budget for async/triggered runs |
|
||||||
|
| `PICLOUD_DISPATCHER_TICK_INTERVAL_MS` | — | dispatcher poll interval |
|
||||||
|
| `PICLOUD_CRON_TICK_INTERVAL_MS` | — | cron scheduler resolution |
|
||||||
|
| `PICLOUD_QUEUE_RECLAIM_INTERVAL_MS` | — | how often expired claims are reclaimed |
|
||||||
|
| `PICLOUD_QUEUE_DEFAULT_VISIBILITY_TIMEOUT_SECS` | — | default queue claim lease |
|
||||||
|
| `PICLOUD_TRIGGER_RETRY_MAX_ATTEMPTS` | `3` | default trigger retry attempts |
|
||||||
|
| `PICLOUD_TRIGGER_RETRY_BACKOFF` | `exponential` | default backoff shape |
|
||||||
|
| `PICLOUD_TRIGGER_RETRY_BASE_MS` | `1000` | default backoff base |
|
||||||
|
| `PICLOUD_TRIGGER_RETRY_JITTER_PCT` | — | retry jitter |
|
||||||
|
| `PICLOUD_DEAD_LETTER_RETENTION_DAYS` | `30` | how long dead-letters are kept |
|
||||||
|
| `PICLOUD_ABANDONED_EXECUTIONS_RETENTION_DAYS` | — | sweep window for stale in-flight executions |
|
||||||
|
|
||||||
|
## Realtime, subscriber tokens & caches
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `PICLOUD_REALTIME_HEARTBEAT_SEC` | `30` | SSE heartbeat interval |
|
||||||
|
| `PICLOUD_REALTIME_BROADCAST_CAPACITY` | — | per-topic broadcast buffer |
|
||||||
|
| `PICLOUD_SUBSCRIBER_TOKEN_TTL_DEFAULT_SEC` / `_MIN_SEC` / `_MAX_SEC` | — | bounds for `pubsub::subscriber_token` TTLs |
|
||||||
|
| `PICLOUD_SCRIPT_CACHE_SIZE` / `PICLOUD_MODULE_CACHE_SIZE` | — | in-memory cache sizes |
|
||||||
|
|
||||||
|
## Files storage
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `PICLOUD_FILES_ROOT` | `./data` | filesystem root for `files` blobs |
|
||||||
|
| `PICLOUD_FILES_ORPHAN_SWEEP_INTERVAL_SEC` / `PICLOUD_FILES_ORPHAN_TMP_TTL_SEC` | — | cleanup of stale temp blobs |
|
||||||
|
|
||||||
|
## App-user token lifetimes (`users` SDK)
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `PICLOUD_APP_USER_SESSION_TTL_HOURS` / `_ABSOLUTE_HOURS` | sliding & absolute session lifetime |
|
||||||
|
| `PICLOUD_APP_USER_VERIFICATION_TTL_HOURS` | email-verification token TTL |
|
||||||
|
| `PICLOUD_APP_USER_PASSWORD_RESET_TTL_HOURS` | password-reset token TTL |
|
||||||
|
| `PICLOUD_APP_USER_INVITATION_TTL_DAYS` | invitation TTL |
|
||||||
|
|
||||||
|
## `pic` CLI
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `PICLOUD_URL` | default server URL for `pic` |
|
||||||
|
| `PICLOUD_TOKEN` | bearer token for `pic` (CI) |
|
||||||
|
|
||||||
|
> Values shown as "—" have an internal default that's safe to leave unset; consult the running
|
||||||
|
> instance or release notes if you need the exact number for capacity planning.
|
||||||
84
docs/dev-guide/src/reference/rest-api/access.md
Normal file
84
docs/dev-guide/src/reference/rest-api/access.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Members, admins & API keys
|
||||||
|
|
||||||
|
Three ways identity attaches to the control plane. See
|
||||||
|
[Capabilities & roles](../config/capabilities.md) for the full permission model.
|
||||||
|
|
||||||
|
## App members
|
||||||
|
|
||||||
|
Grant other admin users a per-app role. Under `/api/v1/admin/apps/{id_or_slug}/members`, capability
|
||||||
|
**`AppAdmin`**.
|
||||||
|
|
||||||
|
| Method & path | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `GET …/members` | list members + roles |
|
||||||
|
| `POST …/members` | grant `{user_id, role}` (`409` if already a member) |
|
||||||
|
| `PATCH …/members/{user_id}` | change `{role}` |
|
||||||
|
| `DELETE …/members/{user_id}` | remove |
|
||||||
|
|
||||||
|
Roles: `viewer` (read scripts/logs/routes/data), `editor` (+ write scripts/routes/KV/docs/files/…),
|
||||||
|
`app_admin` (+ members, domains, triggers, topics, dead-letters). Instance `owner`/`admin` implicitly
|
||||||
|
have full access to every app, so removing the last explicit member can't orphan an app. **CLI:**
|
||||||
|
`pic members ls --app demo`, `pic members add --app demo --user <id> --role editor`,
|
||||||
|
`pic members set --app demo --user <id> --role app_admin`, `pic members rm --app demo --user <id>`.
|
||||||
|
|
||||||
|
## Admin users (instance)
|
||||||
|
|
||||||
|
The platform accounts. Under `/api/v1/admin/admins`, capability **`InstanceManageUsers`** (owner/admin).
|
||||||
|
|
||||||
|
| Method & path | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/v1/admin/admins` | list |
|
||||||
|
| `POST /api/v1/admin/admins` | create `{username, password, email?, instance_role?}` (default role `admin`) |
|
||||||
|
| `GET /api/v1/admin/admins/{id}` | one |
|
||||||
|
| `PATCH /api/v1/admin/admins/{id}` | update `{username?, email?, password?, is_active?, instance_role?}` |
|
||||||
|
| `DELETE /api/v1/admin/admins/{id}` | delete |
|
||||||
|
|
||||||
|
Instance roles: `owner` (everything, including instance settings), `admin` (everything except
|
||||||
|
owner-only settings), `member` (no instance powers; only the apps they're a member of). Deactivating an
|
||||||
|
admin also expires their API keys and sessions; you can't deactivate the last active admin. The first
|
||||||
|
admin is seeded from env vars on a fresh install (see
|
||||||
|
[Server admin commands](../cli/server-admin.md)). **CLI:** `pic admins ls`,
|
||||||
|
`pic admins create alice --password - --instance-role admin`, `pic admins set <id> --active false`.
|
||||||
|
|
||||||
|
## API keys
|
||||||
|
|
||||||
|
Long-lived bearer tokens for non-interactive use (CI, scripts). Under `/api/v1/admin/api-keys`; any
|
||||||
|
authenticated principal manages **their own** keys.
|
||||||
|
|
||||||
|
| Method & path | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/v1/admin/api-keys` | list the caller's keys (no token shown) |
|
||||||
|
| `POST /api/v1/admin/api-keys` | mint `{name, scopes, app_id?, expires_at?}` |
|
||||||
|
| `DELETE /api/v1/admin/api-keys/{id}` | revoke (takes effect immediately) |
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST $PICLOUD/api/v1/admin/api-keys -H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"name":"ci","scopes":["script:read","log:read"]}'
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{"id":"…","prefix":"D3ZBPTRT","name":"ci","scopes":["script:read","log:read"],
|
||||||
|
"app_id":null,"expires_at":null,"last_used_at":null,"created_at":"…Z",
|
||||||
|
"raw_token":"pic_D3ZBPTRT4AKU3VUCJM5PR2WWEJWV35C6YBLBPJJTYLURD6OW4HUQ"}
|
||||||
|
```
|
||||||
|
|
||||||
|
The full `raw_token` is shown **exactly once**, on mint. Store it then; afterward only the `prefix` is
|
||||||
|
visible.
|
||||||
|
|
||||||
|
**Valid scopes** (exactly these seven):
|
||||||
|
|
||||||
|
| Scope | Grants |
|
||||||
|
|---|---|
|
||||||
|
| `script:read` | read scripts |
|
||||||
|
| `script:write` | create/update scripts |
|
||||||
|
| `route:write` | manage routes |
|
||||||
|
| `domain:manage` | manage domains |
|
||||||
|
| `log:read` | read execution logs |
|
||||||
|
| `app:admin` | full app administration |
|
||||||
|
| `instance:admin` | instance-wide administration |
|
||||||
|
|
||||||
|
> Common mistake: `app:read`, `app:write`, `instance:write` are **not** scopes and are rejected (`422`).
|
||||||
|
|
||||||
|
Bind a key to one app with `"app_id"`; a bound key then can't carry `instance:admin` (rejected). Use
|
||||||
|
`"expires_at"` (RFC 3339) for short-lived keys. Present a key just like a session token:
|
||||||
|
`Authorization: Bearer pic_…`. **CLI:** `pic api-keys mint ci --scope script:read --scope log:read`
|
||||||
|
(optionally `--app demo`, `--expires 30d`), `pic api-keys ls`, `pic api-keys rm <id>`.
|
||||||
43
docs/dev-guide/src/reference/rest-api/app-users.md
Normal file
43
docs/dev-guide/src/reference/rest-api/app-users.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# App users
|
||||||
|
|
||||||
|
These are the **administrative** endpoints over your app's end-users — the people who register through
|
||||||
|
your scripts' [`users::*`](../sdk/users.md) calls. Operators use them to inspect users, reset
|
||||||
|
passwords, revoke sessions, and manage invitations.
|
||||||
|
|
||||||
|
> **These are not signup/login endpoints.** End-users never call these. Registration and login happen
|
||||||
|
> in *your* scripts via the `users` SDK, on routes you define. There is no built-in `/signup` or
|
||||||
|
> `/login`. See the [TODO API tutorial](../../examples/todo-api.md).
|
||||||
|
|
||||||
|
Under `/api/v1/admin/apps/{id_or_slug}`, capabilities `AppUsersRead` (reads), `AppUsersWrite`
|
||||||
|
(create/update), `AppUsersAdmin` (delete, reset-password, revoke-sessions).
|
||||||
|
|
||||||
|
| Method & path | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `GET …/users` | list end-users |
|
||||||
|
| `POST …/users` | create `{email, password, display_name?}` (`password` is **required**) |
|
||||||
|
| `GET …/users/{user_id}` | one user |
|
||||||
|
| `PATCH …/users/{user_id}` | update `{display_name?}` (only `display_name` is patchable here) |
|
||||||
|
| `DELETE …/users/{user_id}` | delete |
|
||||||
|
| `POST …/users/{user_id}/reset-password` | mint/apply a reset (returns a one-shot token) |
|
||||||
|
| `POST …/users/{user_id}/revoke-sessions` | invalidate all of the user's sessions |
|
||||||
|
| `GET …/invitations` | list invitations |
|
||||||
|
| `POST …/invitations` | create `{email, display_name?, roles?, template?}` |
|
||||||
|
| `DELETE …/invitations/{invite_id}` | revoke an invitation |
|
||||||
|
|
||||||
|
(There is no per-user `…/users/{user_id}/invitations` endpoint — invitations are managed at the app
|
||||||
|
level via `…/invitations`. Roles aren't set through the create/patch body here; manage them from a
|
||||||
|
script with [`users::add_role`](../sdk/users.md#roles).)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST $PICLOUD/api/v1/admin/apps/$APP/users -H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"email":"u1@example.com","password":"hunter2pass"}'
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{"id":"4fd7df09-…","app_id":"…","email":"u1@example.com","display_name":null,
|
||||||
|
"email_verified_at":null,"last_login_at":null,"created_at":"…Z","updated_at":"…Z","roles":[]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Passwords are hashed with Argon2id; only hashes are stored — operators cannot read them. **CLI:**
|
||||||
|
`pic users ls --app demo`, `pic users show --app demo <id>`,
|
||||||
|
`pic users reset-password --app demo <id>` (prints a one-shot token),
|
||||||
|
`pic users revoke-sessions --app demo <id>`.
|
||||||
62
docs/dev-guide/src/reference/rest-api/apps.md
Normal file
62
docs/dev-guide/src/reference/rest-api/apps.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Apps & domains
|
||||||
|
|
||||||
|
Apps are tenants; domains route hosts to apps. See [Core concepts](../../guide/concepts.md#apps).
|
||||||
|
|
||||||
|
## Apps
|
||||||
|
|
||||||
|
| Method & path | Capability | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /api/v1/admin/apps` | authenticated | lists apps the caller can see (members: only theirs) |
|
||||||
|
| `POST /api/v1/admin/apps` | `InstanceCreateApp` (owner/admin) | create |
|
||||||
|
| `GET /api/v1/admin/apps/{id_or_slug}` | `AppRead` | one app |
|
||||||
|
| `PATCH /api/v1/admin/apps/{id_or_slug}` | `AppAdmin` | update name/description/slug |
|
||||||
|
| `DELETE /api/v1/admin/apps/{id_or_slug}` | `AppAdmin` | delete (cascades scripts, routes, data) |
|
||||||
|
| `POST /api/v1/admin/apps/{id_or_slug}/slug:check` | `AppAdmin` | `{"new_slug":"…"}` → `{ok, conflict_kind?, current_app?, reason?}` |
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST $PICLOUD/api/v1/admin/apps -H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"slug":"demo","name":"Demo App","description":"…"}'
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{"id":"41f42060-…","slug":"demo","name":"Demo App","description":null,
|
||||||
|
"created_at":"2026-…Z","updated_at":"2026-…Z"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Slug rules:** `^[a-z0-9][a-z0-9-]{0,62}$`, and not one of the reserved words (`new`, `api`, `admin`,
|
||||||
|
`apps`, `login`, `healthz`, `version`, …). Renaming a slug records the old one for redirects; reclaim a
|
||||||
|
released slug with `"force_takeover": true` in the create/patch body.
|
||||||
|
|
||||||
|
## Domains
|
||||||
|
|
||||||
|
A non-`default` app's routes only match once the app **claims** the request `Host`.
|
||||||
|
|
||||||
|
| Method & path | Capability |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/v1/admin/apps/{id_or_slug}/domains` | `AppRead` |
|
||||||
|
| `POST /api/v1/admin/apps/{id_or_slug}/domains` | `AppManageDomains` |
|
||||||
|
| `DELETE /api/v1/admin/apps/{id_or_slug}/domains/{domain_id}` | `AppManageDomains` |
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST $PICLOUD/api/v1/admin/apps/demo/domains -H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"pattern":"demo.localhost"}'
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{"id":"6ff04d56-…","app_id":"41f42060-…","pattern":"demo.localhost",
|
||||||
|
"shape":"exact","shape_key":"exact:demo.localhost","created_at":"2026-…Z"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pattern shapes** (use `{name}`, never `:name`, in domains):
|
||||||
|
|
||||||
|
- exact — `app.example.com`
|
||||||
|
- wildcard — `*.example.com`
|
||||||
|
- parameterized — `{tenant}.example.com` (the segment is captured into a route param)
|
||||||
|
|
||||||
|
The most specific claim wins. Claiming a host another app already holds (in the same shape) returns
|
||||||
|
`409`. The `default` app ships claiming `localhost`. Locally, claim `something.localhost` and test with
|
||||||
|
`curl -H 'Host: something.localhost' $PICLOUD/...`.
|
||||||
|
|
||||||
|
**CLI:** `pic apps create demo --name "Demo App"`, `pic apps domains add demo demo.localhost`,
|
||||||
|
`pic apps ls`, `pic apps show demo`.
|
||||||
61
docs/dev-guide/src/reference/rest-api/data-admin.md
Normal file
61
docs/dev-guide/src/reference/rest-api/data-admin.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Secrets, KV & files (admin views)
|
||||||
|
|
||||||
|
These are the operator-facing views over data your scripts write through the
|
||||||
|
[`secrets`](../sdk/secrets.md), [`kv`](../sdk/storage.md#kv), and [`files`](../sdk/storage.md#files)
|
||||||
|
SDKs. They are read-mostly: you *write* through scripts, and inspect (and prune) here.
|
||||||
|
|
||||||
|
## Secrets {#secrets}
|
||||||
|
|
||||||
|
Under `/api/v1/admin/apps/{app_id}/secrets`. Values are **never** returned — only names.
|
||||||
|
|
||||||
|
| Method & path | Capability | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET …/secrets?cursor=&limit=` | `AppSecretsRead` | list names |
|
||||||
|
| `POST …/secrets` | `AppSecretsWrite` | set/upsert `{name, value}` |
|
||||||
|
| `DELETE …/secrets/{name}` | `AppSecretsWrite` | delete |
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST $PICLOUD/api/v1/admin/apps/$APP/secrets -H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"name":"demo_key","value":"s3cr3t"}'
|
||||||
|
|
||||||
|
curl $PICLOUD/api/v1/admin/apps/$APP/secrets -H "Authorization: Bearer $TOKEN"
|
||||||
|
# {"secrets":[{"name":"demo_key","updated_at":"2026-…Z"}],"next_cursor":null}
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no update verb — `POST` upserts. **CLI:** `printf 's3cr3t' | pic secrets set --app demo
|
||||||
|
demo_key`, `pic secrets ls --app demo`, `pic secrets rm --app demo demo_key` (value read from stdin so
|
||||||
|
it never hits shell history).
|
||||||
|
|
||||||
|
## KV {#kv}
|
||||||
|
|
||||||
|
Under `/api/v1/admin/apps/{app_id}/kv`. Read-only (writes go through `kv::...set`).
|
||||||
|
|
||||||
|
| Method & path | Capability | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET …/kv?collection=<name>&cursor=&limit=` | `AppKvRead` | list keys in a collection |
|
||||||
|
| `GET …/kv/{collection}/{key}` | `AppKvRead` | one value |
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl "$PICLOUD/api/v1/admin/apps/$APP/kv?collection=counters" -H "Authorization: Bearer $TOKEN"
|
||||||
|
# {"keys":[…],"next_cursor":null}
|
||||||
|
curl $PICLOUD/api/v1/admin/apps/$APP/kv/counters/home -H "Authorization: Bearer $TOKEN"
|
||||||
|
# {"value":3}
|
||||||
|
```
|
||||||
|
|
||||||
|
**CLI:** `pic kv ls --app demo --collection counters`, `pic kv get --app demo --collection counters home`.
|
||||||
|
|
||||||
|
## Files {#files}
|
||||||
|
|
||||||
|
Under `/api/v1/admin/apps/{app_id}/files`.
|
||||||
|
|
||||||
|
| Method & path | Capability | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET …/files?collection=<name>&cursor=&limit=` | `AppFilesRead` | list metadata |
|
||||||
|
| `GET …/files/{collection}/{file_id}` | `AppFilesRead` | download the bytes (sets `Content-Type`, `Content-Disposition`, `Content-Length`) |
|
||||||
|
| `DELETE …/files/{collection}/{file_id}` | `AppFilesWrite` | delete |
|
||||||
|
|
||||||
|
**CLI:** `pic files ls --app demo --collection avatars`, `pic files get --app demo --collection avatars
|
||||||
|
--id <id> --out a.png`, `pic files rm --app demo --collection avatars --id <id>`.
|
||||||
|
|
||||||
|
Files live on disk under `PICLOUD_FILES_ROOT` (default `./data`) at
|
||||||
|
`<root>/files/<app_id>/<collection>/<id[0:2]>/<id>`; metadata is in Postgres.
|
||||||
106
docs/dev-guide/src/reference/rest-api/overview.md
Normal file
106
docs/dev-guide/src/reference/rest-api/overview.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# HTTP API — overview & authentication
|
||||||
|
|
||||||
|
The control plane is a REST API under `/api/v1/`. The dashboard and the `pic` CLI are both just clients
|
||||||
|
of it; anything they do, you can do with `curl`. The **data plane** (your scripts' routes,
|
||||||
|
`/execute/{id}`, `/healthz`, `/version`) is separate and covered where relevant.
|
||||||
|
|
||||||
|
Base URL in this reference: `$PICLOUD` (e.g. `http://localhost:8000` for the Compose stack, or
|
||||||
|
`http://localhost:18080` for a bare binary).
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Every `/api/v1/admin/*` endpoint (except `auth/login`) requires a **bearer token**:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
A token is either a **session token** (from `auth/login`) or an **API key** (`pic_…`, minted via
|
||||||
|
[api-keys](access.md#api-keys)). There is no cookie auth.
|
||||||
|
|
||||||
|
### Log in
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST $PICLOUD/api/v1/admin/auth/login \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"username":"admin","password":"…"}'
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{"user":{"id":"…","username":"admin","instance_role":"owner","email":null},
|
||||||
|
"token":"V-f-0ey3eEcF…","expires_at":"2026-06-18T19:49:39Z"}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Endpoint | Auth | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST /api/v1/admin/auth/login` | none | exchange username+password for a session token |
|
||||||
|
| `POST /api/v1/admin/auth/logout` | optional | revoke the current session (idempotent → `204`) |
|
||||||
|
| `GET /api/v1/admin/auth/me` | bearer | the principal the token resolves to |
|
||||||
|
|
||||||
|
Sessions slide: each authenticated request extends the TTL (`PICLOUD_SESSION_TTL_HOURS`, default 24).
|
||||||
|
Login is rate-limited per IP and per username.
|
||||||
|
|
||||||
|
## Principals, roles & capabilities
|
||||||
|
|
||||||
|
The token resolves to a **principal** with an **instance role** (`owner` > `admin` > `member`).
|
||||||
|
Members additionally hold per-app roles. Each endpoint requires a **capability**; the full mapping is
|
||||||
|
in [Capabilities & roles](../config/capabilities.md). A request that authenticates but lacks the
|
||||||
|
capability gets `403`.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Content type:** request and response bodies are JSON (`Content-Type: application/json`).
|
||||||
|
- **IDs** are UUID strings. Apps also accept their **slug** wherever an id is taken (`{id_or_slug}`).
|
||||||
|
- **Update verb is `PATCH`** everywhere — send only the fields you want to change — **except scripts,
|
||||||
|
which use `PUT`** (full update).
|
||||||
|
- **Two endpoints use a `:verb` suffix** (not a sub-path): `POST /routes:check`, `POST /routes:match`,
|
||||||
|
`POST /apps/{id_or_slug}/slug:check`.
|
||||||
|
- **Pagination** is keyset/cursor based: list endpoints return a `next_cursor` (`null` when exhausted);
|
||||||
|
pass it back as `?cursor=…`. Some accept `?limit=`.
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
Errors are JSON with an `error` string and an appropriate status:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"error":"no route matches GET /nope"}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `400` | malformed request / bad JSON |
|
||||||
|
| `401` | missing or invalid token |
|
||||||
|
| `403` | authenticated but missing the capability |
|
||||||
|
| `404` | resource not found (or, on the data plane, no app/route matched) |
|
||||||
|
| `409` | conflict (duplicate, state violation) |
|
||||||
|
| `422` | validation error (bad Rhai, route conflict, invalid scope, …) |
|
||||||
|
| `429` | rate-limited (login) |
|
||||||
|
| `503` | overloaded — execution gate full, `Retry-After: 1` (data plane) |
|
||||||
|
|
||||||
|
## `GET /version` and `GET /healthz`
|
||||||
|
|
||||||
|
Public, unauthenticated:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl $PICLOUD/healthz # ok
|
||||||
|
curl $PICLOUD/version
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{"api":1,"product":"1.1.9","public_base_url":"http://localhost:8000","schema":44,"sdk":"1.10","wire":1}
|
||||||
|
```
|
||||||
|
|
||||||
|
`/healthz` is a liveness probe (the literal string `ok`). `/version` reports the
|
||||||
|
[five version surfaces](../../guide/concepts.md#the-five-version-surfaces) plus the configured public
|
||||||
|
base URL.
|
||||||
|
|
||||||
|
## Map of the API
|
||||||
|
|
||||||
|
| Area | Page |
|
||||||
|
|---|---|
|
||||||
|
| Apps, domains | [Apps & domains](apps.md) |
|
||||||
|
| Scripts, routes, logs | [Scripts, routes & logs](scripts.md) |
|
||||||
|
| Triggers | [Triggers](triggers.md) |
|
||||||
|
| Topics, realtime SSE | [Topics & realtime](topics.md) |
|
||||||
|
| Secrets, KV, files (admin views) | [Secrets, KV & files](data-admin.md) |
|
||||||
|
| Queues, dead-letters | [Queues & dead-letters](queues.md) |
|
||||||
|
| App end-users | [App users](app-users.md) |
|
||||||
|
| Members, admin users, API keys | [Members, admins & API keys](access.md) |
|
||||||
46
docs/dev-guide/src/reference/rest-api/queues.md
Normal file
46
docs/dev-guide/src/reference/rest-api/queues.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Queues & dead-letters
|
||||||
|
|
||||||
|
Operator views over the durable [`queue`](../sdk/messaging.md#queue) system and the
|
||||||
|
[dead-letters](../sdk/composition.md#dead-letters) that failed executions produce.
|
||||||
|
|
||||||
|
## Queues
|
||||||
|
|
||||||
|
Under `/api/v1/admin/apps/{app_id}/queues`, capability **`AppLogRead`** (read-only inspection of
|
||||||
|
operational state). Enqueue from scripts; consume via a `queue` trigger.
|
||||||
|
|
||||||
|
| Method & path | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `GET …/queues` | list queues with depth counts |
|
||||||
|
| `GET …/queues/{queue_name}` | one queue's stats + its registered consumer |
|
||||||
|
|
||||||
|
A summary carries `queue_name`, `total`, `pending`, `claimed`. **CLI:** `pic queues ls --app demo`,
|
||||||
|
`pic queues show --app demo emails.send`.
|
||||||
|
|
||||||
|
## Dead-letters
|
||||||
|
|
||||||
|
Under `/api/v1/admin/apps/{app_id}/dead_letters`, capability **`AppDeadLetterManage`** (app_admin+).
|
||||||
|
A dead-letter is written when a triggered execution exhausts its retries.
|
||||||
|
|
||||||
|
| Method & path | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `GET …/dead_letters?unresolved=true&limit=&offset=` | list |
|
||||||
|
| `GET …/dead_letters/count?unresolved=true` | bare count (cheap; for alerting) |
|
||||||
|
| `GET …/dead_letters/{dl_id}` | one row, full payload + error |
|
||||||
|
| `POST …/dead_letters/{dl_id}/replay` | re-enqueue the original event; marks the row resolved `replayed` → `204 No Content` |
|
||||||
|
| `POST …/dead_letters/{dl_id}/resolve` | close without replaying (reason: one of `ignored`, `handled_by_script`, `handler_failed`) → `204 No Content` |
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl "$PICLOUD/api/v1/admin/apps/$APP/dead_letters?unresolved=true" -H "Authorization: Bearer $TOKEN"
|
||||||
|
curl -X POST $PICLOUD/api/v1/admin/apps/$APP/dead_letters/$DL/replay -H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
A `dead_letter` [trigger](triggers.md) can run a script automatically when a dead-letter appears (to
|
||||||
|
alert, or to `dead_letters::replay`/`resolve` programmatically). Retention defaults to 30 days
|
||||||
|
(`PICLOUD_DEAD_LETTER_RETENTION_DAYS`), swept periodically.
|
||||||
|
|
||||||
|
**CLI:** `pic dead-letters count --app demo`, `pic dead-letters ls --app demo --unresolved`,
|
||||||
|
`pic dead-letters show --app demo <id>`, `pic dead-letters replay --app demo <id>`,
|
||||||
|
`pic dead-letters resolve --app demo <id> --reason "handled manually"`.
|
||||||
|
|
||||||
|
The [webhook tutorial](../../examples/webhook-receiver.md#dead-letters) walks the full failure → replay
|
||||||
|
loop.
|
||||||
101
docs/dev-guide/src/reference/rest-api/scripts.md
Normal file
101
docs/dev-guide/src/reference/rest-api/scripts.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Scripts, routes & logs
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
| Method & path | Capability | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /api/v1/admin/scripts?app=<id\|slug>` | `AppRead` | list (filter by app); returns a plain array, not paginated |
|
||||||
|
| `POST /api/v1/admin/scripts` | `AppWriteScript` | create |
|
||||||
|
| `GET /api/v1/admin/scripts/{id}` | `AppRead` | one script (with `source`) |
|
||||||
|
| `PUT /api/v1/admin/scripts/{id}` | `AppWriteScript` | **full update** (note: PUT, not PATCH) |
|
||||||
|
| `DELETE /api/v1/admin/scripts/{id}` | `AppAdmin` | delete |
|
||||||
|
| `GET /api/v1/admin/scripts/{id}/logs?limit=&cursor=&source=` | `AppLogRead` | execution logs (below) |
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST $PICLOUD/api/v1/admin/scripts -H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' -d '{
|
||||||
|
"app_id":"41f42060-…",
|
||||||
|
"name":"greet",
|
||||||
|
"source":"return #{ statusCode: 200, body: #{ ok: true } };",
|
||||||
|
"kind":"endpoint"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields: `app_id` (required), `name` (required, unique within the app), `source` (required), optional
|
||||||
|
`description`, `kind` (`"endpoint"` default | `"module"`), `timeout_seconds`, `memory_limit_mb`, and a
|
||||||
|
`sandbox` object of [overrides](../../guide/writing-scripts.md#sandbox-limits). New scripts default to
|
||||||
|
`timeout_seconds: 30`, `memory_limit_mb: 256`.
|
||||||
|
|
||||||
|
The response is the stored script (with `id`, `version`, timestamps). The source is validated at create
|
||||||
|
time: it must parse as Rhai, and a `module` may contain only `fn`/`const`. Invalid source → `422`.
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
| Method & path | Capability | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /api/v1/admin/scripts/{id}/routes` | `AppRead` | routes bound to a script |
|
||||||
|
| `POST /api/v1/admin/scripts/{id}/routes` | `AppWriteRoute` | bind a route |
|
||||||
|
| `DELETE /api/v1/admin/routes/{route_id}` | `AppWriteRoute` | unbind |
|
||||||
|
| `POST /api/v1/admin/routes:check` | `AppRead` | conflict pre-check (note the `:` suffix) |
|
||||||
|
| `POST /api/v1/admin/routes:match` | `AppRead` | what would a URL match? |
|
||||||
|
|
||||||
|
Bind:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST $PICLOUD/api/v1/admin/scripts/$SID/routes -H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' -d '{
|
||||||
|
"host_kind":"any", "host":"",
|
||||||
|
"path_kind":"param", "path":"/users/:id",
|
||||||
|
"method":"GET", "dispatch_mode":"sync"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{"id":"…","app_id":"…","script_id":"…","host_kind":"any","host":"","host_param_name":null,
|
||||||
|
"path_kind":"param","path":"/users/:id","method":null,"dispatch_mode":"sync","created_at":"…Z"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
- `host_kind`: `any` (default) | `strict` (exact `host`) | `wildcard` (`*.host`, with optional
|
||||||
|
`host_param_name` to capture the subdomain into a param);
|
||||||
|
- `path_kind`: `exact` | `param` (`/users/:id`) | `prefix` (`/files/*`, tail → `ctx.request.rest`);
|
||||||
|
- `method`: `GET`/`POST`/… or omit (`null`) to match any;
|
||||||
|
- `dispatch_mode`: `sync` (default) | `async` (→ `202 Accepted`, background run).
|
||||||
|
|
||||||
|
Routes under `/api/`, `/admin/`, `/healthz`, `/version` are rejected. Conflicts are detected within the
|
||||||
|
app only:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST $PICLOUD/api/v1/admin/routes:check -H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"app_id":"…","host_kind":"any","host":"","path_kind":"exact","path":"/hello"}'
|
||||||
|
# {"ok":false,"conflicting_route":{…},"conflict_reason":"IdenticalExact"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Execution logs
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl "$PICLOUD/api/v1/admin/scripts/$SID/logs?limit=20&source=http" -H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
Each entry:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"id":"…","app_id":"…","script_id":"…","request_id":"…",
|
||||||
|
"request_path":"/count","request_headers":{…},"request_body":null,
|
||||||
|
"response_code":200,"response_body":null,
|
||||||
|
"script_logs":[],"duration_ms":5,"status":"success","source":"http","created_at":"…Z"}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `?source=` filters by origin: `http`, `kv`, `docs`, `files`, `cron`, `queue`, `pubsub`, `email`,
|
||||||
|
`invoke`, `dead_letter`. Omit for all.
|
||||||
|
- `?cursor=` is the keyset cursor (`<rfc3339>_<uuid>`) from the previous page; `?limit=` defaults to 50
|
||||||
|
(max 200).
|
||||||
|
- **`script_logs` and `response_body` are populated for async/triggered runs, but empty/null for
|
||||||
|
synchronous HTTP runs** — see [Writing scripts → Logging](../../guide/writing-scripts.md#logging).
|
||||||
|
|
||||||
|
**CLI:** `pic scripts deploy file.rhai --app demo`, `pic scripts ls --app demo`,
|
||||||
|
`pic routes create --script $SID --path /users/:id --path-kind param --method GET`,
|
||||||
|
`pic routes check --app demo --path /hello`, `pic logs $SID --source http`.
|
||||||
57
docs/dev-guide/src/reference/rest-api/topics.md
Normal file
57
docs/dev-guide/src/reference/rest-api/topics.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Topics & realtime
|
||||||
|
|
||||||
|
Scripts publish to topics with [`pubsub::publish_durable`](../sdk/messaging.md#pubsub) — that needs no
|
||||||
|
registration. **Registering a topic** is only needed to let *external* clients subscribe over
|
||||||
|
Server-Sent Events (SSE) at `/realtime/topics/{topic}`.
|
||||||
|
|
||||||
|
## Topic registry
|
||||||
|
|
||||||
|
Under `/api/v1/admin/apps/{app_id}/topics`, capability **`AppTopicManage`** (app_admin+).
|
||||||
|
|
||||||
|
| Method & path | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `GET …/topics` | list |
|
||||||
|
| `POST …/topics` | register `{name, external_subscribable?, auth_mode?}` |
|
||||||
|
| `PATCH …/topics/{name}` | update external/auth settings |
|
||||||
|
| `DELETE …/topics/{name}` | unregister (disconnects live subscribers) |
|
||||||
|
|
||||||
|
`name` is a concrete topic (no wildcards). `auth_mode` controls what an external subscriber must
|
||||||
|
present:
|
||||||
|
|
||||||
|
| `auth_mode` | Subscriber must… |
|
||||||
|
|---|---|
|
||||||
|
| `public` | nothing — anyone can subscribe |
|
||||||
|
| `token` | present an HMAC subscriber token from [`pubsub::subscriber_token`](../sdk/messaging.md#pubsub) |
|
||||||
|
| `session` | present an app-user session token from [`users::login`](../sdk/users.md) |
|
||||||
|
|
||||||
|
## Subscribing (SSE)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# public topic
|
||||||
|
curl -N $PICLOUD/realtime/topics/orders.created
|
||||||
|
|
||||||
|
# token / session topic — pass the token as a query param or bearer header
|
||||||
|
curl -N "$PICLOUD/realtime/topics/orders.created?token=$SUBTOKEN"
|
||||||
|
curl -N $PICLOUD/realtime/topics/orders.created -H "Authorization: Bearer $SUBTOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
The stream emits each published message as an SSE `data:` event, plus periodic heartbeats
|
||||||
|
(`PICLOUD_REALTIME_HEARTBEAT_SEC`, default 30). The browser equivalent is `new EventSource(url)`.
|
||||||
|
|
||||||
|
Minting a subscriber token inside a script (for a `token`-mode topic):
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let t = pubsub::subscriber_token(["orders.created"], 3600); // valid 1h
|
||||||
|
return #{ statusCode: 200, body: #{ token: t } };
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pic topics create --app demo orders.created --external --auth-mode token # --app is a flag; name is positional
|
||||||
|
pic topics ls --app demo
|
||||||
|
pic topics update --app demo orders.created --auth-mode session
|
||||||
|
pic topics rm --app demo orders.created
|
||||||
|
```
|
||||||
|
|
||||||
|
The [file-upload tutorial](../../examples/file-upload.md) wires a publish → SSE subscriber end to end.
|
||||||
75
docs/dev-guide/src/reference/rest-api/triggers.md
Normal file
75
docs/dev-guide/src/reference/rest-api/triggers.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Triggers
|
||||||
|
|
||||||
|
Triggers fire a script on an event instead of an HTTP request. See
|
||||||
|
[Core concepts](../../guide/concepts.md#triggers-and-events) for the model and
|
||||||
|
[`ctx.event`](../sdk/ctx-and-events.md#events) for what each kind delivers.
|
||||||
|
|
||||||
|
All endpoints are under `/api/v1/admin/apps/{app_id}/triggers` and require **`AppManageTriggers`**
|
||||||
|
(app_admin+).
|
||||||
|
|
||||||
|
| Method & path | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `GET …/triggers` | list (`{ "triggers": [ … ] }`) |
|
||||||
|
| `POST …/triggers/{kind}` | create — **one sub-path per kind** (below) |
|
||||||
|
| `DELETE …/triggers/{trigger_id}` | delete |
|
||||||
|
|
||||||
|
There is **no update verb** — to change a trigger, delete and recreate. The eight kinds (each its own
|
||||||
|
`POST` sub-path): `kv`, `docs`, `files`, `pubsub`, `cron`, `queue`, `email`, `dead_letter`.
|
||||||
|
|
||||||
|
## Common fields
|
||||||
|
|
||||||
|
Every create body takes `script_id` plus optional dispatch/retry settings:
|
||||||
|
|
||||||
|
- `dispatch_mode`: `sync` | `async` (default `async`);
|
||||||
|
- `retry_max_attempts`, `retry_backoff` (`exponential` | `linear` | `fixed`), `retry_base_ms` — all
|
||||||
|
optional, defaulting from instance config (`3` / `exponential` / `1000ms`).
|
||||||
|
|
||||||
|
A created trigger looks like:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"id":"f3214f88-…","app_id":"…","script_id":"…","kind":"cron","enabled":true,
|
||||||
|
"dispatch_mode":"async","retry_max_attempts":3,"retry_backoff":"exponential",
|
||||||
|
"retry_base_ms":1000,"registered_by_principal":"…","created_at":"…Z","updated_at":"…Z"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Per-kind bodies
|
||||||
|
|
||||||
|
| Kind | `POST …/triggers/<kind>` body (besides `script_id`) |
|
||||||
|
|---|---|
|
||||||
|
| `kv` | `collection_glob`, optional `ops` (`["insert","update","delete"]`; empty = any) |
|
||||||
|
| `docs` | `collection_glob`, optional `ops` |
|
||||||
|
| `files` | `collection_glob`, optional `ops` |
|
||||||
|
| `pubsub` | `topic_glob` |
|
||||||
|
| `cron` | `schedule` (6-field cron, with seconds), optional `timezone` (IANA, default UTC) |
|
||||||
|
| `queue` | `queue_name`, optional `visibility_timeout_secs` (≥ 30) |
|
||||||
|
| `email` | optional `from_glob`; an `inbound_secret` for HMAC verification |
|
||||||
|
| `dead_letter` | optional `source`/`trigger_id`/`script_id` filters (omit = every DL in the app) |
|
||||||
|
|
||||||
|
Example — a cron trigger every hour on the hour:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST $PICLOUD/api/v1/admin/apps/$APP/triggers/cron -H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"script_id":"'$SID'","schedule":"0 0 * * * *","timezone":"Europe/Berlin"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Globs match collection/topic names: `*` (all), `users`, `events_*`, `orders.*`.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
The CLI has per-kind wrappers and a JSON escape hatch:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pic triggers create-cron --app demo --script $SID --schedule "0 0 * * * *"
|
||||||
|
pic triggers create-kv --app demo --script $SID --collection 'users' --op insert
|
||||||
|
pic triggers create-queue --app demo --script $SID --queue emails.send
|
||||||
|
pic triggers create-pubsub --app demo --script $SID --topic 'orders.*'
|
||||||
|
pic triggers create-email --app demo --script $SID --inbound-secret <hmac>
|
||||||
|
pic triggers ls --app demo
|
||||||
|
pic triggers rm --app demo <trigger_id>
|
||||||
|
# advanced retry/dispatch tuning beyond the wrappers:
|
||||||
|
pic triggers create-from-json --app demo --kind docs --body @trigger.json
|
||||||
|
```
|
||||||
|
|
||||||
|
(CLI wrappers default `dispatch` to `async`.) Inbound email also requires the platform's inbound
|
||||||
|
webhook to be reachable; see [Email](../sdk/email.md).
|
||||||
91
docs/dev-guide/src/reference/sdk/composition.md
Normal file
91
docs/dev-guide/src/reference/sdk/composition.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# Composition: invoke, retry, dead_letters
|
||||||
|
|
||||||
|
Three small namespaces for composing scripts and handling failure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `invoke` — call another script {#invoke}
|
||||||
|
|
||||||
|
*Since SDK 1.10.* Synchronously call another script **in the same app** and get its return value, or
|
||||||
|
fire it off asynchronously. Like a function call across script boundaries.
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
// Synchronous: runs the target now, returns its value.
|
||||||
|
let result = invoke("/internal/price", #{ sku: "abc" }); // by route path
|
||||||
|
let result = invoke("price_worker", #{ sku: "abc" }); // by script name
|
||||||
|
let result = invoke("01HX…uuid…", #{ sku: "abc" }); // by script id (a UUID string)
|
||||||
|
|
||||||
|
// Asynchronous: enqueue it, get an execution id back immediately.
|
||||||
|
let exec_id = invoke_async("/internal/price", #{ sku: "abc" });
|
||||||
|
```
|
||||||
|
|
||||||
|
| Function | Signature | Returns |
|
||||||
|
|---|---|---|
|
||||||
|
| `invoke(target, args)` | `(string, dynamic)` | the callee's return value |
|
||||||
|
| `invoke_async(target, args)` | `(string, dynamic)` | the new execution id (string) |
|
||||||
|
|
||||||
|
- **Target is a string**, resolved by a simple heuristic: starts with `/` → a **route path** (matched
|
||||||
|
through the app's route trie); a 36-character UUID → a **script id**; anything else → a **script
|
||||||
|
name**. (There is no `script_id(...)` constructor in 1.1.9 — just pass the string.)
|
||||||
|
- **Same-app only** — a cross-app target throws. The callee inherits the caller's principal and runs
|
||||||
|
in the same engine. `ctx.invocation_type` is `"function"` in the callee.
|
||||||
|
- **Args** become the callee's `ctx.request.body`. Closures can't be passed.
|
||||||
|
- **Depth-limited** — re-entrant `invoke` chains are capped (default 8) to prevent runaway recursion;
|
||||||
|
exceeding it throws.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `retry` — retry with backoff {#retry}
|
||||||
|
|
||||||
|
*Since SDK 1.10.* Wrap a fallible closure in a retry loop. Useful around `http`, `invoke`, or any call
|
||||||
|
that can fail transiently.
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let policy = retry::policy(#{
|
||||||
|
max_attempts: 3, // 1–20, default 3
|
||||||
|
backoff: "exponential", // "exponential" | "linear" | "constant", default exponential
|
||||||
|
base_ms: 500, // 1–60000, default 500
|
||||||
|
jitter_pct: 20 // 0–100, default 20
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optionally only retry on certain error substrings:
|
||||||
|
let policy = retry::on_codes(policy, ["http: 503", "http: 504"]);
|
||||||
|
|
||||||
|
let value = retry::run(policy, || http::post(url, payload));
|
||||||
|
```
|
||||||
|
|
||||||
|
| Function | Signature | Returns |
|
||||||
|
|---|---|---|
|
||||||
|
| `retry::policy(opts)` | `(map)` | a `Policy` (all fields clamped to their ranges) |
|
||||||
|
| `retry::on_codes(policy, codes)` | `(Policy, array of string)` | a `Policy` that retries only when the error string contains one of `codes` (empty = retry on any throw) |
|
||||||
|
| `retry::run(policy, closure)` | `(Policy, Fn)` | the closure's value on success; re-throws the last error after the final attempt |
|
||||||
|
|
||||||
|
Backoff between attempts: `constant` = `base_ms`; `linear` = `base_ms × attempt`; `exponential` =
|
||||||
|
`base_ms × 2^(attempt-1)`. Jitter is applied deterministically as a ± fraction. The sleep is safe to
|
||||||
|
perform inside a script (it runs on the blocking worker, not an async reactor).
|
||||||
|
|
||||||
|
> The shipped name is **`retry::run`** (not `with`/`call`, which are Rhai reserved words).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `dead_letters` — handle exhausted retries {#dead-letters}
|
||||||
|
|
||||||
|
*Since SDK 1.2.* When a triggered execution exhausts its retries, the platform writes a **dead-letter**
|
||||||
|
row. From a script (typically a `dead_letter`-trigger handler — see
|
||||||
|
[`ctx.event`](ctx-and-events.md#events)) you can act on it:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
dead_letters::replay(id); // re-enqueue the original event; marks the row resolved "replayed"
|
||||||
|
dead_letters::resolve(id, "ignored"); // close the row without replaying, with a reason
|
||||||
|
```
|
||||||
|
|
||||||
|
| Function | Signature | Returns |
|
||||||
|
|---|---|---|
|
||||||
|
| `dead_letters::replay(id)` | `(string)` | `()` |
|
||||||
|
| `dead_letters::resolve(id, reason)` | `(string, string)` | `()` |
|
||||||
|
|
||||||
|
IDs are UUID strings. `reason` must be one of the fixed set — `ignored`, `handled_by_script`,
|
||||||
|
`handler_failed` (or `replayed`, which `replay` sets for you) — not free text. Operators do the same from the dashboard, `pic dead-letters`, or
|
||||||
|
[the dead-letters API](../rest-api/queues.md#dead-letters). (Listing dead letters from a script is not
|
||||||
|
in 1.1.9 — use the admin surface.) The
|
||||||
|
[webhook tutorial](../../examples/webhook-receiver.md#dead-letters) demonstrates the failure→replay loop.
|
||||||
106
docs/dev-guide/src/reference/sdk/ctx-and-events.md
Normal file
106
docs/dev-guide/src/reference/sdk/ctx-and-events.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# The execution context & events
|
||||||
|
|
||||||
|
Every script runs with a global `ctx` map. For HTTP invocations it describes the request; for triggered
|
||||||
|
invocations it *also* carries `ctx.event` describing what fired the script.
|
||||||
|
|
||||||
|
## `ctx` fields
|
||||||
|
|
||||||
|
| Field | Type | Present |
|
||||||
|
|---|---|---|
|
||||||
|
| `ctx.sdk_version` | string (`"1.10"`) | always |
|
||||||
|
| `ctx.execution_id` | string (UUID) | always |
|
||||||
|
| `ctx.script_id` | string (UUID) | always |
|
||||||
|
| `ctx.script_name` | string | always |
|
||||||
|
| `ctx.request_id` | string (UUID) | always |
|
||||||
|
| `ctx.invocation_type` | string | always — `"http"`, `"function"` (an `invoke()` call), or `"scheduled"` |
|
||||||
|
| `ctx.request` | map | always (synthetic for non-HTTP invocations) |
|
||||||
|
| `ctx.event` | map | **only when triggered** |
|
||||||
|
|
||||||
|
## `ctx.request`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `path` | string | request path |
|
||||||
|
| `method` | string | uppercased verb |
|
||||||
|
| `headers` | map | **lowercased** keys |
|
||||||
|
| `body` | dynamic | JSON-parsed when JSON; string otherwise; `()` if empty |
|
||||||
|
| `params` | map | `:name` route captures |
|
||||||
|
| `query` | map | query-string params |
|
||||||
|
| `rest` | string | tail captured by a `prefix` route |
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let id = ctx.request.params.id; // from /users/:id
|
||||||
|
let page = ctx.request.query.page; // from ?page=2
|
||||||
|
let token = ctx.request.headers["authorization"];
|
||||||
|
let rest = ctx.request.rest; // from a /files/* route
|
||||||
|
```
|
||||||
|
|
||||||
|
> When a script is reached through `POST /api/v1/execute/{id}` (execute-by-id) or `invoke()`, there is
|
||||||
|
> no route match, so `params` and `rest` are empty. Pass everything you need in the body.
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
A triggered script reads `ctx.event`. The map always has a `source` discriminant; the rest depends on
|
||||||
|
the trigger kind. Guard with `if "event" in ctx { ... }` if a script serves both HTTP and triggers.
|
||||||
|
|
||||||
|
### `source: "kv"` — KV mutation
|
||||||
|
```rhai
|
||||||
|
// ctx.event = #{ source:"kv", op:"insert"|"update"|"delete",
|
||||||
|
// kv: #{ collection, key, value } } // value is () on delete
|
||||||
|
let key = ctx.event.kv.key;
|
||||||
|
```
|
||||||
|
|
||||||
|
### `source: "docs"` — document mutation
|
||||||
|
```rhai
|
||||||
|
// ctx.event = #{ source:"docs", op:"insert"|"update"|"delete",
|
||||||
|
// docs: #{ collection, id, data, prev_data } }
|
||||||
|
// `prev_data` is the pre-change document on update/delete (change-data-capture); () on insert.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `source: "files"` — blob mutation
|
||||||
|
```rhai
|
||||||
|
// ctx.event = #{ source:"files", op:"insert"|"update"|"delete",
|
||||||
|
// files: #{ collection, id, name, content_type, size, checksum, prev } }
|
||||||
|
// Metadata only — never the bytes. Fetch them with files::collection(c).get(id) if needed.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `source: "pubsub"` — message published
|
||||||
|
```rhai
|
||||||
|
// ctx.event = #{ source:"pubsub", op:"publish",
|
||||||
|
// pubsub: #{ topic, message, published_at } }
|
||||||
|
let payload = ctx.event.pubsub.message; // the published value (note: `message`, not `payload`)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `source: "queue"` — message claimed
|
||||||
|
```rhai
|
||||||
|
// ctx.event = #{ source:"queue", op:"receive",
|
||||||
|
// queue: #{ queue_name, message, enqueued_at, attempt, message_id } }
|
||||||
|
let payload = ctx.event.queue.message; // the enqueued value (note: `message`, not `payload`)
|
||||||
|
let n = ctx.event.queue.attempt; // 1 on first delivery; >1 on a retry — use it to be idempotent
|
||||||
|
```
|
||||||
|
|
||||||
|
### `source: "cron"` — schedule tick
|
||||||
|
```rhai
|
||||||
|
// ctx.event = #{ source:"cron", op:"tick",
|
||||||
|
// cron: #{ schedule, timezone, scheduled_at, fired_at } }
|
||||||
|
// scheduled_at / fired_at are RFC 3339 strings.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `source: "email"` — inbound mail
|
||||||
|
```rhai
|
||||||
|
// ctx.event = #{ source:"email", op:"receive",
|
||||||
|
// email: #{ from, to:[...], cc:[...], subject, text, html, received_at, message_id } }
|
||||||
|
// text / html are () when absent.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `source: "dead_letter"` — another trigger gave up
|
||||||
|
```rhai
|
||||||
|
// ctx.event = #{ source:"dead_letter",
|
||||||
|
// dead_letter: #{ id, original, attempts, last_error,
|
||||||
|
// trigger_id, script_id, first_attempt_at, last_attempt_at } }
|
||||||
|
// `original` is the event that failed. Use it to alert, or call dead_letters::replay / resolve.
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Triggers](../rest-api/triggers.md) to register a trigger, and the
|
||||||
|
[webhook](../../examples/webhook-receiver.md) and [scheduled-report](../../examples/scheduled-report.md)
|
||||||
|
tutorials for working consumers.
|
||||||
50
docs/dev-guide/src/reference/sdk/email.md
Normal file
50
docs/dev-guide/src/reference/sdk/email.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Email
|
||||||
|
|
||||||
|
*Since SDK 1.8.* Send outbound email through a configured SMTP relay. Receiving email is a
|
||||||
|
[trigger](../rest-api/triggers.md) (`email` kind), surfaced as `ctx.event` with `source: "email"`.
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
email::send(#{
|
||||||
|
to: "alice@example.com",
|
||||||
|
from: "alerts@myapp.com",
|
||||||
|
subject: "Build finished",
|
||||||
|
text: "Your deploy completed successfully."
|
||||||
|
});
|
||||||
|
|
||||||
|
email::send_html(#{
|
||||||
|
to: ["alice@x.com", "bob@y.com"],
|
||||||
|
cc: ["ops@x.com"],
|
||||||
|
bcc: ["audit@x.com"],
|
||||||
|
from: "alerts@myapp.com",
|
||||||
|
reply_to: "support@myapp.com", // optional; defaults to `from`
|
||||||
|
subject: "Weekly report",
|
||||||
|
text: "Plain-text fallback for non-HTML clients.",
|
||||||
|
html: "<h1>Weekly report</h1><p>…</p>"
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
| Function | Required fields | Optional fields |
|
||||||
|
|---|---|---|
|
||||||
|
| `email::send(opts)` | `to`, `from`, `subject`, `text` | `cc`, `bcc`, `reply_to` |
|
||||||
|
| `email::send_html(opts)` | `to`, `from`, `subject`, `text`, `html` | `cc`, `bcc`, `reply_to` |
|
||||||
|
|
||||||
|
- `to` / `cc` / `bcc` accept a single string or an array of strings.
|
||||||
|
- `email::send` is plain-text only; any `html` key is ignored. `email::send_html` requires a non-empty
|
||||||
|
`html` and sends multipart (HTML + the `text` fallback).
|
||||||
|
- Both **throw** on failure.
|
||||||
|
|
||||||
|
## Configuration & dev mode
|
||||||
|
|
||||||
|
- Sending requires an SMTP relay configured via `PICLOUD_SMTP_*` env vars. With no relay configured,
|
||||||
|
`email::send` throws `NotConfigured`.
|
||||||
|
- **Except in dev mode:** when `PICLOUD_DEV_MODE=true` and no relay is set, sends succeed into an
|
||||||
|
**in-memory dev sink** — the last 100 messages are readable at `GET /api/v1/admin/dev/emails`
|
||||||
|
(instance owner/admin only). This route exists *only* in that mode. Great for testing the
|
||||||
|
[scheduled-report tutorial](../../examples/scheduled-report.md) without a real mail server.
|
||||||
|
|
||||||
|
## Rate limiting
|
||||||
|
|
||||||
|
There is **no built-in rate limiting** on `email::send`. If a public route can trigger a send, throttle
|
||||||
|
it yourself (e.g. a `kv` counter keyed by sender/IP) — see
|
||||||
|
[Security](../../operations/security.md). The `users` SDK's verification/reset/invite helpers send
|
||||||
|
mail too; see [Users & auth](users.md).
|
||||||
77
docs/dev-guide/src/reference/sdk/http.md
Normal file
77
docs/dev-guide/src/reference/sdk/http.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Outbound HTTP
|
||||||
|
|
||||||
|
*Since SDK 1.5.* The `http` namespace makes outbound requests from a script — call third-party APIs,
|
||||||
|
post to webhooks, fetch data. It is `fetch`-style: non-2xx responses are returned, not thrown; only
|
||||||
|
transport-level failures (DNS, TLS, timeout, SSRF block, size) throw.
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let r = http::get("https://api.example.com/users/42");
|
||||||
|
if r.status == 200 {
|
||||||
|
let user = r.body; // parsed JSON (because the response was application/json)
|
||||||
|
}
|
||||||
|
|
||||||
|
let r = http::post("https://api.example.com/orders", #{ item: "sku-1", qty: 2 });
|
||||||
|
let r = http::post(url, #{ item: 1 }, #{ headers: #{ "Authorization": "Bearer …" }, timeout_ms: 5000 });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
| Function | Arities |
|
||||||
|
|---|---|
|
||||||
|
| `http::get(url)` / `http::get(url, opts)` | bodyless |
|
||||||
|
| `http::head(url)` / `http::head(url, opts)` | bodyless |
|
||||||
|
| `http::post(url)` / `(url, body)` / `(url, body, opts)` | body |
|
||||||
|
| `http::put` / `http::patch` / `http::delete` | same as `post` |
|
||||||
|
| `http::post_form(url, form)` / `(url, form, opts)` | form-encoded body from a map |
|
||||||
|
| `http::request(method, url)` / `(…, body)` / `(…, body, opts)` | any verb |
|
||||||
|
|
||||||
|
## Body dispatch (positional `body` arg)
|
||||||
|
|
||||||
|
| Rhai value | Sent as |
|
||||||
|
|---|---|
|
||||||
|
| map / array | JSON, `Content-Type: application/json` |
|
||||||
|
| string | raw bytes, `Content-Type: text/plain` |
|
||||||
|
| `()` | no body |
|
||||||
|
|
||||||
|
`GET`/`HEAD` ignore any body. `post_form` always sends `application/x-www-form-urlencoded`.
|
||||||
|
|
||||||
|
## Options map
|
||||||
|
|
||||||
|
| Key | Type | Default | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `headers` | map (string→string) | — | header names lowercased internally |
|
||||||
|
| `timeout_ms` | int | 30000 | max 60000 |
|
||||||
|
| `follow_redirects` | bool | true | |
|
||||||
|
| `max_redirects` | int | 5 | max 10 |
|
||||||
|
|
||||||
|
Any other key throws ("unknown option key") — so a typo fails loudly.
|
||||||
|
|
||||||
|
## Response map
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
#{
|
||||||
|
status: 200, // int
|
||||||
|
headers: #{ ... }, // lowercased keys
|
||||||
|
body: ..., // parsed JSON if the response is application/json and parses; () if empty; else the raw string
|
||||||
|
body_raw: "..." // always the raw response text
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security: the SSRF guard
|
||||||
|
|
||||||
|
`http::*` resolves the target and **blocks requests to private/loopback/link-local addresses** by
|
||||||
|
default, so a script (especially a public one taking a user-supplied URL) can't be tricked into probing
|
||||||
|
your internal network. This is on unless an operator sets `PICLOUD_HTTP_ALLOW_PRIVATE=true` (dev/test
|
||||||
|
only — see [Security](../../operations/security.md#ssrf)).
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
// Robust outbound call: bounded timeout, retried on transient upstream errors.
|
||||||
|
let policy = retry::on_codes(
|
||||||
|
retry::policy(#{ max_attempts: 3, backoff: "exponential", base_ms: 300 }),
|
||||||
|
["http: 502", "http: 503", "http: 504"]
|
||||||
|
);
|
||||||
|
let r = retry::run(policy, || http::post(url, payload, #{ timeout_ms: 5000 }));
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Composition](composition.md) for `retry`. The
|
||||||
|
[webhook tutorial](../../examples/webhook-receiver.md) uses `http` + `retry` against a downstream.
|
||||||
73
docs/dev-guide/src/reference/sdk/messaging.md
Normal file
73
docs/dev-guide/src/reference/sdk/messaging.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Messaging: pubsub, queue
|
||||||
|
|
||||||
|
Two ways for parts of your app (and outside clients) to communicate asynchronously.
|
||||||
|
|
||||||
|
- **`pubsub`** — fan-out. A published message is delivered to *every* matching subscriber (triggers,
|
||||||
|
and external SSE clients). Fire-and-forget; no per-consumer durability guarantee beyond delivery.
|
||||||
|
- **`queue`** — work distribution. An enqueued message is delivered to *one* consumer, with retries,
|
||||||
|
attempt tracking, and dead-lettering. Use it for jobs that must complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `pubsub` — publish/subscribe {#pubsub}
|
||||||
|
|
||||||
|
*Since SDK 1.6 (`publish_durable`), 1.7 (`subscriber_token`).*
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
pubsub::publish_durable("order.created", #{ id: 42, total: 1999 });
|
||||||
|
pubsub::publish_durable("metrics.tick", 1);
|
||||||
|
```
|
||||||
|
|
||||||
|
| Function | Signature | Returns |
|
||||||
|
|---|---|---|
|
||||||
|
| `pubsub::publish_durable(topic, message)` | `(string, dynamic)` | `()` — throws over `PICLOUD_PUBSUB_MAX_MESSAGE_BYTES` (256 KiB) |
|
||||||
|
| `pubsub::subscriber_token(topics)` | `(array of string)` | an HMAC-signed token (string) |
|
||||||
|
| `pubsub::subscriber_token(topics, ttl_secs)` | `(array, int\|())` | same, custom TTL |
|
||||||
|
|
||||||
|
- **Who receives a message:** any `pubsub` [trigger](../rest-api/triggers.md) whose topic glob matches
|
||||||
|
(runs a script), and any external SSE client subscribed to the topic (if it's a registered,
|
||||||
|
externally-subscribable [topic](../rest-api/topics.md)).
|
||||||
|
- **`subscriber_token`** mints a token an outside browser/client presents to subscribe over SSE at
|
||||||
|
`/realtime/topics/{topic}`. Only needed for `secret`/`token`-mode topics. See
|
||||||
|
[Topics & realtime](../rest-api/topics.md).
|
||||||
|
- Message encoding follows the [standard rules](overview.md#value-encoding) — blobs become base64,
|
||||||
|
`()` becomes null, closures are rejected.
|
||||||
|
|
||||||
|
Publishing needs no topic registration; *external subscription* does.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `queue` — durable job queue {#queue}
|
||||||
|
|
||||||
|
*Since SDK 1.10.* A producer + inspection API. Consumption is wired by registering a **`queue`
|
||||||
|
trigger** that runs a script per claimed message (`ctx.event.queue`).
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
queue::enqueue("emails.send", #{ to: "a@b.com", template: "welcome" });
|
||||||
|
queue::enqueue("emails.send", msg, #{ delay_ms: 60000, max_attempts: 5 });
|
||||||
|
|
||||||
|
let total = queue::depth("emails.send"); // all rows
|
||||||
|
let waiting = queue::depth_pending("emails.send"); // currently claimable
|
||||||
|
```
|
||||||
|
|
||||||
|
| Function | Signature | Returns |
|
||||||
|
|---|---|---|
|
||||||
|
| `queue::enqueue(name, message)` | `(string, dynamic)` | `()` — throws over `PICLOUD_QUEUE_MAX_PAYLOAD_BYTES` (256 KiB); closures rejected |
|
||||||
|
| `queue::enqueue(name, message, opts)` | `(string, dynamic, map)` | `()` — `opts.delay_ms` (int), `opts.max_attempts` (int) |
|
||||||
|
| `queue::depth(name)` | `(string)` | `int` (total) |
|
||||||
|
| `queue::depth_pending(name)` | `(string)` | `int` (claimable now) |
|
||||||
|
|
||||||
|
The consumer side:
|
||||||
|
|
||||||
|
1. Deploy a consumer script that reads `ctx.event.queue.message` (the enqueued value).
|
||||||
|
2. Register a queue trigger: `pic triggers create-queue --app <a> --script <id> --queue emails.send`.
|
||||||
|
3. On failure (the script throws), the message is retried up to `max_attempts`; after that it becomes
|
||||||
|
a [dead letter](composition.md#dead-letters) you can inspect and replay.
|
||||||
|
|
||||||
|
There is no script-level peek/dequeue/purge — consumption is the trigger's job. Inspect queues from the
|
||||||
|
dashboard, `pic queues`, or [the queues API](../rest-api/queues.md). The
|
||||||
|
[webhook tutorial](../../examples/webhook-receiver.md) builds a full producer→queue→consumer flow.
|
||||||
|
|
||||||
|
> **Make consumers idempotent.** A message can be delivered more than once (retry after a partial
|
||||||
|
> success, visibility-timeout expiry). Use `ctx.event.queue.attempt` and a dedup key. See
|
||||||
|
> [Best practices](../../operations/best-practices.md#idempotent-consumers).
|
||||||
96
docs/dev-guide/src/reference/sdk/overview.md
Normal file
96
docs/dev-guide/src/reference/sdk/overview.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# SDK reference — overview
|
||||||
|
|
||||||
|
The SDK is the set of functions your Rhai scripts can call. It comes in two layers:
|
||||||
|
|
||||||
|
- **Services** — stateful, app-scoped capabilities backed by the platform: `kv`, `docs`, `files`,
|
||||||
|
`http`, `email`, `users`, `pubsub`, `queue`, `secrets`, `invoke`, `retry`, `dead_letters`, and
|
||||||
|
`log`. These appear and disappear with releases (each is tagged with the SDK minor that added it).
|
||||||
|
- **Standard library** — pure, stateless helpers: `json`, `base64`, `hex`, `url`, `regex`, `random`,
|
||||||
|
`time`. See [Standard library](stdlib.md).
|
||||||
|
|
||||||
|
This page covers the conventions that hold across *every* service. The per-service pages give exact
|
||||||
|
signatures.
|
||||||
|
|
||||||
|
## Namespacing and the handle pattern
|
||||||
|
|
||||||
|
Functions live in namespaces, called with `::`:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let id = random::uuid();
|
||||||
|
log::info("hi");
|
||||||
|
```
|
||||||
|
|
||||||
|
Storage services are **collection-scoped**: you first get a *handle* to a named collection, then call
|
||||||
|
methods on it. This is intentional — it makes the `(app, collection, key)` identity explicit.
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let users = docs::collection("users"); // handle
|
||||||
|
let id = users.create(#{ name: "Ada" }); // method on the handle
|
||||||
|
let doc = users.get(id);
|
||||||
|
```
|
||||||
|
|
||||||
|
So it's `kv::collection("x").get(k)`, **not** `kv::get("x", k)`. Collections are mandatory; a
|
||||||
|
collection name may not be empty.
|
||||||
|
|
||||||
|
## App scoping is automatic and non-negotiable
|
||||||
|
|
||||||
|
Every service call resolves the app from the server-side execution context. **Nothing your script
|
||||||
|
passes can change which app's data it touches** — there is no `app_id` argument anywhere in the SDK.
|
||||||
|
This is the isolation boundary described in [Core concepts](../../guide/concepts.md#apps); treat it as
|
||||||
|
a hard guarantee, not a convention.
|
||||||
|
|
||||||
|
## Error conventions
|
||||||
|
|
||||||
|
Uniform across all services (also covered in [Writing scripts](../../guide/writing-scripts.md#errors)):
|
||||||
|
|
||||||
|
| Situation | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Real failure (DB error, payload too large, authorization denied) | **throws** — catch with `try/catch`, or let it become a 502 |
|
||||||
|
| Item not found / no result | returns **`()`** — test `== ()` |
|
||||||
|
| Existence / was-present check | returns a **`bool`** |
|
||||||
|
|
||||||
|
## Value encoding
|
||||||
|
|
||||||
|
Values you store or send are JSON-encoded on the wire:
|
||||||
|
|
||||||
|
- Maps, arrays, strings, numbers, booleans round-trip cleanly.
|
||||||
|
- `()` (Rhai unit) becomes JSON `null`.
|
||||||
|
- **Blobs** (byte arrays, e.g. from `base64::decode`) are base64-encoded inside JSON payloads
|
||||||
|
(`pubsub`, `queue`). `files` stores raw bytes directly.
|
||||||
|
- **Function pointers / closures cannot be serialized** and are rejected by `queue`, `invoke`, etc.
|
||||||
|
|
||||||
|
## Size caps
|
||||||
|
|
||||||
|
Several services cap payload size to protect Postgres. Defaults (all tunable, see
|
||||||
|
[env vars](../config/env-vars.md)):
|
||||||
|
|
||||||
|
| Service | Env var | Default |
|
||||||
|
|---|---|---|
|
||||||
|
| `kv` value | `PICLOUD_KV_MAX_VALUE_BYTES` | 256 KiB |
|
||||||
|
| `docs` document | `PICLOUD_DOCS_MAX_VALUE_BYTES` | 256 KiB |
|
||||||
|
| `pubsub` message | `PICLOUD_PUBSUB_MAX_MESSAGE_BYTES` | 256 KiB |
|
||||||
|
| `queue` message | `PICLOUD_QUEUE_MAX_PAYLOAD_BYTES` | 256 KiB |
|
||||||
|
| `files` blob | `PICLOUD_FILES_MAX_FILE_SIZE_BYTES` | 100 MiB |
|
||||||
|
|
||||||
|
Exceeding a cap **throws**. For `kv`/`queue` the size is checked *before* authorization so a public
|
||||||
|
script can't be used to hammer the database.
|
||||||
|
|
||||||
|
## The full surface at a glance
|
||||||
|
|
||||||
|
| Namespace | Since SDK | Page |
|
||||||
|
|---|---|---|
|
||||||
|
| `log` | 1.0 | [Writing scripts](../../guide/writing-scripts.md#logging) |
|
||||||
|
| `kv` | 1.2 | [Storage](storage.md#kv) |
|
||||||
|
| `docs` | 1.3 | [Storage](storage.md#docs) |
|
||||||
|
| `files` | 1.6 | [Storage](storage.md#files) |
|
||||||
|
| `http` | 1.5 | [Outbound HTTP](http.md) |
|
||||||
|
| `pubsub` | 1.6 / 1.7 | [Messaging](messaging.md#pubsub) |
|
||||||
|
| `secrets`, `email` | 1.8 | [Secrets](secrets.md), [Email](email.md) |
|
||||||
|
| `users` | 1.9 | [Users & auth](users.md) |
|
||||||
|
| `queue`, `invoke`, `retry` | 1.10 | [Messaging](messaging.md#queue), [Composition](composition.md) |
|
||||||
|
| `dead_letters` | 1.2 | [Composition](composition.md#dead-letters) |
|
||||||
|
| `json` `base64` `hex` `url` `regex` `random` `time` | 1.0 | [Standard library](stdlib.md) |
|
||||||
|
|
||||||
|
Many services also have an **admin/inspection** side on the HTTP API and the `pic` CLI (e.g. browse KV,
|
||||||
|
download files, read secret names). Those are read-mostly; *writing* data is the script's job. Each SDK
|
||||||
|
page links to its admin counterpart.
|
||||||
51
docs/dev-guide/src/reference/sdk/secrets.md
Normal file
51
docs/dev-guide/src/reference/sdk/secrets.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Secrets
|
||||||
|
|
||||||
|
*Since SDK 1.8.* Per-app encrypted key/value storage for credentials — API keys, signing secrets, DB
|
||||||
|
passwords for upstreams. Values are encrypted at rest with AES-256-GCM under the process master key
|
||||||
|
(`PICLOUD_SECRET_KEY`). Unlike `kv`, secrets are **not** collection-scoped: a secret is just a name
|
||||||
|
within the app.
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
secrets::set("stripe_key", "sk_live_…");
|
||||||
|
secrets::set("oauth", #{ client_id: "abc", client_secret: "xyz" }); // any JSON value
|
||||||
|
|
||||||
|
let key = secrets::get("stripe_key"); // the value, or () if missing
|
||||||
|
let was = secrets::delete("stripe_key"); // bool
|
||||||
|
let page = secrets::list(#{ cursor: (), limit: 100 });
|
||||||
|
// page = #{ names: [...], next_cursor: () | "cursor" } — names only, never values
|
||||||
|
```
|
||||||
|
|
||||||
|
| Function | Signature | Returns |
|
||||||
|
|---|---|---|
|
||||||
|
| `secrets::set(name, value)` | `(string, dynamic)` | `()` — upserts (overwrites if present) |
|
||||||
|
| `secrets::get(name)` | `(string)` | the value, or `()` (also `()` if decryption fails) |
|
||||||
|
| `secrets::delete(name)` | `(string)` | `bool` |
|
||||||
|
| `secrets::list(#{ cursor?, limit? })` | | `#{ names, next_cursor }` |
|
||||||
|
|
||||||
|
Strings round-trip as strings; other types round-trip via JSON.
|
||||||
|
|
||||||
|
## Why use secrets instead of `kv`?
|
||||||
|
|
||||||
|
- **Encrypted at rest.** A database dump leaks `kv` values in the clear, but secret values are
|
||||||
|
ciphertext.
|
||||||
|
- **Redacted everywhere.** The admin API, dashboard, and `pic secrets` only ever show secret *names* —
|
||||||
|
values never leave the server after `set`.
|
||||||
|
|
||||||
|
## Setting secrets out-of-band
|
||||||
|
|
||||||
|
You usually don't want to hardcode a secret in a script. Set it from the CLI (value read from stdin so
|
||||||
|
it never lands in shell history) or the dashboard:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
printf 'sk_live_…' | pic secrets set --app myapp stripe_key
|
||||||
|
pic secrets ls --app myapp
|
||||||
|
```
|
||||||
|
|
||||||
|
Then read it at runtime: `let k = secrets::get("stripe_key");`. See
|
||||||
|
[the data-admin API](../rest-api/data-admin.md#secrets) and the
|
||||||
|
[webhook tutorial](../../examples/webhook-receiver.md), which verifies an HMAC using a stored secret.
|
||||||
|
|
||||||
|
> **Master-key caveat:** secrets are encrypted under `PICLOUD_SECRET_KEY`. **Rotating that key makes
|
||||||
|
> existing secrets undecryptable** — `secrets::get` will return `()` for them. There is no automatic
|
||||||
|
> re-encryption in 1.1.9; rotate deliberately and re-`set` your secrets afterward. See
|
||||||
|
> [Security](../../operations/security.md#secrets-and-the-master-key).
|
||||||
100
docs/dev-guide/src/reference/sdk/stdlib.md
Normal file
100
docs/dev-guide/src/reference/sdk/stdlib.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# Standard library
|
||||||
|
|
||||||
|
*Since SDK 1.0.* Pure, stateless helpers for everyday glue. No app scope, no I/O, no failure modes
|
||||||
|
beyond bad input (which throws). For the deep design notes see the engineering reference
|
||||||
|
`docs/stdlib-reference.md` in the repo; this page is the working reference.
|
||||||
|
|
||||||
|
> **Heads-up:** `ctx.request.body` is already parsed when the request is JSON — don't `json::parse` it
|
||||||
|
> again. Use `json::parse` only on raw strings (e.g. an `http` response's `body_raw`).
|
||||||
|
|
||||||
|
## `json` — parse / stringify
|
||||||
|
|
||||||
|
| Function | Signature | Returns |
|
||||||
|
|---|---|---|
|
||||||
|
| `json::parse(s)` | `(string)` | a Rhai value (map/array/scalar/`()` for null) — throws on invalid JSON |
|
||||||
|
| `json::stringify(v)` | `(dynamic)` | compact JSON string |
|
||||||
|
| `json::stringify_pretty(v)` | `(dynamic)` | 2-space-indented JSON |
|
||||||
|
|
||||||
|
## `base64` — standard & URL-safe
|
||||||
|
|
||||||
|
Encoders accept a string or a blob; decoders return a blob.
|
||||||
|
|
||||||
|
| Function | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `base64::encode(input)` | standard alphabet, padded |
|
||||||
|
| `base64::decode(s)` | → blob; throws on invalid |
|
||||||
|
| `base64::encode_url(input)` | URL-safe alphabet, **no** padding |
|
||||||
|
| `base64::decode_url(s)` | → blob; throws on invalid |
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let bytes = base64::decode(ctx.request.body.b64); // → Blob, e.g. for files::create
|
||||||
|
let token = base64::encode_url(random::bytes(32));
|
||||||
|
```
|
||||||
|
|
||||||
|
## `hex`
|
||||||
|
|
||||||
|
| Function | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `hex::encode(input)` | lowercase hex (string or blob in) |
|
||||||
|
| `hex::decode(s)` | → blob; case-insensitive; throws on invalid |
|
||||||
|
|
||||||
|
## `url` — percent-encoding
|
||||||
|
|
||||||
|
| Function | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `url::encode(s)` | percent-encode one component |
|
||||||
|
| `url::decode(s)` | percent-decode; throws on invalid UTF-8 |
|
||||||
|
| `url::encode_query(map)` | build `k1=v1&k2=v2` (keys & values encoded, keys sorted) |
|
||||||
|
|
||||||
|
## `regex` — non-backtracking regular expressions
|
||||||
|
|
||||||
|
Linear-time engine (no catastrophic backtracking). Patterns are cached. Use backticks for literal
|
||||||
|
backslashes: `` regex::find_all(`\d+`, text) ``.
|
||||||
|
|
||||||
|
| Function | Returns |
|
||||||
|
|---|---|
|
||||||
|
| `regex::is_match(pattern, text)` | `bool` |
|
||||||
|
| `regex::find(pattern, text)` | first match string, or `()` |
|
||||||
|
| `regex::find_all(pattern, text)` | array of match strings |
|
||||||
|
| `regex::replace(pattern, text, repl)` | replace first |
|
||||||
|
| `regex::replace_all(pattern, text, repl)` | replace all |
|
||||||
|
| `regex::split(pattern, text)` | array |
|
||||||
|
| `regex::captures(pattern, text)` | `[full, g1, g2, …]`, or `()` |
|
||||||
|
|
||||||
|
Invalid patterns throw.
|
||||||
|
|
||||||
|
## `random` — cryptographically secure
|
||||||
|
|
||||||
|
Backed by the OS CSPRNG; safe for tokens and IDs.
|
||||||
|
|
||||||
|
| Function | Returns |
|
||||||
|
|---|---|
|
||||||
|
| `random::int(min, max)` | uniform int in `[min, max]` (throws if `min > max`) |
|
||||||
|
| `random::float()` | uniform in `[0.0, 1.0)` |
|
||||||
|
| `random::bytes(n)` | blob of `n` bytes (`n ∈ [0, 65536]`) |
|
||||||
|
| `random::string(n)` | `n` alphanumeric chars (`n ∈ [0, 4096]`) |
|
||||||
|
| `random::uuid()` | UUID v4 string |
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let code = random::string(7); // e.g. a short URL slug
|
||||||
|
let id = random::uuid();
|
||||||
|
```
|
||||||
|
|
||||||
|
## `time` — UTC, milliseconds
|
||||||
|
|
||||||
|
Canonical unit is `i64` milliseconds since the Unix epoch; ISO 8601 / RFC 3339 strings for I/O. All
|
||||||
|
UTC.
|
||||||
|
|
||||||
|
| Function | Returns |
|
||||||
|
|---|---|
|
||||||
|
| `time::now()` | current time as ISO 8601 string (with ms) |
|
||||||
|
| `time::now_ms()` | current ms since epoch |
|
||||||
|
| `time::parse(iso)` | ms since epoch (throws on bad input) |
|
||||||
|
| `time::format(ms)` | ISO 8601 string |
|
||||||
|
| `time::add_seconds(ms, secs)` | `ms + secs*1000` (throws on overflow) |
|
||||||
|
| `time::diff_seconds(a_ms, b_ms)` | `(b_ms - a_ms)/1000`, truncated |
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let now = time::now_ms();
|
||||||
|
let deadline = time::add_seconds(now, 3600); // one hour from now
|
||||||
|
```
|
||||||
117
docs/dev-guide/src/reference/sdk/storage.md
Normal file
117
docs/dev-guide/src/reference/sdk/storage.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# Storage: kv, docs, files
|
||||||
|
|
||||||
|
Three collection-scoped storage services. All share the [handle pattern](overview.md#namespacing-and-the-handle-pattern)
|
||||||
|
and [error conventions](overview.md#error-conventions). The identity of any item is the tuple
|
||||||
|
`(app, collection, key/id)` — collections are mandatory and app scoping is automatic.
|
||||||
|
|
||||||
|
Each also has a read-mostly admin surface: [Secrets, KV & files](../rest-api/data-admin.md) on the
|
||||||
|
HTTP API and `pic kv` / `pic files` on the CLI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `kv` — key/value store {#kv}
|
||||||
|
|
||||||
|
*Since SDK 1.2.* Simple string-keyed JSON values. Best for counters, flags, small lookups, caches.
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let c = kv::collection("settings");
|
||||||
|
|
||||||
|
c.set("theme", "dark"); // value can be any JSON-serializable value
|
||||||
|
let t = c.get("theme"); // "dark", or () if absent
|
||||||
|
let exists = c.has("theme"); // bool
|
||||||
|
let was = c.delete("theme"); // bool — was it present?
|
||||||
|
let page = c.list(); // #{ keys: [...], next_cursor: () | "cursor" }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Method | Signature | Returns |
|
||||||
|
|---|---|---|
|
||||||
|
| `kv::collection(name)` | `(string)` | a `KvHandle` |
|
||||||
|
| `.set(key, value)` | `(string, dynamic)` | `()` — throws if value exceeds `PICLOUD_KV_MAX_VALUE_BYTES` (256 KiB) |
|
||||||
|
| `.get(key)` | `(string)` | the value, or `()` if absent |
|
||||||
|
| `.has(key)` | `(string)` | `bool` (cheap; no deserialization) |
|
||||||
|
| `.delete(key)` | `(string)` | `bool` (was-present) |
|
||||||
|
| `.list()` / `.list(cursor)` / `.list(cursor, limit)` | | `#{ keys, next_cursor }` |
|
||||||
|
|
||||||
|
Pagination: `list()` returns a page plus `next_cursor` (`()` when exhausted); pass it back to
|
||||||
|
`list(cursor)` for the next page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `docs` — document store {#docs}
|
||||||
|
|
||||||
|
*Since SDK 1.3.* Auto-IDed JSON documents with a query filter. Best for entities (users, posts,
|
||||||
|
orders) where you want to find by field.
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let posts = docs::collection("posts");
|
||||||
|
|
||||||
|
let id = posts.create(#{ title: "Hi", tag: "intro", views: 0 }); // returns the new id (string)
|
||||||
|
let doc = posts.get(id); // envelope, or () if missing
|
||||||
|
let hits = posts.find(#{ tag: "intro" }); // array of envelopes
|
||||||
|
let one = posts.find_one(#{ tag: "intro" });
|
||||||
|
posts.update(id, #{ title: "Hi", tag: "intro", views: 1 }); // replaces `data`
|
||||||
|
let was = posts.delete(id); // bool
|
||||||
|
let page = posts.list(); // #{ docs: [...], next_cursor: () | "cursor" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Every read returns an **envelope**, with your fields under `data`:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
#{ id: "…", data: #{ title: "Hi", tag: "intro", views: 1 },
|
||||||
|
created_at: "2026-…Z", updated_at: "2026-…Z" }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Method | Signature | Returns |
|
||||||
|
|---|---|---|
|
||||||
|
| `docs::collection(name)` | `(string)` | a `DocsHandle` |
|
||||||
|
| `.create(data)` | `(map)` | new id (string) — throws if over `PICLOUD_DOCS_MAX_VALUE_BYTES` (256 KiB) |
|
||||||
|
| `.get(id)` | `(string)` | envelope, or `()` |
|
||||||
|
| `.find(filter)` | `(map)` | array of envelopes |
|
||||||
|
| `.find_one(filter)` | `(map)` | one envelope, or `()` |
|
||||||
|
| `.update(id, data)` | `(string, map)` | `()` |
|
||||||
|
| `.delete(id)` | `(string)` | `bool` |
|
||||||
|
| `.list()` / `.list(#{ cursor, limit })` | | `#{ docs, next_cursor }` |
|
||||||
|
|
||||||
|
The `find` filter is a map of field equality matches (`#{ tag: "intro", published: true }` matches docs
|
||||||
|
where both hold). It is a deliberately small subset for 1.1.9; richer query operators are roadmap.
|
||||||
|
|
||||||
|
> `update(id, data)` **replaces** the whole `data` map. To change one field, `get` it, mutate, and
|
||||||
|
> `update` with the full map.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `files` — blob storage {#files}
|
||||||
|
|
||||||
|
*Since SDK 1.6.* Binary objects (images, uploads, generated artifacts) stored on the filesystem under
|
||||||
|
`PICLOUD_FILES_ROOT`, with metadata in Postgres. Bytes are passed as Rhai **blobs**.
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let bucket = files::collection("avatars");
|
||||||
|
|
||||||
|
// data must be a Blob — e.g. base64::decode(ctx.request.body.b64)
|
||||||
|
let id = bucket.create(#{ name: "a.png", content_type: "image/png", data: bytes });
|
||||||
|
let meta = bucket.head(id); // metadata map, or () (no bytes)
|
||||||
|
let bytes = bucket.get(id); // the Blob, or ()
|
||||||
|
bucket.update(id, #{ data: new_bytes }); // data required; name/content_type optional
|
||||||
|
let was = bucket.delete(id); // bool
|
||||||
|
let page = bucket.list(); // #{ files: [...], next_cursor: () | "cursor" }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Method | Signature | Returns |
|
||||||
|
|---|---|---|
|
||||||
|
| `files::collection(name)` | `(string)` | a `FilesHandle` |
|
||||||
|
| `.create(#{ name, content_type, data })` | | new id (string) — `data` is a Blob; throws over `PICLOUD_FILES_MAX_FILE_SIZE_BYTES` (100 MiB) |
|
||||||
|
| `.head(id)` | `(string)` | metadata `#{ id, collection, name, content_type, size, checksum, created_at, updated_at }`, or `()` |
|
||||||
|
| `.get(id)` | `(string)` | the Blob, or `()` |
|
||||||
|
| `.update(id, #{ data, name?, content_type? })` | | `()` |
|
||||||
|
| `.delete(id)` | `(string)` | `bool` |
|
||||||
|
| `.list()` / `.list(cursor)` / `.list(cursor, limit)` / `.list(#{ cursor, limit })` | | `#{ files, next_cursor }` |
|
||||||
|
|
||||||
|
Reads are checksum-verified (SHA-256). **Serving the bytes back:** a script response is always JSON, so
|
||||||
|
you can't stream a raw blob through the response body (a blob body serializes to a hex JSON string).
|
||||||
|
Return base64 in JSON for the client to decode, or use the
|
||||||
|
[admin files endpoint](../rest-api/data-admin.md#files) for true raw bytes — see the
|
||||||
|
[file-upload tutorial](../../examples/file-upload.md).
|
||||||
|
|
||||||
|
> Mutating any of these services can fire a [trigger](../rest-api/triggers.md) (`kv`/`docs`/`files`
|
||||||
|
> kinds), letting another script react to the change.
|
||||||
87
docs/dev-guide/src/reference/sdk/users.md
Normal file
87
docs/dev-guide/src/reference/sdk/users.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Users & auth
|
||||||
|
|
||||||
|
*Since SDK 1.9.* The `users` namespace manages **your app's end-users** — registration, login,
|
||||||
|
sessions, email verification, password reset, invitations, and per-user roles. Passwords are hashed
|
||||||
|
with Argon2id; sessions are opaque tokens.
|
||||||
|
|
||||||
|
> **This is the only way to do app-user auth — PiCloud ships no `/signup` or `/login` HTTP endpoints.**
|
||||||
|
> You write scripts that call `users::*` and bind them to *your own* routes (e.g. `POST /auth/signup`).
|
||||||
|
> The [TODO API tutorial](../../examples/todo-api.md) builds the complete flow. (Distinct from
|
||||||
|
> [admin users](../rest-api/access.md), who manage the platform.)
|
||||||
|
|
||||||
|
## The user map
|
||||||
|
|
||||||
|
Reads return:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
#{ id, email, display_name, // display_name is () if unset
|
||||||
|
email_verified_at, last_login_at, // RFC 3339 or ()
|
||||||
|
created_at, updated_at,
|
||||||
|
roles: ["editor", ...] }
|
||||||
|
```
|
||||||
|
|
||||||
|
## CRUD
|
||||||
|
|
||||||
|
| Function | Signature | Returns |
|
||||||
|
|---|---|---|
|
||||||
|
| `users::create(#{ email, password, display_name? })` | | the user map — throws if email taken |
|
||||||
|
| `users::get(id)` | `(string)` | user map, or `()` |
|
||||||
|
| `users::find_by_email(email)` | `(string)` | user map or `()` — **requires an authenticated principal** |
|
||||||
|
| `users::email_available(email)` | `(string)` | `bool` — anonymous-safe pre-check |
|
||||||
|
| `users::update(id, #{ display_name? })` | | updated user map |
|
||||||
|
| `users::delete(id)` | `(string)` | `bool` |
|
||||||
|
| `users::list(#{ "$limit"?, cursor? })` | | `#{ users: [...], next_cursor }` — `limit` is accepted as an alias for `$limit` |
|
||||||
|
|
||||||
|
> **Enumeration:** `find_by_email` is blocked for anonymous (public) scripts to stop attackers probing
|
||||||
|
> who's registered; call it only from authenticated paths. `email_available` *is* anonymous-safe (a
|
||||||
|
> signup form needs it) but is unthrottled — rate-limit it yourself if abuse is a concern. See
|
||||||
|
> [Security](../../operations/security.md#user-enumeration).
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let token = users::login(email, password); // session token string, or () on bad credentials
|
||||||
|
let user = users::verify(token); // user map (and bumps the sliding TTL), or () if invalid/expired
|
||||||
|
users::logout(token); // invalidate
|
||||||
|
```
|
||||||
|
|
||||||
|
A typical gated route:
|
||||||
|
|
||||||
|
```rhai
|
||||||
|
let tok = ctx.request.headers["authorization"].sub_string(7); // strip "Bearer "
|
||||||
|
let me = users::verify(tok);
|
||||||
|
if me == () { return #{ statusCode: 401, body: #{ error: "unauthorized" } }; }
|
||||||
|
// ... me.id, me.roles available
|
||||||
|
```
|
||||||
|
|
||||||
|
## Email-tied flows
|
||||||
|
|
||||||
|
These send mail (need SMTP configured, or dev mode), templated by the options you pass:
|
||||||
|
|
||||||
|
| Function | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `users::send_verification_email(id, #{ link_base, from, subject, body_template })` | send a verify link |
|
||||||
|
| `users::verify_email(token)` | mark verified — returns user map or `()` |
|
||||||
|
| `users::request_password_reset(email, #{ link_base, from, subject, body_template })` | send reset link |
|
||||||
|
| `users::complete_password_reset(token, new_password)` | apply — user map or `()` |
|
||||||
|
| `users::invite(email, #{ link_base?, from?, subject?, body_template?, display_name?, roles? })` | invite |
|
||||||
|
| `users::accept_invite(token, password)` / `(token, password, display_name)` | accept — returns a session token or `()` |
|
||||||
|
|
||||||
|
`body_template` / `link_base` are required only when email sending is configured; the platform builds
|
||||||
|
the link as `link_base` + the one-time token.
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
Per-app, string-tagged. Define whatever vocabulary your app needs (`"editor"`, `"pro"`, …).
|
||||||
|
|
||||||
|
| Function | Returns |
|
||||||
|
|---|---|
|
||||||
|
| `users::add_role(id, role)` | `()` |
|
||||||
|
| `users::remove_role(id, role)` | `bool` (was-present) |
|
||||||
|
| `users::has_role(id, role)` | `bool` |
|
||||||
|
|
||||||
|
## Admin & CLI side
|
||||||
|
|
||||||
|
Operators can list/inspect app users, mint a reset token, and revoke sessions via
|
||||||
|
[the app-users API](../rest-api/app-users.md) and `pic users`. They cannot read passwords (only hashes
|
||||||
|
are stored).
|
||||||
Reference in New Issue
Block a user