Files
PiCloud/examples/cms-poc/FINDINGS.md
MechaCat02 813bc46640
Some checks failed
CI / Rust — fmt, clippy, test (push) Failing after 39m53s
CI / Dashboard — check (push) Successful in 10m15s
docs(examples): add the CMS proof-of-concept dogfooding artifact
A WordPress-inspired CMS whose entire backend is PiCloud Rhai scripts
deployed with pic apply, plus a SvelteKit frontend. Built to dogfood the
project tool, CLI, and SDK; exercises docs/kv/files/users, docs+queue+cron
+pubsub triggers, the transactional-outbox notification chain, a docs
before-interceptor, set_if CAS, SSE, per-app CORS, env overlays, and a
durable Workflow (validate -> enrich || seo -> publish -> finalize) started
with workflow::start and polled with workflow::run_status, visualized live
with Svelte Flow.

FINDINGS.md / SECURITY.md capture every gap, surprise, and security issue
found (each tagged [PiCloud] vs [CMS]); the platform fixes for the
actionable ones ship in the preceding commit.

Also folds in the read-only `pic` allowlist entries added to
.claude/settings.json during the session (fewer-permission-prompts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:15:29 +02:00

294 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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: <uuid>)"}` (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.<env>]` silently misparses
**What:** `pic apply --env dev` merges a **separate** `picloud.<env>.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<string, toml::Value>`, `[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 <slug>` 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 1128 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 <id>` + `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 "<module>" 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: <rewritten value> }`. Verified: a comment body containing `<script>`, `<img onerror>`, `&`, and an `<b>` author_name were all HTML-escaped **before** persistence, and the moderation status was set from a `vars` value — all transparently to the `comment_create` endpoint. Fail-closed verdict (only `#{allowed:true}` allows; a dangling/erroring hook denies) is the right default for a security control. This is a genuinely nice feature for centralised write-time sanitization/validation.
### F-019 🟡 [PiCloud] interceptors bind per-SERVICE+op, not per-collection — needs an in-hook guard
**What:** The docs `create` interceptor fires for **every** docs collection (posts, pages, comments). The hook must branch on `p.collection` and no-op on the rest, or it would rewrite unrelated writes. Verified a post body `if a < b && b > c {}` is preserved only because the hook guards `collection != "comments"`. Also every post/page create now pays an extra script execution. A per-collection binding (or a `collection_glob` like triggers have) would be more ergonomic and less error-prone.
### F-020 🟡 [PiCloud] `ops=["create"]` doesn't cover UPDATE — sanitization gap on edits
**What:** The comment interceptor guards `create` only; a later `docs::update` to the same comment is **not** re-sanitized. Our admin moderation only flips `status` (safe), but any feature that edits a comment/post body through `update` would bypass the escaper. You must remember to also register a `phase="before" ops=["update"]` hook. Easy to overlook; the "sanitize on write" mental model doesn't map cleanly to create-vs-update.
### F-021 🔵 [PiCloud] plan warns an interceptor script "has no route or trigger"
**What:** `pic plan` prints `warning: endpoint 'comment_sanitize' has no route or trigger — only reachable via the execute-by-id bypass / invoke()`. But it IS reachable — as an interceptor. The reachability check doesn't count an `[[interceptors]]` binding, so a legitimately-bound hook looks orphaned. Cosmetic, but could mislead you into "cleaning up" a live hook.
### F-022 🔵 [env] the shipped `pic`/`picloud` 1.1.9 binaries lagged the source
**What:** The pre-installed `target/debug/pic` (self-reported `pic 1.1.9`) **rejected** the `[[interceptors]]` manifest block (`unknown field 'interceptors'`) though the source at `crates/picloud-cli/src/manifest.rs` supports it. A rebuild fixed it — but the version string stayed `1.1.9` across both, so nothing signals the skew. A build/version that doesn't bump when the manifest schema changes makes "which features does my CLI actually support?" unanswerable from `--version`.
---
## S5 — Notifications (docs-trigger → queue → email → Mailpit)
### F-023 🟢 [PiCloud] positive: the docs-trigger → queue → email chain is robust and fast
**What:** Publishing a post fired the whole 3-hop async pipeline — `posts_on_publish` (docs trigger) → `queue::enqueue` → `notify_drain` (queue trigger) → `email::send_html` → Mailpit — delivering one email per reader (4/4) in **~1 second**, from the configured `notify-from-email`. The transactional-outbox dispatch, the publish-transition detection (`prev_data.status`), chunked enqueue, and the SMTP relay all worked first try. This is the most complex integration in the CMS and it was the smoothest — the event/trigger model is a genuine strength. Retry/dead-letter config (`retry_max_attempts`) is declarative per trigger.
### F-024 🔵 [PiCloud] `ctx.event.docs.prev_data` makes transition detection clean
**What:** The docs trigger event carries both `data` and `prev_data`, so "became published" is a simple `data.status=="published" && prev_data.status!="published"`. On a draft create (`prev_data == ()`) and on non-status updates it correctly no-ops. Good event ergonomics.
### F-025 🔵 [CMS] rate-limiter shares one bucket when `x-forwarded-for` is absent
**What:** Our register/comment rate limiter keys on `ctx.request.headers["x-forwarded-for"]`, which is absent on direct `curl` to the origin, so every local client shares the `"local"` bucket — a 6th local registration in an hour is throttled (429). Behind Caddy the header would be set per-client. **Attribution [CMS]**, but note **[PiCloud]**: there is no built-in client-IP surface in `ctx` (no `ctx.request.remote_addr`), so an app behind no proxy has no reliable per-client key for rate limiting — you depend entirely on a trusted upstream setting XFF. A spoofable `x-forwarded-for` from an untrusted client would also let an attacker rotate the bucket (see SECURITY).
---
## S6 — Tags, media (files), cron scheduled-publish
### F-026 🟠 [PiCloud] user routes are JSON-in/JSON-out — non-JSON request bodies are rejected
**What:** The dispatch path does `serde_json::from_slice(&body_bytes)` and returns 400 `invalid JSON body` for any non-empty body that isn't valid JSON (orchestrator-core/src/api.rs:261). So a user route **cannot** accept `application/x-www-form-urlencoded`, `text/plain`, or **raw binary** — a classic HTML `<form>` POST or a file upload with a binary body is rejected before the script runs. This contradicts the "body is a String if not application/json" description. **Impact:** file uploads must be **base64-in-JSON** (done here), inflating payloads ~33% and capped by the 10 MiB body limit; and you can't build a no-JS HTML form that posts directly to a route. Likewise responses: there's no way to stream raw bytes back (media is returned as base64 for the frontend to turn into a `data:` URL — fine for a PoC, wrong for real media serving).
### F-027 🟢 [CMS] SVG upload rejected to prevent stored XSS
**What:** `media_upload` allowlists `image/{png,jpeg,gif,webp}` and rejects `image/svg+xml` (415), because an SVG served same-origin can execute embedded `<script>`. Verified. Attribution [CMS] — but note **[PiCloud]**: `files::` performs no content sniffing/validation and `media_get` would happily hand back whatever `content_type` was stored, so the allowlist is entirely the app's responsibility.
### F-028 🟢 [PiCloud] positive: cron + trigger-fires-trigger chain works
**What:** A 6-field cron trigger (`0 * * * * *`) flipped a due `scheduled` post to `published` within the minute; that `docs::update` then fired the `posts_on_publish` docs trigger → queue → 4 notification emails. The full cron→docs→queue→email chain (a trigger firing a trigger) ran cleanly and stayed within the depth bound. Both `files::` (create/head/get round-tripped a 75-byte PNG byte-exact) and cron behaved as documented.
### F-029 🟡 [PiCloud] docs DSL `$lte` on ISO-timestamp strings works only because ISO-8601 sorts lexically
**What:** `posts_scheduled_publish` filters `publish_at: #{ "$lte": now }` where `now = time::now()` (ISO string). This works because `jsonb_extract_path_text` compares text and ISO-8601 is lexically ordered — but it's an implicit contract: any non-lexicographic timestamp format (epoch millis as a number, `time::now_ms()`) would compare as **text** and silently mis-order. There's no typed/temporal comparison in the DSL; correctness rides on always storing zero-padded ISO strings.
---
## S8/S9 — Frontend, env overlays, prune
### F-030 🟠 [PiCloud] No CORS support — a decoupled SPA can't call the API cross-origin
**What:** User routes send **no** `Access-Control-Allow-Origin` (or any CORS) header, and there's no config to enable CORS. A browser SPA on a different origin (`:5173`) is blocked from calling the API on `:8081`. For a platform whose selling point is "upload scripts, get HTTP endpoints" consumed by a frontend, this forces either (a) same-origin bundling behind Caddy, or (b) a dev/prod reverse proxy. Worked around with Vite's `server.proxy` (`/cms` → `:8081`), which also sidesteps CORS **preflight** (same-origin to the browser). Also relevant: OPTIONS isn't a routable method here, so a genuine cross-origin preflight would 404 regardless. A `cors_allowed_origins` per-app setting would make the headless-CMS story first-class.
### F-031 🟢 [PiCloud] positive: env overlays + prune are solid and safe
**What:** `pic plan --env prod` cleanly showed only the overlaid vars (`site-base-url`, `signups-open`) as `update / value changed`, leaving the rest `noop`; the dev overlay was live (`site_base_url = http://localhost:5173`). `--prune` correctly diffed a removed script+route as `delete`, and **refused to run non-interactively without `--yes`** ("prune deletes resources … cannot be undone. Review with `pic plan`, then re-run with `--yes`") — a good guardrail. After `--prune --yes` the throwaway route 404'd and the rest of the app was untouched. The declarative reconcile loop (plan → apply → prune) is the strongest part of the tooling.
### F-032 🟢 [PiCloud] positive: one script bound to many routes works
**What:** The `pages` and `comments_admin` scripts are each bound to several `[[routes]]` (different methods/params) and dispatch internally on `ctx.request.method` + path — a clean way to build a REST resource without one script per verb. `ctx.request.params` populated correctly across `param` routes (`/cms/pages/:slug`, `/cms/admin/pages/:id`).
---
## S7 — Live comments (pub/sub + realtime SSE)
### F-033 🟢 [PiCloud] positive: pub/sub → SSE realtime works cleanly, incl. anonymous public topics
**What:** Approving a comment fires `comment_on_approve` (docs trigger, `update`) → `pubsub::publish_durable("comments-feed", …)` → the registered **external, `auth_mode=public`** topic streams to any SSE subscriber. Verified end-to-end via `curl -N /realtime/topics/comments-feed` (event arrived ~3s after approval) AND through the Vite dev proxy to the browser `EventSource`. `pic topics create --external --auth-mode public` makes a topic anonymously subscribable — exactly right for a public live-comment feed (no per-viewer token needed). The durable-publish + broadcast split (durable outbox fan-out, then live broadcast) is a nice model.
### F-034 🔵 [PiCloud] SSE topics are concrete (no wildcards) — per-entity live feeds need a shared topic + client filter
**What:** Registered topics must be concrete names (no `*`), so a per-post topic (`comments.<id>`) isn't registerable dynamically. Used one shared public `comments-feed` topic carrying `post_id` and filtered client-side. Fine for public comments, but a per-entity **private** live feed would need `auth_mode=token` + a minted `subscriber_token` scoped per topic — and you'd still need a concrete registered topic per entity, which doesn't scale to unbounded ids. A wildcard/prefix topic registration would help. (`subscriber_token` also requires an authenticated principal, so anonymous *scoped* subscriptions aren't possible — it's all-public or authed.)
---
## Workflows — editorial publish pipeline (added 2026-07-18)
Added a durable-DAG automation to dogfood the Workflows feature (the one major
PiCloud capability the original PoC skipped): `validate → (enrich ∥ seo) →
publish → finalize`, authored as a `[[workflows]]` block, started from a script
via `workflow::start`, visualized live with Svelte Flow (`@xyflow/svelte`).
### F-036 🟢 [PiCloud] positive: the Workflows engine is excellent
**What:** Declarative `[[workflows]]` DAG applied cleanly; the full 5-step run
executed in **<1s**. Verified from timestamps that `enrich` and `seo` ran **in
real parallel** (both fired ~15ms apart, after `validate`), then `publish`, then
`finalize`. Conditional `when = "steps.validate.output.ok == true"` gated the
branch: on a post with an empty body, `validate` failed and enrich/seo/publish
were **skipped**, leaving the post a draft. `{{ input.x }}` input templating and
`steps.<name>.output` references worked; `pic workflows ls/runs` shows the
history; a step's returned body becomes its `output`; steps freely called
`docs`/`kv` and the `publish` step **chained into the existing notification
pipeline** (docs-trigger → queue → 4 emails). This is a genuinely strong,
well-integrated feature — the highlight of the platform.
### F-037 🟡 [PiCloud] workflow-step scripts trip the "no route or trigger" reachability warning
**What:** A script referenced only by `[[workflows.steps]] function = "…"` is
flagged at plan/apply: `warning: endpoint 'wf_validate' has no route or trigger
— only reachable via the execute-by-id bypass / invoke()`. But it IS reachable —
as a workflow step. The F-021 fix taught the reachability check about
**interceptor** bindings but not **workflow-step** bindings, so all five step
scripts look orphaned. Same class of bug as F-021, one binding-type short.
**✅ Resolved (platform):** the reachability set now also counts `[[workflows]]`
step `function`s (and interceptor scripts). Verified live — `pic plan` on this
CMS now emits **zero** reachability warnings for the five workflow-step scripts.
### F-038 🟠 [PiCloud] workflow runs are observable only via the platform-admin API — not from scripts
**What:** `workflow::start(name, input)` is the *only* workflow SDK call — it
returns a run id but there is **no** `workflow::get_run`/status from a script.
Run status/history lives behind the platform-admin API
(`GET /api/v1/admin/apps/{app}/workflow-runs/{id}`), which needs an instance-admin
bearer token — not an app-user token. So a data-plane app **cannot surface its
own workflow progress to its own users** without either borrowing platform-admin
credentials or rolling its own run tracking. I did the latter: each step writes
progress into a `wfruns` KV store (own key per step, so `enrich ∥ seo` don't
contend), and a `/cms/admin/workflow/runs/:id` endpoint reassembles it for the
UI. It works, but "startable from a script, not observable from a script" is an
asymmetry worth closing (even a read-only `workflow::run_status(id)` would do).
**✅ Resolved (platform) + adopted:** `workflow::run_status(run_id)` shipped —
returns `#{ status, output, error, steps: #{ name: status } }` or `()` when no
such run belongs to the app (app-scoped at the SQL, the isolation boundary; same
`AppInvoke` gate as `start`). **The CMS now uses it:** the `wfruns` KV workaround
(and the `wf_lib` progress-writer module) is **deleted** — `pipeline_run` reads
`workflow::run_status` directly, and the step scripts no longer thread a
`run_id`. Re-verified live end-to-end: happy path (all 5 steps `succeeded`,
overall `completed`) and gated path (empty body → `validate` succeeded but
`output.ok=false` → enrich/seo/publish `skipped`, overall `blocked`, post stayed
draft). A run id from another app resolves to `()` → 404.
### F-039 🔵 [PiCloud] a skipped step does NOT block its dependents (skip ≠ fail)
**What:** When `publish`'s `when` is false it is **skipped**, yet `finalize`
(`depends_on = ["publish"]`) still **runs** — a skipped dependency is treated as
satisfied, not as a barrier. Reasonable, but a footgun: my first `finalize`
marked the run "completed" even though nothing was published. Fixed by having
`finalize` inspect whether `publish` actually succeeded (→ reports
"completed" vs "blocked"). Authors must not assume "my dependents only run if I
ran" — model the skip case explicitly. **Status:** confirmed **by design** and
documented — the `WorkflowStepDef.when` contract is "skipped = satisfied-but-
empty"; gate a dependent with its own `when` if it must not run on a skip. (With
F-038 resolved, `workflow::run_status` now reports an **explicit** per-step
`skipped` status from the engine, so `pipeline_run` no longer has to *infer*
skips — it still falls back to `skipped` for a step absent from a terminal run,
but the engine supplies the real marker.)
### F-040 🟢 [tooling] Svelte Flow (`@xyflow/svelte` v1) drops into Svelte 5 cleanly
**What:** `@xyflow/svelte@1.6.2` (peer `svelte ^5.25`) integrated with the
SvelteKit SPA using `bind:nodes`/`bind:edges` on `$state` arrays; a small
longest-path layout + per-status node `style` gives a live-updating DAG that
colors each node as the run progresses (polling `/cms/admin/workflow/runs/:id`).
Good fit for visualizing a PiCloud workflow.