Additive fixes surfaced by building a full CMS on PiCloud
(examples/cms-poc/). One migration (0077_apps_cors.sql); no destructive
changes.
Security:
- Close the 502 info-leak on user routes: an uncaught script error (incl.
a throw) leaked the app UUID + script fn names + source line/col. The
user-route (inbox) path now shares scrub_runtime_detail with the
execute-by-id path — raw detail is logged under a correlation id and the
client sees only "script execution error (ref: <uuid>)".
Added:
- docs::find $contains operator (array membership via JSONB @>), per-app
and group-shared — the inverse of $in.
- App-user role management from API/CLI: pic users add-role / rm-role,
backed by the apps/{id}/users/{user_id}/roles endpoints (AppUsersAdmin).
- Per-app CORS: pic apps cors set, apps.cors_allowed_origins; the
orchestrator echoes an allowed Origin and answers OPTIONS preflight.
- Non-JSON request bodies: form-urlencoded -> object, text/* -> string,
other -> base64; ctx.request.content_type added. Malformed JSON still 400s.
- Binary responses via #{ body_base64 } with a script-set Content-Type.
- workflow::run_status(run_id) SDK (F-038): the starting script can poll a
run — #{ status, output, error, steps } or () when no such run belongs to
the app (app_id is the isolation boundary); same AppInvoke gate as start.
Fixed:
- pic apply "app not found" is now actionable (points at pic apps create).
- Interceptor- (F-021) and workflow-step-bound (F-037) scripts no longer
warn "no route or trigger" at plan time — both count as reachability.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
341 lines
14 KiB
Markdown
341 lines
14 KiB
Markdown
# Rhai stdlib reference
|
|
|
|
Everything in this document is callable from any user script without
|
|
imports — Rhai's built-in standard library plus the seven PiCloud
|
|
utility modules added in v1.1.0. Stateful service modules (KV, docs,
|
|
HTTP, …) ship in subsequent v1.1.x releases and are documented
|
|
separately.
|
|
|
|
For the architectural shape (why some modules are stateless and
|
|
register at engine build, why others are per-call), see
|
|
[sdk-shape.md](sdk-shape.md).
|
|
|
|
## Conventions
|
|
|
|
- **Throw on failure.** Every function throws a Rhai runtime error on
|
|
bad input (invalid pattern, invalid encoding, out-of-range arg). Use
|
|
`try { ... } catch (e) { ... }` if you want to handle it.
|
|
- **`()` for absent.** Functions that semantically may have no result
|
|
(e.g. `regex::find` when nothing matches) return `()`. Test with
|
|
`if v == () { ... }`.
|
|
- **`bool` for predicates.** Yes/no questions return `bool`.
|
|
- **UTC, milliseconds, lowercase hex, RFC 3986.** Defaults chosen once,
|
|
not per call.
|
|
|
|
---
|
|
|
|
## Rhai built-ins (free with every script)
|
|
|
|
These come with the Rhai engine itself. See the
|
|
[Rhai book](https://rhai.rs/book/lib/index.html) for full signatures.
|
|
|
|
**Math:** `+ - * / %`, `min`, `max`, `abs`, `sqrt`, `pow`, `floor`,
|
|
`ceil`, `round`, `to_int`, `to_float`, `sin`, `cos`, `tan`, `asin`,
|
|
`acos`, `atan`, `exp`, `ln`, `log`, `PI()`, `E()`.
|
|
|
|
**String:** `len`, `is_empty`, `contains`, `starts_with`, `ends_with`,
|
|
`index_of`, `split`, `trim`, `to_lower`, `to_upper`, `replace`, `chars`,
|
|
`pad`, `sub_string`, `crop`, `+` (concatenation).
|
|
|
|
> **Footgun — several string methods mutate in place and return `()`.**
|
|
> A whole family of Rhai string methods edit the receiver *in place* and
|
|
> return unit, **not** a new string. `let t = auth.replace("Bearer ", "")`
|
|
> (or `let t = text.trim()`) sets `t` to `()`, so a downstream
|
|
> `text.split(",")` or `users::verify(t)` fails with
|
|
> `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
|
|
> let token = if auth.starts_with("Bearer ") { auth.sub_string(7) } else { auth };
|
|
> ```
|
|
> (Verified against the pinned Rhai `=1.24` build.)
|
|
|
|
**Array:** `push`, `pop`, `shift`, `insert`, `remove`, `len`, `clear`,
|
|
`truncate`, `extend`, `filter`, `map`, `reduce`, `reduce_rev`, `find`,
|
|
`find_map`, `any`, `all`, `index_of`, `contains`, `sort`, `reverse`,
|
|
`dedup`, `chunks`, `splice`, `[]` indexing.
|
|
|
|
**Map:** `len`, `is_empty`, `contains`, `keys`, `values`, `mixin`,
|
|
`remove`, `clear`, `fill_with`, `+` (merge), `[]` and `.` access.
|
|
|
|
**Blob:** `len`, `push`, `pop`, `clear`, `as_string`, `parse_le_int`,
|
|
`write_*`, `[]` indexing. Blobs are `Vec<u8>` at the Rust layer.
|
|
|
|
**Logging:** `log::trace`, `log::info`, `log::warn`, `log::error` —
|
|
each takes a message and optionally a structured-data map. (Documented
|
|
with the SDK contract; mentioned here for completeness.)
|
|
|
|
---
|
|
|
|
## `regex::` — regular expressions
|
|
|
|
Linear-time, no backtracking (powered by the Rust `regex` crate).
|
|
Patterns compile per call.
|
|
|
|
| Function | Description |
|
|
|---|---|
|
|
| `regex::is_match(pattern, text) -> bool` | Whether `text` contains a match. |
|
|
| `regex::find(pattern, text) -> String \| ()` | First match or `()` if none. |
|
|
| `regex::find_all(pattern, text) -> Array` | All matches as `String` array. |
|
|
| `regex::replace(pattern, text, replacement) -> String` | Replace first match only. |
|
|
| `regex::replace_all(pattern, text, replacement) -> String` | Replace every match. |
|
|
| `regex::split(pattern, text) -> Array` | Split `text` on matches. |
|
|
| `regex::captures(pattern, text) -> Array \| ()` | `[full, group1, group2, ...]` from the first match; unmatched optional groups appear as `()`. |
|
|
|
|
Invalid patterns throw. Use `\\` to escape inside Rhai string literals
|
|
(`"\\d+"`) or backtick strings to skip escaping (`` `\d+` ``).
|
|
|
|
```rhai
|
|
if regex::is_match(`^/api/v\d+/`, ctx.request.path) {
|
|
let cap = regex::captures(`/api/v(\d+)/(.+)`, ctx.request.path);
|
|
let version = cap[1]; // "1"
|
|
let rest = cap[2]; // "users"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## `random::` — cryptographically-secure randomness
|
|
|
|
All randomness comes from `OsRng`. There is deliberately no "fast
|
|
non-crypto" variant — scripts shouldn't have to pick.
|
|
|
|
| Function | Description |
|
|
|---|---|
|
|
| `random::int(min, max) -> i64` | Uniform integer in `[min, max]` (inclusive). Throws if `min > max`. |
|
|
| `random::float() -> f64` | Uniform float in `[0.0, 1.0)`. |
|
|
| `random::bytes(n) -> Blob` | `n` random bytes. `n` in `0..=65536`. |
|
|
| `random::string(n) -> String` | `n` random alphanumeric chars (`A-Za-z0-9`). `n` in `0..=4096`. |
|
|
| `random::uuid() -> String` | UUID v4 in canonical 8-4-4-4-12 form. |
|
|
|
|
```rhai
|
|
let token = random::uuid();
|
|
let salt = random::bytes(16);
|
|
let pin = random::int(100000, 999999);
|
|
```
|
|
|
|
---
|
|
|
|
## `time::` — UTC time
|
|
|
|
Canonical time value is **milliseconds since the Unix epoch** as `i64`.
|
|
ISO 8601 / RFC 3339 strings are for I/O. UTC only — no timezone support.
|
|
|
|
| Function | Description |
|
|
|---|---|
|
|
| `time::now() -> String` | Current UTC time as ISO 8601 with ms (e.g. `"2026-05-30T20:15:00.123Z"`). |
|
|
| `time::now_ms() -> i64` | Current ms since Unix epoch. |
|
|
| `time::parse(iso) -> i64` | Parse RFC 3339 / ISO 8601 string to ms. Throws on bad input. |
|
|
| `time::format(ms) -> String` | Format ms-since-epoch as ISO 8601 with ms precision. |
|
|
| `time::add_seconds(ms, secs) -> i64` | `ms + secs*1000`, with overflow check. |
|
|
| `time::diff_seconds(a_ms, b_ms) -> i64` | `(b_ms - a_ms) / 1000`, truncated. |
|
|
|
|
```rhai
|
|
let started_at = time::now_ms();
|
|
// ... do work ...
|
|
let elapsed = time::diff_seconds(started_at, time::now_ms());
|
|
|
|
let deadline = time::format(time::add_seconds(time::now_ms(), 3600));
|
|
```
|
|
|
|
---
|
|
|
|
## `json::` — JSON parse and stringify
|
|
|
|
| Function | Description |
|
|
|---|---|
|
|
| `json::parse(s) -> Dynamic` | Parse a JSON string. Returns Rhai maps, arrays, scalars, or `()` for null. Throws on invalid JSON. |
|
|
| `json::stringify(v) -> String` | Compact JSON. |
|
|
| `json::stringify_pretty(v) -> String` | Pretty-printed (2-space indent). |
|
|
|
|
```rhai
|
|
let payload = json::parse(ctx.request.body); // if body came in as a string
|
|
let body_str = json::stringify(#{ ok: true, items: [1, 2, 3] });
|
|
```
|
|
|
|
Note: `ctx.request.body` is *already* parsed when the request body is
|
|
`Content-Type: application/json` — only call `json::parse` on raw
|
|
strings.
|
|
|
|
---
|
|
|
|
## `base64::` — standard and URL-safe Base64
|
|
|
|
Two alphabets: standard (with `=` padding) and URL-safe (no padding).
|
|
Encoders accept both `String` and `Blob`; decoders always return `Blob`.
|
|
|
|
| Function | Description |
|
|
|---|---|
|
|
| `base64::encode(input) -> String` | Standard alphabet, padded. `input` is `String` or `Blob`. |
|
|
| `base64::decode(s) -> Blob` | Decode standard alphabet. Throws on invalid. |
|
|
| `base64::encode_url(input) -> String` | URL-safe alphabet, **no padding**. |
|
|
| `base64::decode_url(s) -> Blob` | Decode URL-safe alphabet. Throws on invalid. |
|
|
|
|
```rhai
|
|
let token = base64::encode_url(random::bytes(32)); // URL-safe session token
|
|
let raw = base64::decode("aGVsbG8=");
|
|
```
|
|
|
|
---
|
|
|
|
## `hex::` — hexadecimal
|
|
|
|
Encode produces lowercase. Decode accepts mixed case.
|
|
|
|
| Function | Description |
|
|
|---|---|
|
|
| `hex::encode(input) -> String` | Lowercase hex. `input` is `String` or `Blob`. |
|
|
| `hex::decode(s) -> Blob` | Decode hex (case-insensitive). Throws on invalid. |
|
|
|
|
```rhai
|
|
let fingerprint = hex::encode(random::bytes(20));
|
|
```
|
|
|
|
---
|
|
|
|
## `url::` — percent-encoding
|
|
|
|
Unreserved set per RFC 3986 (`A-Z`, `a-z`, `0-9`, `-`, `_`, `.`, `~`)
|
|
is preserved; everything else is percent-encoded.
|
|
|
|
| Function | Description |
|
|
|---|---|
|
|
| `url::encode(s) -> String` | Percent-encode a component value. |
|
|
| `url::decode(s) -> String` | Percent-decode. Throws on invalid UTF-8 in the decoded output. |
|
|
| `url::encode_query(map) -> String` | Build `k1=v1&k2=v2` from a Map. Both keys and values are percent-encoded. Non-string values are coerced via `to_string()`. |
|
|
|
|
`url::encode_query` emits keys in the Map's natural order, which is
|
|
alphabetical (Rhai's `Map` is a `BTreeMap`). RFC 3986 leaves query
|
|
parameter ordering unspecified, so this is fine for any conforming
|
|
consumer; if you need a specific ordering, build the string by hand.
|
|
|
|
```rhai
|
|
let qs = url::encode_query(#{ q: "rust regex", page: 2 });
|
|
// → "page=2&q=rust%20regex"
|
|
```
|
|
|
|
---
|
|
|
|
## Request context (`ctx`) and the response envelope
|
|
|
|
Every script sees a `ctx` map describing the invocation. The request
|
|
fields under `ctx.request` are:
|
|
|
|
| Field | Type | Notes |
|
|
|---|---|---|
|
|
| `ctx.request.path` | `String` | Request path. |
|
|
| `ctx.request.method` | `String` | Uppercased HTTP verb (`"GET"`, `"POST"`, …). Empty for non-HTTP triggers. Branch on it to serve several verbs from one script. |
|
|
| `ctx.request.headers` | `Map` | Lowercased header names → values. |
|
|
| `ctx.request.body` | parsed | Already-parsed JSON when `Content-Type: application/json`; otherwise a string. |
|
|
| `ctx.request.params` | `Map` | Captures from `:name` route segments. |
|
|
| `ctx.request.query` | `Map` | Query-string parameters. |
|
|
| `ctx.request.rest` | `String` | Suffix captured by a `prefix` (`/*`) route. |
|
|
|
|
```rhai
|
|
// One script, many verbs:
|
|
let m = ctx.request.method;
|
|
if m == "GET" { /* list */ }
|
|
else if m == "POST" { /* create */ }
|
|
else { return #{ statusCode: 405, body: #{ error: "method not allowed" } }; }
|
|
```
|
|
|
|
### The response envelope is `statusCode`-gated
|
|
|
|
A returned map is unwrapped into `{statusCode, headers, body}` **only when
|
|
it contains a `statusCode` key**. Without `statusCode`, the *entire*
|
|
returned map becomes the response body, with an implicit `200`:
|
|
|
|
```rhai
|
|
// WRONG — no statusCode, so the whole map is the body:
|
|
#{ body: #{ token: t } } // → HTTP 200 {"body":{"token":"…"}} (double-nested!)
|
|
|
|
// RIGHT — statusCode present, so it's treated as the envelope:
|
|
#{ statusCode: 200, body: #{ token: t } } // → HTTP 200 {"token":"…"}
|
|
```
|
|
|
|
Always include `statusCode` when you mean to set headers or a specific
|
|
body shape. Returning a bare value (`42`, `#{ ok: true }`) is fine — it
|
|
becomes a `200` body as-is.
|
|
|
|
**Abort with a status by *returning* the envelope, never `throw`.** A
|
|
`throw` (even `throw #{ statusCode: 403 }`) is an uncaught runtime error →
|
|
HTTP 502, not your status. Return a `statusCode` envelope to short-circuit:
|
|
|
|
```rhai
|
|
if !authorized { return #{ statusCode: 403, body: #{ error: "forbidden" } }; }
|
|
```
|
|
|
|
### Binary responses — `body_base64`
|
|
|
|
To return raw bytes (an image, a PDF, a download) instead of JSON, put the
|
|
base64 of the bytes in a `body_base64` field and set `Content-Type`
|
|
yourself. `body` is ignored when `body_base64` is present.
|
|
|
|
```rhai
|
|
#{ statusCode: 200,
|
|
headers: #{ "content-type": "image/png" },
|
|
body_base64: file.data } // base64 string → raw PNG bytes on the wire
|
|
```
|
|
|
|
Absent a `Content-Type` the platform defaults to `application/octet-stream`.
|
|
|
|
### Non-JSON request bodies
|
|
|
|
`ctx.request.body` is the parsed JSON for a JSON request. A non-JSON body is
|
|
no longer rejected: `application/x-www-form-urlencoded` arrives as an object
|
|
of string fields, `text/*` as the raw string, and any other content type as
|
|
a base64 string of the raw bytes. Read `ctx.request.content_type` (a
|
|
convenience mirror of the `content-type` header) to decide how to interpret
|
|
`ctx.request.body`.
|
|
|
|
### Security note — `users::email_available` and account enumeration
|
|
|
|
`users::email_available(email) -> bool` is the anonymous-safe way to
|
|
pre-check an email during self-serve registration (`users::find_by_email`
|
|
is forbidden for anonymous public scripts to prevent enumeration). The
|
|
probe returns only `true`/`false` — the same bit a register form already
|
|
leaks via "email already taken" (and via `create`'s own uniqueness error),
|
|
so it adds no *new* enumeration vector — but it is **cheap (no password
|
|
hash), has no side effect, and is not rate limited**, which makes bulk
|
|
probing faster than going through `create`.
|
|
|
|
There is no built-in per-route throttle or CAPTCHA primitive today (only
|
|
the global `PICLOUD_MAX_CONCURRENT_EXECUTIONS` cap). If account enumeration
|
|
matters for your app, the available mitigation is a `kv`-based counter
|
|
keyed on the client (e.g. an IP/email bucket you increment and check) —
|
|
the same pattern you'd use for any custom rate limit:
|
|
|
|
```rhai
|
|
// Pre-check, then create. Gate behind your own kv counter if enumeration
|
|
// is a concern — the platform does not rate-limit this probe for you.
|
|
if !users::email_available(email) {
|
|
return #{ statusCode: 409, body: #{ error: "email already registered" } };
|
|
}
|
|
```
|
|
|
|
## What's not here
|
|
|
|
- **Crypto** (sha256/hmac/argon2/encryption) — deferred to a focused
|
|
later PR.
|
|
- **Timezones** — UTC only in v1.1.0. Format with an offset upstream
|
|
if you need local time.
|
|
- **JWT, YAML, XML, CSV, Markdown** — not planned for v1.1.x.
|
|
- **Stateful services** (KV, docs, HTTP, cron, files, pubsub, secrets,
|
|
email, users, queue, invoke) — land per the v1.1.x roadmap in the
|
|
[blueprint §12](../serverless_cloud_blueprint.md).
|