Files
PiCloud/docs/stdlib-reference.md
MechaCat02 5682be366c fix(cms-poc): remediate findings surfaced by the CMS proof-of-concept
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>
2026-07-19 20:15:17 +02:00

14 KiB

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.

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 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:

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:

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

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

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.
// 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:

// 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:

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.

#{ 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:

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