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>
This commit is contained in:
293
examples/cms-poc/FINDINGS.md
Normal file
293
examples/cms-poc/FINDINGS.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# 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 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 <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.
|
||||
Reference in New Issue
Block a user