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:
@@ -37,6 +37,25 @@
|
|||||||
"Bash(dig *)",
|
"Bash(dig *)",
|
||||||
"Bash(host *)",
|
"Bash(host *)",
|
||||||
|
|
||||||
|
"Bash(./target/debug/pic plan *)",
|
||||||
|
"Bash(./target/debug/pic config *)",
|
||||||
|
"Bash(./target/debug/pic whoami)",
|
||||||
|
"Bash(./target/debug/pic pull *)",
|
||||||
|
"Bash(./target/debug/pic apps domains ls *)",
|
||||||
|
"Bash(./target/debug/pic routes ls *)",
|
||||||
|
"Bash(./target/debug/pic kv get *)",
|
||||||
|
"Bash(./target/debug/pic kv ls *)",
|
||||||
|
"Bash(./target/debug/pic dead-letters ls *)",
|
||||||
|
"Bash(/home/fabi/PiCloud/target/debug/pic plan *)",
|
||||||
|
"Bash(/home/fabi/PiCloud/target/debug/pic config *)",
|
||||||
|
"Bash(/home/fabi/PiCloud/target/debug/pic whoami)",
|
||||||
|
"Bash(/home/fabi/PiCloud/target/debug/pic pull *)",
|
||||||
|
"Bash(/home/fabi/PiCloud/target/debug/pic apps domains ls *)",
|
||||||
|
"Bash(/home/fabi/PiCloud/target/debug/pic routes ls *)",
|
||||||
|
"Bash(/home/fabi/PiCloud/target/debug/pic kv get *)",
|
||||||
|
"Bash(/home/fabi/PiCloud/target/debug/pic kv ls *)",
|
||||||
|
"Bash(/home/fabi/PiCloud/target/debug/pic dead-letters ls *)",
|
||||||
|
|
||||||
"Bash(ls)",
|
"Bash(ls)",
|
||||||
"Bash(ls *)",
|
"Bash(ls *)",
|
||||||
"Bash(pwd)",
|
"Bash(pwd)",
|
||||||
|
|||||||
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.
|
||||||
96
examples/cms-poc/README.md
Normal file
96
examples/cms-poc/README.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# PiCloud CMS PoC — a WordPress-inspired CMS on PiCloud
|
||||||
|
|
||||||
|
A proof-of-concept content-management system whose **entire backend is PiCloud
|
||||||
|
Rhai scripts**, deployed declaratively with `pic apply`, plus a SvelteKit
|
||||||
|
frontend. Built to **dogfood the PiCloud project tool, CLI, and SDK** — the
|
||||||
|
gaps/surprises/issues found along the way are in [FINDINGS.md](FINDINGS.md) and
|
||||||
|
the security audit is in [SECURITY.md](SECURITY.md).
|
||||||
|
|
||||||
|
> This is a **test artifact**, not a production CMS. It deliberately exercises
|
||||||
|
> as many PiCloud features as fit a blog, and records where they helped or hurt.
|
||||||
|
|
||||||
|
## What it does (WordPress-style)
|
||||||
|
|
||||||
|
| Feature | PiCloud pieces used |
|
||||||
|
|---|---|
|
||||||
|
| Admin dashboard + admin/author users | `users::` app-users + roles (`admin`/`author`/`reader`), a shared `auth` **module** as the access boundary |
|
||||||
|
| Post management + editor | `docs` collection `posts`, `kv` slug index, `kv::set_if` **CAS** view counter |
|
||||||
|
| Page management + editor | `docs` collection `pages` (one method-dispatched script on many routes) |
|
||||||
|
| Post comments + moderation | `docs` collection `comments`, a **docs `before` interceptor** that HTML-escapes + sets moderation status |
|
||||||
|
| Reader users | `users::` register/login/verify, `kv` rate-limiter |
|
||||||
|
| Reader email on new posts | `docs` trigger → **durable queue** → `email::send_html` → **Mailpit** |
|
||||||
|
| Post tags | `docs` tag arrays + a tag-cloud endpoint |
|
||||||
|
| Scheduled publishing | **cron** trigger flips `scheduled`→`published` (which re-fires the notify chain) |
|
||||||
|
| Media uploads | `files` collection (base64-in-JSON, image allowlist) |
|
||||||
|
| Live comments | `pubsub::publish_durable` → **realtime SSE** (public topic) → browser `EventSource` |
|
||||||
|
| Editorial automation | **Workflow** (durable DAG: validate → enrich ∥ seo → publish → finalize) started with `workflow::start`, polled with `workflow::run_status`; live-visualized with **Svelte Flow** in the admin dashboard |
|
||||||
|
| Config / secrets / envs | `vars` (+ `picloud.dev/prod.toml` **overlays**), `secrets` (`setup-token`) |
|
||||||
|
| Deploy | `[project]` + `[app]`, declarative `pic plan/apply --dir --env`, `--prune` |
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
picloud.toml # the whole backend: scripts, routes, triggers, interceptor, vars, secrets
|
||||||
|
picloud.dev.toml # dev env overlay picloud.prod.toml # prod env overlay
|
||||||
|
scripts/**/*.rhai # ~30 Rhai scripts (endpoints, modules, triggers, interceptor)
|
||||||
|
run-server.sh # boots picloud on :8081 against the picloud_cms DB, relaying mail to Mailpit
|
||||||
|
docker-compose.override.yml # Mailpit
|
||||||
|
frontend/ # SvelteKit SPA (public site + admin dashboard)
|
||||||
|
FINDINGS.md SECURITY.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 0. infra: Postgres (repo compose) + Mailpit
|
||||||
|
docker compose up -d postgres # from repo root
|
||||||
|
docker run -d --name cms-mailpit -p 127.0.0.1:1025:1025 -p 127.0.0.1:8025:8025 \
|
||||||
|
-e MP_SMTP_AUTH_ACCEPT_ANY=true axllent/mailpit:latest
|
||||||
|
docker exec picloud-postgres-1 psql -U picloud -d picloud -c "CREATE DATABASE picloud_cms"
|
||||||
|
|
||||||
|
# 1. build + boot picloud (all-in-one) on :8081, relaying mail to Mailpit
|
||||||
|
cargo build -p picloud
|
||||||
|
bash examples/cms-poc/run-server.sh & # see the script for the exact env
|
||||||
|
|
||||||
|
# 2. deploy the CMS (from examples/cms-poc/)
|
||||||
|
cd examples/cms-poc
|
||||||
|
pic login --url http://localhost:8081 # admin / admin
|
||||||
|
pic apps create cms --name "PiCloud CMS" # apply can't create apps (FINDINGS F-006)
|
||||||
|
pic apps domains rm default <id> && pic apps domains add cms localhost # claim the host (F-009)
|
||||||
|
echo -n "cms-setup-secret-123" | pic secrets set --app cms setup-token
|
||||||
|
pic apply --dir . --env dev
|
||||||
|
|
||||||
|
# 3. bootstrap the first admin (no CLI to grant app-user roles — FINDINGS F-012)
|
||||||
|
curl -X POST localhost:8081/cms/auth/bootstrap -H 'Content-Type: application/json' \
|
||||||
|
-d '{"setup_token":"cms-setup-secret-123","email":"admin@cms.test","password":"adminpass1","display_name":"Site Admin"}'
|
||||||
|
|
||||||
|
# 4. frontend
|
||||||
|
cd frontend && npm install && npm run dev # http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
Then: register a reader, write+publish a post as admin, watch the email land in
|
||||||
|
Mailpit (http://localhost:8025), comment on the post, approve it, and see it
|
||||||
|
appear live.
|
||||||
|
|
||||||
|
## Backend API (all under `/cms/`, since `/api/` is reserved — FINDINGS F-007)
|
||||||
|
|
||||||
|
- Public: `GET /cms/posts`, `GET /cms/posts/:slug`, `GET /cms/posts/:id/comments`,
|
||||||
|
`POST /cms/posts/:id/comments`, `GET /cms/pages`, `GET /cms/pages/:slug`,
|
||||||
|
`GET /cms/tags`, `GET /cms/media/:id`, `POST /cms/auth/{register,login,logout}`, `GET /cms/auth/me`
|
||||||
|
- Author+: `GET/POST /cms/admin/posts`, `PUT/DELETE /cms/admin/posts/:id`, `POST /cms/admin/media`
|
||||||
|
- Admin: `/cms/admin/pages*`, `/cms/admin/comments*`, `/cms/admin/users*`
|
||||||
|
|
||||||
|
## Headlines from the audit
|
||||||
|
|
||||||
|
- 🟢 **Strengths:** the event model (docs/queue/cron/pubsub triggers), the
|
||||||
|
transactional-outbox notification chain, `set_if` CAS, the docs `before`
|
||||||
|
interceptor for write-time sanitization, `users::` + roles, SSE, and the
|
||||||
|
declarative `plan/apply/--prune/--env` loop all worked well.
|
||||||
|
- 🟠 **Rough edges:** `pic apply` can't create an app (only groups); `/api/` is a
|
||||||
|
reserved route prefix; `throw #{statusCode}` becomes a 502 that leaks script
|
||||||
|
internals; the docs filter DSL can't match array membership; user routes are
|
||||||
|
JSON-only (no form/binary bodies); no CORS; no CLI to grant app-user roles.
|
||||||
|
- 🔴 **Security root cause:** a public route holds **full app authority** and
|
||||||
|
there's **no per-row authz** — every check is app code — while uncaught errors
|
||||||
|
return a **502 that leaks the app id + script structure**. Get one `guard()`
|
||||||
|
wrong and it's a silent full bypass. See [SECURITY.md](SECURITY.md).
|
||||||
139
examples/cms-poc/SECURITY.md
Normal file
139
examples/cms-poc/SECURITY.md
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# PiCloud CMS PoC — Security Audit
|
||||||
|
|
||||||
|
Security findings from building & probing the CMS. Each is tagged **[PiCloud]**
|
||||||
|
(the weakness originates in the platform / SDK) or **[CMS]** (our application
|
||||||
|
code, i.e. a mistake the platform lets you make). Cross-referenced with
|
||||||
|
[FINDINGS.md](FINDINGS.md).
|
||||||
|
|
||||||
|
Severity: 🔴 critical · 🟠 high · 🟡 medium · 🔵 low/info
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **UPDATE 2026-07-18 (`fix/cms-poc-findings`): S-01/S-12 FIXED and re-verified.**
|
||||||
|
> The user-route path now runs errors through the shared `scrub_runtime_detail`
|
||||||
|
> helper: the client receives only `{"error":"script execution error (ref:
|
||||||
|
> <uuid>)"}` (HTTP 502) and the full detail is logged server-side under the same
|
||||||
|
> correlation id. Live-verified with a `throw` carrying a secret marker — no
|
||||||
|
> marker, app-id, function name, or source line reached the client. The
|
||||||
|
> highest-impact platform issue in this audit is closed. (S-02, the *no per-row
|
||||||
|
> authz* design point, remains by design — but its blast-radius amplifier is
|
||||||
|
> gone.)
|
||||||
|
|
||||||
|
## S-01 🟠→🟢 [PiCloud] Uncaught script errors return a 502 that leaks script internals — FIXED
|
||||||
|
**Finding:** Any uncaught Rhai error — including an intentional `throw`, but
|
||||||
|
also an unexpected SDK error (KV/docs failure, a `users::*` throw, a type
|
||||||
|
error) — is returned to the HTTP client as a **502** whose JSON body embeds:
|
||||||
|
the **app UUID** (`app:2cced4ba-e20b-44c9-9080-d698cc2d4cca`), the **function
|
||||||
|
names** in the call stack (`require_user`), and **line/column positions** of
|
||||||
|
the script source.
|
||||||
|
|
||||||
|
**Impact:** Information disclosure. An attacker who can trigger an error on any
|
||||||
|
public route (malformed input, oversized payload, a value that makes a script
|
||||||
|
throw) harvests the app's internal id and a map of its script structure. The
|
||||||
|
app id is a capability-shaped identifier used across the admin API.
|
||||||
|
|
||||||
|
**Attribution:** Platform. A script author cannot change the 502 shape; the
|
||||||
|
only defense is to `try/catch` *every* fallible SDK call and every code path —
|
||||||
|
impractical and easy to miss. The platform should return an opaque 500/502 and
|
||||||
|
log the detail server-side, not ship the stack to the client.
|
||||||
|
|
||||||
|
**CMS mitigation:** the `auth::guard()` pattern never throws across the
|
||||||
|
endpoint boundary, and endpoints wrap `users::create`/parse steps in
|
||||||
|
`try/catch`. This is defense the *app* must remember to apply everywhere;
|
||||||
|
coverage is only as good as author discipline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S-02 🔵 [PiCloud] Public route = full app authority (documented footgun)
|
||||||
|
**Finding:** A script bound to a public route runs with `principal: None` yet
|
||||||
|
holds **full app authority** — it can read/write every KV/docs/files
|
||||||
|
collection and read every secret. There is no platform-level per-row or
|
||||||
|
per-collection authorization; `authz::script_gate` returns Ok for an anonymous
|
||||||
|
caller. The script body is the *only* access boundary.
|
||||||
|
|
||||||
|
**Impact:** Every authorization decision in the CMS (is this user an admin? may
|
||||||
|
they edit this post? may they read this draft?) is app code the author must get
|
||||||
|
right. A single missing check exposes the whole app's data. See S-03 (IDOR) and
|
||||||
|
the S3/S4 audit rows.
|
||||||
|
|
||||||
|
**Attribution:** Platform design (documented in `docs/sdk-shape.md`). Noted
|
||||||
|
here because it is the root cause that makes S-01/S-03-class bugs so
|
||||||
|
high-impact: there is no backstop.
|
||||||
|
|
||||||
|
**CMS mitigation:** the `auth` module is imported by every privileged endpoint;
|
||||||
|
public endpoints are enumerated and each scoped to only published/approved rows
|
||||||
|
(verified in later stages).
|
||||||
|
|
||||||
|
_(Further rows added as later stages are probed: comment XSS, IDOR on docs ids,
|
||||||
|
password-reset tokens, enumeration/rate-limits, email header injection, SSRF,
|
||||||
|
secret exposure, SSE topic authz.)_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S-03 🟢 [CMS] IDOR / RBAC enforced in-script — verified
|
||||||
|
**Finding (positive, with caveat):** Because the platform provides no per-row
|
||||||
|
authz (S-02), the CMS enforces it in code. Probed:
|
||||||
|
- author editing another author's post → **403** (`post_update` checks
|
||||||
|
`cur.author_id == user.id` unless admin);
|
||||||
|
- reader creating a post → **403** (`auth::guard(["admin","author"])`);
|
||||||
|
- anonymous hitting an admin route → **401**;
|
||||||
|
- public `GET /cms/posts/:slug` on a draft → **404** (status re-checked after
|
||||||
|
slug→id resolve, so guessing a draft's slug reveals nothing).
|
||||||
|
|
||||||
|
**Caveat / attribution:** every one of these is app code that could have been
|
||||||
|
omitted with no platform warning. The doc id is a UUID (not enumerable), which
|
||||||
|
helps, but the security of the whole content model rests on each endpoint
|
||||||
|
importing `auth` and re-checking ownership. This is the S-02 footgun realized:
|
||||||
|
correct here, but only by discipline. A missing `guard()` on any single admin
|
||||||
|
route would be a silent full bypass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S-04 🟢 [CMS+PiCloud] Stored-XSS defense via interceptor — verified, with a rendering contract
|
||||||
|
**Finding:** Comment bodies + author names are HTML-escaped at write time by the
|
||||||
|
`comment_sanitize` docs interceptor (see FINDINGS F-018). Probed `<script>`,
|
||||||
|
`<img src=x onerror>`, `&`, `<b>` — all persisted escaped; `status` forced to
|
||||||
|
`pending` under manual moderation so nothing shows publicly until an admin
|
||||||
|
approves.
|
||||||
|
|
||||||
|
**Caveats / attribution:**
|
||||||
|
- **[PiCloud] create-only coverage** — the hook is `ops=["create"]`; a `docs::update`
|
||||||
|
to a comment body would NOT be re-escaped (F-020). Defense depends on the
|
||||||
|
author remembering to hook update too.
|
||||||
|
- **[CMS] rendering contract** — because bodies are stored *escaped*, the
|
||||||
|
frontend must treat them as pre-escaped (render the escaped string as text,
|
||||||
|
never re-inject raw). Storing escaped + rendering raw-innerHTML would be safe;
|
||||||
|
storing escaped + double-escaping is only a display bug; the dangerous combo
|
||||||
|
(store raw + innerHTML) is avoided by escaping at the boundary.
|
||||||
|
- **[PiCloud] fail-open risk** — if the sanitizer is mis-scoped or its `ops`
|
||||||
|
list misses a path, the platform gives no warning that a write reached the
|
||||||
|
store unsanitized. The interceptor's own fail-*closed* verdict only protects
|
||||||
|
the ops it's actually registered for.
|
||||||
|
|
||||||
|
## S-05 🔵 [CMS] Commenter emails withheld from public API — verified
|
||||||
|
`comments_for_post` strips `author_email` before returning; only approved
|
||||||
|
comments are listed. Admin-only `comments_admin` retains the email for
|
||||||
|
moderation. No email harvesting via the public comment feed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S-06 🟢 [PiCloud] Email header injection is prevented by the SDK
|
||||||
|
Published a post whose title contained `Injected\r\nBcc: attacker@evil.test\r\nX-Evil: 1`; that title flows into the notification `email::send_html` subject. The delivered message encoded the subject as an RFC 2047 word (`Subject: =?utf-8?b?...?=`), so the CRLF is inert — **no injected `Bcc` recipient, no `X-Evil` header** (verified against the raw message in Mailpit). `email::send`/lettre encodes header values, so CRLF-in-header-value injection is not possible. Attribution: platform (good).
|
||||||
|
|
||||||
|
## S-07 🟢 [PiCloud] `http::` blocks SSRF to private/loopback by default
|
||||||
|
The outbound `http::` service wires an `SsrfResolver` with `allow_private = false` by default (http_service.rs:70, `SsrfPolicy`), filtering private/loopback/link-local addresses at DNS resolution; only an admin opt-in flips it. This CMS doesn't fetch user-supplied URLs, so SSRF isn't reachable here — but had we (e.g. remote-image import), the platform defends by default. Attribution: platform (good).
|
||||||
|
|
||||||
|
## S-08 🟢 [PiCloud] Secrets never returned in plaintext
|
||||||
|
`pic config --effective` shows `setup-token` as `<set>` (status `managed`), never the value; no endpoint echoes `secrets::get`, and the bootstrap endpoint compares server-side without reflecting the token. AES-GCM at rest. Good.
|
||||||
|
|
||||||
|
## S-09 🟡 [CMS] User-existence oracle via registration + `email_available`
|
||||||
|
`POST /cms/auth/register` returns **409 "email already registered"** for a taken email vs 201 for a new one — a reliable existence oracle. `users::email_available` is documented as the anon-safe probe (and *is* the honest way to check), but it's **not rate-limited by the platform** (only our per-IP kv limiter gates it, and that limiter shares one bucket when `x-forwarded-for` is absent / is spoofable). **Attribution: mostly [CMS]** (we chose to surface 409), but **[PiCloud]** contributes: there's no platform throttle on `users::*`, no captcha primitive, and no trustworthy client-IP in `ctx` to key a limiter on. A production CMS would return a generic "check your email" and add real rate limiting at the proxy.
|
||||||
|
|
||||||
|
## S-10 🟡 [PiCloud] Session token lives in `localStorage` (XSS → token theft)
|
||||||
|
`users::login` returns a bearer token the SPA stores in `localStorage` (there's no httpOnly-cookie session option from `users::`). Any XSS in the app can exfiltrate it. The comment interceptor (S-04) is the main stored-XSS guard, but the token model puts a premium on that guard never failing. **Attribution [PiCloud]**: the app-user auth model is bearer-token only; it offers no cookie/CSRF-style option, so browser apps must hold the token in JS-reachable storage. (Upside: no cookies → no CSRF surface.)
|
||||||
|
|
||||||
|
## S-11 🔵 [CMS] Public media is world-readable by id; no per-object ACL
|
||||||
|
`GET /cms/media/:id` returns any file in the `media` collection to anyone. Fine for a blog (media is public) and ids are UUIDs, but there's no platform per-object authz (S-02), so a "private draft image" feature would need app-enforced checks. Noted, not exploitable here.
|
||||||
|
|
||||||
|
## S-12 🟠 [PiCloud] The 502 info-leak (S-01) is the highest-impact platform issue
|
||||||
|
Re-flagging: because uncaught errors leak the app UUID + script structure (S-01), and because all authz is app-code (S-02), the blast radius of any un-`try`-caught path is amplified. This pairing — no per-row authz + verbose error disclosure — is the single most important thing a PiCloud app author must design around.
|
||||||
7
examples/cms-poc/frontend/.gitignore
vendored
Normal file
7
examples/cms-poc/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
.svelte-kit/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.local
|
||||||
2
examples/cms-poc/frontend/.npmrc
Normal file
2
examples/cms-poc/frontend/.npmrc
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fund=false
|
||||||
|
audit=false
|
||||||
1607
examples/cms-poc/frontend/package-lock.json
generated
Normal file
1607
examples/cms-poc/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
examples/cms-poc/frontend/package.json
Normal file
21
examples/cms-poc/frontend/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "cms-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev --port 5173 --host",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-static": "^3.0.6",
|
||||||
|
"@sveltejs/kit": "^2.8.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
|
"svelte": "^5.1.0",
|
||||||
|
"vite": "^5.4.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@xyflow/svelte": "^1.6.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
examples/cms-poc/frontend/src/app.html
Normal file
12
examples/cms-poc/frontend/src/app.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>PiCloud CMS</title>
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div style="max-width:820px;margin:0 auto;padding:0 16px">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
39
examples/cms-poc/frontend/src/lib/api.ts
Normal file
39
examples/cms-poc/frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// Thin fetch wrapper around the PiCloud CMS backend. The browser calls
|
||||||
|
// same-origin /cms/* (Vite proxies to :8081), so there's no CORS/preflight.
|
||||||
|
const TOKEN_KEY = 'cms_token';
|
||||||
|
|
||||||
|
export function getToken(): string | null {
|
||||||
|
if (typeof localStorage === 'undefined') return null;
|
||||||
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
export function setToken(t: string | null) {
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
if (t) localStorage.setItem(TOKEN_KEY, t);
|
||||||
|
else localStorage.removeItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function req(method: string, path: string, body?: unknown) {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
const t = getToken();
|
||||||
|
if (t) headers['Authorization'] = 'Bearer ' + t;
|
||||||
|
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||||
|
const res = await fetch('/cms' + path, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let data: any = null;
|
||||||
|
try { data = text ? JSON.parse(text) : null; } catch { data = text; }
|
||||||
|
if (!res.ok) {
|
||||||
|
throw Object.assign(new Error((data && data.error) || res.statusText), { status: res.status, data });
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: (p: string) => req('GET', p),
|
||||||
|
post: (p: string, b?: unknown) => req('POST', p, b),
|
||||||
|
put: (p: string, b?: unknown) => req('PUT', p, b),
|
||||||
|
del: (p: string) => req('DELETE', p)
|
||||||
|
};
|
||||||
27
examples/cms-poc/frontend/src/lib/auth.ts
Normal file
27
examples/cms-poc/frontend/src/lib/auth.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import { api, getToken, setToken } from './api';
|
||||||
|
|
||||||
|
export const user = writable<any | null>(null);
|
||||||
|
|
||||||
|
export async function refreshUser() {
|
||||||
|
if (!getToken()) { user.set(null); return; }
|
||||||
|
try { user.set(await api.get('/auth/me')); }
|
||||||
|
catch { setToken(null); user.set(null); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(email: string, password: string) {
|
||||||
|
const r = await api.post('/auth/login', { email, password });
|
||||||
|
setToken(r.token);
|
||||||
|
user.set(r.user);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
try { await api.post('/auth/logout'); } catch { /* ignore */ }
|
||||||
|
setToken(null);
|
||||||
|
user.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasRole(u: any, role: string): boolean {
|
||||||
|
return !!u && Array.isArray(u.roles) && u.roles.includes(role);
|
||||||
|
}
|
||||||
25
examples/cms-poc/frontend/src/lib/markdown.ts
Normal file
25
examples/cms-poc/frontend/src/lib/markdown.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// Tiny, safe-ish markdown renderer for post bodies. It ESCAPES all HTML first
|
||||||
|
// (so even a trusted author can't inject script), then applies a small set of
|
||||||
|
// inline/block transforms. Not a full CommonMark impl — enough for a PoC.
|
||||||
|
export function renderMarkdown(src: string): string {
|
||||||
|
if (!src) return '';
|
||||||
|
let s = src
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
// headings
|
||||||
|
s = s.replace(/^### (.*)$/gm, '<h3>$1</h3>');
|
||||||
|
s = s.replace(/^## (.*)$/gm, '<h2>$1</h2>');
|
||||||
|
s = s.replace(/^# (.*)$/gm, '<h1>$1</h1>');
|
||||||
|
// bold / italic / code
|
||||||
|
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||||
|
s = s.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||||
|
s = s.replace(/`(.+?)`/g, '<code>$1</code>');
|
||||||
|
// links [text](http...) — only http(s) URLs
|
||||||
|
s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" rel="noopener noreferrer">$1</a>');
|
||||||
|
// paragraphs from blank-line separated blocks (skip block-level tags)
|
||||||
|
return s
|
||||||
|
.split(/\n{2,}/)
|
||||||
|
.map((b) => (/^\s*<h[1-3]>/.test(b) ? b : '<p>' + b.replace(/\n/g, '<br>') + '</p>'))
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
55
examples/cms-poc/frontend/src/routes/+layout.svelte
Normal file
55
examples/cms-poc/frontend/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { user, refreshUser, logout, hasRole } from '$lib/auth';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
onMount(refreshUser);
|
||||||
|
|
||||||
|
async function doLogout() {
|
||||||
|
await logout();
|
||||||
|
goto('/');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<nav>
|
||||||
|
<a href="/" class="brand">📝 PiCloud Blog</a>
|
||||||
|
<a href="/">Home</a>
|
||||||
|
<a href="/pages/about">About</a>
|
||||||
|
{#if $user && (hasRole($user, 'admin') || hasRole($user, 'author'))}
|
||||||
|
<a href="/admin">Dashboard</a>
|
||||||
|
{/if}
|
||||||
|
<span class="spacer"></span>
|
||||||
|
{#if $user}
|
||||||
|
<span class="who">{$user.display_name || $user.email} ({$user.roles.join(', ')})</span>
|
||||||
|
<button onclick={doLogout}>Log out</button>
|
||||||
|
{:else}
|
||||||
|
<a href="/login">Log in</a>
|
||||||
|
<a href="/register">Register</a>
|
||||||
|
{/if}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>PiCloud CMS PoC — backend is 100% PiCloud Rhai scripts.</footer>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(body) { font-family: system-ui, sans-serif; color: #222; line-height: 1.5; }
|
||||||
|
header { border-bottom: 1px solid #eee; margin-bottom: 1.5rem; }
|
||||||
|
nav { display: flex; gap: 1rem; align-items: center; padding: 0.8rem 0; flex-wrap: wrap; }
|
||||||
|
nav a { text-decoration: none; color: #2563eb; }
|
||||||
|
nav .brand { font-weight: 700; color: #111; }
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
.who { color: #666; font-size: 0.85rem; }
|
||||||
|
button { cursor: pointer; border: 1px solid #ccc; background: #f8f8f8; border-radius: 6px; padding: 0.3rem 0.7rem; }
|
||||||
|
footer { margin: 3rem 0 1rem; color: #999; font-size: 0.8rem; border-top: 1px solid #eee; padding-top: 1rem; }
|
||||||
|
:global(input), :global(textarea), :global(select) { font: inherit; padding: 0.4rem; border: 1px solid #ccc; border-radius: 6px; width: 100%; box-sizing: border-box; }
|
||||||
|
:global(.btn) { background: #2563eb; color: #fff; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; }
|
||||||
|
:global(.err) { color: #b91c1c; }
|
||||||
|
:global(.card) { border: 1px solid #eee; border-radius: 8px; padding: 1rem; margin: 0.8rem 0; }
|
||||||
|
:global(label) { display: block; margin: 0.6rem 0 0.2rem; font-size: 0.85rem; color: #555; }
|
||||||
|
</style>
|
||||||
3
examples/cms-poc/frontend/src/routes/+layout.ts
Normal file
3
examples/cms-poc/frontend/src/routes/+layout.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// SPA: no SSR (everything is client-side fetch against the proxied backend).
|
||||||
|
export const ssr = false;
|
||||||
|
export const prerender = false;
|
||||||
55
examples/cms-poc/frontend/src/routes/+page.svelte
Normal file
55
examples/cms-poc/frontend/src/routes/+page.svelte
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
let posts = $state<any[]>([]);
|
||||||
|
let tags = $state<any[]>([]);
|
||||||
|
let activeTag = $state<string | null>(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
async function load(tag: string | null) {
|
||||||
|
loading = true;
|
||||||
|
activeTag = tag;
|
||||||
|
const q = tag ? '?tag=' + encodeURIComponent(tag) : '';
|
||||||
|
const [p, t] = await Promise.all([api.get('/posts' + q), api.get('/tags')]);
|
||||||
|
posts = p.posts;
|
||||||
|
tags = t.tags;
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
onMount(() => load(null));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>Latest posts</h1>
|
||||||
|
|
||||||
|
{#if tags.length}
|
||||||
|
<div class="tagcloud">
|
||||||
|
<button class:active={!activeTag} onclick={() => load(null)}>all</button>
|
||||||
|
{#each tags as t}
|
||||||
|
<button class:active={activeTag === t.tag} onclick={() => load(t.tag)}>#{t.tag} ({t.count})</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p>Loading…</p>
|
||||||
|
{:else if posts.length === 0}
|
||||||
|
<p>No posts yet.</p>
|
||||||
|
{:else}
|
||||||
|
{#each posts as post}
|
||||||
|
<article class="card">
|
||||||
|
<h2><a href={'/posts/' + post.slug}>{post.title}</a></h2>
|
||||||
|
<p class="meta">by {post.author_name} · {new Date(post.publish_at).toLocaleDateString()}</p>
|
||||||
|
{#if post.excerpt}<p>{post.excerpt}</p>{/if}
|
||||||
|
<p class="tags">{#each post.tags as tg}<span>#{tg}</span> {/each}</p>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tagcloud { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-bottom: 1rem; }
|
||||||
|
.tagcloud button { border: 1px solid #ddd; background: #fafafa; border-radius: 999px; padding: 0.2rem 0.7rem; cursor: pointer; font-size: 0.8rem; }
|
||||||
|
.tagcloud button.active { background: #2563eb; color: #fff; border-color: #2563eb; }
|
||||||
|
.meta { color: #888; font-size: 0.85rem; margin: 0.2rem 0; }
|
||||||
|
.tags span { color: #2563eb; font-size: 0.8rem; margin-right: 0.3rem; }
|
||||||
|
</style>
|
||||||
37
examples/cms-poc/frontend/src/routes/admin/+layout.svelte
Normal file
37
examples/cms-poc/frontend/src/routes/admin/+layout.svelte
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { user, refreshUser, hasRole } from '$lib/auth';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
let { children } = $props();
|
||||||
|
let ready = $state(false);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await refreshUser();
|
||||||
|
ready = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (ready && (!$user || (!hasRole($user, 'admin') && !hasRole($user, 'author')))) {
|
||||||
|
goto('/login');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if ready && $user && (hasRole($user, 'admin') || hasRole($user, 'author'))}
|
||||||
|
<div class="adminnav">
|
||||||
|
<a href="/admin">Overview</a>
|
||||||
|
<a href="/admin/posts">Posts</a>
|
||||||
|
<a href="/admin/pages">Pages</a>
|
||||||
|
<a href="/admin/comments">Comments</a>
|
||||||
|
<a href="/admin/workflow">Pipeline</a>
|
||||||
|
{#if hasRole($user, 'admin')}<a href="/admin/users">Users</a>{/if}
|
||||||
|
</div>
|
||||||
|
{@render children()}
|
||||||
|
{:else}
|
||||||
|
<p>Checking access…</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.adminnav { display: flex; gap: 1rem; padding: 0.6rem 0.8rem; background: #f3f4f6; border-radius: 8px; margin-bottom: 1rem; }
|
||||||
|
.adminnav a { text-decoration: none; color: #374151; font-weight: 500; }
|
||||||
|
</style>
|
||||||
32
examples/cms-poc/frontend/src/routes/admin/+page.svelte
Normal file
32
examples/cms-poc/frontend/src/routes/admin/+page.svelte
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { user, hasRole } from '$lib/auth';
|
||||||
|
let stats = $state({ posts: 0, pending: 0, users: 0 });
|
||||||
|
onMount(async () => {
|
||||||
|
const posts = (await api.get('/admin/posts')).posts;
|
||||||
|
stats.posts = posts.length;
|
||||||
|
try {
|
||||||
|
const pending = (await api.get('/admin/comments?status=pending')).comments;
|
||||||
|
stats.pending = pending.length;
|
||||||
|
} catch { /* authors can't list comments */ }
|
||||||
|
if (hasRole($user, 'admin')) {
|
||||||
|
try { stats.users = (await api.get('/admin/users')).users.length; } catch { /* */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card"><h2>{stats.posts}</h2><p>your posts</p><a href="/admin/posts">Manage →</a></div>
|
||||||
|
<div class="card"><h2>{stats.pending}</h2><p>pending comments</p><a href="/admin/comments">Moderate →</a></div>
|
||||||
|
{#if hasRole($user, 'admin')}
|
||||||
|
<div class="card"><h2>{stats.users}</h2><p>users</p><a href="/admin/users">Manage →</a></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 1rem; }
|
||||||
|
.card h2 { margin: 0; font-size: 2rem; }
|
||||||
|
.card p { margin: 0.2rem 0; color: #666; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
let comments = $state<any[]>([]);
|
||||||
|
let filter = $state('pending');
|
||||||
|
let msg = $state('');
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const q = filter ? '?status=' + filter : '';
|
||||||
|
comments = (await api.get('/admin/comments' + q)).comments;
|
||||||
|
}
|
||||||
|
onMount(load);
|
||||||
|
|
||||||
|
async function setStatus(c: any, status: string) {
|
||||||
|
try { await api.put('/admin/comments/' + c.id, { status }); await load(); }
|
||||||
|
catch (e: any) { msg = e.message; }
|
||||||
|
}
|
||||||
|
async function del(c: any) {
|
||||||
|
if (!confirm('Delete comment?')) return;
|
||||||
|
try { await api.del('/admin/comments/' + c.id); await load(); }
|
||||||
|
catch (e: any) { msg = e.message; }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>Comment moderation</h1>
|
||||||
|
<p>
|
||||||
|
Filter:
|
||||||
|
<select bind:value={filter} onchange={load} style="width:auto;display:inline-block">
|
||||||
|
<option value="pending">pending</option>
|
||||||
|
<option value="approved">approved</option>
|
||||||
|
<option value="spam">spam</option>
|
||||||
|
<option value="">all</option>
|
||||||
|
</select>
|
||||||
|
<span class="err">{msg}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#each comments as c}
|
||||||
|
<div class="card">
|
||||||
|
<p><strong>{@html c.author_name}</strong> on <a href={'/posts/' + c.post_slug}>{c.post_title}</a>
|
||||||
|
· <span class="meta">{new Date(c.created_at).toLocaleString()}</span>
|
||||||
|
· <span class="badge">{c.status}</span></p>
|
||||||
|
<!-- pre-escaped by the interceptor -->
|
||||||
|
<p>{@html c.body}</p>
|
||||||
|
<p>
|
||||||
|
{#if c.status !== 'approved'}<button class="btn" onclick={() => setStatus(c, 'approved')}>approve</button>{/if}
|
||||||
|
{#if c.status !== 'spam'}<button onclick={() => setStatus(c, 'spam')}>spam</button>{/if}
|
||||||
|
<button onclick={() => del(c)}>delete</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p>No comments.</p>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.meta { color: #888; font-size: 0.85rem; }
|
||||||
|
.badge { background: #eef2ff; color: #3730a3; padding: 0.1rem 0.5rem; border-radius: 999px; font-size: 0.75rem; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
let pages = $state<any[]>([]);
|
||||||
|
let editing = $state<any | null>(null);
|
||||||
|
let msg = $state('');
|
||||||
|
const blank = () => ({ id: null, title: '', slug: '', body_md: '', status: 'draft' });
|
||||||
|
async function load() { pages = (await api.get('/admin/pages')).pages; }
|
||||||
|
onMount(load);
|
||||||
|
async function save(e: Event) {
|
||||||
|
e.preventDefault(); msg = '';
|
||||||
|
const b = { title: editing.title, slug: editing.slug, body_md: editing.body_md, status: editing.status };
|
||||||
|
try {
|
||||||
|
if (editing.id) await api.put('/admin/pages/' + editing.id, b);
|
||||||
|
else await api.post('/admin/pages', b);
|
||||||
|
editing = null; await load(); msg = 'Saved.';
|
||||||
|
} catch (err: any) { msg = 'Error: ' + err.message; }
|
||||||
|
}
|
||||||
|
async function del(p: any) {
|
||||||
|
if (!confirm('Delete page?')) return;
|
||||||
|
await api.del('/admin/pages/' + p.id); await load();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>Pages</h1>
|
||||||
|
<p><button class="btn" onclick={() => (editing = blank())}>+ New page</button> {msg}</p>
|
||||||
|
|
||||||
|
{#if editing}
|
||||||
|
<form class="card" onsubmit={save}>
|
||||||
|
<label>Title</label><input bind:value={editing.title} required />
|
||||||
|
<label>Slug</label><input bind:value={editing.slug} />
|
||||||
|
<label>Body (markdown)</label><textarea bind:value={editing.body_md} rows="8"></textarea>
|
||||||
|
<label>Status</label>
|
||||||
|
<select bind:value={editing.status}><option value="draft">draft</option><option value="published">published</option></select>
|
||||||
|
<p><button class="btn" type="submit">Save</button> <button type="button" onclick={() => (editing = null)}>Cancel</button></p>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{#each pages as p}
|
||||||
|
<li>
|
||||||
|
<strong>{p.title}</strong> <span class="badge">{p.status}</span>
|
||||||
|
— <a href={'/pages/' + p.slug}>view</a>
|
||||||
|
<button onclick={() => (editing = { ...p })}>edit</button>
|
||||||
|
<button onclick={() => del(p)}>del</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
li { margin: 0.4rem 0; }
|
||||||
|
.badge { background: #f3f4f6; color: #555; padding: 0.1rem 0.5rem; border-radius: 999px; font-size: 0.75rem; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
|
||||||
|
let posts = $state<any[]>([]);
|
||||||
|
let editing = $state<any | null>(null);
|
||||||
|
let msg = $state('');
|
||||||
|
|
||||||
|
const blank = () => ({ id: null, title: '', slug: '', body_md: '', excerpt: '', tags: '', status: 'draft', publish_at: '' });
|
||||||
|
|
||||||
|
async function load() { posts = (await api.get('/admin/posts')).posts; }
|
||||||
|
onMount(load);
|
||||||
|
|
||||||
|
function newPost() { editing = blank(); }
|
||||||
|
function edit(p: any) {
|
||||||
|
editing = { ...p, tags: (p.tags || []).join(', '), publish_at: p.publish_at || '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
msg = '';
|
||||||
|
const body: any = {
|
||||||
|
title: editing.title,
|
||||||
|
slug: editing.slug,
|
||||||
|
body_md: editing.body_md,
|
||||||
|
excerpt: editing.excerpt,
|
||||||
|
status: editing.status,
|
||||||
|
tags: editing.tags.split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||||
|
};
|
||||||
|
if (editing.status === 'scheduled' && editing.publish_at) body.publish_at = editing.publish_at;
|
||||||
|
try {
|
||||||
|
if (editing.id) await api.put('/admin/posts/' + editing.id, body);
|
||||||
|
else await api.post('/admin/posts', body);
|
||||||
|
msg = 'Saved.';
|
||||||
|
editing = null;
|
||||||
|
await load();
|
||||||
|
} catch (err: any) { msg = 'Error: ' + err.message; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function del(p: any) {
|
||||||
|
if (!confirm('Delete "' + p.title + '"?')) return;
|
||||||
|
try { await api.del('/admin/posts/' + p.id); await load(); }
|
||||||
|
catch (e: any) { msg = 'Error: ' + e.message; }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>Posts</h1>
|
||||||
|
<p><button class="btn" onclick={newPost}>+ New post</button> {msg}</p>
|
||||||
|
|
||||||
|
{#if editing}
|
||||||
|
<form class="card" onsubmit={save}>
|
||||||
|
<h3>{editing.id ? 'Edit' : 'New'} post</h3>
|
||||||
|
<label>Title</label><input bind:value={editing.title} required />
|
||||||
|
<label>Slug (optional)</label><input bind:value={editing.slug} />
|
||||||
|
<label>Excerpt</label><input bind:value={editing.excerpt} />
|
||||||
|
<label>Tags (comma separated)</label><input bind:value={editing.tags} />
|
||||||
|
<label>Body (markdown)</label><textarea bind:value={editing.body_md} rows="8"></textarea>
|
||||||
|
<label>Status</label>
|
||||||
|
<select bind:value={editing.status}>
|
||||||
|
<option value="draft">draft</option>
|
||||||
|
<option value="published">published</option>
|
||||||
|
<option value="scheduled">scheduled</option>
|
||||||
|
</select>
|
||||||
|
{#if editing.status === 'scheduled'}
|
||||||
|
<label>Publish at (ISO, e.g. 2026-07-18T09:00:00Z)</label>
|
||||||
|
<input bind:value={editing.publish_at} />
|
||||||
|
{/if}
|
||||||
|
<p><button class="btn" type="submit">Save</button> <button type="button" onclick={() => (editing = null)}>Cancel</button></p>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Title</th><th>Status</th><th>Tags</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{#each posts as p}
|
||||||
|
<tr>
|
||||||
|
<td>{p.title}</td>
|
||||||
|
<td><span class={'badge ' + p.status}>{p.status}</span></td>
|
||||||
|
<td>{(p.tags || []).join(', ')}</td>
|
||||||
|
<td>
|
||||||
|
<button onclick={() => edit(p)}>edit</button>
|
||||||
|
<button onclick={() => del(p)}>del</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #eee; font-size: 0.9rem; }
|
||||||
|
.badge { padding: 0.1rem 0.5rem; border-radius: 999px; font-size: 0.75rem; }
|
||||||
|
.badge.published { background: #dcfce7; color: #166534; }
|
||||||
|
.badge.draft { background: #f3f4f6; color: #555; }
|
||||||
|
.badge.scheduled { background: #fef9c3; color: #854d0e; }
|
||||||
|
td button { margin-right: 0.3rem; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
let users = $state<any[]>([]);
|
||||||
|
let msg = $state('');
|
||||||
|
const ROLES = ['admin', 'author', 'reader'];
|
||||||
|
|
||||||
|
async function load() { users = (await api.get('/admin/users')).users; }
|
||||||
|
onMount(load);
|
||||||
|
|
||||||
|
async function toggle(u: any, role: string) {
|
||||||
|
const has = u.roles.includes(role);
|
||||||
|
try {
|
||||||
|
await api.put('/admin/users/' + u.id + '/roles', has ? { remove: [role] } : { add: [role] });
|
||||||
|
await load();
|
||||||
|
} catch (e: any) { msg = e.message; }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>Users</h1>
|
||||||
|
<p class="err">{msg}</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Email</th><th>Name</th><th>Roles</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{#each users as u}
|
||||||
|
<tr>
|
||||||
|
<td>{u.email}</td>
|
||||||
|
<td>{u.display_name || '—'}</td>
|
||||||
|
<td>
|
||||||
|
{#each ROLES as r}
|
||||||
|
<label class="chip">
|
||||||
|
<input type="checkbox" checked={u.roles.includes(r)} onchange={() => toggle(u, r)} />
|
||||||
|
{r}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #eee; font-size: 0.9rem; }
|
||||||
|
.chip { display: inline-flex; align-items: center; gap: 0.2rem; margin-right: 0.6rem; width: auto; font-size: 0.85rem; }
|
||||||
|
.chip input { width: auto; }
|
||||||
|
</style>
|
||||||
148
examples/cms-poc/frontend/src/routes/admin/workflow/+page.svelte
Normal file
148
examples/cms-poc/frontend/src/routes/admin/workflow/+page.svelte
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { SvelteFlow, Background, Controls } from '@xyflow/svelte';
|
||||||
|
import '@xyflow/svelte/dist/style.css';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
|
||||||
|
let nodes = $state<any[]>([]);
|
||||||
|
let edges = $state<any[]>([]);
|
||||||
|
let dag = $state<any>(null);
|
||||||
|
let drafts = $state<any[]>([]);
|
||||||
|
let selected = $state<string>('');
|
||||||
|
let runId = $state<string | null>(null);
|
||||||
|
let overall = $state<string>('');
|
||||||
|
let msg = $state('');
|
||||||
|
let poll: any = null;
|
||||||
|
|
||||||
|
const STATUS_STYLE: Record<string, string> = {
|
||||||
|
pending: 'background:#f3f4f6;border:1px solid #d1d5db;color:#6b7280',
|
||||||
|
running: 'background:#dbeafe;border:2px solid #2563eb;color:#1e40af',
|
||||||
|
succeeded: 'background:#dcfce7;border:2px solid #16a34a;color:#166534',
|
||||||
|
failed: 'background:#fee2e2;border:2px solid #dc2626;color:#991b1b',
|
||||||
|
skipped: 'background:#fafafa;border:1px dashed #cbd5e1;color:#94a3b8'
|
||||||
|
};
|
||||||
|
|
||||||
|
// layer each node by longest dependency path, then spread within the layer
|
||||||
|
function layout(steps: any[]) {
|
||||||
|
const byName: Record<string, any> = {};
|
||||||
|
steps.forEach((s) => (byName[s.name] = s));
|
||||||
|
const layer: Record<string, number> = {};
|
||||||
|
const depth = (n: string): number => {
|
||||||
|
if (layer[n] != null) return layer[n];
|
||||||
|
const deps = byName[n].depends_on || [];
|
||||||
|
const d = deps.length ? 1 + Math.max(...deps.map(depth)) : 0;
|
||||||
|
return (layer[n] = d);
|
||||||
|
};
|
||||||
|
steps.forEach((s) => depth(s.name));
|
||||||
|
const layers: Record<number, string[]> = {};
|
||||||
|
steps.forEach((s) => (layers[layer[s.name]] ||= []).push(s.name));
|
||||||
|
const pos: Record<string, { x: number; y: number }> = {};
|
||||||
|
Object.entries(layers).forEach(([l, names]) => {
|
||||||
|
names.forEach((n, i) => {
|
||||||
|
pos[n] = { x: 120 + i * 240 - ((names.length - 1) * 240) / 2 + 200, y: +l * 130 };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGraph(steps: any[], statuses: Record<string, any> = {}) {
|
||||||
|
const pos = layout(steps);
|
||||||
|
nodes = steps.map((s) => {
|
||||||
|
const st = statuses[s.name]?.status || 'pending';
|
||||||
|
const sub = s.when ? `\nwhen: ${s.when}` : '';
|
||||||
|
return {
|
||||||
|
id: s.name,
|
||||||
|
data: { label: `${s.label}\n[${st}]${sub}` },
|
||||||
|
position: pos[s.name],
|
||||||
|
style: `${STATUS_STYLE[st] || STATUS_STYLE.pending};border-radius:8px;padding:8px 10px;width:190px;white-space:pre-line;font-size:12px;text-align:center`
|
||||||
|
};
|
||||||
|
});
|
||||||
|
edges = steps.flatMap((s) =>
|
||||||
|
(s.depends_on || []).map((d: string) => ({
|
||||||
|
id: `${d}-${s.name}`,
|
||||||
|
source: d,
|
||||||
|
target: s.name,
|
||||||
|
animated: statuses[s.name]?.status === 'running'
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDag() {
|
||||||
|
dag = await api.get('/admin/workflow');
|
||||||
|
buildGraph(dag.steps);
|
||||||
|
const posts = (await api.get('/admin/posts')).posts;
|
||||||
|
drafts = posts.filter((p: any) => p.status === 'draft');
|
||||||
|
if (drafts.length) selected = drafts[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
if (!selected) return;
|
||||||
|
msg = 'starting…';
|
||||||
|
stopPoll();
|
||||||
|
const r = await api.post(`/admin/posts/${selected}/pipeline`, {});
|
||||||
|
runId = r.run_id;
|
||||||
|
overall = 'running';
|
||||||
|
msg = `run ${runId.slice(0, 8)}… started`;
|
||||||
|
poll = setInterval(tick, 700);
|
||||||
|
tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
if (!runId) return;
|
||||||
|
try {
|
||||||
|
const r = await api.get(`/admin/workflow/runs/${runId}`);
|
||||||
|
overall = r.overall.status;
|
||||||
|
buildGraph(dag.steps, r.steps);
|
||||||
|
if (overall !== 'running') {
|
||||||
|
stopPoll();
|
||||||
|
msg = `run ${overall}`;
|
||||||
|
loadDraftsSoon();
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
msg = e.message;
|
||||||
|
stopPoll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function loadDraftsSoon() {
|
||||||
|
const posts = (await api.get('/admin/posts')).posts;
|
||||||
|
drafts = posts.filter((p: any) => p.status === 'draft');
|
||||||
|
}
|
||||||
|
function stopPoll() { if (poll) { clearInterval(poll); poll = null; } }
|
||||||
|
|
||||||
|
onMount(loadDag);
|
||||||
|
onDestroy(stopPoll);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>Editorial pipeline <span class="muted">(PiCloud Workflow)</span></h1>
|
||||||
|
<p class="muted">
|
||||||
|
A durable DAG: <code>validate → (enrich ∥ seo) → publish → finalize</code>. Pick a draft and run it —
|
||||||
|
the nodes light up as each step completes. Publishing chains into the reader-notification pipeline.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="bar">
|
||||||
|
<select bind:value={selected} style="width:auto;max-width:320px">
|
||||||
|
{#if drafts.length === 0}<option value="">— no drafts (create one in Posts) —</option>{/if}
|
||||||
|
{#each drafts as d}<option value={d.id}>{d.title}</option>{/each}
|
||||||
|
</select>
|
||||||
|
<button class="btn" onclick={run} disabled={!selected || overall === 'running'}>▶ Run pipeline</button>
|
||||||
|
{#if overall}<span class="pill {overall}">{overall}</span>{/if}
|
||||||
|
<span class="muted">{msg}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flow">
|
||||||
|
<SvelteFlow bind:nodes bind:edges fitView>
|
||||||
|
<Background />
|
||||||
|
<Controls />
|
||||||
|
</SvelteFlow>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.muted { color: #888; font-weight: 400; font-size: 0.9rem; }
|
||||||
|
.bar { display: flex; gap: 0.75rem; align-items: center; margin: 1rem 0; flex-wrap: wrap; }
|
||||||
|
.flow { height: 460px; border: 1px solid #e5e7eb; border-radius: 10px; overflow: hidden; }
|
||||||
|
.pill { padding: 0.15rem 0.6rem; border-radius: 999px; font-size: 0.8rem; text-transform: uppercase; }
|
||||||
|
.pill.running { background: #dbeafe; color: #1e40af; }
|
||||||
|
.pill.completed { background: #dcfce7; color: #166534; }
|
||||||
|
.pill.blocked { background: #fee2e2; color: #991b1b; }
|
||||||
|
code { background: #f3f4f6; padding: 0.05rem 0.3rem; border-radius: 4px; }
|
||||||
|
</style>
|
||||||
24
examples/cms-poc/frontend/src/routes/login/+page.svelte
Normal file
24
examples/cms-poc/frontend/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { login } from '$lib/auth';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
let email = $state('');
|
||||||
|
let password = $state('');
|
||||||
|
let error = $state('');
|
||||||
|
async function submit(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
error = '';
|
||||||
|
try { await login(email, password); goto('/'); }
|
||||||
|
catch (err: any) { error = err.message || 'login failed'; }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>Log in</h1>
|
||||||
|
<form onsubmit={submit} style="max-width:360px">
|
||||||
|
<label>Email</label>
|
||||||
|
<input bind:value={email} type="email" required />
|
||||||
|
<label>Password</label>
|
||||||
|
<input bind:value={password} type="password" required />
|
||||||
|
<p><button class="btn" type="submit">Log in</button></p>
|
||||||
|
{#if error}<p class="err">{error}</p>{/if}
|
||||||
|
</form>
|
||||||
|
<p>No account? <a href="/register">Register as a reader</a>.</p>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { renderMarkdown } from '$lib/markdown';
|
||||||
|
let pg = $state<any>(null);
|
||||||
|
let error = $state('');
|
||||||
|
const slug = $derived($page.params.slug);
|
||||||
|
async function load() {
|
||||||
|
try { pg = await api.get('/pages/' + slug); }
|
||||||
|
catch (e: any) { error = e.message; }
|
||||||
|
}
|
||||||
|
onMount(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="err">Page not found.</p>
|
||||||
|
{:else if pg}
|
||||||
|
<h1>{pg.title}</h1>
|
||||||
|
<div>{@html renderMarkdown(pg.body_md)}</div>
|
||||||
|
{:else}
|
||||||
|
<p>Loading…</p>
|
||||||
|
{/if}
|
||||||
110
examples/cms-poc/frontend/src/routes/posts/[slug]/+page.svelte
Normal file
110
examples/cms-poc/frontend/src/routes/posts/[slug]/+page.svelte
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { renderMarkdown } from '$lib/markdown';
|
||||||
|
|
||||||
|
let post = $state<any>(null);
|
||||||
|
let comments = $state<any[]>([]);
|
||||||
|
let error = $state('');
|
||||||
|
let form = $state({ author_name: '', author_email: '', body: '' });
|
||||||
|
let submitMsg = $state('');
|
||||||
|
|
||||||
|
const slug = $derived($page.params.slug);
|
||||||
|
|
||||||
|
let live = $state(false);
|
||||||
|
let es: EventSource | null = null;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
post = await api.get('/posts/' + slug);
|
||||||
|
comments = (await api.get('/posts/' + post.id + '/comments')).comments;
|
||||||
|
subscribeLive();
|
||||||
|
} catch (e: any) {
|
||||||
|
error = e.message || 'not found';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeLive() {
|
||||||
|
if (es) es.close();
|
||||||
|
// Public SSE feed of approved comments; filter to this post, dedupe by id-ish.
|
||||||
|
es = new EventSource('/realtime/topics/comments-feed');
|
||||||
|
es.onopen = () => (live = true);
|
||||||
|
es.onerror = () => (live = false);
|
||||||
|
es.onmessage = (ev) => {
|
||||||
|
try {
|
||||||
|
const m = JSON.parse(ev.data).message;
|
||||||
|
if (m && m.post_id === post.id) {
|
||||||
|
const exists = comments.some(
|
||||||
|
(c) => c.body === m.body && c.author_name === m.author_name && c.created_at === m.created_at
|
||||||
|
);
|
||||||
|
if (!exists) comments = [...comments, m];
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
load();
|
||||||
|
return () => es?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submitComment(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
submitMsg = '';
|
||||||
|
try {
|
||||||
|
await api.post('/posts/' + post.id + '/comments', form);
|
||||||
|
submitMsg = 'Thanks! Your comment is awaiting moderation.';
|
||||||
|
form = { author_name: '', author_email: '', body: '' };
|
||||||
|
} catch (e: any) {
|
||||||
|
submitMsg = 'Error: ' + (e.message || 'failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="err">Post not found.</p>
|
||||||
|
<a href="/">← back</a>
|
||||||
|
{:else if post}
|
||||||
|
<a href="/">← all posts</a>
|
||||||
|
<h1>{post.title}</h1>
|
||||||
|
<p class="meta">by {post.author_name} · {new Date(post.publish_at).toLocaleString()} · {post.views} views</p>
|
||||||
|
<p class="tags">{#each post.tags as t}<a href={'/?tag=' + t}>#{t}</a> {/each}</p>
|
||||||
|
<!-- body_md is author-authored; renderMarkdown escapes HTML before formatting -->
|
||||||
|
<div class="body">{@html renderMarkdown(post.body_md)}</div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
<h3>Comments ({comments.length}) {#if live}<span class="live">● live</span>{/if}</h3>
|
||||||
|
{#each comments as c}
|
||||||
|
<div class="card comment">
|
||||||
|
<strong>{@html c.author_name}</strong>
|
||||||
|
<span class="meta">{new Date(c.created_at).toLocaleDateString()}</span>
|
||||||
|
<!-- c.body is stored HTML-escaped by the interceptor; {@html} renders the
|
||||||
|
escaped entities as harmless literal text (no tag is formed). -->
|
||||||
|
<p>{@html c.body}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<h3>Leave a comment</h3>
|
||||||
|
<form onsubmit={submitComment}>
|
||||||
|
<label>Name</label>
|
||||||
|
<input bind:value={form.author_name} required />
|
||||||
|
<label>Email (optional, never shown publicly)</label>
|
||||||
|
<input bind:value={form.author_email} type="email" />
|
||||||
|
<label>Comment</label>
|
||||||
|
<textarea bind:value={form.body} rows="4" required></textarea>
|
||||||
|
<p><button class="btn" type="submit">Submit</button></p>
|
||||||
|
{#if submitMsg}<p>{submitMsg}</p>{/if}
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<p>Loading…</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.meta { color: #888; font-size: 0.85rem; }
|
||||||
|
.tags a { color: #2563eb; font-size: 0.8rem; margin-right: 0.3rem; text-decoration: none; }
|
||||||
|
.body :global(p) { margin: 0.8rem 0; }
|
||||||
|
.comment strong { margin-right: 0.5rem; }
|
||||||
|
.live { color: #16a34a; font-size: 0.7rem; vertical-align: middle; }
|
||||||
|
</style>
|
||||||
29
examples/cms-poc/frontend/src/routes/register/+page.svelte
Normal file
29
examples/cms-poc/frontend/src/routes/register/+page.svelte
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
let email = $state('');
|
||||||
|
let password = $state('');
|
||||||
|
let display_name = $state('');
|
||||||
|
let msg = $state('');
|
||||||
|
let error = $state('');
|
||||||
|
async function submit(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
msg = ''; error = '';
|
||||||
|
try {
|
||||||
|
await api.post('/auth/register', { email, password, display_name });
|
||||||
|
msg = 'Registered! You can now log in. (Readers get emailed when new posts publish.)';
|
||||||
|
} catch (err: any) { error = err.message || 'registration failed'; }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>Register as a reader</h1>
|
||||||
|
<form onsubmit={submit} style="max-width:360px">
|
||||||
|
<label>Display name</label>
|
||||||
|
<input bind:value={display_name} />
|
||||||
|
<label>Email</label>
|
||||||
|
<input bind:value={email} type="email" required />
|
||||||
|
<label>Password (min 8 chars)</label>
|
||||||
|
<input bind:value={password} type="password" required />
|
||||||
|
<p><button class="btn" type="submit">Register</button></p>
|
||||||
|
{#if msg}<p>{msg}</p>{/if}
|
||||||
|
{#if error}<p class="err">{error}</p>{/if}
|
||||||
|
</form>
|
||||||
12
examples/cms-poc/frontend/svelte.config.js
Normal file
12
examples/cms-poc/frontend/svelte.config.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-static';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
kit: {
|
||||||
|
// SPA: prerender the shell, client-side route + fetch everything.
|
||||||
|
adapter: adapter({ fallback: 'index.html' }),
|
||||||
|
prerender: { entries: [] }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
23
examples/cms-poc/frontend/vite.config.ts
Normal file
23
examples/cms-poc/frontend/vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
// The browser calls same-origin /cms/* on :5173; Vite proxies to the PiCloud
|
||||||
|
// backend on :8081. This sidesteps PiCloud's lack of CORS (FINDINGS F-030) and
|
||||||
|
// avoids CORS preflight entirely (the request is same-origin to the browser).
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [sveltekit()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/cms': {
|
||||||
|
target: 'http://localhost:8081',
|
||||||
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
// SSE realtime stream (live comments)
|
||||||
|
'/realtime': {
|
||||||
|
target: 'http://localhost:8081',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
4
examples/cms-poc/picloud.dev.toml
Normal file
4
examples/cms-poc/picloud.dev.toml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# dev env overlay — merged onto picloud.toml with `pic apply --env dev`.
|
||||||
|
# Overlay files may carry [app] (slug/name override), [secrets], and [vars].
|
||||||
|
[vars]
|
||||||
|
site-base-url = "http://localhost:5173"
|
||||||
5
examples/cms-poc/picloud.prod.toml
Normal file
5
examples/cms-poc/picloud.prod.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# prod env overlay — merged onto picloud.toml with `pic apply --env prod`.
|
||||||
|
[vars]
|
||||||
|
site-base-url = "https://blog.example.com"
|
||||||
|
comment-moderation = "manual"
|
||||||
|
signups-open = "false"
|
||||||
526
examples/cms-poc/picloud.toml
Normal file
526
examples/cms-poc/picloud.toml
Normal file
@@ -0,0 +1,526 @@
|
|||||||
|
## PiCloud CMS PoC — declarative manifest.
|
||||||
|
## Deploy: pic apply --env dev (from this directory, after `pic login`).
|
||||||
|
## Everything (scripts, routes, triggers, interceptors, config) lives here.
|
||||||
|
|
||||||
|
[project]
|
||||||
|
slug = "cms"
|
||||||
|
name = "PiCloud CMS PoC"
|
||||||
|
|
||||||
|
[app]
|
||||||
|
slug = "cms"
|
||||||
|
name = "PiCloud CMS"
|
||||||
|
description = "A WordPress-inspired CMS whose backend is 100% PiCloud Rhai scripts."
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ config
|
||||||
|
# NOTE: env-specific values (e.g. site_base_url) live in the overlay files
|
||||||
|
# picloud.dev.toml / picloud.prod.toml, NOT in inline [vars.<env>] sections
|
||||||
|
# (an inline [vars.dev] here would parse as a var literally NAMED "dev"). See
|
||||||
|
# FINDINGS F-005.
|
||||||
|
# NOTE: var KEYS must be kebab-case ([a-z0-9-], no underscores) — see FINDINGS F-008.
|
||||||
|
[vars]
|
||||||
|
site-title = "My PiCloud Blog"
|
||||||
|
posts-per-page = "10"
|
||||||
|
comment-moderation = "manual" # "manual" | "auto"
|
||||||
|
notify-from-email = "blog@example.com"
|
||||||
|
signups-open = "true"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ secrets
|
||||||
|
# Values are pushed out-of-band via `pic secrets set <name>` (never in the
|
||||||
|
# manifest). `setup-token` gates the one-time admin bootstrap endpoint.
|
||||||
|
[secrets]
|
||||||
|
names = ["setup-token"]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ scripts
|
||||||
|
[[scripts]]
|
||||||
|
name = "hello"
|
||||||
|
file = "scripts/hello.rhai"
|
||||||
|
kind = "endpoint"
|
||||||
|
description = "Skeleton smoke-test endpoint."
|
||||||
|
|
||||||
|
# --- auth (shared module + endpoints) ---
|
||||||
|
[[scripts]]
|
||||||
|
name = "auth"
|
||||||
|
file = "scripts/auth/auth.rhai"
|
||||||
|
kind = "module"
|
||||||
|
description = "Role-check helper module — the CMS access boundary."
|
||||||
|
|
||||||
|
[[scripts]]
|
||||||
|
name = "auth_register"
|
||||||
|
file = "scripts/auth/register.rhai"
|
||||||
|
description = "Reader self-signup."
|
||||||
|
|
||||||
|
[[scripts]]
|
||||||
|
name = "auth_login"
|
||||||
|
file = "scripts/auth/login.rhai"
|
||||||
|
description = "Email+password -> session token."
|
||||||
|
|
||||||
|
[[scripts]]
|
||||||
|
name = "auth_logout"
|
||||||
|
file = "scripts/auth/logout.rhai"
|
||||||
|
description = "Invalidate the current session."
|
||||||
|
|
||||||
|
[[scripts]]
|
||||||
|
name = "auth_me"
|
||||||
|
file = "scripts/auth/me.rhai"
|
||||||
|
description = "Current user + roles."
|
||||||
|
|
||||||
|
[[scripts]]
|
||||||
|
name = "auth_bootstrap"
|
||||||
|
file = "scripts/auth/bootstrap.rhai"
|
||||||
|
description = "One-time first-admin creation (setup-token gated)."
|
||||||
|
|
||||||
|
[[scripts]]
|
||||||
|
name = "admin_users_list"
|
||||||
|
file = "scripts/auth/admin_users_list.rhai"
|
||||||
|
description = "List app-users + roles (admin)."
|
||||||
|
|
||||||
|
[[scripts]]
|
||||||
|
name = "admin_user_roles"
|
||||||
|
file = "scripts/auth/admin_user_roles.rhai"
|
||||||
|
description = "Grant/revoke user roles (admin)."
|
||||||
|
|
||||||
|
# --- shared util module ---
|
||||||
|
[[scripts]]
|
||||||
|
name = "util"
|
||||||
|
file = "scripts/util.rhai"
|
||||||
|
kind = "module"
|
||||||
|
description = "slugify / doc-shaping / CAS counter helpers."
|
||||||
|
|
||||||
|
# --- posts ---
|
||||||
|
[[scripts]]
|
||||||
|
name = "posts_list"
|
||||||
|
file = "scripts/posts/posts_list.rhai"
|
||||||
|
description = "Public list of published posts."
|
||||||
|
|
||||||
|
[[scripts]]
|
||||||
|
name = "post_get"
|
||||||
|
file = "scripts/posts/post_get.rhai"
|
||||||
|
description = "Public single post by slug (+ view counter)."
|
||||||
|
|
||||||
|
[[scripts]]
|
||||||
|
name = "posts_admin_list"
|
||||||
|
file = "scripts/posts/posts_admin_list.rhai"
|
||||||
|
description = "Dashboard post list (author+)."
|
||||||
|
|
||||||
|
[[scripts]]
|
||||||
|
name = "post_create"
|
||||||
|
file = "scripts/posts/post_create.rhai"
|
||||||
|
description = "Create a post (author+)."
|
||||||
|
|
||||||
|
[[scripts]]
|
||||||
|
name = "post_update"
|
||||||
|
file = "scripts/posts/post_update.rhai"
|
||||||
|
description = "Update a post (own-only unless admin)."
|
||||||
|
|
||||||
|
[[scripts]]
|
||||||
|
name = "post_delete"
|
||||||
|
file = "scripts/posts/post_delete.rhai"
|
||||||
|
description = "Delete a post (admin)."
|
||||||
|
|
||||||
|
# --- pages (one script, method-dispatched) ---
|
||||||
|
[[scripts]]
|
||||||
|
name = "pages"
|
||||||
|
file = "scripts/pages/pages.rhai"
|
||||||
|
description = "Page CRUD (public reads + admin writes)."
|
||||||
|
|
||||||
|
# --- comments ---
|
||||||
|
[[scripts]]
|
||||||
|
name = "comment_sanitize"
|
||||||
|
file = "scripts/comments/comment_sanitize.rhai"
|
||||||
|
description = "docs before-interceptor: escape HTML + set moderation status."
|
||||||
|
|
||||||
|
[[scripts]]
|
||||||
|
name = "comment_create"
|
||||||
|
file = "scripts/comments/comment_create.rhai"
|
||||||
|
description = "Submit a comment (public, rate-limited)."
|
||||||
|
|
||||||
|
[[scripts]]
|
||||||
|
name = "comments_for_post"
|
||||||
|
file = "scripts/comments/comments_for_post.rhai"
|
||||||
|
description = "Public approved comments for a post."
|
||||||
|
|
||||||
|
[[scripts]]
|
||||||
|
name = "comments_admin"
|
||||||
|
file = "scripts/comments/comments_admin.rhai"
|
||||||
|
description = "Comment moderation (admin)."
|
||||||
|
|
||||||
|
[[scripts]]
|
||||||
|
name = "comment_on_approve"
|
||||||
|
file = "scripts/comments/comment_on_approve.rhai"
|
||||||
|
description = "docs trigger: publish approved comment to SSE feed."
|
||||||
|
|
||||||
|
# --- notifications ---
|
||||||
|
[[scripts]]
|
||||||
|
name = "posts_on_publish"
|
||||||
|
file = "scripts/posts/posts_on_publish.rhai"
|
||||||
|
description = "docs trigger: enqueue reader notifications on publish."
|
||||||
|
|
||||||
|
[[scripts]]
|
||||||
|
name = "notify_drain"
|
||||||
|
file = "scripts/notify/notify_drain.rhai"
|
||||||
|
description = "queue trigger: send notification emails."
|
||||||
|
|
||||||
|
[[scripts]]
|
||||||
|
name = "posts_scheduled_publish"
|
||||||
|
file = "scripts/posts/posts_scheduled_publish.rhai"
|
||||||
|
description = "cron trigger: publish due scheduled posts."
|
||||||
|
|
||||||
|
# --- tags ---
|
||||||
|
[[scripts]]
|
||||||
|
name = "tags_list"
|
||||||
|
file = "scripts/tags/tags_list.rhai"
|
||||||
|
description = "Public tag cloud (distinct tags + counts)."
|
||||||
|
|
||||||
|
# --- media ---
|
||||||
|
[[scripts]]
|
||||||
|
name = "media_upload"
|
||||||
|
file = "scripts/media/media_upload.rhai"
|
||||||
|
description = "Upload an image (author+, base64)."
|
||||||
|
|
||||||
|
[[scripts]]
|
||||||
|
name = "media_get"
|
||||||
|
file = "scripts/media/media_get.rhai"
|
||||||
|
description = "Fetch an image as base64 (public)."
|
||||||
|
|
||||||
|
# --- workflow: editorial publish pipeline ---
|
||||||
|
[[scripts]]
|
||||||
|
name = "wf_validate"
|
||||||
|
file = "scripts/workflow/wf_validate.rhai"
|
||||||
|
description = "Workflow step: validate content."
|
||||||
|
|
||||||
|
[[scripts]]
|
||||||
|
name = "wf_enrich"
|
||||||
|
file = "scripts/workflow/wf_enrich.rhai"
|
||||||
|
description = "Workflow step: auto-excerpt + reading time."
|
||||||
|
|
||||||
|
[[scripts]]
|
||||||
|
name = "wf_seo"
|
||||||
|
file = "scripts/workflow/wf_seo.rhai"
|
||||||
|
description = "Workflow step: SEO scoring."
|
||||||
|
|
||||||
|
[[scripts]]
|
||||||
|
name = "wf_publish"
|
||||||
|
file = "scripts/workflow/wf_publish.rhai"
|
||||||
|
description = "Workflow step: publish (chains into notifications)."
|
||||||
|
|
||||||
|
[[scripts]]
|
||||||
|
name = "wf_finalize"
|
||||||
|
file = "scripts/workflow/wf_finalize.rhai"
|
||||||
|
description = "Workflow step: finalize run."
|
||||||
|
|
||||||
|
[[scripts]]
|
||||||
|
name = "pipeline_start"
|
||||||
|
file = "scripts/workflow/pipeline_start.rhai"
|
||||||
|
description = "Start the editorial pipeline on a post (author+)."
|
||||||
|
|
||||||
|
[[scripts]]
|
||||||
|
name = "pipeline_dag"
|
||||||
|
file = "scripts/workflow/pipeline_dag.rhai"
|
||||||
|
description = "Return the pipeline DAG for the visual editor."
|
||||||
|
|
||||||
|
[[scripts]]
|
||||||
|
name = "pipeline_run"
|
||||||
|
file = "scripts/workflow/pipeline_run.rhai"
|
||||||
|
description = "Return a run's live per-step status."
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ routes
|
||||||
|
[[routes]]
|
||||||
|
script = "hello"
|
||||||
|
method = "GET"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "exact"
|
||||||
|
path = "/cms/hello"
|
||||||
|
dispatch_mode = "sync"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
script = "auth_register"
|
||||||
|
method = "POST"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "exact"
|
||||||
|
path = "/cms/auth/register"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
script = "auth_login"
|
||||||
|
method = "POST"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "exact"
|
||||||
|
path = "/cms/auth/login"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
script = "auth_logout"
|
||||||
|
method = "POST"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "exact"
|
||||||
|
path = "/cms/auth/logout"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
script = "auth_me"
|
||||||
|
method = "GET"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "exact"
|
||||||
|
path = "/cms/auth/me"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
script = "auth_bootstrap"
|
||||||
|
method = "POST"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "exact"
|
||||||
|
path = "/cms/auth/bootstrap"
|
||||||
|
|
||||||
|
# --- admin user management ---
|
||||||
|
[[routes]]
|
||||||
|
script = "admin_users_list"
|
||||||
|
method = "GET"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "exact"
|
||||||
|
path = "/cms/admin/users"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
script = "admin_user_roles"
|
||||||
|
method = "PUT"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "param"
|
||||||
|
path = "/cms/admin/users/:id/roles"
|
||||||
|
|
||||||
|
# --- posts (public) ---
|
||||||
|
[[routes]]
|
||||||
|
script = "posts_list"
|
||||||
|
method = "GET"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "exact"
|
||||||
|
path = "/cms/posts"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
script = "post_get"
|
||||||
|
method = "GET"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "param"
|
||||||
|
path = "/cms/posts/:slug"
|
||||||
|
|
||||||
|
# --- posts (admin) ---
|
||||||
|
[[routes]]
|
||||||
|
script = "posts_admin_list"
|
||||||
|
method = "GET"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "exact"
|
||||||
|
path = "/cms/admin/posts"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
script = "post_create"
|
||||||
|
method = "POST"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "exact"
|
||||||
|
path = "/cms/admin/posts"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
script = "post_update"
|
||||||
|
method = "PUT"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "param"
|
||||||
|
path = "/cms/admin/posts/:id"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
script = "post_delete"
|
||||||
|
method = "DELETE"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "param"
|
||||||
|
path = "/cms/admin/posts/:id"
|
||||||
|
|
||||||
|
# --- pages ---
|
||||||
|
[[routes]]
|
||||||
|
script = "pages"
|
||||||
|
method = "GET"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "exact"
|
||||||
|
path = "/cms/pages"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
script = "pages"
|
||||||
|
method = "GET"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "param"
|
||||||
|
path = "/cms/pages/:slug"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
script = "pages"
|
||||||
|
method = "GET"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "exact"
|
||||||
|
path = "/cms/admin/pages"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
script = "pages"
|
||||||
|
method = "POST"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "exact"
|
||||||
|
path = "/cms/admin/pages"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
script = "pages"
|
||||||
|
method = "PUT"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "param"
|
||||||
|
path = "/cms/admin/pages/:id"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
script = "pages"
|
||||||
|
method = "DELETE"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "param"
|
||||||
|
path = "/cms/admin/pages/:id"
|
||||||
|
|
||||||
|
# --- comments ---
|
||||||
|
[[routes]]
|
||||||
|
script = "comment_create"
|
||||||
|
method = "POST"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "param"
|
||||||
|
path = "/cms/posts/:id/comments"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
script = "comments_for_post"
|
||||||
|
method = "GET"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "param"
|
||||||
|
path = "/cms/posts/:id/comments"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
script = "comments_admin"
|
||||||
|
method = "GET"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "exact"
|
||||||
|
path = "/cms/admin/comments"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
script = "comments_admin"
|
||||||
|
method = "PUT"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "param"
|
||||||
|
path = "/cms/admin/comments/:id"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
script = "comments_admin"
|
||||||
|
method = "DELETE"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "param"
|
||||||
|
path = "/cms/admin/comments/:id"
|
||||||
|
|
||||||
|
# --- tags ---
|
||||||
|
[[routes]]
|
||||||
|
script = "tags_list"
|
||||||
|
method = "GET"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "exact"
|
||||||
|
path = "/cms/tags"
|
||||||
|
|
||||||
|
# --- media ---
|
||||||
|
[[routes]]
|
||||||
|
script = "media_upload"
|
||||||
|
method = "POST"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "exact"
|
||||||
|
path = "/cms/admin/media"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
script = "media_get"
|
||||||
|
method = "GET"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "param"
|
||||||
|
path = "/cms/media/:id"
|
||||||
|
|
||||||
|
# --- workflow ---
|
||||||
|
[[routes]]
|
||||||
|
script = "pipeline_start"
|
||||||
|
method = "POST"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "param"
|
||||||
|
path = "/cms/admin/posts/:id/pipeline"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
script = "pipeline_dag"
|
||||||
|
method = "GET"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "exact"
|
||||||
|
path = "/cms/admin/workflow"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
script = "pipeline_run"
|
||||||
|
method = "GET"
|
||||||
|
host_kind = "any"
|
||||||
|
path_kind = "param"
|
||||||
|
path = "/cms/admin/workflow/runs/:id"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ triggers
|
||||||
|
# docs trigger: fire posts_on_publish when a post is created/updated.
|
||||||
|
[[triggers.docs]]
|
||||||
|
script = "posts_on_publish"
|
||||||
|
collection_glob = "posts"
|
||||||
|
ops = ["create", "update"]
|
||||||
|
retry_max_attempts = 3
|
||||||
|
|
||||||
|
# docs trigger: publish an approved comment to the SSE feed.
|
||||||
|
[[triggers.docs]]
|
||||||
|
script = "comment_on_approve"
|
||||||
|
collection_glob = "comments"
|
||||||
|
ops = ["update"]
|
||||||
|
retry_max_attempts = 2
|
||||||
|
|
||||||
|
# queue trigger: drain post-notifications, send emails.
|
||||||
|
[[triggers.queue]]
|
||||||
|
script = "notify_drain"
|
||||||
|
queue_name = "post-notifications"
|
||||||
|
retry_max_attempts = 5
|
||||||
|
|
||||||
|
# cron trigger: publish due scheduled posts every minute (6-field: sec min hr ...).
|
||||||
|
[[triggers.cron]]
|
||||||
|
script = "posts_scheduled_publish"
|
||||||
|
schedule = "0 * * * * *"
|
||||||
|
timezone = "UTC"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ interceptors
|
||||||
|
[[interceptors]]
|
||||||
|
script = "comment_sanitize"
|
||||||
|
service = "docs"
|
||||||
|
ops = ["create"]
|
||||||
|
phase = "before"
|
||||||
|
timeout_ms = 500
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ workflows
|
||||||
|
# Editorial publish pipeline (durable DAG). validate → (enrich ∥ seo) → publish
|
||||||
|
# → finalize. Each step reads post_id from its input mapping; `when` gates the
|
||||||
|
# branch on validation passing. Per-step progress is read back via the platform
|
||||||
|
# `workflow::run_status` SDK (F-038) — no app-side run tracking needed.
|
||||||
|
[[workflows]]
|
||||||
|
name = "editorial_pipeline"
|
||||||
|
|
||||||
|
[[workflows.steps]]
|
||||||
|
name = "validate"
|
||||||
|
function = "wf_validate"
|
||||||
|
input = { post_id = "{{ input.post_id }}" }
|
||||||
|
|
||||||
|
[[workflows.steps]]
|
||||||
|
name = "enrich"
|
||||||
|
function = "wf_enrich"
|
||||||
|
depends_on = ["validate"]
|
||||||
|
when = "steps.validate.output.ok == true"
|
||||||
|
input = { post_id = "{{ input.post_id }}" }
|
||||||
|
|
||||||
|
[[workflows.steps]]
|
||||||
|
name = "seo"
|
||||||
|
function = "wf_seo"
|
||||||
|
depends_on = ["validate"]
|
||||||
|
when = "steps.validate.output.ok == true"
|
||||||
|
input = { post_id = "{{ input.post_id }}" }
|
||||||
|
|
||||||
|
[[workflows.steps]]
|
||||||
|
name = "publish"
|
||||||
|
function = "wf_publish"
|
||||||
|
depends_on = ["enrich", "seo"]
|
||||||
|
when = "steps.validate.output.ok == true"
|
||||||
|
input = { post_id = "{{ input.post_id }}" }
|
||||||
|
|
||||||
|
[[workflows.steps]]
|
||||||
|
name = "finalize"
|
||||||
|
function = "wf_finalize"
|
||||||
|
depends_on = ["publish"]
|
||||||
|
input = { post_id = "{{ input.post_id }}" }
|
||||||
23
examples/cms-poc/run-server.sh
Executable file
23
examples/cms-poc/run-server.sh
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Boot the picloud all-in-one for the CMS PoC against the clean picloud_cms DB,
|
||||||
|
# relaying email through the local Mailpit (docker) on :1025.
|
||||||
|
# Postgres + Mailpit are expected up (docker compose up -d postgres; docker run ... mailpit).
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/../.." # repo root
|
||||||
|
|
||||||
|
export DATABASE_URL="postgres://picloud:picloud@127.0.0.1:15432/picloud_cms"
|
||||||
|
export PICLOUD_BIND="0.0.0.0:8081"
|
||||||
|
export PICLOUD_DEV_MODE=true
|
||||||
|
export PICLOUD_DEV_INSECURE_KEY="i-understand-this-is-insecure"
|
||||||
|
export PICLOUD_ADMIN_USERNAME=admin
|
||||||
|
export PICLOUD_ADMIN_PASSWORD=admin
|
||||||
|
export PICLOUD_PUBLIC_BASE_URL="http://localhost:8081"
|
||||||
|
# --- SMTP relay -> Mailpit (all three of HOST/USER/PASSWORD required; TLS=none for Mailpit) ---
|
||||||
|
export PICLOUD_SMTP_HOST=127.0.0.1
|
||||||
|
export PICLOUD_SMTP_PORT=1025
|
||||||
|
export PICLOUD_SMTP_USER=cms
|
||||||
|
export PICLOUD_SMTP_PASSWORD=cms
|
||||||
|
export PICLOUD_SMTP_TLS=none
|
||||||
|
export RUST_LOG="${RUST_LOG:-info,picloud=info,manager_core=info}"
|
||||||
|
|
||||||
|
exec ./target/debug/picloud
|
||||||
17
examples/cms-poc/scripts/auth/admin_user_roles.rhai
Normal file
17
examples/cms-poc/scripts/auth/admin_user_roles.rhai
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// PUT /cms/admin/users/:id/roles — grant/revoke roles (admin only).
|
||||||
|
// body: { "add": ["author"], "remove": ["reader"] }
|
||||||
|
import "auth" as auth;
|
||||||
|
let g = auth::guard(ctx, ["admin"]);
|
||||||
|
if !g.ok { return g.response; }
|
||||||
|
|
||||||
|
let id = ctx.request.params.id;
|
||||||
|
let target = users::get(id);
|
||||||
|
if target == () { return #{ statusCode: 404, body: #{ error: "no such user" } }; }
|
||||||
|
|
||||||
|
let valid = ["admin", "author", "reader"];
|
||||||
|
let b = ctx.request.body;
|
||||||
|
if b != () {
|
||||||
|
if b.add != () { for r in b.add { if valid.contains(r) { users::add_role(id, r); } } }
|
||||||
|
if b.remove != () { for r in b.remove { if valid.contains(r) { users::remove_role(id, r); } } }
|
||||||
|
}
|
||||||
|
#{ statusCode: 200, body: #{ id: id, roles: auth::roles_of(target) } }
|
||||||
17
examples/cms-poc/scripts/auth/admin_users_list.rhai
Normal file
17
examples/cms-poc/scripts/auth/admin_users_list.rhai
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// GET /cms/admin/users — list app-users + their roles (admin only).
|
||||||
|
import "auth" as auth;
|
||||||
|
let g = auth::guard(ctx, ["admin"]);
|
||||||
|
if !g.ok { return g.response; }
|
||||||
|
|
||||||
|
let res = users::list(#{ "$limit": 200 });
|
||||||
|
let out = [];
|
||||||
|
for u in res.users {
|
||||||
|
out.push(#{
|
||||||
|
id: u.id,
|
||||||
|
email: u.email,
|
||||||
|
display_name: u.display_name,
|
||||||
|
roles: auth::roles_of(u),
|
||||||
|
created_at: u.created_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
#{ statusCode: 200, body: #{ users: out } }
|
||||||
64
examples/cms-poc/scripts/auth/auth.rhai
Normal file
64
examples/cms-poc/scripts/auth/auth.rhai
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// auth — shared module (kind=module). THE access boundary for the CMS.
|
||||||
|
// A public route runs with full app authority (principal:None); every
|
||||||
|
// protected endpoint MUST import this and call require_role/require_user
|
||||||
|
// as its first statement. Imported as: import "auth" as auth;
|
||||||
|
|
||||||
|
// Extract a bearer token from the Authorization header, or () if absent.
|
||||||
|
fn bearer_token(ctx) {
|
||||||
|
let h = ctx.request.headers["authorization"];
|
||||||
|
if h == () { return (); }
|
||||||
|
let t = h;
|
||||||
|
if t.starts_with("Bearer ") { t = t.sub_string(7); }
|
||||||
|
else if t.starts_with("bearer ") { t = t.sub_string(7); }
|
||||||
|
t.trim();
|
||||||
|
if t == "" { return (); }
|
||||||
|
t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the current app-user from the bearer token, or () if none/invalid.
|
||||||
|
fn current_user(ctx) {
|
||||||
|
let t = bearer_token(ctx);
|
||||||
|
if t == () { return (); }
|
||||||
|
try { users::verify(t) } catch(e) { () }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The list of CMS roles, most-privileged first.
|
||||||
|
fn all_roles() { ["admin", "author", "reader"] }
|
||||||
|
|
||||||
|
// Collect the roles a user actually holds (for /me and UI gating).
|
||||||
|
fn roles_of(user) {
|
||||||
|
let out = [];
|
||||||
|
for r in all_roles() { if users::has_role(user.id, r) { out.push(r); } }
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_any_role(user, roles) {
|
||||||
|
for r in roles { if users::has_role(user.id, r) { return true; } }
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- guard pattern -------------------------------------------------------
|
||||||
|
// We do NOT `throw` an HTTP envelope: an uncaught throw surfaces as a 502
|
||||||
|
// whose body leaks the app id + script internals (see FINDINGS F-011 /
|
||||||
|
// SECURITY S-01). Instead a guard RETURNS { ok, user } on success or
|
||||||
|
// { ok:false, response } on failure, and the caller does:
|
||||||
|
// let g = auth::guard(ctx, ["admin"]);
|
||||||
|
// if !g.ok { return g.response; }
|
||||||
|
// let user = g.user;
|
||||||
|
|
||||||
|
fn guard_user(ctx) {
|
||||||
|
let u = current_user(ctx);
|
||||||
|
if u == () {
|
||||||
|
return #{ ok: false, response: #{ statusCode: 401, body: #{ error: "unauthenticated" } } };
|
||||||
|
}
|
||||||
|
#{ ok: true, user: u }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn guard(ctx, roles) {
|
||||||
|
let g = guard_user(ctx);
|
||||||
|
if !g.ok { return g; }
|
||||||
|
if !has_any_role(g.user, roles) {
|
||||||
|
return #{ ok: false, response: #{ statusCode: 403, body: #{ error: "forbidden" } } };
|
||||||
|
}
|
||||||
|
g
|
||||||
|
}
|
||||||
25
examples/cms-poc/scripts/auth/bootstrap.rhai
Normal file
25
examples/cms-poc/scripts/auth/bootstrap.rhai
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// POST /cms/auth/bootstrap — create the first admin/author account.
|
||||||
|
// Gated by the `setup-token` secret (compared server-side; never echoed).
|
||||||
|
// This exists because there is no CLI/API to grant an app-user a role —
|
||||||
|
// role assignment is script-only (users::add_role). See FINDINGS.
|
||||||
|
let b = ctx.request.body;
|
||||||
|
if b == () || b.setup_token == () {
|
||||||
|
return #{ statusCode: 400, body: #{ error: "setup_token required" } };
|
||||||
|
}
|
||||||
|
let want = secrets::get("setup-token");
|
||||||
|
if want == () {
|
||||||
|
return #{ statusCode: 500, body: #{ error: "server not configured for bootstrap" } };
|
||||||
|
}
|
||||||
|
if b.setup_token != want {
|
||||||
|
return #{ statusCode: 403, body: #{ error: "bad setup token" } };
|
||||||
|
}
|
||||||
|
if b.email == () || b.password == () {
|
||||||
|
return #{ statusCode: 400, body: #{ error: "email and password required" } };
|
||||||
|
}
|
||||||
|
if !users::email_available(b.email) {
|
||||||
|
return #{ statusCode: 409, body: #{ error: "email already registered" } };
|
||||||
|
}
|
||||||
|
let u = users::create(#{ email: b.email, password: b.password, display_name: b.display_name });
|
||||||
|
users::add_role(u.id, "admin");
|
||||||
|
users::add_role(u.id, "author");
|
||||||
|
#{ statusCode: 201, body: #{ id: u.id, email: u.email, roles: ["admin", "author"] } }
|
||||||
23
examples/cms-poc/scripts/auth/login.rhai
Normal file
23
examples/cms-poc/scripts/auth/login.rhai
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// POST /cms/auth/login — exchange email+password for a session token (public).
|
||||||
|
// Guard the body type: since the platform fix (F-026) non-JSON bodies now reach
|
||||||
|
// the script (text -> string, binary -> base64), so an endpoint expecting a JSON
|
||||||
|
// object must type-check rather than assume a map (else it throws -> 502).
|
||||||
|
let b = ctx.request.body;
|
||||||
|
if type_of(b) != "map" || b.email == () || b.password == () {
|
||||||
|
return #{ statusCode: 400, body: #{ error: "email and password required" } };
|
||||||
|
}
|
||||||
|
let token = ();
|
||||||
|
try { token = users::login(b.email, b.password); } catch(e) { token = (); }
|
||||||
|
if token == () {
|
||||||
|
return #{ statusCode: 401, body: #{ error: "invalid credentials" } };
|
||||||
|
}
|
||||||
|
let u = users::verify(token);
|
||||||
|
let roles = [];
|
||||||
|
for r in ["admin", "author", "reader"] { if users::has_role(u.id, r) { roles.push(r); } }
|
||||||
|
#{
|
||||||
|
statusCode: 200,
|
||||||
|
body: #{
|
||||||
|
token: token,
|
||||||
|
user: #{ id: u.id, email: u.email, display_name: u.display_name, roles: roles }
|
||||||
|
}
|
||||||
|
}
|
||||||
5
examples/cms-poc/scripts/auth/logout.rhai
Normal file
5
examples/cms-poc/scripts/auth/logout.rhai
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// POST /cms/auth/logout — invalidate the current session token server-side.
|
||||||
|
import "auth" as auth;
|
||||||
|
let t = auth::bearer_token(ctx);
|
||||||
|
if t != () { try { users::logout(t); } catch(e) {} }
|
||||||
|
#{ statusCode: 200, body: #{ ok: true } }
|
||||||
9
examples/cms-poc/scripts/auth/me.rhai
Normal file
9
examples/cms-poc/scripts/auth/me.rhai
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// GET /cms/auth/me — the current user + roles (401 if unauthenticated).
|
||||||
|
import "auth" as auth;
|
||||||
|
let g = auth::guard_user(ctx);
|
||||||
|
if !g.ok { return g.response; }
|
||||||
|
let u = g.user;
|
||||||
|
#{
|
||||||
|
statusCode: 200,
|
||||||
|
body: #{ id: u.id, email: u.email, display_name: u.display_name, roles: auth::roles_of(u) }
|
||||||
|
}
|
||||||
44
examples/cms-poc/scripts/auth/register.rhai
Normal file
44
examples/cms-poc/scripts/auth/register.rhai
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// POST /cms/auth/register — reader self-signup (public).
|
||||||
|
// Rate-limited by client IP + email to blunt enumeration/abuse (the only
|
||||||
|
// primitive PiCloud gives us for this is a kv counter).
|
||||||
|
let b = ctx.request.body;
|
||||||
|
if b == () || type_of(b) != "map" {
|
||||||
|
return #{ statusCode: 400, body: #{ error: "expected JSON body" } };
|
||||||
|
}
|
||||||
|
let email = b.email;
|
||||||
|
let password = b.password;
|
||||||
|
let name = if b.display_name == () { () } else { b.display_name };
|
||||||
|
|
||||||
|
if email == () || password == () {
|
||||||
|
return #{ statusCode: 400, body: #{ error: "email and password required" } };
|
||||||
|
}
|
||||||
|
if vars::get("signups-open") != "true" {
|
||||||
|
return #{ statusCode: 403, body: #{ error: "signups are closed" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- basic rate limit: max 5 registration attempts / hour / IP ---
|
||||||
|
let ip = ctx.request.headers["x-forwarded-for"];
|
||||||
|
if ip == () { ip = "local"; }
|
||||||
|
let rl = kv::collection("ratelimit");
|
||||||
|
let bucket = "register:" + ip;
|
||||||
|
let cur = rl.get(bucket);
|
||||||
|
let count = if cur == () { 0 } else { cur.count };
|
||||||
|
if count >= 5 {
|
||||||
|
return #{ statusCode: 429, body: #{ error: "too many attempts, try later" } };
|
||||||
|
}
|
||||||
|
rl.set(bucket, #{ count: count + 1, at: time::now() });
|
||||||
|
|
||||||
|
// email_available is the anon-safe probe (find_by_email requires a principal).
|
||||||
|
if !users::email_available(email) {
|
||||||
|
return #{ statusCode: 409, body: #{ error: "email already registered" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
let u = ();
|
||||||
|
try {
|
||||||
|
u = users::create(#{ email: email, password: password, display_name: name });
|
||||||
|
} catch(e) {
|
||||||
|
return #{ statusCode: 400, body: #{ error: "could not create user: " + e } };
|
||||||
|
}
|
||||||
|
users::add_role(u.id, "reader");
|
||||||
|
|
||||||
|
#{ statusCode: 201, body: #{ id: u.id, email: u.email, roles: ["reader"] } }
|
||||||
39
examples/cms-poc/scripts/comments/comment_create.rhai
Normal file
39
examples/cms-poc/scripts/comments/comment_create.rhai
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// POST /cms/posts/:id/comments — submit a comment (public, rate-limited).
|
||||||
|
// The docs `before` interceptor (comment_sanitize) escapes the body and sets
|
||||||
|
// the moderation status; this endpoint just validates + writes.
|
||||||
|
let post_id = ctx.request.params.id;
|
||||||
|
let b = ctx.request.body;
|
||||||
|
if b == () || b.body == () || b.author_name == () {
|
||||||
|
return #{ statusCode: 400, body: #{ error: "author_name and body required" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// the post must exist and be published
|
||||||
|
let posts = docs::collection("posts");
|
||||||
|
let pd = posts.get(post_id);
|
||||||
|
if pd == () || pd.data.status != "published" {
|
||||||
|
return #{ statusCode: 404, body: #{ error: "post not found" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// rate limit: max 10 comments / hour / IP
|
||||||
|
let ip = ctx.request.headers["x-forwarded-for"];
|
||||||
|
if ip == () { ip = "local"; }
|
||||||
|
let rl = kv::collection("ratelimit");
|
||||||
|
let bk = "comment:" + ip;
|
||||||
|
let cur = rl.get(bk);
|
||||||
|
let cnt = if cur == () { 0 } else { cur.count };
|
||||||
|
if cnt >= 10 { return #{ statusCode: 429, body: #{ error: "slow down" } }; }
|
||||||
|
rl.set(bk, #{ count: cnt + 1, at: time::now() });
|
||||||
|
|
||||||
|
let comments = docs::collection("comments");
|
||||||
|
let data = #{
|
||||||
|
post_id: post_id,
|
||||||
|
post_slug: pd.data.slug,
|
||||||
|
post_title: pd.data.title,
|
||||||
|
author_name: b.author_name,
|
||||||
|
author_email: if b.author_email == () { "" } else { b.author_email },
|
||||||
|
body: b.body,
|
||||||
|
status: "pending", // interceptor re-affirms/overrides
|
||||||
|
created_at: time::now(),
|
||||||
|
};
|
||||||
|
let id = comments.create(data); // <- interceptor sanitizes + sets status here
|
||||||
|
#{ statusCode: 201, body: #{ id: id, status: "submitted" } }
|
||||||
20
examples/cms-poc/scripts/comments/comment_on_approve.rhai
Normal file
20
examples/cms-poc/scripts/comments/comment_on_approve.rhai
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// comment_on_approve — docs TRIGGER on `comments` (update). When a comment
|
||||||
|
// transitions INTO "approved", publish it to the public `comments-feed` topic
|
||||||
|
// so post pages can show it live over SSE. (Comments are public once approved.)
|
||||||
|
let ev = ctx.event.docs;
|
||||||
|
if ev == () { return; }
|
||||||
|
let data = ev.data;
|
||||||
|
if data == () { return; }
|
||||||
|
|
||||||
|
let was = if ev.prev_data == () { () } else { ev.prev_data.status };
|
||||||
|
let became_approved = data.status == "approved" && was != "approved";
|
||||||
|
if !became_approved { return; }
|
||||||
|
|
||||||
|
pubsub::publish_durable("comments-feed", #{
|
||||||
|
post_id: data.post_id,
|
||||||
|
post_slug: data.post_slug,
|
||||||
|
author_name: data.author_name, // already HTML-escaped at write time
|
||||||
|
body: data.body, // already HTML-escaped at write time
|
||||||
|
created_at: data.created_at,
|
||||||
|
});
|
||||||
|
log::info("published approved comment", #{ post: data.post_id });
|
||||||
36
examples/cms-poc/scripts/comments/comment_sanitize.rhai
Normal file
36
examples/cms-poc/scripts/comments/comment_sanitize.rhai
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// comment_sanitize — a docs `before`/`create` INTERCEPTOR.
|
||||||
|
// Fires for EVERY docs create in the app (posts, pages, comments), so it must
|
||||||
|
// branch on the collection and no-op on everything but comments (F-020).
|
||||||
|
// Receives the op-context as its request body: { service, op, collection, key, value }.
|
||||||
|
// Returns #{ allowed: true, data: <rewritten value> }; anything else DENIES.
|
||||||
|
let p = ctx.request.body;
|
||||||
|
if p == () || p.collection != "comments" {
|
||||||
|
return #{ allowed: true }; // not a comment — pass through untouched
|
||||||
|
}
|
||||||
|
|
||||||
|
let v = p.value;
|
||||||
|
if v == () { return #{ allowed: true }; }
|
||||||
|
|
||||||
|
// --- sanitize the comment body: escape HTML so it can't inject markup ---
|
||||||
|
let raw = v.body;
|
||||||
|
let body = if raw == () { "" } else { "" + raw };
|
||||||
|
body.replace("&", "&"); // must be first
|
||||||
|
body.replace("<", "<");
|
||||||
|
body.replace(">", ">");
|
||||||
|
body.replace("\"", """);
|
||||||
|
body.replace("'", "'");
|
||||||
|
// also escape any HTML the display_name might carry
|
||||||
|
let an = v.author_name;
|
||||||
|
let name = if an == () { "anonymous" } else { "" + an };
|
||||||
|
name.replace("&", "&");
|
||||||
|
name.replace("<", "<");
|
||||||
|
name.replace(">", ">");
|
||||||
|
|
||||||
|
v.body = body;
|
||||||
|
v.author_name = name;
|
||||||
|
|
||||||
|
// --- moderation status from the site config var ---
|
||||||
|
let mode = vars::get("comment-moderation");
|
||||||
|
if mode == "auto" { v.status = "approved"; } else { v.status = "pending"; }
|
||||||
|
|
||||||
|
#{ allowed: true, data: v }
|
||||||
41
examples/cms-poc/scripts/comments/comments_admin.rhai
Normal file
41
examples/cms-poc/scripts/comments/comments_admin.rhai
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// Admin comment moderation, dispatched by method:
|
||||||
|
// GET /cms/admin/comments?status=pending -> list (admin)
|
||||||
|
// PUT /cms/admin/comments/:id { status } -> approve/spam (admin)
|
||||||
|
// DELETE /cms/admin/comments/:id -> delete (admin)
|
||||||
|
import "auth" as auth;
|
||||||
|
import "util" as util;
|
||||||
|
let g = auth::guard(ctx, ["admin"]);
|
||||||
|
if !g.ok { return g.response; }
|
||||||
|
|
||||||
|
let comments = docs::collection("comments");
|
||||||
|
let m = ctx.request.method;
|
||||||
|
|
||||||
|
if m == "GET" {
|
||||||
|
let status = ctx.request.query.status;
|
||||||
|
let filter = #{ "$sort": #{ created_at: -1 }, "$limit": 200 };
|
||||||
|
if status != () { filter.status = status; }
|
||||||
|
let rows = comments.find(filter);
|
||||||
|
let out = [];
|
||||||
|
for r in rows { out.push(util::shape_doc(r)); }
|
||||||
|
return #{ statusCode: 200, body: #{ comments: out } };
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = ctx.request.params.id;
|
||||||
|
let doc = comments.get(id);
|
||||||
|
if doc == () { return #{ statusCode: 404, body: #{ error: "not found" } }; }
|
||||||
|
|
||||||
|
if m == "DELETE" {
|
||||||
|
comments.delete(id);
|
||||||
|
return #{ statusCode: 200, body: #{ ok: true } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if m == "PUT" {
|
||||||
|
let b = ctx.request.body;
|
||||||
|
let cur = doc.data;
|
||||||
|
let valid = ["pending", "approved", "spam"];
|
||||||
|
if b.status != () && valid.contains(b.status) { cur.status = b.status; }
|
||||||
|
comments.update(id, cur); // status->approved fires comment_on_approve (SSE)
|
||||||
|
return #{ statusCode: 200, body: #{ id: id, status: cur.status } };
|
||||||
|
}
|
||||||
|
|
||||||
|
#{ statusCode: 405, body: #{ error: "method not allowed" } }
|
||||||
17
examples/cms-poc/scripts/comments/comments_for_post.rhai
Normal file
17
examples/cms-poc/scripts/comments/comments_for_post.rhai
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// GET /cms/posts/:id/comments — public list of APPROVED comments for a post.
|
||||||
|
import "util" as util;
|
||||||
|
let post_id = ctx.request.params.id;
|
||||||
|
let comments = docs::collection("comments");
|
||||||
|
let rows = comments.find(#{
|
||||||
|
post_id: post_id,
|
||||||
|
status: "approved",
|
||||||
|
"$sort": #{ created_at: 1 },
|
||||||
|
"$limit": 200,
|
||||||
|
});
|
||||||
|
let out = [];
|
||||||
|
for r in rows {
|
||||||
|
let c = util::shape_doc(r);
|
||||||
|
c.remove("author_email"); // never expose commenter emails publicly
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
#{ statusCode: 200, body: #{ comments: out } }
|
||||||
11
examples/cms-poc/scripts/hello.rhai
Normal file
11
examples/cms-poc/scripts/hello.rhai
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// Skeleton smoke-test endpoint. Returns site config to prove vars resolve.
|
||||||
|
// (Var KEYS are kebab-case strings; Rhai map keys must be valid identifiers.)
|
||||||
|
#{
|
||||||
|
statusCode: 200,
|
||||||
|
body: #{
|
||||||
|
ok: true,
|
||||||
|
site_title: vars::get("site-title"),
|
||||||
|
site_base_url: vars::get("site-base-url"),
|
||||||
|
sdk: ctx.sdk_version,
|
||||||
|
}
|
||||||
|
}
|
||||||
18
examples/cms-poc/scripts/media/media_get.rhai
Normal file
18
examples/cms-poc/scripts/media/media_get.rhai
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// GET /cms/media/:id — serve the image as REAL binary bytes (public).
|
||||||
|
// Since the platform fix (F-026), a response with a `body_base64` field returns
|
||||||
|
// raw bytes with a script-set Content-Type, so <img src="/cms/media/:id"> works
|
||||||
|
// directly (no more base64 data-URL workaround).
|
||||||
|
let id = ctx.request.params.id;
|
||||||
|
let media = files::collection("media");
|
||||||
|
let meta = media.head(id);
|
||||||
|
if meta == () { return #{ statusCode: 404, body: #{ error: "not found" } }; }
|
||||||
|
let bytes = media.get(id);
|
||||||
|
if bytes == () { return #{ statusCode: 404, body: #{ error: "not found" } }; }
|
||||||
|
#{
|
||||||
|
statusCode: 200,
|
||||||
|
headers: #{
|
||||||
|
"content-type": meta.content_type,
|
||||||
|
"cache-control": "public, max-age=3600",
|
||||||
|
},
|
||||||
|
body_base64: base64::encode(bytes),
|
||||||
|
}
|
||||||
43
examples/cms-poc/scripts/media/media_upload.rhai
Normal file
43
examples/cms-poc/scripts/media/media_upload.rhai
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// POST /cms/admin/media — upload an image (author+). Two ways in, since the
|
||||||
|
// platform fix (F-026):
|
||||||
|
// 1. JSON: { name, content_type, data_base64 }
|
||||||
|
// 2. RAW BINARY: Content-Type: image/png, body = the bytes, ?name=file.png
|
||||||
|
// (the platform hands a non-JSON binary body to the script as base64).
|
||||||
|
// SVG is rejected (it can carry scripts -> stored XSS).
|
||||||
|
import "auth" as auth;
|
||||||
|
let g = auth::guard(ctx, ["admin", "author"]);
|
||||||
|
if !g.ok { return g.response; }
|
||||||
|
|
||||||
|
let allowed = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||||
|
let name = ();
|
||||||
|
let content_type = ();
|
||||||
|
let bytes = ();
|
||||||
|
|
||||||
|
let b = ctx.request.body;
|
||||||
|
if type_of(b) == "map" {
|
||||||
|
// (1) JSON path
|
||||||
|
if b.data_base64 == () || b.name == () || b.content_type == () {
|
||||||
|
return #{ statusCode: 400, body: #{ error: "name, content_type, data_base64 required" } };
|
||||||
|
}
|
||||||
|
name = b.name;
|
||||||
|
content_type = b.content_type;
|
||||||
|
bytes = base64::decode(b.data_base64);
|
||||||
|
} else if type_of(b) == "string" {
|
||||||
|
// (2) raw binary path — body is the platform's base64 of the raw bytes
|
||||||
|
let ct = ctx.request.headers["content-type"];
|
||||||
|
if ct == () { return #{ statusCode: 400, body: #{ error: "content-type header required" } }; }
|
||||||
|
content_type = ct;
|
||||||
|
name = ctx.request.query["name"];
|
||||||
|
if name == () { name = "upload"; }
|
||||||
|
bytes = base64::decode(b);
|
||||||
|
} else {
|
||||||
|
return #{ statusCode: 400, body: #{ error: "empty body" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if !allowed.contains(content_type) {
|
||||||
|
return #{ statusCode: 415, body: #{ error: "unsupported content_type (images only, no svg)" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
let media = files::collection("media");
|
||||||
|
let id = media.create(#{ name: name, content_type: content_type, data: bytes });
|
||||||
|
#{ statusCode: 201, body: #{ id: id, name: name, content_type: content_type, size: bytes.len() } }
|
||||||
28
examples/cms-poc/scripts/notify/notify_drain.rhai
Normal file
28
examples/cms-poc/scripts/notify/notify_drain.rhai
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// notify_drain — queue TRIGGER on `post-notifications`. Sends one email per
|
||||||
|
// reader in the batch via email::send_html (relayed to Mailpit in dev).
|
||||||
|
let msg = ctx.event.queue.message;
|
||||||
|
if msg == () { return; }
|
||||||
|
|
||||||
|
let from = vars::get("notify-from-email");
|
||||||
|
let base = vars::get("site-base-url");
|
||||||
|
let title = msg.title;
|
||||||
|
let url = base + "/posts/" + msg.slug;
|
||||||
|
|
||||||
|
let sent = 0;
|
||||||
|
for addr in msg.emails {
|
||||||
|
try {
|
||||||
|
email::send_html(#{
|
||||||
|
to: addr,
|
||||||
|
from: from,
|
||||||
|
subject: "New post: " + title,
|
||||||
|
text: "A new post was published: " + title + "\n\nRead it: " + url,
|
||||||
|
html: "<h2>" + title + "</h2>"
|
||||||
|
+ "<p>A new post was just published on the blog.</p>"
|
||||||
|
+ "<p><a href=\"" + url + "\">Read it here</a></p>",
|
||||||
|
});
|
||||||
|
sent += 1;
|
||||||
|
} catch(e) {
|
||||||
|
log::warn("notify email failed", #{ to: addr, err: "" + e });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log::info("sent post notifications", #{ title: title, sent: sent });
|
||||||
96
examples/cms-poc/scripts/pages/pages.rhai
Normal file
96
examples/cms-poc/scripts/pages/pages.rhai
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// pages — one script handling all page operations, dispatched by method+path.
|
||||||
|
// Demonstrates a single script bound to several routes (method/param varies).
|
||||||
|
// GET /cms/pages -> public list (published)
|
||||||
|
// GET /cms/pages/:slug -> public get by slug
|
||||||
|
// GET /cms/admin/pages -> admin list (all)
|
||||||
|
// POST /cms/admin/pages -> create (admin)
|
||||||
|
// PUT /cms/admin/pages/:id -> update (admin)
|
||||||
|
// DELETE /cms/admin/pages/:id -> delete (admin)
|
||||||
|
import "auth" as auth;
|
||||||
|
import "util" as util;
|
||||||
|
|
||||||
|
let m = ctx.request.method;
|
||||||
|
let path = ctx.request.path;
|
||||||
|
let is_admin_path = path.contains("/cms/admin/");
|
||||||
|
let pages = docs::collection("pages");
|
||||||
|
let idx = kv::collection("slugs");
|
||||||
|
|
||||||
|
// ---- public reads ----
|
||||||
|
if !is_admin_path && m == "GET" && ctx.request.params.slug != () {
|
||||||
|
let id = idx.get("page:" + ctx.request.params.slug);
|
||||||
|
if id == () { return #{ statusCode: 404, body: #{ error: "not found" } }; }
|
||||||
|
let doc = pages.get(id);
|
||||||
|
if doc == () { return #{ statusCode: 404, body: #{ error: "not found" } }; }
|
||||||
|
let p = util::shape_doc(doc);
|
||||||
|
if p.status != "published" { return #{ statusCode: 404, body: #{ error: "not found" } }; }
|
||||||
|
return #{ statusCode: 200, body: p };
|
||||||
|
}
|
||||||
|
if !is_admin_path && m == "GET" {
|
||||||
|
let rows = pages.find(#{ status: "published", "$sort": #{ title: 1 }, "$limit": 100 });
|
||||||
|
let out = [];
|
||||||
|
for r in rows { let p = util::shape_doc(r); p.remove("body_md"); out.push(p); }
|
||||||
|
return #{ statusCode: 200, body: #{ pages: out } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- everything else is admin ----
|
||||||
|
let g = auth::guard(ctx, ["admin"]);
|
||||||
|
if !g.ok { return g.response; }
|
||||||
|
let user = g.user;
|
||||||
|
|
||||||
|
if m == "GET" {
|
||||||
|
let rows = pages.find(#{ "$sort": #{ updated_at: -1 }, "$limit": 100 });
|
||||||
|
let out = [];
|
||||||
|
for r in rows { let p = util::shape_doc(r); p.remove("body_md"); out.push(p); }
|
||||||
|
return #{ statusCode: 200, body: #{ pages: out } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if m == "POST" {
|
||||||
|
let b = ctx.request.body;
|
||||||
|
if b == () || b.title == () { return #{ statusCode: 400, body: #{ error: "title required" } }; }
|
||||||
|
let slug = if b.slug == () { util::slugify(b.title) } else { util::slugify(b.slug) };
|
||||||
|
if idx.has("page:" + slug) { slug = slug + "-" + random::string(4).to_lower(); }
|
||||||
|
let data = #{
|
||||||
|
title: b.title,
|
||||||
|
slug: slug,
|
||||||
|
body_md: if b.body_md == () { "" } else { b.body_md },
|
||||||
|
status: if b.status == () { "draft" } else { b.status },
|
||||||
|
author_id: user.id,
|
||||||
|
author_name: user.display_name,
|
||||||
|
};
|
||||||
|
let id = pages.create(data);
|
||||||
|
idx.set("page:" + slug, id);
|
||||||
|
return #{ statusCode: 201, body: #{ id: id, slug: slug } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if m == "PUT" {
|
||||||
|
let id = ctx.request.params.id;
|
||||||
|
let doc = pages.get(id);
|
||||||
|
if doc == () { return #{ statusCode: 404, body: #{ error: "not found" } }; }
|
||||||
|
let cur = doc.data;
|
||||||
|
let b = ctx.request.body;
|
||||||
|
if b.title != () { cur.title = b.title; }
|
||||||
|
if b.body_md != () { cur.body_md = b.body_md; }
|
||||||
|
if b.status != () { cur.status = b.status; }
|
||||||
|
if b.slug != () {
|
||||||
|
let ns = util::slugify(b.slug);
|
||||||
|
if ns != cur.slug {
|
||||||
|
idx.delete("page:" + cur.slug);
|
||||||
|
if idx.has("page:" + ns) { ns = ns + "-" + random::string(4).to_lower(); }
|
||||||
|
idx.set("page:" + ns, id);
|
||||||
|
cur.slug = ns;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pages.update(id, cur);
|
||||||
|
return #{ statusCode: 200, body: #{ id: id, slug: cur.slug } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if m == "DELETE" {
|
||||||
|
let id = ctx.request.params.id;
|
||||||
|
let doc = pages.get(id);
|
||||||
|
if doc == () { return #{ statusCode: 404, body: #{ error: "not found" } }; }
|
||||||
|
pages.delete(id);
|
||||||
|
idx.delete("page:" + doc.data.slug);
|
||||||
|
return #{ statusCode: 200, body: #{ ok: true } };
|
||||||
|
}
|
||||||
|
|
||||||
|
#{ statusCode: 405, body: #{ error: "method not allowed" } }
|
||||||
39
examples/cms-poc/scripts/posts/post_create.rhai
Normal file
39
examples/cms-poc/scripts/posts/post_create.rhai
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// POST /cms/admin/posts — create a post (author+).
|
||||||
|
import "auth" as auth;
|
||||||
|
import "util" as util;
|
||||||
|
let g = auth::guard(ctx, ["admin", "author"]);
|
||||||
|
if !g.ok { return g.response; }
|
||||||
|
let user = g.user;
|
||||||
|
|
||||||
|
let b = ctx.request.body;
|
||||||
|
if b == () || b.title == () {
|
||||||
|
return #{ statusCode: 400, body: #{ error: "title required" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
let posts = docs::collection("posts");
|
||||||
|
let idx = kv::collection("slugs");
|
||||||
|
let slug = if b.slug == () { util::slugify(b.title) } else { util::slugify(b.slug) };
|
||||||
|
if idx.has("post:" + slug) {
|
||||||
|
slug = slug + "-" + random::string(4).to_lower();
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = time::now();
|
||||||
|
let status = if b.status == () { "draft" } else { b.status };
|
||||||
|
let publish_at = ();
|
||||||
|
if status == "published" { publish_at = now; }
|
||||||
|
else if status == "scheduled" && b.publish_at != () { publish_at = b.publish_at; }
|
||||||
|
|
||||||
|
let data = #{
|
||||||
|
title: b.title,
|
||||||
|
slug: slug,
|
||||||
|
body_md: if b.body_md == () { "" } else { b.body_md },
|
||||||
|
excerpt: if b.excerpt == () { "" } else { b.excerpt },
|
||||||
|
status: status,
|
||||||
|
author_id: user.id,
|
||||||
|
author_name: user.display_name,
|
||||||
|
tags: if b.tags == () { [] } else { b.tags },
|
||||||
|
publish_at: publish_at,
|
||||||
|
};
|
||||||
|
let id = posts.create(data);
|
||||||
|
idx.set("post:" + slug, id);
|
||||||
|
#{ statusCode: 201, body: #{ id: id, slug: slug, status: status } }
|
||||||
13
examples/cms-poc/scripts/posts/post_delete.rhai
Normal file
13
examples/cms-poc/scripts/posts/post_delete.rhai
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// DELETE /cms/admin/posts/:id — delete a post (admin only).
|
||||||
|
import "auth" as auth;
|
||||||
|
let g = auth::guard(ctx, ["admin"]);
|
||||||
|
if !g.ok { return g.response; }
|
||||||
|
|
||||||
|
let id = ctx.request.params.id;
|
||||||
|
let posts = docs::collection("posts");
|
||||||
|
let doc = posts.get(id);
|
||||||
|
if doc == () { return #{ statusCode: 404, body: #{ error: "not found" } }; }
|
||||||
|
let slug = doc.data.slug;
|
||||||
|
posts.delete(id);
|
||||||
|
kv::collection("slugs").delete("post:" + slug);
|
||||||
|
#{ statusCode: 200, body: #{ ok: true } }
|
||||||
24
examples/cms-poc/scripts/posts/post_get.rhai
Normal file
24
examples/cms-poc/scripts/posts/post_get.rhai
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// GET /cms/posts/:slug — public single published post (by slug).
|
||||||
|
// Resolves slug -> id via the kv slug index, increments a CAS view counter.
|
||||||
|
import "util" as util;
|
||||||
|
let slug = ctx.request.params.slug;
|
||||||
|
if slug == () { return #{ statusCode: 400, body: #{ error: "missing slug" } }; }
|
||||||
|
|
||||||
|
let idx = kv::collection("slugs");
|
||||||
|
let id = idx.get("post:" + slug);
|
||||||
|
if id == () { return #{ statusCode: 404, body: #{ error: "not found" } }; }
|
||||||
|
|
||||||
|
let posts = docs::collection("posts");
|
||||||
|
let doc = posts.get(id);
|
||||||
|
if doc == () { return #{ statusCode: 404, body: #{ error: "not found" } }; }
|
||||||
|
|
||||||
|
let p = util::shape_doc(doc);
|
||||||
|
// A public reader may only see PUBLISHED posts — never a draft/scheduled one,
|
||||||
|
// even if they guess the slug. (No platform per-row authz; enforced here.)
|
||||||
|
if p.status != "published" {
|
||||||
|
return #{ statusCode: 404, body: #{ error: "not found" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
let views = util::incr("views", "post:" + id);
|
||||||
|
p.views = views;
|
||||||
|
#{ statusCode: 200, body: p }
|
||||||
44
examples/cms-poc/scripts/posts/post_update.rhai
Normal file
44
examples/cms-poc/scripts/posts/post_update.rhai
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// PUT /cms/admin/posts/:id — update a post (author+ ; own-only unless admin).
|
||||||
|
import "auth" as auth;
|
||||||
|
import "util" as util;
|
||||||
|
let g = auth::guard(ctx, ["admin", "author"]);
|
||||||
|
if !g.ok { return g.response; }
|
||||||
|
let user = g.user;
|
||||||
|
|
||||||
|
let id = ctx.request.params.id;
|
||||||
|
let posts = docs::collection("posts");
|
||||||
|
let doc = posts.get(id);
|
||||||
|
if doc == () { return #{ statusCode: 404, body: #{ error: "not found" } }; }
|
||||||
|
let cur = doc.data;
|
||||||
|
|
||||||
|
// IDOR guard: an author may edit only their OWN post; an admin edits any.
|
||||||
|
let is_admin = users::has_role(user.id, "admin");
|
||||||
|
if !is_admin && cur.author_id != user.id {
|
||||||
|
return #{ statusCode: 403, body: #{ error: "not your post" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
let b = ctx.request.body;
|
||||||
|
if b.title != () { cur.title = b.title; }
|
||||||
|
if b.body_md != () { cur.body_md = b.body_md; }
|
||||||
|
if b.excerpt != () { cur.excerpt = b.excerpt; }
|
||||||
|
if b.tags != () { cur.tags = b.tags; }
|
||||||
|
|
||||||
|
let idx = kv::collection("slugs");
|
||||||
|
if b.slug != () {
|
||||||
|
let newslug = util::slugify(b.slug);
|
||||||
|
if newslug != cur.slug {
|
||||||
|
idx.delete("post:" + cur.slug);
|
||||||
|
if idx.has("post:" + newslug) { newslug = newslug + "-" + random::string(4).to_lower(); }
|
||||||
|
idx.set("post:" + newslug, id);
|
||||||
|
cur.slug = newslug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if b.status != () && b.status != cur.status {
|
||||||
|
cur.status = b.status;
|
||||||
|
if b.status == "published" && cur.publish_at == () { cur.publish_at = time::now(); }
|
||||||
|
if b.status == "scheduled" && b.publish_at != () { cur.publish_at = b.publish_at; }
|
||||||
|
}
|
||||||
|
|
||||||
|
posts.update(id, cur);
|
||||||
|
#{ statusCode: 200, body: #{ id: id, slug: cur.slug, status: cur.status } }
|
||||||
21
examples/cms-poc/scripts/posts/posts_admin_list.rhai
Normal file
21
examples/cms-poc/scripts/posts/posts_admin_list.rhai
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// GET /cms/admin/posts — list posts for the dashboard (author+).
|
||||||
|
// Admin sees all; an author sees only their own.
|
||||||
|
import "auth" as auth;
|
||||||
|
import "util" as util;
|
||||||
|
let g = auth::guard(ctx, ["admin", "author"]);
|
||||||
|
if !g.ok { return g.response; }
|
||||||
|
let user = g.user;
|
||||||
|
|
||||||
|
let posts = docs::collection("posts");
|
||||||
|
let is_admin = users::has_role(user.id, "admin");
|
||||||
|
let filter = #{ "$sort": #{ updated_at: -1 }, "$limit": 100 };
|
||||||
|
if !is_admin { filter.author_id = user.id; }
|
||||||
|
|
||||||
|
let rows = posts.find(filter);
|
||||||
|
let out = [];
|
||||||
|
for r in rows {
|
||||||
|
let p = util::shape_doc(r);
|
||||||
|
p.remove("body_md");
|
||||||
|
out.push(p);
|
||||||
|
}
|
||||||
|
#{ statusCode: 200, body: #{ posts: out } }
|
||||||
23
examples/cms-poc/scripts/posts/posts_list.rhai
Normal file
23
examples/cms-poc/scripts/posts/posts_list.rhai
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// GET /cms/posts — public list of PUBLISHED posts, newest first.
|
||||||
|
// Optional ?tag=<slug> filters by tag. Since the platform fix (F-015), the docs
|
||||||
|
// filter DSL supports `$contains` (JSONB @>) for array-membership, so the tag
|
||||||
|
// filter now runs IN THE DATABASE with real pagination — no over-fetch/in-script
|
||||||
|
// scan needed.
|
||||||
|
import "util" as util;
|
||||||
|
let posts = docs::collection("posts");
|
||||||
|
let per = util::to_int_or(vars::get("posts-per-page"), 10);
|
||||||
|
let tag = ctx.request.query["tag"];
|
||||||
|
|
||||||
|
let filter = #{ status: "published", "$sort": #{ publish_at: -1 }, "$limit": per };
|
||||||
|
if tag != () {
|
||||||
|
filter.tags = #{ "$contains": tag };
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = posts.find(filter);
|
||||||
|
let out = [];
|
||||||
|
for r in rows {
|
||||||
|
let p = util::shape_doc(r);
|
||||||
|
p.remove("body_md"); // list view: excerpt only
|
||||||
|
out.push(p);
|
||||||
|
}
|
||||||
|
#{ statusCode: 200, body: #{ posts: out, tag: tag } }
|
||||||
33
examples/cms-poc/scripts/posts/posts_on_publish.rhai
Normal file
33
examples/cms-poc/scripts/posts/posts_on_publish.rhai
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// posts_on_publish — docs TRIGGER on the `posts` collection (create|update).
|
||||||
|
// When a post transitions INTO "published", enqueue batched reader-notification
|
||||||
|
// jobs onto the `post-notifications` queue (decoupling the burst from delivery,
|
||||||
|
// and staying well under the per-execution emission ceiling via chunking).
|
||||||
|
let ev = ctx.event.docs;
|
||||||
|
if ev == () { return; }
|
||||||
|
let data = ev.data;
|
||||||
|
if data == () { return; }
|
||||||
|
|
||||||
|
let was = if ev.prev_data == () { () } else { ev.prev_data.status };
|
||||||
|
let became_published = data.status == "published" && was != "published";
|
||||||
|
if !became_published { return; }
|
||||||
|
|
||||||
|
// collect reader emails in chunks of 50
|
||||||
|
let res = users::list(#{ "$limit": 500 });
|
||||||
|
let batch = [];
|
||||||
|
let total = 0;
|
||||||
|
for u in res.users {
|
||||||
|
if users::has_role(u.id, "reader") {
|
||||||
|
batch.push(u.email);
|
||||||
|
total += 1;
|
||||||
|
if batch.len() >= 50 {
|
||||||
|
queue::enqueue("post-notifications",
|
||||||
|
#{ post_id: ev.id, title: data.title, slug: data.slug, emails: batch });
|
||||||
|
batch = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if batch.len() > 0 {
|
||||||
|
queue::enqueue("post-notifications",
|
||||||
|
#{ post_id: ev.id, title: data.title, slug: data.slug, emails: batch });
|
||||||
|
}
|
||||||
|
log::info("queued post notifications", #{ post: ev.id, title: data.title, readers: total });
|
||||||
18
examples/cms-poc/scripts/posts/posts_scheduled_publish.rhai
Normal file
18
examples/cms-poc/scripts/posts/posts_scheduled_publish.rhai
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// posts_scheduled_publish — cron TRIGGER (every minute). Flips due `scheduled`
|
||||||
|
// posts to `published`, which itself fires posts_on_publish -> notifications
|
||||||
|
// (a bounded trigger-fires-trigger chain).
|
||||||
|
let posts = docs::collection("posts");
|
||||||
|
let now = time::now();
|
||||||
|
let due = posts.find(#{
|
||||||
|
status: "scheduled",
|
||||||
|
publish_at: #{ "$lte": now },
|
||||||
|
"$limit": 100,
|
||||||
|
});
|
||||||
|
let n = 0;
|
||||||
|
for r in due {
|
||||||
|
let cur = r.data;
|
||||||
|
cur.status = "published";
|
||||||
|
posts.update(r.id, cur);
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
if n > 0 { log::info("cron published scheduled posts", #{ count: n }); }
|
||||||
15
examples/cms-poc/scripts/tags/tags_list.rhai
Normal file
15
examples/cms-poc/scripts/tags/tags_list.rhai
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// GET /cms/tags — public tag cloud: distinct tags across published posts + counts.
|
||||||
|
let posts = docs::collection("posts");
|
||||||
|
let rows = posts.find(#{ status: "published", "$limit": 500 });
|
||||||
|
let counts = #{};
|
||||||
|
for r in rows {
|
||||||
|
let tags = r.data.tags;
|
||||||
|
if tags != () {
|
||||||
|
for t in tags {
|
||||||
|
if counts[t] == () { counts[t] = 1; } else { counts[t] = counts[t] + 1; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let out = [];
|
||||||
|
for k in counts.keys() { out.push(#{ tag: k, count: counts[k] }); }
|
||||||
|
#{ statusCode: 200, body: #{ tags: out } }
|
||||||
40
examples/cms-poc/scripts/util.rhai
Normal file
40
examples/cms-poc/scripts/util.rhai
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// util — shared helpers (kind=module). import "util" as util;
|
||||||
|
|
||||||
|
// Turn a title into a URL slug: lowercase, non-alnum -> hyphen, trim hyphens.
|
||||||
|
fn slugify(s) {
|
||||||
|
let out = s.to_lower();
|
||||||
|
out = regex::replace_all("[^a-z0-9]+", out, "-");
|
||||||
|
out = regex::replace_all("(^-+|-+$)", out, "");
|
||||||
|
if out == "" { out = "untitled"; }
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten a docs envelope { id, data, created_at, updated_at } into a single
|
||||||
|
// map (data fields + id/timestamps) for JSON responses.
|
||||||
|
fn shape_doc(doc) {
|
||||||
|
let d = doc.data;
|
||||||
|
d.id = doc.id;
|
||||||
|
d.created_at = doc.created_at;
|
||||||
|
d.updated_at = doc.updated_at;
|
||||||
|
d
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_int_or(s, dflt) {
|
||||||
|
if s == () { return dflt; }
|
||||||
|
if type_of(s) == "i64" || type_of(s) == "int" { return s; }
|
||||||
|
try { parse_int(s) } catch(e) { dflt }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic KV counter increment via set_if (CAS), bounded retries.
|
||||||
|
fn incr(coll_name, key) {
|
||||||
|
let c = kv::collection(coll_name);
|
||||||
|
let i = 0;
|
||||||
|
while i < 8 {
|
||||||
|
let cur = c.get(key);
|
||||||
|
let n = if cur == () { 0 } else { cur };
|
||||||
|
if c.set_if(key, cur, n + 1) { return n + 1; }
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
// give up gracefully — a stale counter is not worth failing the request
|
||||||
|
c.get(key)
|
||||||
|
}
|
||||||
18
examples/cms-poc/scripts/workflow/pipeline_dag.rhai
Normal file
18
examples/cms-poc/scripts/workflow/pipeline_dag.rhai
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// GET /cms/admin/workflow — the editorial pipeline's DAG structure, for the
|
||||||
|
// Svelte Flow visual editor (nodes + edges). Mirrors the [[workflows]] manifest.
|
||||||
|
import "auth" as auth;
|
||||||
|
let g = auth::guard(ctx, ["admin", "author"]);
|
||||||
|
if !g.ok { return g.response; }
|
||||||
|
#{
|
||||||
|
statusCode: 200,
|
||||||
|
body: #{
|
||||||
|
name: "editorial_pipeline",
|
||||||
|
steps: [
|
||||||
|
#{ name: "validate", label: "Validate content", depends_on: [] },
|
||||||
|
#{ name: "enrich", label: "Auto-excerpt + reading time", depends_on: ["validate"], when: "validate ok" },
|
||||||
|
#{ name: "seo", label: "SEO check", depends_on: ["validate"], when: "validate ok" },
|
||||||
|
#{ name: "publish", label: "Publish + notify readers", depends_on: ["enrich", "seo"], when: "validate ok" },
|
||||||
|
#{ name: "finalize", label: "Finalize run", depends_on: ["publish"] },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
41
examples/cms-poc/scripts/workflow/pipeline_run.rhai
Normal file
41
examples/cms-poc/scripts/workflow/pipeline_run.rhai
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// GET /cms/admin/workflow/runs/:id — one run's overall + per-step status, read
|
||||||
|
// straight from the platform via workflow::run_status (F-038). Replaces the
|
||||||
|
// former app-side `wfruns` KV tracking — the SDK is app-scoped, so a run from
|
||||||
|
// another app resolves to () (404) here.
|
||||||
|
import "auth" as auth;
|
||||||
|
let g = auth::guard(ctx, ["admin", "author"]);
|
||||||
|
if !g.ok { return g.response; }
|
||||||
|
|
||||||
|
let run_id = ctx.request.params.id;
|
||||||
|
let st = workflow::run_status(run_id);
|
||||||
|
if st == () { return #{ statusCode: 404, body: #{ error: "no such run" } }; }
|
||||||
|
|
||||||
|
let engine = st.status; // pending|running|succeeded|failed
|
||||||
|
let terminal = engine == "succeeded" || engine == "failed";
|
||||||
|
|
||||||
|
// Normalize per-step status for the UI. A DAG step absent from the run — its
|
||||||
|
// `when` gate was false — shows as skipped once the run is terminal, else
|
||||||
|
// pending; the engine's "ready" (queued) also maps to pending.
|
||||||
|
let steps = #{};
|
||||||
|
for name in ["validate", "enrich", "seo", "publish", "finalize"] {
|
||||||
|
let s = st.steps[name];
|
||||||
|
if s == () {
|
||||||
|
s = if terminal { "skipped" } else { "pending" };
|
||||||
|
} else if s == "ready" {
|
||||||
|
s = "pending";
|
||||||
|
}
|
||||||
|
steps[name] = #{ status: s };
|
||||||
|
}
|
||||||
|
|
||||||
|
// CMS-level overall: "blocked" when the gate stopped publish, else mirror the
|
||||||
|
// engine's terminal state (skip != fail, so the engine itself reports
|
||||||
|
// "succeeded" even when publish was gated out — F-039).
|
||||||
|
let overall_status = if !terminal {
|
||||||
|
"running"
|
||||||
|
} else if steps.publish.status == "succeeded" {
|
||||||
|
"completed"
|
||||||
|
} else {
|
||||||
|
"blocked"
|
||||||
|
};
|
||||||
|
|
||||||
|
#{ statusCode: 200, body: #{ run_id: run_id, overall: #{ status: overall_status }, steps: steps } }
|
||||||
21
examples/cms-poc/scripts/workflow/pipeline_start.rhai
Normal file
21
examples/cms-poc/scripts/workflow/pipeline_start.rhai
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// POST /cms/admin/posts/:id/pipeline — run the editorial publish pipeline on a
|
||||||
|
// post (author+, own-only unless admin). Starts the durable workflow and
|
||||||
|
// returns its run id; the UI polls it via workflow::run_status (F-038).
|
||||||
|
import "auth" as auth;
|
||||||
|
let g = auth::guard(ctx, ["admin", "author"]);
|
||||||
|
if !g.ok { return g.response; }
|
||||||
|
let user = g.user;
|
||||||
|
|
||||||
|
let post_id = ctx.request.params.id;
|
||||||
|
let post = docs::collection("posts").get(post_id);
|
||||||
|
if post == () { return #{ statusCode: 404, body: #{ error: "post not found" } }; }
|
||||||
|
|
||||||
|
let is_admin = users::has_role(user.id, "admin");
|
||||||
|
if !is_admin && post.data.author_id != user.id {
|
||||||
|
return #{ statusCode: 403, body: #{ error: "not your post" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// durable DAG; each step reads post_id from its input mapping
|
||||||
|
let run_id = workflow::start("editorial_pipeline", #{ post_id: post_id });
|
||||||
|
|
||||||
|
#{ statusCode: 202, body: #{ run_id: run_id, workflow_run_id: run_id } }
|
||||||
24
examples/cms-poc/scripts/workflow/wf_enrich.rhai
Normal file
24
examples/cms-poc/scripts/workflow/wf_enrich.rhai
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// workflow step: enrich — auto-generate an excerpt (if missing) and a reading
|
||||||
|
// time, and write them back onto the post. Runs in PARALLEL with `seo`.
|
||||||
|
let b = ctx.request.body;
|
||||||
|
let posts = docs::collection("posts");
|
||||||
|
let post = posts.get(b.post_id);
|
||||||
|
let d = post.data;
|
||||||
|
let body = if d.body_md == () { "" } else { d.body_md };
|
||||||
|
let words = if body == "" { 0 } else { body.split(" ").len() };
|
||||||
|
let reading_time = (words / 200) + 1;
|
||||||
|
|
||||||
|
let excerpt = d.excerpt;
|
||||||
|
if excerpt == () || excerpt == "" {
|
||||||
|
let parts = body.split(" ");
|
||||||
|
let n = if parts.len() < 24 { parts.len() } else { 24 };
|
||||||
|
let ex = "";
|
||||||
|
for i in 0..n { ex += parts[i] + " "; }
|
||||||
|
ex.trim();
|
||||||
|
excerpt = ex + "…";
|
||||||
|
d.excerpt = excerpt;
|
||||||
|
}
|
||||||
|
d.reading_time = reading_time;
|
||||||
|
posts.update(b.post_id, d);
|
||||||
|
|
||||||
|
#{ excerpt: excerpt, reading_time: reading_time }
|
||||||
6
examples/cms-poc/scripts/workflow/wf_finalize.rhai
Normal file
6
examples/cms-poc/scripts/workflow/wf_finalize.rhai
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// workflow step: finalize — terminal join of the DAG. Runs even when upstream
|
||||||
|
// steps were SKIPPED (a skipped dep doesn't block dependents — F-039), so it
|
||||||
|
// makes no assumption that `publish` ran. Overall completed-vs-blocked is
|
||||||
|
// derived by the reader from `workflow::run_status` (F-038), not here.
|
||||||
|
let b = ctx.request.body;
|
||||||
|
#{ finalized: true, post_id: b.post_id }
|
||||||
13
examples/cms-poc/scripts/workflow/wf_publish.rhai
Normal file
13
examples/cms-poc/scripts/workflow/wf_publish.rhai
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// workflow step: publish — flip the post to published. This docs::update fires
|
||||||
|
// the existing posts_on_publish trigger → queue → reader notification emails,
|
||||||
|
// so the workflow chains into the rest of the CMS event graph.
|
||||||
|
let b = ctx.request.body;
|
||||||
|
let posts = docs::collection("posts");
|
||||||
|
let post = posts.get(b.post_id);
|
||||||
|
let d = post.data;
|
||||||
|
if d.status != "published" {
|
||||||
|
d.status = "published";
|
||||||
|
if d.publish_at == () { d.publish_at = time::now(); }
|
||||||
|
posts.update(b.post_id, d);
|
||||||
|
}
|
||||||
|
#{ published: true, slug: d.slug }
|
||||||
13
examples/cms-poc/scripts/workflow/wf_seo.rhai
Normal file
13
examples/cms-poc/scripts/workflow/wf_seo.rhai
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// workflow step: seo — score the post on a few SEO heuristics. Runs in PARALLEL
|
||||||
|
// with `enrich`. Output { score, warnings } (advisory; doesn't block publish).
|
||||||
|
let b = ctx.request.body;
|
||||||
|
let post = docs::collection("posts").get(b.post_id);
|
||||||
|
let d = post.data;
|
||||||
|
let warnings = [];
|
||||||
|
let score = 100;
|
||||||
|
if d.tags == () || d.tags.len() == 0 { warnings.push("no tags"); score -= 30; }
|
||||||
|
let tlen = if d.title == () { 0 } else { d.title.len() };
|
||||||
|
if tlen < 10 { warnings.push("title is short (<10 chars)"); score -= 20; }
|
||||||
|
if tlen > 70 { warnings.push("title is long (>70 chars)"); score -= 10; }
|
||||||
|
if score < 0 { score = 0; }
|
||||||
|
#{ score: score, warnings: warnings }
|
||||||
14
examples/cms-poc/scripts/workflow/wf_validate.rhai
Normal file
14
examples/cms-poc/scripts/workflow/wf_validate.rhai
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// workflow step: validate — check the post has a title + enough body.
|
||||||
|
// Output { ok, word_count, issues } → gates the rest of the DAG via `when`.
|
||||||
|
let b = ctx.request.body;
|
||||||
|
let post = docs::collection("posts").get(b.post_id);
|
||||||
|
if post == () {
|
||||||
|
return #{ ok: false, issues: ["post not found"], word_count: 0 };
|
||||||
|
}
|
||||||
|
let d = post.data;
|
||||||
|
let issues = [];
|
||||||
|
if d.title == () || d.title == "" { issues.push("missing title"); }
|
||||||
|
let body = if d.body_md == () { "" } else { d.body_md };
|
||||||
|
let words = if body == "" { 0 } else { body.split(" ").len() };
|
||||||
|
if words < 3 { issues.push("body too short"); }
|
||||||
|
#{ ok: issues.len() == 0, word_count: words, issues: issues }
|
||||||
Reference in New Issue
Block a user