Compare commits

..

2 Commits

Author SHA1 Message Date
MechaCat02
05ea29fbd0 docs: add comprehensive developer guide (mdBook)
Add a browsable mdBook developer guide under docs/dev-guide/ covering the
full PiCloud v1.1.9 surface for developers building on the platform:

- Guide: introduction, 10-minute quickstart, core concepts, writing scripts
- Tutorials: 5 end-to-end example apps (URL shortener, webhook receiver,
  TODO API with auth, scheduled report, file-upload service) + feature matrix
- SDK reference: kv/docs/files, pubsub/queue, http, email, users, secrets,
  invoke/retry/dead_letters, stdlib, ctx & events
- HTTP API reference: every /api/v1 endpoint, auth, and capability
- CLI reference: the `pic` client + the server admin recovery command
- Config: every PICLOUD_* env var, capabilities & roles
- Deployment: Docker Compose, bare binary, Caddy/TLS, production checklist
- Operations: security, best practices, troubleshooting

Every example was built and run against a live instance; SDK signatures,
endpoint paths, and env vars were verified against source. Build the site
with `mdbook serve docs/dev-guide`. A pointer is added to README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:54:23 +02:00
MechaCat02
51f14fa2b1 feat: E2E #2 (Stash) gap remediation + S6 hardening
Some checks failed
CI / Rust — fmt, clippy, test (push) Failing after 6m19s
CI / Dashboard — check (push) Successful in 9m48s
Closes the gaps and the one security finding from the second end-to-end
CLI test (E2E_STASH_REPORT.md), plus the H1 boot-regression found while
re-reviewing those fixes.

Security
- S6: reserved-path validation (`check_reserved`) now case-folds before
  comparing, so `/API/v2/x`, `/HEALTHZ`, `/Admin/x` are rejected like
  their lowercase forms. Request-time matching stays case-sensitive.
- S10: "public route != public data" callout in sdk-shape.md (script_gate
  skips authz when the principal is anonymous).

Observability / features
- G1: trigger executions now write `execution_logs`. Migration 0043 adds
  a `source` column (CHECK mirrors ExecutionSource/OutboxSourceKind,
  DEFAULT 'http' backfills history); a shared `build_execution_log` helper
  in executor-core; dispatcher logging for outbox triggers + queue
  consumers (skips sync-HTTP rows the orchestrator already logs). `pic
  logs` gains a source column + `--source` filter.
- G5: dev-only in-memory email capture under PICLOUD_DEV_MODE with no SMTP
  (email::send succeeds locally), readable at GET /api/v1/admin/dev/emails
  (Owner/Admin only; route mounted only in capture mode).
- G6: generalized the Rhai in-place-mutation footgun note (trim/replace/
  make_upper/make_lower/crop/truncate/pad return ()).
- G2/G3/G4 (CLI): `pic members`, `pic files`, `pic queues`, read-only
  `pic kv` (+ new kv_api.rs); `pic deploy --timeout/--memory/--kind/
  --sandbox`; first-class `pic triggers create-{docs,files,pubsub,queue,
  email}` wrappers. All new client path segments percent-encoded via seg().

H1 regression fix (found in re-review)
- The S6 change also runs in `compile_routes`, which compiles every stored
  route at boot and on each route CRUD. A single stored route the new
  validation rejects (creatable while the S6 gap existed) made the whole
  compile Err and aborted startup. `compile_routes` is now lenient: it
  skips an un-compilable row with a warning instead of bricking boot
  (route creation still validates separately). Migration 0044 sweeps
  pre-existing reserved-path routes on upgrade (WHERE mirrors
  check_reserved exactly). Added regression tests for both.

Verified: cargo fmt, clippy --all-targets --all-features -D warnings, the
schema_snapshot test, and the new S6/lenient-compile unit tests all pass;
boot-resilience and G1/G5 confirmed live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:01:04 +02:00
77 changed files with 6212 additions and 195 deletions

View File

@@ -117,7 +117,7 @@ Environment variables consumed by the `picloud` binary:
| `PICLOUD_MAX_CONCURRENT_EXECUTIONS` | `32` | Global concurrency cap on data-plane script executions. Overflow returns HTTP 503 with `Retry-After: 1` immediately (no queue). | | `PICLOUD_MAX_CONCURRENT_EXECUTIONS` | `32` | Global concurrency cap on data-plane script executions. Overflow returns HTTP 503 with `Retry-After: 1` immediately (no queue). |
| `DATABASE_URL` | — | Required. Postgres connection string. | | `DATABASE_URL` | — | Required. Postgres connection string. |
| `PICLOUD_SECRET_KEY` | — | Master encryption key (base64). Required at startup unless dev mode is acknowledged (below). | | `PICLOUD_SECRET_KEY` | — | Master encryption key (base64). Required at startup unless dev mode is acknowledged (below). |
| `PICLOUD_DEV_MODE` | `false` | `true` enables local-dev conveniences. Without `PICLOUD_SECRET_KEY` it ALSO requires the acknowledgement var below — `PICLOUD_DEV_MODE=true` alone aborts at startup. | | `PICLOUD_DEV_MODE` | `false` | `true` enables local-dev conveniences. Without `PICLOUD_SECRET_KEY` it ALSO requires the acknowledgement var below — `PICLOUD_DEV_MODE=true` alone aborts at startup. Also: when no SMTP relay is configured, `email::send` switches from disabled (`NotConfigured`) to an **in-memory dev sink** — sends succeed and the last 100 messages are readable at `GET /api/v1/admin/dev/emails` (instance Owner/Admin only; route exists only in this mode). Never in production. |
| `PICLOUD_DEV_INSECURE_KEY` | — | Set to the literal `i-understand-this-is-insecure` to let dev mode boot without `PICLOUD_SECRET_KEY`, using a deterministic, world-known dev master key. Never set in production — it would encrypt everything with a public value. | | `PICLOUD_DEV_INSECURE_KEY` | — | Set to the literal `i-understand-this-is-insecure` to let dev mode boot without `PICLOUD_SECRET_KEY`, using a deterministic, world-known dev master key. Never set in production — it would encrypt everything with a public value. |
| `PICLOUD_DB_MAX_CONNECTIONS` | `32` | Postgres pool size. Matched to `PICLOUD_MAX_CONCURRENT_EXECUTIONS` so the data plane can't starve background workers. | | `PICLOUD_DB_MAX_CONNECTIONS` | `32` | Postgres pool size. Matched to `PICLOUD_MAX_CONCURRENT_EXECUTIONS` so the data plane can't starve background workers. |
| `PICLOUD_SESSION_TTL_HOURS` | `24` | Sliding-window session lifetime. | | `PICLOUD_SESSION_TTL_HOURS` | `24` | Sliding-window session lifetime. |

138
E2E_STASH_REPORT.md Normal file
View File

@@ -0,0 +1,138 @@
# E2E Test #2 — "Stash" (paste + file-drop) + security matrix
**Date:** 2026-06-13 · **Build:** v1.1.9 · **Instance:** host `picloud` on `127.0.0.1:8099`
(dev mode, `PICLOUD_FILES_MAX_FILE_SIZE_BYTES=1048576`, SSRF guard on, SMTP unset), Postgres in
Docker. Driven entirely through the `pic` CLI; data-plane traffic via `curl`.
## Goal
Second end-to-end pass. The first test (To-Do, `E2E_TODO_REPORT.md`) covered users/docs/pubsub/
cron/routes/domains/topics. This one builds a **new** app to exercise the *untested* half of the
platform — **kv, files, queue, invoke, secrets, api-keys, dead-letters, async + two-param routes**
— and then runs a **10-probe security matrix**. Findings: feature/CLI gaps + security verdicts.
## Verdict
**Every feature worked** end-to-end, and **every security control held except one Low-severity
gap** (reserved-path validation is case-sensitive). Highlights of what's *good*: cross-app
isolation is airtight, the SSRF guard blocks cloud-metadata, size/op caps fire before authz, and
internal script errors are scrubbed from responses with a correlation id. Two notable
**observability/ergonomics gaps** (trigger executions invisible to `pic logs`; the `.trim()`/`.replace()`
return-`()` footgun) and the expected CLI-coverage gaps round it out.
---
## The app — "Stash" (built CLI-only, no raw admin curl)
A pastebin with file attachments and a background word-count worker. 9 scripts, 6 routes, a queue
consumer trigger, a dead-letter trigger, 2 API keys, 1 secret — all via `pic`.
| Feature exercised | How | Result |
|---|---|---|
| **kv** | paste store, hit counter, `/stats` aggregates, event log | ✅ get/set/list round-trip |
| **files** | `files::create` from base64 blob + `get`/`head`, two-param download route | ✅ bytes round-trip (`"hello attachment bytes"`, size 22) |
| **queue** | `queue::enqueue("ingest")` + `create-from-json --kind queue` consumer | ✅ worker fired |
| **invoke** | worker → `invoke("analyzer", #{text})` function-to-function | ✅ returns word count |
| **secrets** | `pic secrets set` (stdin) + `secrets::get` to gate `/stats` | ✅ 401 w/o token, 200 with |
| **async route** | `routes create --dispatch async` for `POST /events` | ✅ **202** + execution_id |
| **dead-letters** | poison job → 3 attempts → DL row; `pic dead-letters replay` | ✅ captured + replayed (+9 words) |
| **api-keys** | `pic api-keys mint --scope … [--app …]` | ✅ minted, scopes enforced (below) |
| **two-param route** | `/pastes/:code/files/:fid` | ✅ both params captured |
End-to-end stats after the run: `{total_pastes: 2, total_words: 13}` — proving queue → worker →
invoke → kv all fired on real Postgres rows + on-disk file bytes.
---
## Security matrix (10 probes, all reproduced live)
| # | Probe | Verdict | Evidence |
|---|---|---|---|
| **S1** | Cross-app isolation | ✅ HOLDS | A script in app `evil` doing `kv…list()` / `secrets::get("admin-token")` / `files…list()` returned `{paste_keys:[], attachment_count:0, stolen_secret:null, my_secret_names:[]}`. `app_id` derives from `cx.app_id`; no SDK call takes a script-passed app_id. |
| **S2** | Secret confidentiality | ✅ HOLDS | `pic secrets ls` shows names only; the plaintext `sup3r-s3cret-admin` does **not** appear anywhere in the server log even though `/stats` reads it each request. |
| **S3** | API-key scope + app-binding | ✅ HOLDS | `script:read` key → **403** on `POST /apps` and `GET /admins`, **200** on in-scope `GET /scripts`. `app:admin` key bound to `stash`**403** on app `evil`. `instance:admin` + `--app`**422**. Unknown scope `bogus:scope` → rejected by CLI. |
| **S4** | Sandbox op-budget | ✅ HOLDS | `loop {}`**HTTP 507 "execution exceeded operation budget"** in **0.30 s**; `/healthz` still `ok` (no hang). |
| **S5** | Value/size caps | ✅ HOLDS | Oversized kv write rejected (HTTP 502). Notably the detail (`"Length of string too large"`) was **scrubbed from the response** and logged with `correlation_id` — good error hygiene. Caps are checked before authz, so anonymous callers can't DoS. |
| **S6** | Reserved-path prefixes | ⚠️ **GAP (Low)** | `/api/x`, `/admin/x`, `/healthz`, `/version` → correctly **422**. But case variants `/API/v2/x`, `/Admin/x`, `/HEALTHZ` were **accepted**. See finding below. |
| **S7** | Files path traversal | ✅ HOLDS | `files::collection("../../../etc")``"invalid collection name: must not contain '/', '\\', '..', or NUL"`; a non-UUID file id → not found. No FS escape. |
| **S8** | docs filter injection | ✅ HOLDS | `docs::find(#{ "x'); DROP TABLE docs;--": 1 })` → ran safely, `count=0`; the `docs` table still exists (`to_regclass` = true). Values/paths are bound as `$N` params. |
| **S9** | SSRF via `http::` | ✅ HOLDS | `http::get("http://127.0.0.1:8099/healthz")``"blocked by SSRF policy: loopback"`; `http://169.254.169.254/…` (cloud metadata) → `"blocked by SSRF policy: link-local"`. |
| **S10** | Anonymous data-plane access | By design (caveat) | Every Stash route is public (no auth) yet freely uses kv/secrets/files/queue. `script_gate` returns `Ok` when `principal.is_none()` — the *script* is the gate. Correct per design, but a sharp threat-model edge (see note). |
### S6 — reserved-path validation is case-sensitive (Low)
**What:** route-creation rejects the exact reserved prefixes but accepts case variants:
```
pic routes create --path /api/x -> 422 "path '/api/x' is reserved"
pic routes create --path /API/v2/x -> Created route … (GET * /API/v2/x) # accepted
pic routes create --path /HEALTHZ -> Created route … # accepted
pic routes create --path /Admin/x -> Created route … # accepted
```
**Current impact — Low, *not* a full bypass:** path matching is case-*sensitive*, so a request to
the real lowercase `/api/v2/x` still 404s (the system namespace is safe); only the exact
mixed-case path serves the script (`GET /API/v2/x` → 200, `/HEALTHZ` → 200). Real `/healthz`
still returns `ok` from the top-level handler.
**Why it still matters:** (1) inconsistent enforcement of a stated security boundary; (2) lets a
tenant publish convincing look-alike paths (`/Admin/login`, `/API/v1/…`) for phishing or log
confusion; (3) **fragile** — method matching is already case-insensitive (`matcher.rs:275`); if
path matching is ever made case-insensitive too, this instantly becomes a real shadowing bypass
of `/api/`, `/admin/`, `/healthz`. The validation should be the durable guard.
**Fix:** normalize case before the reserved check in `orchestrator-core/src/routing/pattern.rs`
(`check_reserved` ~line 110) — compare `raw.to_ascii_lowercase()` against the reserved list (or
reject any case-insensitive match).
### S10 — anonymous public scripts have full app data-plane access (design caveat)
Not a bug, but worth surfacing for users: a *public* (unauthenticated) route's script can read/write
**all** of the app's kv, docs, files, secrets, and queue — the platform only gates *authenticated*
principals; for anonymous ingress the script itself must enforce access. A developer who assumes
"this route is public" ≠ "this code can read every secret in my app" could over-expose data. Worth
a prominent doc callout near the SDK auth model.
---
## Feature / CLI gaps
- **G1 — trigger executions are invisible to `pic logs`.** After successful queue-worker and
analyzer runs, `pic logs <worker>` and `pic logs <analyzer>` were **empty**. Trigger-dispatched
executions (queue/cron/dead-letter/invoke) don't surface in the per-script log tail — the only
built-in visibility into worker activity is **dead-letters (failures only)** or the script's own
side effects. This is a real observability gap for background workloads. *Suggest: include
trigger executions in the logs surface, or add a `pic logs --trigger`/events view.*
- **G2 — no `pic kv` / `files` / `queues` / `members` commands.** Data-plane stores are
script/HTTP-only (no admin CLI to inspect kv/files/queue contents; queues are read-only HTTP).
App **membership** (`/apps/{id}/members`) also has no CLI — multi-user app roles can't be managed
with `pic`. *(Confirmed absent via `pic <cmd> --help`.)*
- **G3 — no per-script sandbox/timeout flags in `pic deploy`/`scripts`.** `timeout_seconds`,
`memory_limit_mb`, and sandbox overrides are only settable via the raw scripts API or instance
env (`PICLOUD_SANDBOX_MAX_*`). A developer can't cap a single script's runtime from the CLI.
- **G4 — uneven trigger wrappers.** Only `kv`, `cron`, `dead-letter` have first-class
`pic triggers create-*` wrappers; `docs`/`files`/`pubsub`/`queue`/`email` require hand-built
`create-from-json --kind … --body '{…}'`. Works, but the developer must know each body schema.
- **G5 — `email::send` unusable in dev.** Returns `"email is not configured: set
PICLOUD_SMTP_HOST/USER/PASSWORD"`. Expected (no silent drop), but the email feature can't be
exercised at all without an SMTP relay — worth a documented local-dev fake/sink.
- **G6 — Rhai `.trim()` returns `()` (in-place mutation footgun).** Same family as `.replace()`
(already documented). `let t = text.trim()` sets `t` to unit; my analyzer dead-lettered with
`Function not found: split((), …)` until rewritten to `text.trim();` as a statement. *Suggest:
extend the existing stdlib footgun note to list `trim`/`to_upper`/`to_lower` etc., not just
`replace`.*
## Things done especially well (no action)
- Cross-app isolation has no script-controlled `app_id` anywhere — the boundary is structural.
- SSRF guard covers loopback **and** link-local (cloud-metadata `169.254.169.254`), and blocks at
every redirect hop.
- Size/operation caps are enforced **before** authz, so anonymous public scripts can't DoS the DB.
- Script runtime errors are **scrubbed** from the HTTP response and logged with a `correlation_id`
— no internal detail leaks to the caller.
- `deploy`/`apps create` honor `--output json` (the earlier F1 fix), so the whole build scripts
cleanly with captured ids.
## Reproduction / teardown
Scripts under `/tmp/stash/*.rhai`; apps `stash` + `evil` and their data persist in the dev
Postgres. Server: `target/debug/picloud` on `:8099` (see Phase 0 env). Teardown: kill the host
`picloud`; `docker compose down [-v]`. (Note: the S6 probe left harmless `/API/v2/x`, `/HEALTHZ`,
`/Admin/x`, `/Api/y` routes on `stash` — they vanish with the app.)
## Priority
**S6** is the only code-level finding (Low — confirm-and-harden the reserved-path check). **G1**
(trigger-execution observability) is the most impactful *developer-experience* gap for anyone
running background workers. Everything else is documented-behavior or known CLI-coverage gaps.

View File

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

View File

@@ -19,5 +19,6 @@ pub use module_resolver::{
}; };
pub use sandbox::Limits; pub use sandbox::Limits;
pub use types::{ pub use types::{
ExecError, ExecRequest, ExecResponse, ExecStats, InvocationType, LogEntry, LogLevel, build_execution_log, ExecError, ExecRequest, ExecResponse, ExecStats, InvocationType, LogEntry,
LogLevel,
}; };

View File

@@ -2,10 +2,12 @@ use std::collections::BTreeMap;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use picloud_shared::{ use picloud_shared::{
AppId, ExecutionId, Principal, RequestId, ScriptId, ScriptSandbox, TriggerEvent, AppId, ExecutionId, ExecutionLog, ExecutionSource, ExecutionStatus, Principal, RequestId,
ScriptId, ScriptSandbox, TriggerEvent,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
use uuid::Uuid;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
@@ -167,3 +169,77 @@ pub enum ExecError {
#[error("execution declined: server at capacity (retry after {retry_after_secs}s)")] #[error("execution declined: server at capacity (retry after {retry_after_secs}s)")]
Overloaded { retry_after_secs: u32 }, Overloaded { retry_after_secs: u32 },
} }
/// Build an `ExecutionLog` row from one invocation's outcome.
///
/// Shared by every execution path so the log shape stays identical
/// regardless of who ran the script: the orchestrator's sync/direct HTTP
/// handlers pass `ExecutionSource::Http`, while the manager's trigger
/// dispatcher passes the trigger's kind (`Kv`, `Cron`, `Invoke`, …). That
/// `source` is what lets `pic logs` surface background runs that were
/// previously invisible. `Overloaded` is never logged — admission is
/// refused before a row exists — but it maps to `Error` defensively.
#[allow(clippy::too_many_arguments)]
#[must_use]
pub fn build_execution_log(
app_id: AppId,
script_id: ScriptId,
request_id: RequestId,
request_path: String,
request_headers: BTreeMap<String, String>,
request_body: serde_json::Value,
source: ExecutionSource,
outcome: &Result<ExecResponse, ExecError>,
started: DateTime<Utc>,
finished: DateTime<Utc>,
) -> ExecutionLog {
let duration_ms = u64::try_from(
finished
.signed_duration_since(started)
.num_milliseconds()
.max(0),
)
.unwrap_or(0);
let (status, response_code, response_body, script_logs) = match outcome {
Ok(resp) => {
let logs = serde_json::to_value(&resp.logs).unwrap_or(serde_json::Value::Array(vec![]));
(
ExecutionStatus::Success,
Some(resp.status_code),
Some(resp.body.clone()),
logs,
)
}
Err(e) => {
let status = match e {
ExecError::Timeout(_) => ExecutionStatus::Timeout,
ExecError::OperationBudgetExceeded => ExecutionStatus::BudgetExceeded,
_ => ExecutionStatus::Error,
};
(
status,
None,
Some(serde_json::json!({ "error": e.to_string() })),
serde_json::Value::Array(vec![]),
)
}
};
ExecutionLog {
id: Uuid::new_v4(),
app_id,
script_id,
request_id,
request_path,
request_headers,
request_body,
response_code,
response_body,
script_logs,
duration_ms,
status,
source,
created_at: started,
}
}

View File

@@ -0,0 +1,20 @@
-- G1 (E2E #2 "Stash"): trigger executions were invisible to `pic logs`.
--
-- Only HTTP-route executions ever wrote an `execution_logs` row; queue,
-- cron, dead-letter, and `invoke()` runs left no trace, so background
-- workers were observable only via dead-letters (failures) or their own
-- side effects. The dispatcher now logs every trigger run too — this
-- column records which kind of event dispatched each execution so the
-- logs surface can show, and filter by, the origin.
--
-- DEFAULT 'http' backfills every pre-existing row: before this change the
-- only thing that logged was the HTTP path, so 'http' is correct history.
-- The CHECK list mirrors `manager-core::OutboxSourceKind` /
-- `shared::ExecutionSource`; keep all three in sync.
ALTER TABLE execution_logs
ADD COLUMN source TEXT NOT NULL DEFAULT 'http'
CHECK (source IN (
'http', 'kv', 'docs', 'dead_letter', 'cron',
'files', 'pubsub', 'email', 'invoke', 'queue'
));

View File

@@ -0,0 +1,22 @@
-- H1 (re-review of the S6 reserved-path fix): the reserved-prefix check is
-- now case-insensitive, both at route creation AND when the route table is
-- compiled at boot. Routes created before the fix — while validation was
-- case-sensitive — could hold paths like `/API/v2/x`, `/Admin/x`, or
-- `/HEALTHZ`. `compile_routes` now skips such rows with a warning instead of
-- aborting startup (so an un-upgraded boot can't be bricked), but those
-- routes violate the reserved namespace and can never be served safely, so
-- sweep them here on upgrade.
--
-- Mirrors `orchestrator-core::routing::pattern::check_reserved` exactly,
-- case-insensitively: a path is reserved if its lowercased form equals one
-- of the bare names (`/api` `/admin` `/healthz` `/version`) or starts with
-- one of the prefixes. Note `/api/` and `/admin/` reserve on the trailing
-- slash, while `/healthz` and `/version` reserve on bare prefix — matching
-- the RESERVED_PATH_PREFIXES list. (Idempotent: a no-op once swept.)
DELETE FROM routes
WHERE lower(path) IN ('/api', '/admin', '/healthz', '/version')
OR lower(path) LIKE '/api/%'
OR lower(path) LIKE '/admin/%'
OR lower(path) LIKE '/healthz%'
OR lower(path) LIKE '/version%';

View File

@@ -12,8 +12,8 @@ use axum::{
Extension, Json, Router, Extension, Json, Router,
}; };
use picloud_shared::{ use picloud_shared::{
AppId, ExecutionLog, InstanceRole, Principal, Script, ScriptId, ScriptKind, ScriptSandbox, AppId, ExecutionLog, ExecutionSource, InstanceRole, Principal, Script, ScriptId, ScriptKind,
ScriptValidator, ValidatedScript, ValidationError, ScriptSandbox, ScriptValidator, ValidatedScript, ValidationError,
}; };
use serde::Deserialize; use serde::Deserialize;
@@ -385,6 +385,10 @@ pub struct LogsQuery {
#[serde(default, rename = "offset")] #[serde(default, rename = "offset")]
#[allow(dead_code)] #[allow(dead_code)]
pub legacy_offset: Option<i64>, pub legacy_offset: Option<i64>,
/// Optional origin filter (`http`, `kv`, `cron`, `invoke`, …). Absent
/// → all sources. An unrecognized value is a 422 (see `list_logs`).
#[serde(default)]
pub source: Option<String>,
} }
const fn default_limit() -> i64 { const fn default_limit() -> i64 {
@@ -411,7 +415,17 @@ async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
.cursor .cursor
.as_deref() .as_deref()
.and_then(crate::repo::ExecutionLogCursor::decode); .and_then(crate::repo::ExecutionLogCursor::decode);
let logs = state.logs.list_for_script(id, limit, cursor).await?; let source = match q.source.as_deref() {
None | Some("" | "all") => None,
Some(s) => Some(
ExecutionSource::from_wire(s)
.ok_or_else(|| ApiError::BadRequest(format!("unknown log source: {s:?}")))?,
),
};
let logs = state
.logs
.list_for_script(id, limit, cursor, source)
.await?;
Ok(Json(logs)) Ok(Json(logs))
} }
@@ -427,6 +441,9 @@ pub enum ApiError {
#[error("app not found: {0}")] #[error("app not found: {0}")]
AppNotFound(String), AppNotFound(String),
#[error("bad request: {0}")]
BadRequest(String),
#[error("conflict: {0}")] #[error("conflict: {0}")]
Conflict(String), Conflict(String),
@@ -459,11 +476,11 @@ impl IntoResponse for ApiError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let (status, message) = match &self { let (status, message) = match &self {
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()), Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
Self::AppNotFound(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()), Self::AppNotFound(_)
| Self::BadRequest(_)
| Self::Invalid(_)
| Self::Ceiling(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()), Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
Self::Invalid(_) | Self::Ceiling(_) => {
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
}
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()), Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
Self::AuthzRepo(e) => { Self::AuthzRepo(e) => {
tracing::error!(error = %e, "authz repo error"); tracing::error!(error = %e, "authz repo error");

View File

@@ -0,0 +1,48 @@
//! `GET /api/v1/admin/dev/emails` — dev-only inspection of mail captured
//! by the in-memory email sink (G5).
//!
//! Mounted **only** when the email service is running in dev-capture mode
//! (`PICLOUD_DEV_MODE=true` and no SMTP relay configured). In every other
//! configuration the route does not exist, so there is no production
//! surface here. Capture is instance-wide (the SMTP transport seam can't
//! see a script's `app_id`), so the endpoint is instance-wide too and is
//! restricted to instance Owners/Admins.
use std::sync::Arc;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::Json;
use axum::routing::get;
use axum::{Extension, Router};
use picloud_shared::{InstanceRole, Principal};
use crate::email_service::{CapturedEmail, DevEmailSink};
#[derive(Clone)]
pub struct DevEmailState {
pub sink: Arc<DevEmailSink>,
}
/// Build the dev-email router. Callers mount this only when dev-capture
/// mode is active (i.e. they hold a `Some(sink)`).
pub fn dev_emails_router(state: DevEmailState) -> Router {
Router::new()
.route("/dev/emails", get(list_dev_emails))
.with_state(state)
}
async fn list_dev_emails(
Extension(principal): Extension<Principal>,
State(state): State<DevEmailState>,
) -> Result<Json<Vec<CapturedEmail>>, StatusCode> {
// Instance-wide data → require an instance Owner/Admin. A Member
// (app-scoped) principal has no business reading every app's mail.
if !matches!(
principal.instance_role,
InstanceRole::Owner | InstanceRole::Admin
) {
return Err(StatusCode::FORBIDDEN);
}
Ok(Json(state.sink.snapshot()))
}

View File

@@ -24,11 +24,14 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType}; use picloud_executor_core::{
build_execution_log, ExecError, ExecRequest, ExecResponse, InvocationType,
};
use picloud_orchestrator_core::{ExecutionGate, ExecutorClient}; use picloud_orchestrator_core::{ExecutionGate, ExecutorClient};
use picloud_shared::{ use picloud_shared::{
DeadLetterId, ExecResponseSummary, ExecutionId, HttpDispatchPayload, InboxDeliveryOutcome, DeadLetterId, ExecResponseSummary, ExecutionId, ExecutionLogSink, ExecutionSource,
InboxFailureKind, InboxResolver, InboxResult, RequestId, ScriptId, ScriptSandbox, TriggerEvent, HttpDispatchPayload, InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult,
RequestId, ScriptId, ScriptSandbox, TriggerEvent,
}; };
use rand::Rng; use rand::Rng;
use uuid::Uuid; use uuid::Uuid;
@@ -53,6 +56,11 @@ pub struct Dispatcher {
pub principals: Arc<dyn PrincipalResolver>, pub principals: Arc<dyn PrincipalResolver>,
pub executor: Arc<dyn ExecutorClient>, pub executor: Arc<dyn ExecutorClient>,
pub gate: Arc<ExecutionGate>, pub gate: Arc<ExecutionGate>,
/// G1: records an `execution_logs` row for every trigger run so
/// background workers (queue / cron / dead-letter / invoke) show up
/// in `pic logs`, not just synchronous HTTP. Same sink the
/// orchestrator's data plane writes through.
pub log_sink: Arc<dyn ExecutionLogSink>,
pub inbox: Arc<dyn InboxResolver>, pub inbox: Arc<dyn InboxResolver>,
/// v1.1.9. Reads `queue_messages` for the queue arm + the reclaim /// v1.1.9. Reads `queue_messages` for the queue arm + the reclaim
/// task. None in tests / harnesses that don't exercise queues. /// task. None in tests / harnesses that don't exercise queues.
@@ -149,6 +157,39 @@ fn async_exec_timeout_from_env() -> Duration {
/// parallelism, not the dispatcher's serial-await. /// parallelism, not the dispatcher's serial-await.
const QUEUE_DISPATCH_PARALLELISM: usize = 32; const QUEUE_DISPATCH_PARALLELISM: usize = 32;
/// Map an outbox row's source kind to the execution-log `source`. The
/// wire strings are identical, so this is total in practice; an unknown
/// kind would default to `Http`.
fn exec_source(row: &OutboxRow) -> ExecutionSource {
ExecutionSource::from_wire(row.source_kind.as_str()).unwrap_or_default()
}
/// G1: the slice of an `ExecRequest` needed to write an execution-log
/// row, captured before the request is moved into the executor.
struct ExecLogContext {
app_id: picloud_shared::AppId,
script_id: ScriptId,
request_id: RequestId,
path: String,
headers: std::collections::BTreeMap<String, String>,
body: serde_json::Value,
source: ExecutionSource,
}
impl ExecLogContext {
fn from_request(req: &ExecRequest, source: ExecutionSource) -> Self {
Self {
app_id: req.app_id,
script_id: req.script_id,
request_id: req.request_id,
path: req.path.clone(),
headers: req.headers.clone(),
body: req.body.clone(),
source,
}
}
}
impl Dispatcher { impl Dispatcher {
/// Spawn the dispatcher loop as a detached `tokio::task`. Also /// Spawn the dispatcher loop as a detached `tokio::task`. Also
/// spawns the v1.1.9 queue visibility-timeout reclaim task. Both /// spawns the v1.1.9 queue visibility-timeout reclaim task. Both
@@ -376,6 +417,11 @@ impl Dispatcher {
script_id: consumer.script_id, script_id: consumer.script_id,
updated_at: script.updated_at, updated_at: script.updated_at,
}; };
// G1: queue consumers dispatch outside the outbox, so they need
// their own log write. Source is always `queue`; no `reply_to`
// here, so there's no double-logging concern.
let log_cx = ExecLogContext::from_request(&exec_req, ExecutionSource::Queue);
let started = Utc::now();
let outcome = self let outcome = self
.executor .executor
.execute_with_identity( .execute_with_identity(
@@ -385,8 +431,12 @@ impl Dispatcher {
async_exec_timeout_from_env(), async_exec_timeout_from_env(),
) )
.await; .await;
let finished = Utc::now();
drop(permit); drop(permit);
self.record_execution_log(&log_cx, &outcome, started, finished)
.await;
// Best-effort touch on last_fired_at (a failure here doesn't // Best-effort touch on last_fired_at (a failure here doesn't
// change ack/nack behavior). // change ack/nack behavior).
if let Err(e) = self if let Err(e) = self
@@ -585,18 +635,67 @@ impl Dispatcher {
script_id: resolved.script_id, script_id: resolved.script_id,
updated_at: resolved.script_updated_at, updated_at: resolved.script_updated_at,
}; };
// G1: capture the request context before `exec_req` is consumed so
// we can write an execution-log row for this run (see below).
let log_cx = ExecLogContext::from_request(&exec_req, exec_source(&row));
let started = Utc::now();
let outcome = self let outcome = self
.executor .executor
.execute_with_identity(identity, &source, exec_req, async_exec_timeout_from_env()) .execute_with_identity(identity, &source, exec_req, async_exec_timeout_from_env())
.await; .await;
let finished = Utc::now();
drop(permit); drop(permit);
// G1: persist an execution-log row so this run shows up in
// `pic logs`. Skip rows with a synchronous receiver (`reply_to`
// is set) — those are sync HTTP routes that the orchestrator's
// inbox path already logs, and logging here too would duplicate
// them. Trigger rows and fire-and-forget async HTTP (202) have no
// receiver, so the dispatcher is the only place that can log them.
if row.reply_to.is_none() {
self.record_execution_log(&log_cx, &outcome, started, finished)
.await;
}
match outcome { match outcome {
Ok(resp) => self.handle_success(&row, &resolved, resp).await, Ok(resp) => self.handle_success(&row, &resolved, resp).await,
Err(err) => self.handle_failure(&row, &resolved, err).await, Err(err) => self.handle_failure(&row, &resolved, err).await,
} }
} }
/// G1: best-effort persistence of an execution-log row for a
/// dispatcher-run script. A sink failure is logged but never changes
/// ack/nack/retry behavior — the audit trail is not on the hot path
/// of message-delivery correctness.
async fn record_execution_log(
&self,
cx: &ExecLogContext,
outcome: &Result<ExecResponse, ExecError>,
started: DateTime<Utc>,
finished: DateTime<Utc>,
) {
let log = build_execution_log(
cx.app_id,
cx.script_id,
cx.request_id,
cx.path.clone(),
cx.headers.clone(),
cx.body.clone(),
cx.source,
outcome,
started,
finished,
);
if let Err(e) = self.log_sink.record(log).await {
tracing::warn!(
error = %e,
script_id = %cx.script_id,
source = cx.source.as_str(),
"failed to persist trigger execution log"
);
}
}
async fn resolve_trigger(&self, row: &OutboxRow) -> Result<ResolvedTrigger, DispatcherError> { async fn resolve_trigger(&self, row: &OutboxRow) -> Result<ResolvedTrigger, DispatcherError> {
// For KV and DL kinds, the outbox carries `trigger_id`. Use it // For KV and DL kinds, the outbox carries `trigger_id`. Use it
// to look up the trigger row, then resolve the script. // to look up the trigger row, then resolve the script.

View File

@@ -248,6 +248,15 @@ fn non_empty_env(key: &str) -> Option<String> {
std::env::var(key).ok().filter(|v| !v.trim().is_empty()) std::env::var(key).ok().filter(|v| !v.trim().is_empty())
} }
/// `PICLOUD_DEV_MODE=true` (case-insensitive). Matches the detection in
/// `shared::crypto` so the dev email sink and the dev master key turn on
/// together.
fn dev_mode_enabled() -> bool {
std::env::var("PICLOUD_DEV_MODE")
.map(|v| v.trim().eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
/// Internal transport seam so the service can be tested without a live /// Internal transport seam so the service can be tested without a live
/// SMTP server. The production impl is [`LettreEmailTransport`]; tests /// SMTP server. The production impl is [`LettreEmailTransport`]; tests
/// use a recording fake. /// use a recording fake.
@@ -299,6 +308,91 @@ impl EmailTransport for LettreEmailTransport {
} }
} }
/// G5: how many recently-captured dev emails the in-memory sink keeps.
/// Old entries are evicted FIFO; this is a debugging aid, not storage.
pub const DEV_EMAIL_CAPACITY: usize = 100;
/// One email captured by the dev sink instead of being relayed. Serialized
/// straight onto the dev-only inspection endpoint.
#[derive(Clone, serde::Serialize)]
pub struct CapturedEmail {
pub captured_at: chrono::DateTime<chrono::Utc>,
pub from: Option<String>,
pub to: Vec<String>,
/// The full RFC 5322 message (headers + body), exactly as it would
/// have hit the relay — enough to eyeball subject/body in dev.
pub raw: String,
}
/// In-memory ring buffer of captured dev emails. Shared (`Arc`) between
/// the [`DevEmailTransport`] that writes and the dev endpoint that reads.
pub struct DevEmailSink {
captured: std::sync::Mutex<std::collections::VecDeque<CapturedEmail>>,
capacity: usize,
}
impl DevEmailSink {
#[must_use]
pub fn new(capacity: usize) -> Self {
Self {
captured: std::sync::Mutex::new(std::collections::VecDeque::new()),
capacity: capacity.max(1),
}
}
fn push(&self, email: CapturedEmail) {
let mut q = self.captured.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
while q.len() >= self.capacity {
q.pop_front();
}
q.push_back(email);
}
/// Newest-first snapshot of the captured mail.
#[must_use]
pub fn snapshot(&self) -> Vec<CapturedEmail> {
let q = self.captured.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
q.iter().rev().cloned().collect()
}
}
/// Dev transport: instead of relaying, capture the message in memory and
/// log it. Wired only when `PICLOUD_DEV_MODE=true` and no SMTP relay is
/// configured, so `email::send` is exercisable locally without a relay.
/// NEVER constructed in production (no dev mode → disabled mode instead).
pub struct DevEmailTransport {
sink: Arc<DevEmailSink>,
}
impl DevEmailTransport {
#[must_use]
pub fn new(sink: Arc<DevEmailSink>) -> Self {
Self { sink }
}
}
#[async_trait]
impl EmailTransport for DevEmailTransport {
async fn send(&self, message: &Message) -> Result<(), EmailError> {
let envelope = message.envelope();
let from = envelope.from().map(ToString::to_string);
let to: Vec<String> = envelope.to().iter().map(ToString::to_string).collect();
let raw = String::from_utf8_lossy(&message.formatted()).into_owned();
tracing::info!(
?from,
?to,
"email DEV CAPTURE: message captured in memory (not relayed)"
);
self.sink.push(CapturedEmail {
captured_at: chrono::Utc::now(),
from,
to,
raw,
});
Ok(())
}
}
pub struct EmailServiceImpl { pub struct EmailServiceImpl {
/// `None` → disabled mode (every send returns `NotConfigured`). /// `None` → disabled mode (every send returns `NotConfigured`).
transport: Option<Arc<dyn EmailTransport>>, transport: Option<Arc<dyn EmailTransport>>,
@@ -328,27 +422,58 @@ impl EmailServiceImpl {
/// — email is non-critical and must not block startup. /// — email is non-critical and must not block startup.
#[must_use] #[must_use]
pub fn from_env(authz: Arc<dyn AuthzRepo>) -> Self { pub fn from_env(authz: Arc<dyn AuthzRepo>) -> Self {
Self::from_env_with_dev_capture(authz).0
}
/// Like [`from_env`](Self::from_env), but in **dev mode with no SMTP
/// relay** it wires a [`DevEmailTransport`] that captures mail in
/// memory instead of returning `NotConfigured` — so `email::send` is
/// exercisable locally (G5). Returns the sink handle (`Some`) when
/// capture mode is active, so the caller can expose it via the
/// dev-only inspection endpoint.
///
/// Production is unaffected: without `PICLOUD_DEV_MODE=true` an unset
/// relay still yields disabled mode (`NotConfigured`), never capture.
#[must_use]
pub fn from_env_with_dev_capture(
authz: Arc<dyn AuthzRepo>,
) -> (Self, Option<Arc<DevEmailSink>>) {
let config = EmailConfig::from_env(); let config = EmailConfig::from_env();
let transport: Option<Arc<dyn EmailTransport>> = match SmtpConfig::from_env() { match SmtpConfig::from_env() {
Some(cfg) => {
let transport: Option<Arc<dyn EmailTransport>> = match LettreEmailTransport::build(
&cfg,
) {
Ok(t) => {
tracing::info!(host = %cfg.host, port = cfg.port, "outbound email enabled");
Some(Arc::new(t))
}
Err(e) => {
tracing::error!(error = %e, "failed to build SMTP transport; email DISABLED");
None
}
};
(Self::new(transport, authz, config), None)
}
None if dev_mode_enabled() => {
tracing::warn!(
"email DEV CAPTURE: PICLOUD_DEV_MODE=true and no SMTP relay configured — \
email::send will SUCCEED and capture messages in memory (last {DEV_EMAIL_CAPACITY}, \
readable at GET /api/v1/admin/dev/emails). NEVER use this in production."
);
let sink = Arc::new(DevEmailSink::new(DEV_EMAIL_CAPACITY));
let transport: Arc<dyn EmailTransport> =
Arc::new(DevEmailTransport::new(sink.clone()));
(Self::new(Some(transport), authz, config), Some(sink))
}
None => { None => {
tracing::warn!( tracing::warn!(
"email is DISABLED: set PICLOUD_SMTP_HOST/USER/PASSWORD to enable \ "email is DISABLED: set PICLOUD_SMTP_HOST/USER/PASSWORD to enable \
email::send. Scripts calling email::send will get an error." email::send. Scripts calling email::send will get an error."
); );
None (Self::new(None, authz, config), None)
} }
Some(cfg) => match LettreEmailTransport::build(&cfg) { }
Ok(t) => {
tracing::info!(host = %cfg.host, port = cfg.port, "outbound email enabled");
Some(Arc::new(t))
}
Err(e) => {
tracing::error!(error = %e, "failed to build SMTP transport; email DISABLED");
None
}
},
};
Self::new(transport, authz, config)
} }
async fn check_send(&self, cx: &SdkCallCx) -> Result<(), EmailError> { async fn check_send(&self, cx: &SdkCallCx) -> Result<(), EmailError> {

View File

@@ -0,0 +1,162 @@
//! `/api/v1/admin/apps/{id}/kv*` — read-only KV inspection (G2).
//!
//! Mirrors the minimal `files_api` / `queues_api` admin surface so the
//! `pic kv` CLI (and a future dashboard tab) can browse stored keys
//! without a script. **Read-only by design** — KV writes go through
//! `kv::set` in scripts, which emit change events the trigger framework
//! depends on; an admin write would bypass that and could break app
//! invariants, so it is deliberately out of scope here (matching the
//! read-only queues precedent).
//!
//! Two operations:
//! * `GET /apps/{id}/kv?collection=<c>&cursor=&limit=` — list keys in a
//! collection (cursor-paginated).
//! * `GET /apps/{id}/kv/{collection}/{key}` — fetch one value.
//!
//! Capability: `AppKvRead`, resolved against the app loaded from the
//! path (same tier the SDK read path uses).
use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::response::{IntoResponse, Json, Response};
use axum::routing::get;
use axum::{Extension, Router};
use picloud_shared::{AppId, Principal};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
use crate::kv_repo::KvRepo;
#[derive(Clone)]
pub struct KvAdminState {
pub kv: Arc<dyn KvRepo>,
pub apps: Arc<dyn AppRepository>,
pub authz: Arc<dyn AuthzRepo>,
}
pub fn kv_admin_router(state: KvAdminState) -> Router {
Router::new()
.route("/apps/{app_id}/kv", get(list_keys))
.route("/apps/{app_id}/kv/{collection}/{key}", get(get_value))
.with_state(state)
}
#[derive(Debug, Deserialize)]
pub struct ListKvQuery {
pub collection: String,
#[serde(default)]
pub cursor: Option<String>,
#[serde(default)]
pub limit: Option<u32>,
}
#[derive(Debug, Serialize)]
struct GetValueResponse {
value: serde_json::Value,
}
/// Serialize mirror of `shared::KvListPage` (which is not `Serialize`).
#[derive(Debug, Serialize)]
struct ListKeysResponse {
keys: Vec<String>,
next_cursor: Option<String>,
}
async fn list_keys(
State(s): State<KvAdminState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Query(q): Query<ListKvQuery>,
) -> Result<Json<ListKeysResponse>, KvApiError> {
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require(s.authz.as_ref(), &principal, Capability::AppKvRead(app_id)).await?;
let page =
s.kv.list(
app_id,
&q.collection,
q.cursor.as_deref(),
q.limit.unwrap_or(0),
)
.await
.map_err(|e| KvApiError::Backend(e.to_string()))?;
Ok(Json(ListKeysResponse {
keys: page.keys,
next_cursor: page.next_cursor,
}))
}
async fn get_value(
State(s): State<KvAdminState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, collection, key)): Path<(String, String, String)>,
) -> Result<Json<GetValueResponse>, KvApiError> {
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require(s.authz.as_ref(), &principal, Capability::AppKvRead(app_id)).await?;
let value =
s.kv.get(app_id, &collection, &key)
.await
.map_err(|e| KvApiError::Backend(e.to_string()))?
.ok_or(KvApiError::NotFound)?;
Ok(Json(GetValueResponse { value }))
}
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, KvApiError> {
crate::app_repo::resolve_app(apps, ident)
.await
.map_err(|e| KvApiError::Backend(e.to_string()))?
.map(|l| l.app.id)
.ok_or(KvApiError::AppNotFound)
}
#[derive(Debug, thiserror::Error)]
pub enum KvApiError {
#[error("app not found")]
AppNotFound,
#[error("key not found")]
NotFound,
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("kv backend: {0}")]
Backend(String),
}
impl From<AuthzDenied> for KvApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl IntoResponse for KvApiError {
fn into_response(self) -> Response {
use axum::http::StatusCode;
let (status, body) = match &self {
Self::AppNotFound | Self::NotFound => {
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
}
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "kv admin authz error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::Backend(e) => {
tracing::error!(error = %e, "kv admin backend error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
};
(status, Json(body)).into_response()
}
}

View File

@@ -33,6 +33,7 @@ pub mod cron_scheduler;
pub mod dead_letter_repo; pub mod dead_letter_repo;
pub mod dead_letter_service; pub mod dead_letter_service;
pub mod dead_letters_api; pub mod dead_letters_api;
pub mod dev_email_api;
pub mod dispatcher; pub mod dispatcher;
pub mod docs_filter; pub mod docs_filter;
pub mod docs_repo; pub mod docs_repo;
@@ -46,6 +47,7 @@ pub mod files_sweep;
pub mod gc; pub mod gc;
pub mod http_service; pub mod http_service;
pub mod invoke_service; pub mod invoke_service;
pub mod kv_api;
pub mod kv_repo; pub mod kv_repo;
pub mod kv_service; pub mod kv_service;
pub mod log_sink; pub mod log_sink;
@@ -143,6 +145,7 @@ pub use dead_letter_repo::{
}; };
pub use dead_letter_service::PostgresDeadLetterService; pub use dead_letter_service::PostgresDeadLetterService;
pub use dead_letters_api::{dead_letters_router, DeadLettersApiError, DeadLettersState}; pub use dead_letters_api::{dead_letters_router, DeadLettersApiError, DeadLettersState};
pub use dev_email_api::{dev_emails_router, DevEmailState};
pub use dispatcher::{compute_backoff, Dispatcher, DispatcherError}; pub use dispatcher::{compute_backoff, Dispatcher, DispatcherError};
pub use docs_repo::{DocsRepo, DocsRepoError, PostgresDocsRepo}; pub use docs_repo::{DocsRepo, DocsRepoError, PostgresDocsRepo};
pub use docs_service::DocsServiceImpl; pub use docs_service::DocsServiceImpl;
@@ -150,8 +153,8 @@ pub use email_inbound_api::{
email_inbound_router, EmailInboundError, EmailInboundState, InboundNonceDedup, email_inbound_router, EmailInboundError, EmailInboundState, InboundNonceDedup,
}; };
pub use email_service::{ pub use email_service::{
EmailConfig, EmailServiceImpl, EmailTransport, LettreEmailTransport, SmtpConfig, SmtpTls, CapturedEmail, DevEmailSink, DevEmailTransport, EmailConfig, EmailServiceImpl, EmailTransport,
DEFAULT_EMAIL_MAX_MESSAGE_BYTES, LettreEmailTransport, SmtpConfig, SmtpTls, DEFAULT_EMAIL_MAX_MESSAGE_BYTES,
}; };
pub use files_api::{files_admin_router, FilesAdminState}; pub use files_api::{files_admin_router, FilesAdminState};
pub use files_repo::{FilesConfig, FilesRepo, FilesRepoError, FsFilesRepo}; pub use files_repo::{FilesConfig, FilesRepo, FilesRepoError, FsFilesRepo};
@@ -159,6 +162,7 @@ pub use files_service::FilesServiceImpl;
pub use files_sweep::{spawn_files_orphan_sweep, sweep_orphan_tmp_files, SweepStats}; pub use files_sweep::{spawn_files_orphan_sweep, sweep_orphan_tmp_files, SweepStats};
pub use gc::{spawn_abandoned_gc, spawn_app_user_token_gc, spawn_dead_letter_gc}; pub use gc::{spawn_abandoned_gc, spawn_app_user_token_gc, spawn_dead_letter_gc};
pub use http_service::{HttpConfig, HttpServiceImpl}; pub use http_service::{HttpConfig, HttpServiceImpl};
pub use kv_api::{kv_admin_router, KvAdminState};
pub use kv_repo::{KvRepo, KvRepoError, PostgresKvRepo}; pub use kv_repo::{KvRepo, KvRepoError, PostgresKvRepo};
pub use kv_service::KvServiceImpl; pub use kv_service::KvServiceImpl;
pub use log_sink::PostgresExecutionLogSink; pub use log_sink::PostgresExecutionLogSink;

View File

@@ -31,9 +31,9 @@ impl ExecutionLogSink for PostgresExecutionLogSink {
id, app_id, script_id, request_id, \ id, app_id, script_id, request_id, \
request_path, request_headers, request_body, \ request_path, request_headers, request_body, \
response_code, response_body, \ response_code, response_body, \
logs, duration_ms, status, created_at \ logs, duration_ms, status, source, created_at \
) VALUES ( \ ) VALUES ( \
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 \ $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 \
)", )",
) )
.bind(log.id) .bind(log.id)
@@ -48,6 +48,7 @@ impl ExecutionLogSink for PostgresExecutionLogSink {
.bind(&log.script_logs) .bind(&log.script_logs)
.bind(duration_ms) .bind(duration_ms)
.bind(log.status.as_str()) .bind(log.status.as_str())
.bind(log.source.as_str())
.bind(log.created_at) .bind(log.created_at)
.execute(&self.pool) .execute(&self.pool)
.await .await

View File

@@ -3,8 +3,8 @@ use std::collections::BTreeMap;
use async_trait::async_trait; use async_trait::async_trait;
use picloud_orchestrator_core::{ResolverError, ScriptResolver}; use picloud_orchestrator_core::{ResolverError, ScriptResolver};
use picloud_shared::{ use picloud_shared::{
AdminUserId, AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptKind, AdminUserId, AppId, ExecutionLog, ExecutionSource, ExecutionStatus, RequestId, Script,
ScriptSandbox, ScriptId, ScriptKind, ScriptSandbox,
}; };
use sqlx::PgPool; use sqlx::PgPool;
@@ -584,6 +584,7 @@ pub trait ExecutionLogRepository: Send + Sync {
script_id: ScriptId, script_id: ScriptId,
limit: i64, limit: i64,
cursor: Option<ExecutionLogCursor>, cursor: Option<ExecutionLogCursor>,
source: Option<ExecutionSource>,
) -> Result<Vec<ExecutionLog>, ScriptRepositoryError>; ) -> Result<Vec<ExecutionLog>, ScriptRepositoryError>;
} }
@@ -605,23 +606,30 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository {
script_id: ScriptId, script_id: ScriptId,
limit: i64, limit: i64,
cursor: Option<ExecutionLogCursor>, cursor: Option<ExecutionLogCursor>,
source: Option<ExecutionSource>,
) -> Result<Vec<ExecutionLog>, ScriptRepositoryError> { ) -> Result<Vec<ExecutionLog>, ScriptRepositoryError> {
// The optional `source` filter is folded into one bind via
// `$N::text IS NULL OR source = $N` so we don't fan out into four
// query strings. `None` → the predicate is always true (no filter).
let source = source.map(ExecutionSource::as_str);
let rows = match cursor { let rows = match cursor {
Some(c) => { Some(c) => {
sqlx::query_as::<_, ExecutionLogRow>( sqlx::query_as::<_, ExecutionLogRow>(
"SELECT id, app_id, script_id, request_id, \ "SELECT id, app_id, script_id, request_id, \
request_path, request_headers, request_body, \ request_path, request_headers, request_body, \
response_code, response_body, \ response_code, response_body, \
logs, duration_ms, status, created_at \ logs, duration_ms, status, source, created_at \
FROM execution_logs \ FROM execution_logs \
WHERE script_id = $1 \ WHERE script_id = $1 \
AND (created_at, id) < ($2, $3) \ AND (created_at, id) < ($2, $3) \
AND ($4::text IS NULL OR source = $4) \
ORDER BY created_at DESC, id DESC \ ORDER BY created_at DESC, id DESC \
LIMIT $4", LIMIT $5",
) )
.bind(script_id.into_inner()) .bind(script_id.into_inner())
.bind(c.created_at) .bind(c.created_at)
.bind(c.id) .bind(c.id)
.bind(source)
.bind(limit) .bind(limit)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await? .await?
@@ -631,13 +639,15 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository {
"SELECT id, app_id, script_id, request_id, \ "SELECT id, app_id, script_id, request_id, \
request_path, request_headers, request_body, \ request_path, request_headers, request_body, \
response_code, response_body, \ response_code, response_body, \
logs, duration_ms, status, created_at \ logs, duration_ms, status, source, created_at \
FROM execution_logs \ FROM execution_logs \
WHERE script_id = $1 \ WHERE script_id = $1 \
AND ($2::text IS NULL OR source = $2) \
ORDER BY created_at DESC, id DESC \ ORDER BY created_at DESC, id DESC \
LIMIT $2", LIMIT $3",
) )
.bind(script_id.into_inner()) .bind(script_id.into_inner())
.bind(source)
.bind(limit) .bind(limit)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await? .await?
@@ -662,6 +672,7 @@ struct ExecutionLogRow {
logs: serde_json::Value, logs: serde_json::Value,
duration_ms: i32, duration_ms: i32,
status: String, status: String,
source: String,
created_at: chrono::DateTime<chrono::Utc>, created_at: chrono::DateTime<chrono::Utc>,
} }
@@ -675,6 +686,9 @@ impl From<ExecutionLogRow> for ExecutionLog {
"budget_exceeded" => ExecutionStatus::BudgetExceeded, "budget_exceeded" => ExecutionStatus::BudgetExceeded,
_ => ExecutionStatus::Error, _ => ExecutionStatus::Error,
}; };
// Unknown values can't occur (CHECK constraint) but default to
// Http rather than panicking on a forward-compat surprise.
let source = ExecutionSource::from_wire(&r.source).unwrap_or_default();
Self { Self {
id: r.id, id: r.id,
app_id: r.app_id.into(), app_id: r.app_id.into(),
@@ -688,6 +702,7 @@ impl From<ExecutionLogRow> for ExecutionLog {
script_logs: r.logs, script_logs: r.logs,
duration_ms: u64::try_from(r.duration_ms).unwrap_or(0), duration_ms: u64::try_from(r.duration_ms).unwrap_or(0),
status, status,
source,
created_at: r.created_at, created_at: r.created_at,
} }
} }

View File

@@ -373,27 +373,56 @@ async fn refresh_table<RR: RouteRepository, SR: ScriptRepository>(
state: &RouteAdminState<RR, SR>, state: &RouteAdminState<RR, SR>,
) -> Result<(), RouteApiError> { ) -> Result<(), RouteApiError> {
let rows = state.routes.list_all().await?; let rows = state.routes.list_all().await?;
let compiled = compile_routes(&rows)?; let compiled = compile_routes(&rows);
state.table.replace_all(compiled); state.table.replace_all(compiled);
Ok(()) Ok(())
} }
pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::ParseError> { /// Compile stored route rows into the in-memory match table.
///
/// **Lenient by design (H1).** A row that fails to parse is *skipped with
/// a warning*, not propagated as an error. The motivating case: a path
/// that was valid when created but became reserved under a later, stricter
/// validation (e.g. the case-insensitive reserved-prefix check) — but this
/// also covers any other parse failure. A single un-compilable legacy row
/// must never take down the entire data plane: this function runs at
/// startup (where a hard error aborts boot) and on every table rebuild
/// after a route edit (where it would fail an unrelated CRUD op). A skipped
/// route simply doesn't match; the warning tells the operator to delete or
/// fix it (and migration 0044 sweeps the reserved-path offenders on
/// upgrade).
#[must_use]
pub fn compile_routes(rows: &[Route]) -> Vec<CompiledRoute> {
rows.iter() rows.iter()
.map(|r| { .filter_map(|r| match compile_route(r) {
Ok(CompiledRoute { Ok(compiled) => Some(compiled),
route_id: r.id, Err(e) => {
app_id: r.app_id, tracing::warn!(
script_id: r.script_id, route_id = %r.id,
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?, app_id = %r.app_id,
path: pattern::parse_path(r.path_kind, &r.path)?, path = %r.path,
method: r.method.clone(), error = %e,
dispatch_mode: r.dispatch_mode, "skipping un-compilable stored route — it will not match; \
}) delete or fix it"
);
None
}
}) })
.collect() .collect()
} }
fn compile_route(r: &Route) -> Result<CompiledRoute, pattern::ParseError> {
Ok(CompiledRoute {
route_id: r.id,
app_id: r.app_id,
script_id: r.script_id,
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
path: pattern::parse_path(r.path_kind, &r.path)?,
method: r.method.clone(),
dispatch_mode: r.dispatch_mode,
})
}
/// 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.
@@ -577,3 +606,49 @@ impl IntoResponse for RouteApiError {
(status, Json(body)).into_response() (status, Json(body)).into_response()
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use picloud_shared::DispatchMode;
use uuid::Uuid;
fn route_with_path(path: &str) -> Route {
Route {
id: Uuid::new_v4(),
app_id: AppId::from(Uuid::new_v4()),
script_id: ScriptId::from(Uuid::new_v4()),
host_kind: HostKind::Any,
host: String::new(),
host_param_name: None,
path_kind: PathKind::Exact,
path: path.to_string(),
method: None,
dispatch_mode: DispatchMode::default(),
created_at: chrono::Utc::now(),
}
}
#[test]
fn compile_routes_skips_uncompilable_rows_instead_of_failing() {
// H1 regression guard: a stored route whose path is now reserved
// (creatable before the case-insensitive reserved-prefix fix) must
// be skipped, not abort the whole compile — otherwise one legacy
// row bricks startup (`compile_routes` runs in `build_app`).
let good_a = route_with_path("/ok");
let bad = route_with_path("/API/v2/x"); // now reserved, case-insensitive
let good_b = route_with_path("/items");
let rows = vec![good_a.clone(), bad.clone(), good_b.clone()];
let compiled = compile_routes(&rows);
let ids: Vec<Uuid> = compiled.iter().map(|c| c.route_id).collect();
assert_eq!(compiled.len(), 2, "the reserved row must be dropped");
assert!(ids.contains(&good_a.id));
assert!(ids.contains(&good_b.id));
assert!(
!ids.contains(&bad.id),
"a reserved-path route must be skipped, never abort the compile"
);
}
}

View File

@@ -194,6 +194,7 @@ table: execution_logs
status: text NOT NULL status: text NOT NULL
created_at: timestamp with time zone NOT NULL default=now() created_at: timestamp with time zone NOT NULL default=now()
app_id: uuid NOT NULL app_id: uuid NOT NULL
source: text NOT NULL default='http'::text
table: files table: files
app_id: uuid NOT NULL app_id: uuid NOT NULL
@@ -590,6 +591,7 @@ constraints on email_trigger_details:
[PRIMARY KEY] email_trigger_details_pkey: PRIMARY KEY (trigger_id) [PRIMARY KEY] email_trigger_details_pkey: PRIMARY KEY (trigger_id)
constraints on execution_logs: constraints on execution_logs:
[CHECK] execution_logs_source_check: CHECK ((source = ANY (ARRAY['http'::text, 'kv'::text, 'docs'::text, 'dead_letter'::text, 'cron'::text, 'files'::text, 'pubsub'::text, 'email'::text, 'invoke'::text, 'queue'::text])))
[CHECK] execution_logs_status_check: CHECK ((status = ANY (ARRAY['success'::text, 'error'::text, 'timeout'::text, 'budget_exceeded'::text]))) [CHECK] execution_logs_status_check: CHECK ((status = ANY (ARRAY['success'::text, 'error'::text, 'timeout'::text, 'budget_exceeded'::text])))
[FOREIGN KEY] execution_logs_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE [FOREIGN KEY] execution_logs_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE SET NULL [FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE SET NULL
@@ -711,3 +713,5 @@ constraints on triggers:
0040: execution logs keep history 0040: execution logs keep history
0041: dead letters composite idx 0041: dead letters composite idx
0042: secrets envelope version 0042: secrets envelope version
0043: execution logs source
0044: delete reserved path routes

View File

@@ -15,11 +15,13 @@ use axum::{
Extension, Json, Router, Extension, Json, Router,
}; };
use chrono::Utc; use chrono::Utc;
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType}; use picloud_executor_core::{
build_execution_log, ExecError, ExecRequest, ExecResponse, InvocationType,
};
use picloud_shared::{ use picloud_shared::{
AppId, DispatchMode, ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, AppId, DispatchMode, ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionSource,
HttpDispatchPayload, InboxFailureKind, InboxResult, NewHttpOutbox, OutboxWriter, Principal, ExecutionStatus, HttpDispatchPayload, InboxFailureKind, InboxResult, NewHttpOutbox,
RequestId, ScriptId, OutboxWriter, Principal, RequestId, ScriptId,
}; };
use serde_json::Value as Json_; use serde_json::Value as Json_;
use uuid::Uuid; use uuid::Uuid;
@@ -150,6 +152,7 @@ where
request_path, request_path,
request_headers, request_headers,
request_body, request_body,
ExecutionSource::Http,
&outcome, &outcome,
started, started,
finished, finished,
@@ -530,6 +533,7 @@ fn build_inbox_execution_log(
script_logs: Json_::Array(vec![]), script_logs: Json_::Array(vec![]),
duration_ms, duration_ms,
status, status,
source: ExecutionSource::Http,
created_at: started, created_at: started,
} }
} }
@@ -630,67 +634,9 @@ fn exec_response_to_http(resp: ExecResponse) -> Response {
(status, http_headers, Json(resp.body)).into_response() (status, http_headers, Json(resp.body)).into_response()
} }
#[allow(clippy::too_many_arguments)] // `build_execution_log` moved to `picloud_executor_core` so the manager's
fn build_execution_log( // trigger dispatcher can build identically-shaped rows (G1 — trigger
app_id: AppId, // executions are now logged with their `ExecutionSource`).
script_id: ScriptId,
request_id: RequestId,
request_path: String,
request_headers: BTreeMap<String, String>,
request_body: Json_,
outcome: &Result<ExecResponse, ExecError>,
started: chrono::DateTime<Utc>,
finished: chrono::DateTime<Utc>,
) -> ExecutionLog {
let duration_ms = u64::try_from(
finished
.signed_duration_since(started)
.num_milliseconds()
.max(0),
)
.unwrap_or(0);
let (status, response_code, response_body, script_logs) = match outcome {
Ok(resp) => {
let logs = serde_json::to_value(&resp.logs).unwrap_or(Json_::Array(vec![]));
(
ExecutionStatus::Success,
Some(resp.status_code),
Some(resp.body.clone()),
logs,
)
}
Err(e) => {
let status = match e {
ExecError::Timeout(_) => ExecutionStatus::Timeout,
ExecError::OperationBudgetExceeded => ExecutionStatus::BudgetExceeded,
_ => ExecutionStatus::Error,
};
(
status,
None,
Some(serde_json::json!({ "error": e.to_string() })),
Json_::Array(vec![]),
)
}
};
ExecutionLog {
id: Uuid::new_v4(),
app_id,
script_id,
request_id,
request_path,
request_headers,
request_body,
response_code,
response_body,
script_logs,
duration_ms,
status,
created_at: started,
}
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Errors // Errors

View File

@@ -108,8 +108,15 @@ pub fn parse_path(kind: PathKind, raw: &str) -> Result<PathPattern, ParseError>
} }
fn check_reserved(raw: &str) -> Result<(), ParseError> { fn check_reserved(raw: &str) -> Result<(), ParseError> {
// Case-fold before comparing: request-time path matching is case-sensitive,
// but the reserved namespace must be rejected regardless of case so a tenant
// can't publish look-alikes like `/Admin/login` or `/API/v2/x`. Method/host
// matching are already case-insensitive; this keeps the validation guard
// durable even if path matching ever follows.
let lowered = raw.to_ascii_lowercase();
for r in RESERVED_PATH_PREFIXES { for r in RESERVED_PATH_PREFIXES {
if raw == r.trim_end_matches('/') || raw.starts_with(r) { if lowered == r.trim_end_matches('/') || lowered.starts_with(r) {
// Preserve the caller's original case in the error for clarity.
return Err(ParseError::ReservedPath(raw.to_string())); return Err(ParseError::ReservedPath(raw.to_string()));
} }
} }
@@ -456,6 +463,29 @@ mod tests {
} }
} }
#[test]
fn rejects_reserved_paths_case_insensitively() {
// Case variants of every reserved prefix must be rejected: the reserved
// namespace is a security boundary and must not be bypassable by casing.
for raw in [
"/API/v2/foo",
"/Api/v2/foo",
"/aPi/x",
"/Admin/dashboard",
"/ADMIN/x",
"/HEALTHZ",
"/HealthZ",
"/Version",
"/VERSION",
] {
let e = parse_path(PathKind::Exact, raw).unwrap_err();
assert!(
matches!(e, ParseError::ReservedPath(_)),
"expected reserved for {raw:?}, got {e:?}"
);
}
}
#[test] #[test]
fn rejects_missing_leading_slash() { fn rejects_missing_leading_slash() {
let e = parse_path(PathKind::Exact, "greet").unwrap_err(); let e = parse_path(PathKind::Exact, "greet").unwrap_err();

View File

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

View File

@@ -0,0 +1,71 @@
//! `pic files ls | get | rm` — operator-facing files inspection (G2).
//!
//! Wraps the read-only `/api/v1/admin/apps/{id}/files*` surface. There is
//! no `set`/`upload` — blob writes go through scripts (`files::create`);
//! the admin surface is inspect + delete only, matching the dashboard.
use std::io::Write;
use std::path::Path;
use anyhow::{Context, Result};
use crate::client::Client;
use crate::config;
use crate::output::{OutputMode, Table};
pub async fn ls(app: &str, collection: &str, limit: u32, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let page = client.files_list(app, collection, limit).await?;
let mut table = Table::new(["id", "name", "content_type", "size", "updated_at"]);
for f in page.files {
table.row([
f.id,
f.name,
f.content_type,
f.size.to_string(),
f.updated_at,
]);
}
table.print(mode);
if page.next_cursor.is_some() {
let _ = writeln!(
std::io::stderr(),
"(more results available — raise --limit to see them)"
);
}
Ok(())
}
/// Download a file's bytes. With `--out <path>` writes to disk; otherwise
/// streams raw bytes to stdout (pipe to a file or `xxd`).
pub async fn get(app: &str, collection: &str, file_id: &str, out: Option<&Path>) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let bytes = client.files_get_bytes(app, collection, file_id).await?;
match out {
Some(path) => {
std::fs::write(path, &bytes).with_context(|| format!("writing {}", path.display()))?;
let _ = writeln!(
std::io::stderr(),
"Wrote {} bytes to {}",
bytes.len(),
path.display()
);
}
None => {
std::io::stdout()
.write_all(&bytes)
.context("writing bytes to stdout")?;
}
}
Ok(())
}
pub async fn rm(app: &str, collection: &str, file_id: &str) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
client.files_delete(app, collection, file_id).await?;
println!("Deleted file {file_id} from {collection}");
Ok(())
}

View File

@@ -0,0 +1,42 @@
//! `pic kv ls | get` — read-only KV inspection (G2).
//!
//! Wraps the read-only `/api/v1/admin/apps/{id}/kv*` surface. There is no
//! `set`/`rm` on purpose: KV writes go through `kv::set` in scripts (which
//! emit the change events triggers depend on); an admin write would bypass
//! that, so the CLI stays read-only (matching the queues precedent).
use std::io::Write;
use anyhow::Result;
use crate::client::Client;
use crate::config;
use crate::output::{OutputMode, Table};
pub async fn ls(app: &str, collection: &str, limit: u32, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let page = client.kv_list(app, collection, limit).await?;
let mut table = Table::new(["key"]);
for k in page.keys {
table.row([k]);
}
table.print(mode);
if page.next_cursor.is_some() {
let _ = writeln!(
std::io::stderr(),
"(more keys available — raise --limit to see them)"
);
}
Ok(())
}
pub async fn get(app: &str, collection: &str, key: &str) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let value = client.kv_get(app, collection, key).await?;
// Always emit the JSON value (pretty) so it pipes cleanly into jq.
let pretty = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
println!("{pretty}");
Ok(())
}

View File

@@ -12,10 +12,15 @@ use crate::client::Client;
use crate::config; use crate::config;
use crate::output::{OutputMode, Table}; use crate::output::{OutputMode, Table};
pub async fn run(script_id: &str, limit: u32, mode: OutputMode) -> Result<()> { pub async fn run(
script_id: &str,
limit: u32,
source: Option<&str>,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?; let creds = config::resolve()?;
let client = Client::from_creds(&creds)?; let client = Client::from_creds(&creds)?;
let entries = client.logs_list(script_id, limit).await?; let entries = client.logs_list(script_id, limit, source).await?;
match mode { match mode {
OutputMode::Tsv => render_tsv(&entries), OutputMode::Tsv => render_tsv(&entries),
OutputMode::Json => render_json(&entries), OutputMode::Json => render_json(&entries),
@@ -24,11 +29,14 @@ pub async fn run(script_id: &str, limit: u32, mode: OutputMode) -> Result<()> {
} }
fn render_tsv(entries: &[ExecutionLog]) { fn render_tsv(entries: &[ExecutionLog]) {
let mut table = Table::new(["created_at", "status", "summary"]); // `source` is shown so background runs (queue/cron/invoke/…) are
// distinguishable from HTTP at a glance — the whole point of G1.
let mut table = Table::new(["created_at", "source", "status", "summary"]);
for e in entries { for e in entries {
let summary = summarize(&e.response_body, &e.script_logs); let summary = summarize(&e.response_body, &e.script_logs);
table.row([ table.row([
e.created_at.to_rfc3339(), e.created_at.to_rfc3339(),
e.source.as_str().to_string(),
status_label(&e.status).to_string(), status_label(&e.status).to_string(),
truncate(&summary, 120), truncate(&summary, 120),
]); ]);

View File

@@ -0,0 +1,86 @@
//! `pic members ls | add | set | rm` — manage app membership (G2).
//!
//! Wraps `/api/v1/admin/apps/{id}/members*`. All gated on
//! `AppAdmin(app)` server-side; Editors/Viewers get a 403.
use anyhow::Result;
use picloud_shared::AppRole;
use crate::client::Client;
use crate::config;
use crate::output::{KvBlock, OutputMode, Table};
pub async fn ls(app: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let members = client.members_list(app).await?;
let mut table = Table::new(["user_id", "username", "role", "instance_role", "active"]);
for m in members {
table.row([
m.user_id.to_string(),
m.username,
app_role_str(m.role).to_string(),
format!("{:?}", m.instance_role).to_lowercase(),
m.is_active.to_string(),
]);
}
table.print(mode);
Ok(())
}
pub async fn add(app: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let m = client
.members_grant(app, user_id, parse_role(role)?)
.await?;
print_member(&m, mode);
Ok(())
}
pub async fn set(app: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let m = client
.members_set_role(app, user_id, parse_role(role)?)
.await?;
print_member(&m, mode);
Ok(())
}
pub async fn rm(app: &str, user_id: &str) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
client.members_remove(app, user_id).await?;
println!("Removed {user_id} from {app}");
Ok(())
}
fn print_member(m: &crate::client::AppMemberDto, mode: OutputMode) {
let mut block = KvBlock::new();
block
.field("user_id", m.user_id.to_string())
.field("username", m.username.clone())
.field("role", app_role_str(m.role))
.field("created_at", m.created_at.to_rfc3339());
block.print(mode);
}
fn parse_role(s: &str) -> Result<AppRole> {
match s.to_ascii_lowercase().as_str() {
"app_admin" | "admin" => Ok(AppRole::AppAdmin),
"editor" => Ok(AppRole::Editor),
"viewer" => Ok(AppRole::Viewer),
other => Err(anyhow::anyhow!(
"unknown role {other:?} (want app_admin | editor | viewer)"
)),
}
}
fn app_role_str(r: AppRole) -> &'static str {
match r {
AppRole::AppAdmin => "app_admin",
AppRole::Editor => "editor",
AppRole::Viewer => "viewer",
}
}

View File

@@ -3,9 +3,13 @@ pub mod api_keys;
pub mod apps; pub mod apps;
pub mod apps_domains; pub mod apps_domains;
pub mod dead_letters; pub mod dead_letters;
pub mod files;
pub mod kv;
pub mod login; pub mod login;
pub mod logout; pub mod logout;
pub mod logs; pub mod logs;
pub mod members;
pub mod queues;
pub mod routes; pub mod routes;
pub mod scripts; pub mod scripts;
pub mod secrets; pub mod secrets;

View File

@@ -0,0 +1,62 @@
//! `pic queues ls | show` — read-only queue inspection (G2).
//!
//! Wraps the read-only `/api/v1/admin/apps/{id}/queues*` surface (no
//! purge/requeue — that is v1.2). Gated on `AppLogRead` server-side.
use anyhow::Result;
use crate::client::Client;
use crate::config;
use crate::output::{KvBlock, OutputMode, Table};
pub async fn ls(app: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let queues = client.queues_list(app).await?;
let mut table = Table::new(["queue", "total", "pending", "claimed"]);
for q in queues {
table.row([
q.queue_name,
q.total.to_string(),
q.pending.to_string(),
q.claimed.to_string(),
]);
}
table.print(mode);
Ok(())
}
pub async fn show(app: &str, queue_name: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let q = client.queue_get(app, queue_name).await?;
let mut block = KvBlock::new();
block
.field("queue", q.queue_name)
.field("total", q.total.to_string())
.field("pending", q.pending.to_string())
.field("claimed", q.claimed.to_string());
match q.consumer {
Some(c) => {
block
.field("consumer_script", c.script_name)
.field("consumer_script_id", c.script_id.to_string())
.field("consumer_trigger_id", c.trigger_id)
.field(
"visibility_timeout_secs",
c.visibility_timeout_secs.to_string(),
)
.field(
"last_fired_at",
c.last_fired_at
.map(|t| t.to_rfc3339())
.unwrap_or_else(|| "-".to_string()),
);
}
None => {
block.field("consumer", "(none registered)");
}
}
block.print(mode);
Ok(())
}

View File

@@ -8,7 +8,7 @@ use anyhow::{anyhow, Context, Result};
use picloud_shared::AppId; use picloud_shared::AppId;
use serde_json::Value; use serde_json::Value;
use crate::client::{Client, CreateScriptBody}; use crate::client::{Client, CreateScriptBody, ScriptConfig};
use crate::config; use crate::config;
use crate::output::{KvBlock, OutputMode, Table}; use crate::output::{KvBlock, OutputMode, Table};
@@ -62,6 +62,7 @@ pub async fn deploy(
app_ident: &str, app_ident: &str,
name_override: Option<&str>, name_override: Option<&str>,
description: Option<&str>, description: Option<&str>,
cfg: &ScriptConfig,
mode: OutputMode, mode: OutputMode,
) -> Result<()> { ) -> Result<()> {
let creds = config::resolve()?; let creds = config::resolve()?;
@@ -90,7 +91,7 @@ pub async fn deploy(
let existing = client.scripts_list_by_app(app_ident).await?; let existing = client.scripts_list_by_app(app_ident).await?;
let (script, action) = if let Some(s) = existing.into_iter().find(|s| s.name == name) { let (script, action) = if let Some(s) = existing.into_iter().find(|s| s.name == name) {
let updated = client let updated = client
.scripts_update_source(&s.id.to_string(), &source) .scripts_update_source(&s.id.to_string(), &source, cfg)
.await?; .await?;
(updated, "updated") (updated, "updated")
} else { } else {
@@ -99,6 +100,10 @@ pub async fn deploy(
name: &name, name: &name,
description, description,
source: &source, source: &source,
timeout_seconds: cfg.timeout_seconds,
memory_limit_mb: cfg.memory_limit_mb,
kind: cfg.kind,
sandbox: cfg.sandbox,
}; };
(client.scripts_create(&body).await?, "created") (client.scripts_create(&body).await?, "created")
}; };

View File

@@ -124,6 +124,114 @@ pub async fn create_dead_letter(
Ok(()) Ok(())
} }
/// docs/files share KV's `{collection_glob, ops?}` shape.
async fn create_collection_trigger(
kind: &str,
app: &str,
script_id: &str,
collection_glob: &str,
ops: &[String],
dispatch: &str,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let mut body = base_body(script_id, dispatch);
body["collection_glob"] = json!(collection_glob);
if !ops.is_empty() {
body["ops"] = json!(ops);
}
let created = client.triggers_create(app, kind, &body).await?;
print_created(&created, mode);
Ok(())
}
pub async fn create_docs(
app: &str,
script_id: &str,
collection_glob: &str,
ops: &[String],
dispatch: &str,
mode: OutputMode,
) -> Result<()> {
create_collection_trigger("docs", app, script_id, collection_glob, ops, dispatch, mode).await
}
pub async fn create_files(
app: &str,
script_id: &str,
collection_glob: &str,
ops: &[String],
dispatch: &str,
mode: OutputMode,
) -> Result<()> {
create_collection_trigger(
"files",
app,
script_id,
collection_glob,
ops,
dispatch,
mode,
)
.await
}
pub async fn create_pubsub(
app: &str,
script_id: &str,
topic_pattern: &str,
dispatch: &str,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let mut body = base_body(script_id, dispatch);
body["topic_pattern"] = json!(topic_pattern);
let created = client.triggers_create(app, "pubsub", &body).await?;
print_created(&created, mode);
Ok(())
}
pub async fn create_queue(
app: &str,
script_id: &str,
queue_name: &str,
visibility_timeout_secs: Option<u32>,
dispatch: &str,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let mut body = base_body(script_id, dispatch);
body["queue_name"] = json!(queue_name);
if let Some(v) = visibility_timeout_secs {
body["visibility_timeout_secs"] = json!(v);
}
let created = client.triggers_create(app, "queue", &body).await?;
print_created(&created, mode);
Ok(())
}
pub async fn create_email(
app: &str,
script_id: &str,
inbound_secret: Option<&str>,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
// Email triggers take no dispatch_mode (inbound webhook only) and an
// optional shared HMAC secret the provider signs POSTs with.
let mut body = json!({ "script_id": script_id });
if let Some(secret) = inbound_secret {
body["inbound_secret"] = json!(secret);
}
let created = client.triggers_create(app, "email", &body).await?;
print_created(&created, mode);
Ok(())
}
pub async fn create_from_json(app: &str, kind: &str, body: &str, mode: OutputMode) -> Result<()> { pub async fn create_from_json(app: &str, kind: &str, body: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?; let creds = config::resolve()?;
let client = Client::from_creds(&creds)?; let client = Client::from_creds(&creds)?;

View File

@@ -128,6 +128,138 @@ enum Cmd {
#[command(subcommand)] #[command(subcommand)]
cmd: SecretsCmd, cmd: SecretsCmd,
}, },
/// App membership — list members and grant / change / revoke their
/// per-app role (app_admin | editor | viewer).
Members {
#[command(subcommand)]
cmd: MembersCmd,
},
/// Files inspection — list a collection's blobs, download bytes, or
/// delete a file. Read + delete only; writes go through scripts.
Files {
#[command(subcommand)]
cmd: FilesCmd,
},
/// Queue inspection — list queues with depth counts, or drill into
/// one queue's stats + registered consumer. Read-only.
Queues {
#[command(subcommand)]
cmd: QueuesCmd,
},
/// KV inspection — list keys in a collection or fetch one value.
/// Read-only; writes go through `kv::set` in scripts.
Kv {
#[command(subcommand)]
cmd: KvCmd,
},
}
#[derive(Subcommand)]
enum KvCmd {
/// List keys in a collection.
Ls {
#[arg(long)]
app: String,
#[arg(long)]
collection: String,
#[arg(long, default_value_t = 100)]
limit: u32,
},
/// Fetch one key's value (printed as JSON).
Get {
#[arg(long)]
app: String,
#[arg(long)]
collection: String,
key: String,
},
}
#[derive(Subcommand)]
enum MembersCmd {
/// List app members.
Ls {
#[arg(long)]
app: String,
},
/// Grant a user a role on the app.
Add {
#[arg(long)]
app: String,
#[arg(long = "user")]
user_id: String,
/// `app_admin` | `editor` | `viewer`.
#[arg(long)]
role: String,
},
/// Change an existing member's role.
Set {
#[arg(long)]
app: String,
#[arg(long = "user")]
user_id: String,
#[arg(long)]
role: String,
},
/// Remove a member.
Rm {
#[arg(long)]
app: String,
#[arg(long = "user")]
user_id: String,
},
}
#[derive(Subcommand)]
enum FilesCmd {
/// List files in a collection.
Ls {
#[arg(long)]
app: String,
#[arg(long)]
collection: String,
#[arg(long, default_value_t = 100)]
limit: u32,
},
/// Download a file's bytes (to `--out <path>` or stdout).
Get {
#[arg(long)]
app: String,
#[arg(long)]
collection: String,
#[arg(long = "id")]
file_id: String,
#[arg(long)]
out: Option<PathBuf>,
},
/// Delete a file.
Rm {
#[arg(long)]
app: String,
#[arg(long)]
collection: String,
#[arg(long = "id")]
file_id: String,
},
}
#[derive(Subcommand)]
enum QueuesCmd {
/// List queues with depth counts.
Ls {
#[arg(long)]
app: String,
},
/// Show one queue's stats + consumer.
Show {
#[arg(long)]
app: String,
queue_name: String,
},
} }
#[derive(Args)] #[derive(Args)]
@@ -234,6 +366,82 @@ struct DeployArgs {
name: Option<String>, name: Option<String>,
#[arg(long)] #[arg(long)]
description: Option<String>, description: Option<String>,
/// Per-script wall-clock timeout in seconds (overrides the instance
/// default; still clamped by `PICLOUD_SANDBOX_MAX_*` ceilings).
#[arg(long)]
timeout: Option<i32>,
/// Per-script memory ceiling in MB.
#[arg(long)]
memory: Option<i32>,
/// Script kind: `endpoint` (default) or `module` (importable, no route).
#[arg(long, value_enum)]
kind: Option<ScriptKindArg>,
/// Sandbox override as `key=value`, repeatable. Keys: `max_operations`,
/// `max_string_size`, `max_array_size`, `max_map_size`,
/// `max_call_levels`, `max_expr_depth`. E.g. `--sandbox max_operations=500000`.
#[arg(long = "sandbox", value_name = "KEY=VALUE")]
sandbox: Vec<String>,
}
#[derive(Copy, Clone, clap::ValueEnum)]
enum ScriptKindArg {
Endpoint,
Module,
}
impl From<ScriptKindArg> for picloud_shared::ScriptKind {
fn from(v: ScriptKindArg) -> Self {
match v {
ScriptKindArg::Endpoint => Self::Endpoint,
ScriptKindArg::Module => Self::Module,
}
}
}
impl DeployArgs {
/// Fold the runtime-config flags into a `ScriptConfig`, parsing and
/// validating the `--sandbox key=value` pairs.
fn script_config(&self) -> anyhow::Result<client::ScriptConfig> {
let sandbox = parse_sandbox_overrides(&self.sandbox)?;
Ok(client::ScriptConfig {
timeout_seconds: self.timeout,
memory_limit_mb: self.memory,
kind: self.kind.map(Into::into),
sandbox,
})
}
}
/// Parse repeatable `--sandbox key=value` flags into a `ScriptSandbox`.
/// Unknown keys and non-integer values are hard errors so a typo doesn't
/// silently deploy an unrestricted script.
fn parse_sandbox_overrides(
pairs: &[String],
) -> anyhow::Result<Option<picloud_shared::ScriptSandbox>> {
use anyhow::{anyhow, Context};
if pairs.is_empty() {
return Ok(None);
}
let mut sb = picloud_shared::ScriptSandbox::default();
for raw in pairs {
let (key, val) = raw
.split_once('=')
.ok_or_else(|| anyhow!("--sandbox expects key=value, got {raw:?}"))?;
let n: u64 = val
.trim()
.parse()
.with_context(|| format!("--sandbox {key}: {val:?} is not a non-negative integer"))?;
match key.trim() {
"max_operations" => sb.max_operations = Some(n),
"max_string_size" => sb.max_string_size = Some(n),
"max_array_size" => sb.max_array_size = Some(n),
"max_map_size" => sb.max_map_size = Some(n),
"max_call_levels" => sb.max_call_levels = Some(n),
"max_expr_depth" => sb.max_expr_depth = Some(n),
other => return Err(anyhow!("unknown --sandbox key: {other:?}")),
}
}
Ok(Some(sb))
} }
#[derive(Args)] #[derive(Args)]
@@ -275,6 +483,11 @@ struct LogsArgs {
script_id: String, script_id: String,
#[arg(long, default_value_t = 50)] #[arg(long, default_value_t = 50)]
limit: u32, limit: u32,
/// Filter by execution origin: `http`, `kv`, `cron`, `queue`,
/// `invoke`, `dead_letter`, `docs`, `files`, `pubsub`, `email`.
/// Omit (or `all`) to show every source.
#[arg(long)]
source: Option<String>,
} }
#[derive(Clone, Copy, ValueEnum)] #[derive(Clone, Copy, ValueEnum)]
@@ -498,10 +711,89 @@ enum TriggersCmd {
dispatch: DispatchModeArg, dispatch: DispatchModeArg,
}, },
/// Create a docs trigger — fires on document mutations in matching
/// collections.
#[command(name = "create-docs")]
CreateDocs {
#[arg(long)]
app: String,
#[arg(long)]
script: String,
/// Glob over collection names (`*`, `posts`, `events_*`, …).
#[arg(long)]
collection: String,
/// Repeat to filter ops: `--op insert --op delete`. Empty fires on any.
#[arg(long = "op")]
ops: Vec<String>,
#[arg(long, value_enum, default_value_t = DispatchModeArg::Async)]
dispatch: DispatchModeArg,
},
/// Create a files trigger — fires on blob create/update/delete in
/// matching collections.
#[command(name = "create-files")]
CreateFiles {
#[arg(long)]
app: String,
#[arg(long)]
script: String,
#[arg(long)]
collection: String,
#[arg(long = "op")]
ops: Vec<String>,
#[arg(long, value_enum, default_value_t = DispatchModeArg::Async)]
dispatch: DispatchModeArg,
},
/// Create a pub/sub trigger — fires on messages published to topics
/// matching the pattern.
#[command(name = "create-pubsub")]
CreatePubsub {
#[arg(long)]
app: String,
#[arg(long)]
script: String,
/// Topic glob (`*`, `orders.*`, `user.signup`, …).
#[arg(long = "topic")]
topic_pattern: String,
#[arg(long, value_enum, default_value_t = DispatchModeArg::Async)]
dispatch: DispatchModeArg,
},
/// Create a queue consumer trigger — fires per message claimed off
/// the named queue.
#[command(name = "create-queue")]
CreateQueue {
#[arg(long)]
app: String,
#[arg(long)]
script: String,
#[arg(long = "queue")]
queue_name: String,
/// Per-message visibility timeout in seconds (claim lease).
#[arg(long = "visibility-timeout")]
visibility_timeout_secs: Option<u32>,
#[arg(long, value_enum, default_value_t = DispatchModeArg::Async)]
dispatch: DispatchModeArg,
},
/// Create an email trigger — fires on inbound mail POSTed to the
/// webhook receiver. No dispatch mode (inbound webhook only).
#[command(name = "create-email")]
CreateEmail {
#[arg(long)]
app: String,
#[arg(long)]
script: String,
/// Shared HMAC secret the provider signs inbound POSTs with.
/// Omit to accept unsigned POSTs.
#[arg(long = "inbound-secret")]
inbound_secret: Option<String>,
},
/// Create a trigger of any kind from a raw JSON body — escape /// Create a trigger of any kind from a raw JSON body — escape
/// hatch for kinds the CLI doesn't expose per-kind wrappers for /// hatch for advanced retry/dispatch settings beyond the per-kind
/// (docs/files/pubsub/email/queue) and for advanced retry/dispatch /// wrappers' defaults.
/// settings beyond the per-kind defaults.
#[command(name = "create-from-json")] #[command(name = "create-from-json")]
CreateFromJson { CreateFromJson {
#[arg(long)] #[arg(long)]
@@ -790,16 +1082,20 @@ async fn main() -> ExitCode {
} => cmds::scripts::ls(app.as_deref(), mode).await, } => cmds::scripts::ls(app.as_deref(), mode).await,
Cmd::Scripts { Cmd::Scripts {
cmd: ScriptsCmd::Deploy(args), cmd: ScriptsCmd::Deploy(args),
} => { } => match args.script_config() {
cmds::scripts::deploy( Ok(cfg) => {
&args.file, cmds::scripts::deploy(
&args.app, &args.file,
args.name.as_deref(), &args.app,
args.description.as_deref(), args.name.as_deref(),
mode, args.description.as_deref(),
) &cfg,
.await mode,
} )
.await
}
Err(e) => Err(e),
},
Cmd::Scripts { Cmd::Scripts {
cmd: ScriptsCmd::Invoke(args), cmd: ScriptsCmd::Invoke(args),
} => cmds::scripts::invoke(&args.id, args.body.as_deref(), &args.headers).await, } => cmds::scripts::invoke(&args.id, args.body.as_deref(), &args.headers).await,
@@ -821,20 +1117,28 @@ async fn main() -> ExitCode {
Cmd::ApiKeys { Cmd::ApiKeys {
cmd: ApiKeysCmd::Rm { id }, cmd: ApiKeysCmd::Rm { id },
} => cmds::api_keys::rm(&id).await, } => cmds::api_keys::rm(&id).await,
Cmd::Logs(LogsArgs { script_id, limit }) => cmds::logs::run(&script_id, limit, mode).await, Cmd::Logs(LogsArgs {
script_id,
limit,
source,
}) => cmds::logs::run(&script_id, limit, source.as_deref(), mode).await,
Cmd::Invoke(args) => { Cmd::Invoke(args) => {
cmds::scripts::invoke(&args.id, args.body.as_deref(), &args.headers).await cmds::scripts::invoke(&args.id, args.body.as_deref(), &args.headers).await
} }
Cmd::Deploy(args) => { Cmd::Deploy(args) => match args.script_config() {
cmds::scripts::deploy( Ok(cfg) => {
&args.file, cmds::scripts::deploy(
&args.app, &args.file,
args.name.as_deref(), &args.app,
args.description.as_deref(), args.name.as_deref(),
mode, args.description.as_deref(),
) &cfg,
.await mode,
} )
.await
}
Err(e) => Err(e),
},
Cmd::Routes { Cmd::Routes {
cmd: RoutesCmd::Ls { script_id }, cmd: RoutesCmd::Ls { script_id },
} => cmds::routes::ls(&script_id, mode).await, } => cmds::routes::ls(&script_id, mode).await,
@@ -1004,6 +1308,92 @@ async fn main() -> ExitCode {
) )
.await .await
} }
Cmd::Triggers {
cmd:
TriggersCmd::CreateDocs {
app,
script,
collection,
ops,
dispatch,
},
} => {
cmds::triggers::create_docs(
&app,
&script,
&collection,
&ops,
dispatch_wire(dispatch),
mode,
)
.await
}
Cmd::Triggers {
cmd:
TriggersCmd::CreateFiles {
app,
script,
collection,
ops,
dispatch,
},
} => {
cmds::triggers::create_files(
&app,
&script,
&collection,
&ops,
dispatch_wire(dispatch),
mode,
)
.await
}
Cmd::Triggers {
cmd:
TriggersCmd::CreatePubsub {
app,
script,
topic_pattern,
dispatch,
},
} => {
cmds::triggers::create_pubsub(
&app,
&script,
&topic_pattern,
dispatch_wire(dispatch),
mode,
)
.await
}
Cmd::Triggers {
cmd:
TriggersCmd::CreateQueue {
app,
script,
queue_name,
visibility_timeout_secs,
dispatch,
},
} => {
cmds::triggers::create_queue(
&app,
&script,
&queue_name,
visibility_timeout_secs,
dispatch_wire(dispatch),
mode,
)
.await
}
Cmd::Triggers {
cmd:
TriggersCmd::CreateEmail {
app,
script,
inbound_secret,
},
} => cmds::triggers::create_email(&app, &script, inbound_secret.as_deref(), mode).await,
Cmd::Triggers { Cmd::Triggers {
cmd: TriggersCmd::CreateFromJson { app, kind, body }, cmd: TriggersCmd::CreateFromJson { app, kind, body },
} => cmds::triggers::create_from_json(&app, &kind, &body, mode).await, } => cmds::triggers::create_from_json(&app, &kind, &body, mode).await,
@@ -1074,6 +1464,65 @@ async fn main() -> ExitCode {
Cmd::Secrets { Cmd::Secrets {
cmd: SecretsCmd::Rm { app, name }, cmd: SecretsCmd::Rm { app, name },
} => cmds::secrets::rm(&app, &name).await, } => cmds::secrets::rm(&app, &name).await,
Cmd::Members {
cmd: MembersCmd::Ls { app },
} => cmds::members::ls(&app, mode).await,
Cmd::Members {
cmd: MembersCmd::Add { app, user_id, role },
} => cmds::members::add(&app, &user_id, &role, mode).await,
Cmd::Members {
cmd: MembersCmd::Set { app, user_id, role },
} => cmds::members::set(&app, &user_id, &role, mode).await,
Cmd::Members {
cmd: MembersCmd::Rm { app, user_id },
} => cmds::members::rm(&app, &user_id).await,
Cmd::Files {
cmd:
FilesCmd::Ls {
app,
collection,
limit,
},
} => cmds::files::ls(&app, &collection, limit, mode).await,
Cmd::Files {
cmd:
FilesCmd::Get {
app,
collection,
file_id,
out,
},
} => cmds::files::get(&app, &collection, &file_id, out.as_deref()).await,
Cmd::Files {
cmd:
FilesCmd::Rm {
app,
collection,
file_id,
},
} => cmds::files::rm(&app, &collection, &file_id).await,
Cmd::Queues {
cmd: QueuesCmd::Ls { app },
} => cmds::queues::ls(&app, mode).await,
Cmd::Queues {
cmd: QueuesCmd::Show { app, queue_name },
} => cmds::queues::show(&app, &queue_name, mode).await,
Cmd::Kv {
cmd:
KvCmd::Ls {
app,
collection,
limit,
},
} => cmds::kv::ls(&app, &collection, limit, mode).await,
Cmd::Kv {
cmd:
KvCmd::Get {
app,
collection,
key,
},
} => cmds::kv::get(&app, &collection, &key).await,
}; };
match result { match result {

View File

@@ -12,26 +12,26 @@ use picloud_executor_core::{Engine, Limits};
use picloud_manager_core::{ use picloud_manager_core::{
admin_router, admins_router, api_keys_router, app_members_router, apps_api, apps_router, admin_router, admins_router, api_keys_router, app_members_router, apps_api, apps_router,
attach_principal_if_present, auth_router, compile_routes, dead_letters_router, attach_principal_if_present, auth_router, compile_routes, dead_letters_router,
email_inbound_router, files_admin_router, migrations, require_authenticated, dev_emails_router, email_inbound_router, files_admin_router, kv_admin_router, migrations,
route_admin_router, secrets_router, topics_router, triggers_router, AbandonedRepo, require_authenticated, route_admin_router, secrets_router, topics_router, triggers_router,
AdminPrincipalResolver, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState, AdminUserRepository,
ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState, AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository,
AppRepository, AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, Dispatcher, AppMembersState, AppRepository, AppsState, AuthState, AuthzRepo, DeadLetterRepo,
DocsServiceImpl, EmailInboundState, EmailServiceImpl, FilesAdminState, FilesConfig, DeadLettersState, DevEmailState, Dispatcher, DocsServiceImpl, EmailInboundState,
FilesServiceImpl, FsFilesRepo, HttpConfig, HttpServiceImpl, InboundNonceDedup, KvServiceImpl, EmailServiceImpl, FilesAdminState, FilesConfig, FilesServiceImpl, FsFilesRepo, HttpConfig,
OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter,
PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository, OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
PostgresAppMembersRepository, PostgresAppRepository, PostgresAppSecretsRepo, PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
PostgresAppUserInvitationRepo, PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo,
PostgresAppUserRoleRepo, PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, PostgresAppUserRoleRepo,
PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo, PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresDeadLetterRepo,
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo, PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository,
PostgresPubsubRepo, PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo,
PostgresTopicRepo, PostgresTriggerRepo, PrincipalResolver, PubsubServiceImpl, PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo,
RealtimeAuthorityImpl, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling, PostgresTriggerRepo, PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver,
ScriptRepository, SecretsConfig, SecretsServiceImpl, SecretsState, SubscriberTokenConfig, RouteAdminState, RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig,
TopicRepo, TopicsState, TriggerConfig, TriggerRepo, TriggersState, UsersServiceConfig, SecretsServiceImpl, SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig,
UsersServiceImpl, TriggerRepo, TriggersState, UsersServiceConfig, UsersServiceImpl,
}; };
use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS; use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS;
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable}; use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
@@ -146,7 +146,7 @@ pub async fn build_app(
outbox_repo.clone(), outbox_repo.clone(),
)); ));
let kv: Arc<dyn KvService> = Arc::new(KvServiceImpl::with_max_value_bytes( let kv: Arc<dyn KvService> = Arc::new(KvServiceImpl::with_max_value_bytes(
kv_repo, kv_repo.clone(),
authz.clone(), authz.clone(),
events.clone(), events.clone(),
picloud_manager_core::kv_service::kv_max_value_bytes_from_env(), picloud_manager_core::kv_service::kv_max_value_bytes_from_env(),
@@ -239,8 +239,12 @@ pub async fn build_app(
secrets_config, secrets_config,
)); ));
// v1.1.7 outbound email. Builds a lettre SMTP transport from // v1.1.7 outbound email. Builds a lettre SMTP transport from
// PICLOUD_SMTP_* env (disabled mode + warning if unconfigured). // PICLOUD_SMTP_* env (disabled mode + warning if unconfigured). G5: in
let email: Arc<dyn EmailService> = Arc::new(EmailServiceImpl::from_env(authz.clone())); // dev mode with no relay, captures mail in memory instead of erroring;
// `dev_email_sink` is `Some` then, and we mount the dev inspection
// endpoint below.
let (email_impl, dev_email_sink) = EmailServiceImpl::from_env_with_dev_capture(authz.clone());
let email: Arc<dyn EmailService> = Arc::new(email_impl);
// v1.1.8 data-plane user management. Wires Argon2id-hashed user // v1.1.8 data-plane user management. Wires Argon2id-hashed user
// rows + SHA-256-hashed sliding-window sessions to the Rhai // rows + SHA-256-hashed sliding-window sessions to the Rhai
// `users::*` namespace and the admin /apps/{id}/users HTTP surface. // `users::*` namespace and the admin /apps/{id}/users HTTP surface.
@@ -290,8 +294,10 @@ pub async fn build_app(
// routes. // routes.
let route_table = Arc::new(RouteTable::new()); let route_table = Arc::new(RouteTable::new());
let initial = route_repo.list_all().await?; let initial = route_repo.list_all().await?;
let compiled = compile_routes(&initial) // Lenient: a single un-compilable stored route (e.g. one whose path
.map_err(|e| anyhow::anyhow!("failed to compile stored routes: {e}"))?; // became reserved under stricter validation) is skipped-with-warning
// inside compile_routes, never aborting boot. (H1)
let compiled = compile_routes(&initial);
route_table.replace_all(compiled); route_table.replace_all(compiled);
// v1.1.9 function composition. InvokeServiceImpl resolves targets // v1.1.9 function composition. InvokeServiceImpl resolves targets
@@ -380,6 +386,7 @@ pub async fn build_app(
principals, principals,
executor: executor.clone(), executor: executor.clone(),
gate, gate,
log_sink: log_sink.clone(),
inbox: inbox_resolver, inbox: inbox_resolver,
queue: queue_repo.clone(), queue: queue_repo.clone(),
config: trigger_config, config: trigger_config,
@@ -544,7 +551,7 @@ pub async fn build_app(
// else under /admin gets the require_authenticated layer; capability // else under /admin gets the require_authenticated layer; capability
// checks live in each handler (after the resource is loaded so the // checks live in each handler (after the resource is loaded so the
// capability binds to the resource's actual app_id). // capability binds to the resource's actual app_id).
let guarded_admin = Router::new() let mut guarded_admin = Router::new()
.merge(admin_router(admin)) .merge(admin_router(admin))
.merge(route_admin_router(route_admin)) .merge(route_admin_router(route_admin))
.merge(admins_router(admins_state)) .merge(admins_router(admins_state))
@@ -565,13 +572,24 @@ pub async fn build_app(
}, },
)) ))
.merge(files_admin_router(files_admin_state)) .merge(files_admin_router(files_admin_state))
.merge(kv_admin_router(KvAdminState {
kv: kv_repo.clone(),
apps: apps_repo.clone(),
authz: authz.clone(),
}))
.merge(topics_router(topics_state)) .merge(topics_router(topics_state))
.merge(secrets_router(secrets_state)) .merge(secrets_router(secrets_state))
.merge(dead_letters_router(dead_letters_state)) .merge(dead_letters_router(dead_letters_state));
.layer(from_fn_with_state( // G5: dev-only mail inspection — mounted exactly when the email
auth_state.clone(), // service is capturing in memory (dev mode + no relay). Same
require_authenticated, // `require_authenticated` layer as the rest of /admin applies below.
)); if let Some(sink) = dev_email_sink {
guarded_admin = guarded_admin.merge(dev_emails_router(DevEmailState { sink }));
}
let guarded_admin = guarded_admin.layer(from_fn_with_state(
auth_state.clone(),
require_authenticated,
));
// Silence "unused import" lint on `apps_api` — we re-export via the // Silence "unused import" lint on `apps_api` — we re-export via the
// facade above; the bare module path is retained so it's discoverable. // facade above; the bare module path is retained so it's discoverable.

View File

@@ -30,9 +30,77 @@ pub struct ExecutionLog {
pub duration_ms: u64, pub duration_ms: u64,
pub status: ExecutionStatus, pub status: ExecutionStatus,
/// What dispatched this execution: `http` for direct data-plane
/// ingress, or one of the trigger kinds (`kv`, `cron`, `queue`,
/// `invoke`, …) for background runs. Materialized so `pic logs`
/// can surface — and filter by — the origin of every run, not just
/// the HTTP ones. Defaults to `http` for rows written before the
/// column existed (migration 0043).
#[serde(default)]
pub source: ExecutionSource,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
/// Origin of an execution. Wire strings mirror
/// `manager-core::OutboxSourceKind` (plus `Queue`, which the queue
/// consumer dispatches outside the outbox) so a trigger's source kind
/// maps straight through to its execution-log row. Keep the variants and
/// the `source` CHECK constraint in migration 0043 in sync.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionSource {
#[default]
Http,
Kv,
Docs,
DeadLetter,
Cron,
Files,
Pubsub,
Email,
Invoke,
Queue,
}
impl ExecutionSource {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Http => "http",
Self::Kv => "kv",
Self::Docs => "docs",
Self::DeadLetter => "dead_letter",
Self::Cron => "cron",
Self::Files => "files",
Self::Pubsub => "pubsub",
Self::Email => "email",
Self::Invoke => "invoke",
Self::Queue => "queue",
}
}
/// Parse a wire string back into a variant. Returns `None` for an
/// unknown value (callers treat that as "no filter" or a 422).
#[must_use]
pub fn from_wire(s: &str) -> Option<Self> {
match s {
"http" => Some(Self::Http),
"kv" => Some(Self::Kv),
"docs" => Some(Self::Docs),
"dead_letter" => Some(Self::DeadLetter),
"cron" => Some(Self::Cron),
"files" => Some(Self::Files),
"pubsub" => Some(Self::Pubsub),
"email" => Some(Self::Email),
"invoke" => Some(Self::Invoke),
"queue" => Some(Self::Queue),
_ => None,
}
}
}
/// Matches the CHECK constraint on `execution_logs.status`. Keep the /// Matches the CHECK constraint on `execution_logs.status`. Keep the
/// serde rename in sync with the migration. /// serde rename in sync with the migration.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]

View File

@@ -48,7 +48,7 @@ pub use email::{EmailError, EmailService, NoopEmailService, OutboundEmail};
pub use error::Error; pub use error::Error;
pub use events::{EmitError, NoopEventEmitter, ServiceEvent, ServiceEventEmitter}; pub use events::{EmitError, NoopEventEmitter, ServiceEvent, ServiceEventEmitter};
pub use exec_summary::ExecResponseSummary; pub use exec_summary::ExecResponseSummary;
pub use execution_log::{ExecutionLog, ExecutionStatus}; pub use execution_log::{ExecutionLog, ExecutionSource, ExecutionStatus};
pub use files::{ pub use files::{
sanitize_stored_content_type, validate_collection as validate_files_collection, FileMeta, sanitize_stored_content_type, validate_collection as validate_files_collection, FileMeta,
FileUpdate, FilesError, FilesListPage, FilesService, NewFile, NoopFilesService, FileUpdate, FilesError, FilesListPage, FilesService, NewFile, NoopFilesService,

2
docs/dev-guide/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
# mdBook build output — generated by `mdbook build`, not source.
book/

23
docs/dev-guide/book.toml Normal file
View 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

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

View 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).

View 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.

View 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).

View 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.

View 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).

View 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.

View 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.

View 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.

View 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.

View 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).

View 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.

View 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.

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

View 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.

View 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.

View 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.

View 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).

View 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.

View 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`).

View 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).

View 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.

View 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>`.

View 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>`.

View 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`.

View 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.

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

View 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.

View 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`.

View 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.

View 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).

View 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, // 120, default 3
backoff: "exponential", // "exponential" | "linear" | "constant", default exponential
base_ms: 500, // 160000, default 500
jitter_pct: 20 // 0100, 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.

View 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.

View 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).

View 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.

View 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).

View 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.

View 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).

View 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
```

View 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.

View 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).

View File

@@ -122,6 +122,18 @@ unauthenticated by default — public HTTP scripts run with `None`.
Services that need an authenticated identity (e.g., `users::*`) check Services that need an authenticated identity (e.g., `users::*`) check
`cx.principal.is_some()` and throw if missing. `cx.principal.is_some()` and throw if missing.
> **A public route ≠ public *data*.** When a route is unauthenticated,
> the script runs with `principal: None`, and the capability gate
> (`authz::script_gate`) returns `Ok` immediately — the script holds
> **full app authority**. It can read and write *all* of the app's
> `kv`, `docs`, `files`, `secrets`, `queue`, and `pubsub`, because the
> platform only gates *authenticated* principals; for anonymous ingress
> **the script itself is the only access boundary**. If a public route
> must not expose every secret or every row in the app, the script must
> enforce that — check a token, scope the collection, gate the
> operation. "This route is public" does not mean "this code may only
> touch public data." See blueprint §11.6 (principal model).
## Sync ↔ async bridge ## Sync ↔ async bridge
Rhai is synchronous; service trait methods (KV writes, HTTP calls) are Rhai is synchronous; service trait methods (KV writes, HTTP calls) are

View File

@@ -37,16 +37,33 @@ These come with the Rhai engine itself. See the
`index_of`, `split`, `trim`, `to_lower`, `to_upper`, `replace`, `chars`, `index_of`, `split`, `trim`, `to_lower`, `to_upper`, `replace`, `chars`,
`pad`, `sub_string`, `crop`, `+` (concatenation). `pad`, `sub_string`, `crop`, `+` (concatenation).
> **Footgun — `replace` mutates in place and returns `()`.** Rhai's > **Footgun — several string methods mutate in place and return `()`.**
> `String.replace(from, to)` edits the receiver and returns unit, *not* > A whole family of Rhai string methods edit the receiver *in place* and
> a new string. `let t = auth.replace("Bearer ", "")` sets `t` to `()`, > return unit, **not** a new string. `let t = auth.replace("Bearer ", "")`
> so a downstream `users::verify(t)` fails with > (or `let t = text.trim()`) sets `t` to `()`, so a downstream
> `Function not found: users::verify (())`. To strip a known prefix, > `text.split(",")` or `users::verify(t)` fails with
> slice instead: > `Function not found: split (())` / `… (())`.
>
> | Method | Returns | Use it as |
> |---|---|---|
> | `replace`, `trim`, `make_upper`, `make_lower`, `crop`, `truncate`, `pad` | `()` — **mutates in place** | a statement: `text.trim();` then read `text` |
> | `to_upper`, `to_lower`, `sub_string`, `split`, `chars` | a **new value** (string / array / range) | an expression: `let u = name.to_upper();` |
>
> So `to_upper`/`to_lower` are the *copying* variants (safe in `let x = …`),
> while `make_upper`/`make_lower` are the in-place ones. To trim and keep
> the result, mutate then read the same binding:
> ```rhai
> let token = auth;
> token.trim(); // mutate in place
> if token.starts_with("Bearer ") { // now use `token`
> token.replace("Bearer ", ""); // again: statement, not `let x = …`
> }
> ```
> Or, to strip a known prefix without mutation, slice:
> ```rhai > ```rhai
> let token = if auth.starts_with("Bearer ") { auth.sub_string(7) } else { auth }; > let token = if auth.starts_with("Bearer ") { auth.sub_string(7) } else { auth };
> ``` > ```
> Or call `replace` purely for its side effect: `let t = auth; t.replace("Bearer ", "");`. > (Verified against the pinned Rhai `=1.24` build.)
**Array:** `push`, `pop`, `shift`, `insert`, `remove`, `len`, `clear`, **Array:** `push`, `pop`, `shift`, `insert`, `remove`, `len`, `clear`,
`truncate`, `extend`, `filter`, `map`, `reduce`, `reduce_rev`, `find`, `truncate`, `extend`, `filter`, `map`, `reduce`, `reduce_rev`, `find`,