# PiCloud CMS PoC β€” Findings Log Running log of gaps, inconsistencies, surprises, and issues encountered while building a WordPress-inspired CMS on PiCloud. Each entry is tagged **[PiCloud]** (platform gap/footgun/bug) or **[CMS]** (our own code). Security findings live in [SECURITY.md](SECURITY.md). Severity: πŸ”΄ blocker Β· 🟠 significant Β· 🟑 minor/ergonomic Β· πŸ”΅ note/observation ## Re-verification β€” `fix/cms-poc-findings` (2026-07-18) A platform agent fixed the actionable findings. I rebuilt `picloud` + `pic` on that branch, redeployed the CMS onto the same DB, ran a **12/12 regression sweep** (all existing flows green), then verified each fix live and re-ran the async chains. **All 7 fixes confirmed; no regressions.** A **second round** then closed the two actionable workflow findings (**F-037** reachability, **F-038** `workflow::run_status`). Both verified live, and the CMS was refactored to **adopt** `workflow::run_status` β€” the `wfruns` KV / `wf_lib` run-tracking workaround is now deleted (see the F-037/F-038 rows below). | Finding | Fix | Live re-verification | |---|---|---| | **S-01/S-12/F-011** 502 info-leak | shared `scrub_runtime_detail` now also on the user-route (inbox) path | βœ… `throw #{…"SECRET_INTERNAL_MARKER"…}` on a user route β†’ client got `{"error":"script execution error (ref: )"}` (502), **no** marker / app-id / line / func; server log kept the full detail under the same `correlation_id` | | **F-015** docs array-membership | `$contains` (JSONB `@>`) | βœ… rewrote `posts_list` to `tags: #{ "$contains": tag }` β€” DB-side filter with real `$limit`: `picloud`β†’3, `meta`β†’1, `comments`β†’1, `nope`β†’0 | | **F-026** JSON-only bodies | content-type-aware bodies (formβ†’object, `text/*`β†’string, binaryβ†’base64) + `body_base64` responses | βœ… raw `--data-binary` PNG upload stored byte-exact; `media_get` now serves **real** `image/png` bytes (cmp = equal); form-urlencoded login β†’ 200+token; bad JSON still 400 | | **F-030** no CORS | per-app allow-list (`pic apps cors set`), Origin echo + OPTIONS preflight; migration 0077 | βœ… default-off preserved; matching Origin echoed; preflight β†’ **204** with `allow-methods/headers/max-age`; foreign origin **not** echoed | | **F-012** no role CLI | `pic users add-role` / `rm-role` + admin API | βœ… granted `author` to a reader and revoked it, verified via the users API | | **F-006** opaque apply 404 | actionable message | βœ… `app not found: ghost-app-xyz β€” apply does not create apps; create it first with \`pic apps create ghost-app-xyz\`…` | | **F-021** interceptor "unreachable" warning | interceptor bindings count as reachability | βœ… `plan` no longer warns that `comment_sanitize` has no route/trigger | | **F-037** workflow-step "unreachable" warning | reachability set also counts `[[workflows]]` step functions | βœ… `plan` emits **zero** warnings for the five `wf_*` step scripts | | **F-038** workflow runs unobservable from a script | new read-only `workflow::run_status(run_id)` SDK (app-scoped) | βœ… **adopted** β€” deleted the `wfruns`/`wf_lib` workaround; `pipeline_run` reads `run_status` directly. Happy path β†’ all `succeeded`/`completed`; gated path β†’ `skipped`/`blocked`, post stayed draft; foreign run id β†’ `()`/404 | **New finding from re-testing** (a small consequence of the F-026 fix): ### F-035 🟑 [CMS] F-026 shifts body-shape validation into every script Now that non-JSON bodies reach the script (textβ†’string, binaryβ†’base64), an endpoint that *assumes* `ctx.request.body` is a map will **throw β†’ 502** if a client sends `text/plain`/binary. Observed: `text/plain` to `/cms/auth/login` (which did `body.email`) β†’ 502 (correctly scrubbed, but a needless error). Pre-fix, the platform rejected the non-JSON body at 400 before the script ran. **Fix in-CMS:** guard `if type_of(b) != "map" { return 400 }` on JSON endpoints (applied to `login`; `register`/`media` already did). Attribution [CMS], but a note for the platform: the extra flexibility moves content-type discipline onto every author β€” a per-route "expected content types" declaration could help. The remaining open items are the ones the fix agent **deliberately deferred** (documented, not regressions): S-02 platform per-row authz (fundamental design; its 502 amplifier is now removed), F-011 throw-as-envelope semantics (scrub-only by choice), F-034 wildcard SSE topics, S-09/S-10 platform auth rate-limit & httpOnly cookies, F-002 `pic`/groff name clash, F-022 version skew. --- ## Summary β€” the findings that most affect building on PiCloud **Most significant friction (🟠):** - **F-006** `pic apply` can't create an app β€” only groups; the first deploy needs `pic apps create` first. - **F-007** `/api/` (and `/admin/`) are reserved route prefixes β€” the obvious REST layout is rejected; namespace elsewhere. - **F-011** `throw #{statusCode}` becomes a **502 that leaks the app id + script internals** β€” the response-envelope convention is *return-only*; a footgun for authz aborts (and an info-leak β€” see SECURITY S-01). - **F-012** No CLI/API to grant an app-user a role β€” role assignment is script-only; you must build a bootstrap endpoint. - **F-015** The docs filter DSL **can't match membership inside an array field** (no `$contains`/`@>`) β€” "posts with tag X" isn't expressible. - **F-026** User routes are **JSON-in/JSON-out only** β€” no form posts, no raw binary uploads, no binary responses. - **F-030** **No CORS** and no config for it β€” a cross-origin SPA can't call the API; you must reverse-proxy or bundle same-origin. **Ergonomic gaps (🟑):** F-001 (SMTP env vars β‰  docs), F-004 (`host_kind`/`path_kind` enum values), F-005 (inline `[vars.env]` silently misparses), F-008 (kebab-only var keys), F-017 (docs envelope shape), F-019/F-020 (interceptor is per-service not per-collection, create-only), F-029 (DSL timestamp compare is lexical), F-034 (SSE topics are concrete-only). **Genuine strengths (🟒):** F-010 (plan compiles Rhai server-side), F-013/F-014 (`users::` + roles, module imports), F-016 (`set_if` CAS + kv index), F-018 (docs `before` interceptor transform), F-023/F-028 (docsβ†’queueβ†’email + cron trigger chains), F-031 (env overlays + safe `--prune`), F-032 (one script β†’ many routes), F-033 (pub/sub β†’ public SSE). Plus SDK security wins: **S-06** (email header injection prevented), **S-07** (SSRF blocked by default), **S-08** (secrets never plaintext). **Headline security posture:** a public route holds **full app authority** with **no per-row authz** (S-02), and uncaught errors **leak internals via 502** (S-01). Every access decision is app code; one missing `guard()` is a silent full bypass. See [SECURITY.md](SECURITY.md). --- ## S0 β€” Infra & environment ### F-001 🟑 [PiCloud] SMTP relay env vars differ from documentation **What:** To relay `email::send` through a real SMTP server (Mailpit), the docs / CLAUDE.md and onboarding notes suggested `PICLOUD_SMTP_HOST/PORT/FROM/USERNAME/PASSWORD/REQUIRE_TLS`. The **actual** env vars (crates/manager-core/src/email_service.rs:208-236) are: - `PICLOUD_SMTP_HOST`, `PICLOUD_SMTP_USER`, `PICLOUD_SMTP_PASSWORD` β€” **all three required** or the relay stays disabled (falls back to dev capture sink). - `PICLOUD_SMTP_PORT` (default 587/465 depending on TLS), `PICLOUD_SMTP_TLS` (`implicit`/`starttls`/`none`), `PICLOUD_SMTP_TIMEOUT_SECS`. - **There is NO `PICLOUD_SMTP_FROM`** β€” the sender is taken from each `email::send(#{ from })` call. **`USER` not `USERNAME`; `TLS` not `REQUIRE_TLS`.** **Impact:** Setting `PICLOUD_SMTP_HOST/PORT/FROM` alone (as the docs imply) silently leaves email in dev-capture mode β€” the log line `email DEV CAPTURE ...` is the only signal. Confusing for a first-time operator. **Surprise/why it matters:** the relay is all-or-nothing on three vars, and one of the "documented" vars (`FROM`) doesn't exist while the SDK requires `from` per-call. For Mailpit (no TLS, accepts any auth) the working combo is: `PICLOUD_SMTP_HOST=127.0.0.1 PICLOUD_SMTP_USER=x PICLOUD_SMTP_PASSWORD=x PICLOUD_SMTP_PORT=1025 PICLOUD_SMTP_TLS=none`. ### F-002 πŸ”΅ [PiCloud] CLI binary name collision with groff **What:** The PiCloud CLI builds as `pic`, but `/usr/bin/pic` on this host is **groff's** `pic` (picture preprocessor). A naive `pic ...` invocation runs groff, not PiCloud. Had to use `./target/debug/pic` explicitly. Minor, but a packaging/name-collision worth noting for a tool meant to be installed on developer machines. ### F-003 πŸ”΅ [PiCloud] No per-kind `docs`/`pubsub`/`email`/`queue`/`files` trigger CLI wrapper **What:** `pic triggers` only has per-kind create wrappers for some kinds; docs/files/pubsub/email/queue triggers must go through `pic triggers create-from-json` OR the declarative manifest. Fine for us (we use the manifest), but noted: the imperative CLI is not at parity with the declarative surface. ### F-004 🟑 [PiCloud] `host_kind` / `path_kind` enum values are non-obvious **What:** Route `host_kind` values are `any` / `strict` / `wildcard` (NOT `exact`), and `path_kind` values are `exact` / `prefix` / `param` (NOT `pattern`/`regex`). Several summaries/docs say `host_kind = "exact"` or `path_kind = "regex"`, which fail `deny_unknown_fields`/enum parsing. Path parameters (`:slug`) require `path_kind = "param"` β€” using `exact` with a `:slug` in the path would match the literal string `:slug`, not capture it. (Source: crates/shared/src/route.rs:14-38.) --- ## S1 β€” Skeleton apply & routing ### F-005 🟑 [PiCloud] Env overlays are separate files; inline `[vars.]` silently misparses **What:** `pic apply --env dev` merges a **separate** `picloud..toml` overlay file. Putting `[vars.dev]` inline in the base `picloud.toml` does NOT create a dev-env overlay β€” because the base `vars` field is a `map`, `[vars.dev]` parses as a var literally **named `dev`** whose value is a table. No error is raised; it just silently produces a bogus var. Overlay files may carry `[app]` (slug/name), `[secrets]`, `[vars]`. **Footgun:** the natural inline form is silently wrong. ### F-006 🟠 [PiCloud] `pic apply` cannot CREATE an app β€” only groups **What:** The declarative tree apply (`/tree/apply`) reconciles the group tree SHAPE (creates groups) but for an app node calls `resolve_app`, which throws `AppNotFound` if the app doesn't exist (apply_service.rs:3358-3366). So the *very first* deploy of an app fails with `HTTP 404: app not found: cms` until you run `pic apps create ` imperatively. `pic init` scaffolds the manifest but does not create the app. **Surprise:** contradicts the "declarative, one-command" framing β€” the app is a manual prerequisite; only groups are declaratively creatable. Also: a `[project]` block over a bare app (no group) shows `ownership: unclaimed` at plan and is effectively **inert** β€” apps are only claimed via a nearest-ancestor *group*, so single-app repos can't exercise ownership at all. ### F-007 🟠 [PiCloud] `/api/` and `/admin/` are reserved route prefixes β€” a headless-CMS gotcha **What:** User routes may not start with `/api/`, `/admin/`, `/healthz`, `/version` (422 `path ... is reserved`). The instinctive REST layout (`/api/posts`, `/api/admin/posts`) is rejected wholesale because `/api/` is the platform control plane. Had to namespace the whole CMS under `/cms/...`. **Impact:** any app modelling itself as an HTTP API must avoid the single most conventional prefix; the reservation is a hard, instance-global rule not scoped per app/host. ### F-008 🟑 [PiCloud] `vars` keys are kebab-case only (`[a-z0-9-]`, no underscores) **What:** A var key like `comment_moderation` is rejected: "must be 1–128 chars of lowercase letters, digits, and hyphens, starting with a letter or digit." So `site_title`, `posts_per_page`, etc. are all invalid β€” must be `site-title`, `posts-per-page`. Meanwhile Rhai **map literal keys** can't contain hyphens (parsed as minus), so the two namespaces diverge: `vars::get("site-title")` (kebab string) feeding a Rhai field `site_title:` (snake identifier). Mildly confusing; easy to trip on. (Secrets, by contrast β€” see later β€” and script/route names have their own rules.) ### F-009 πŸ”΅ [PiCloud] A new app's routes 404 until it claims the request Host **What:** Two-phase dispatch resolves Hostβ†’app first (most-specific domain claim wins; `Any` matches everything, `Strict` beats it). A freshly-created app has **no** domain claim, so every route 404s (`no route matches GET /cms/hello`) even though `pic apply` reported the route created. The seeded **`default`** app claims `localhost` (exact), so on a dev box `localhost:8081` resolves to `default`, and a new app is unreachable there until you *move* or *add* a host claim. This IS documented in `pic apps domains --help`, but it's an easy-to-miss step between "apply succeeded" and "route works." Resolution for this PoC: `pic apps domains rm default ` + `pic apps domains add cms localhost`. ### F-010 🟒 [PiCloud] positive: `pic plan` compiles Rhai server-side **What:** `pic plan`/`apply` compile every script's Rhai source on the server and reject syntax errors at plan time (`invalid script source: Expecting ':' ... (line 6, position 13)`), with line/column. Nice β€” you catch a broken script before it's deployed, not at first request. (Minor CLI nit: `pic routes ls` takes a positional `SCRIPT_ID`, not `--app`, unlike most other `ls` commands which accept `--app`.) --- ## S2 β€” Auth (app-users + roles) ### F-011 🟠 [PiCloud] `throw #{ statusCode }` becomes a 502 that leaks internals β€” envelope convention is return-only **What:** The response-envelope convention (`#{ statusCode, headers, body }`) applies ONLY to a **returned** value. **Throwing** the same map does not produce that HTTP status β€” the executor treats it as an uncaught runtime error and returns **HTTP 502** with a body like: `{"error":"Runtime error: #{\"body\": #{\"error\": \"unauthenticated\"}, \"statusCode\": 401} @ 'app:2cced4ba-...-...' (line 43, position 18)\nin call to function 'require_user' ... (line 3, position 15)"}` **Two problems:** (1) the intuitive `throw #{statusCode:403}` for authz aborts is silently a 502, not a 403; (2) the 502 body **leaks the app UUID, function names, and line/column** of the script (info disclosure β€” see SECURITY S-01). **Mitigation in this CMS:** a non-throwing `auth::guard()` that RETURNS `{ok,response}`; the caller returns `g.response`. Any endpoint author reaching for `throw` to signal an HTTP error will get this wrong. ### F-012 🟠 [PiCloud] No CLI/API to grant an app-user a role β€” role assignment is script-only **What:** `pic users` can list/inspect users, mint reset tokens, revoke sessions β€” but there is **no** command (nor admin HTTP endpoint) to add/remove an app-user *role*. `users::add_role` exists only inside Rhai. So bootstrapping the very first admin/author requires writing a dedicated **script** endpoint (here `auth_bootstrap`, gated by a `setup-token` secret). An operator cannot promote a user from the outside; every role change must be mediated by app code. Ergonomic gap for an admin/RBAC-heavy app. ### F-013 🟒 [PiCloud] positive: the `users::` app-user system is solid & ergonomic **What:** `users::create/login/verify/logout/has_role/add_role/email_available` all behaved exactly as documented, returning `()` for absent and tokens/bools cleanly. `email_available` is the anon-safe probe; `login` returned a session token; `verify` round-tripped the user with a sliding TTL. Roles composed nicely. This is the strongest part of the SDK for building an auth layer β€” the app-user tier being distinct from platform admins is the right model for a CMS's readers+authors. ### F-014 🟒 [PiCloud] positive: `import "" as m` + module funcs calling the SDK works **What:** A `kind=module` script (`auth`) imported via `import "auth" as auth;` and its functions call `users::*` freely; `ctx` is passed in as a parameter (modules don't capture caller scope). The shared access-boundary module pattern works as intended. --- ## S3 β€” Posts (docs + kv index + CAS) ### F-015 🟠 [PiCloud] docs filter DSL cannot match membership inside an array field **What:** `docs::find` builds SQL via `jsonb_extract_path_text(data, 'field')`, which for an **array** field returns the array serialized as compact JSON text (docs_repo.rs:378-380). So a filter like `#{ tags: #{ "$in": [tag] } }` compares the tag against the *whole serialized array string* (`["intro","picloud"]`), never element membership. There is **no `$contains` / `@>` / array-element operator**. **Impact:** the single most common blog query β€” "posts with tag X" β€” isn't expressible in the DSL. Worked around by fetching published posts and filtering in Rhai (breaks true pagination) β€” or you'd maintain a separate kv/docs index. A JSONB containment operator would close this cleanly. ### F-016 🟒 [PiCloud] positive: `set_if` CAS + kv index pattern works well **What:** The CAS view counter (`kv::set_if(key, expected, new)` in a bounded retry loop) incremented correctly under repeated hits (1β†’2β†’3), and the `slugs` kv collection as an O(1) slugβ†’id index worked cleanly for public post URLs. `$sort`/`$limit` on scalar fields (status, updated_at, publish_at ISO strings) behaved as documented. Solid primitives for the read/index paths. ### F-017 🟑 [PiCloud] docs stores return an ENVELOPE, not the bare doc β€” easy to double-nest **What:** `docs::get`/`find` return `#{ id, data, created_at, updated_at }` with the user's fields under `.data`. It's easy to accidentally return the envelope (leaking the wrapper) or to double-nest. Combined with the response-map convention (a returned map with no `statusCode` becomes the whole body), a script that does `return doc;` ships `{id, data:{...}, ...}` to the client. Not a bug, but a shape mismatch you must normalize (`shape_doc`) on every read path. --- ## S4 β€” Comments + sanitization interceptor ### F-018 🟒 [PiCloud] positive: `before` docs interceptor transform works end-to-end **What:** A `[[interceptors]] service="docs" ops=["create"] phase="before"` hook receives the op-context (`{service, op, collection, key, value}`) **as its request body** and returns `#{ allowed: true, data: }`. Verified: a comment body containing `