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

Latest posts

+ +{#if tags.length} +
+ + {#each tags as t} + + {/each} +
+{/if} + +{#if loading} +

Loading…

+{:else if posts.length === 0} +

No posts yet.

+{:else} + {#each posts as post} +
+

{post.title}

+

by {post.author_name} Β· {new Date(post.publish_at).toLocaleDateString()}

+ {#if post.excerpt}

{post.excerpt}

{/if} +

{#each post.tags as tg}#{tg} {/each}

+
+ {/each} +{/if} + + diff --git a/examples/cms-poc/frontend/src/routes/admin/+layout.svelte b/examples/cms-poc/frontend/src/routes/admin/+layout.svelte new file mode 100644 index 0000000..89f5c71 --- /dev/null +++ b/examples/cms-poc/frontend/src/routes/admin/+layout.svelte @@ -0,0 +1,37 @@ + + +{#if ready && $user && (hasRole($user, 'admin') || hasRole($user, 'author'))} +
+ Overview + Posts + Pages + Comments + Pipeline + {#if hasRole($user, 'admin')}Users{/if} +
+ {@render children()} +{:else} +

Checking access…

+{/if} + + diff --git a/examples/cms-poc/frontend/src/routes/admin/+page.svelte b/examples/cms-poc/frontend/src/routes/admin/+page.svelte new file mode 100644 index 0000000..5c25a98 --- /dev/null +++ b/examples/cms-poc/frontend/src/routes/admin/+page.svelte @@ -0,0 +1,32 @@ + + +

Dashboard

+
+

{stats.posts}

your posts

Manage β†’
+

{stats.pending}

pending comments

Moderate β†’
+ {#if hasRole($user, 'admin')} +

{stats.users}

users

Manage β†’
+ {/if} +
+ + diff --git a/examples/cms-poc/frontend/src/routes/admin/comments/+page.svelte b/examples/cms-poc/frontend/src/routes/admin/comments/+page.svelte new file mode 100644 index 0000000..1f09d98 --- /dev/null +++ b/examples/cms-poc/frontend/src/routes/admin/comments/+page.svelte @@ -0,0 +1,57 @@ + + +

Comment moderation

+

+ Filter: + + {msg} +

+ +{#each comments as c} +
+

{@html c.author_name} on {c.post_title} + Β· {new Date(c.created_at).toLocaleString()} + Β· {c.status}

+ +

{@html c.body}

+

+ {#if c.status !== 'approved'}{/if} + {#if c.status !== 'spam'}{/if} + +

+
+{:else} +

No comments.

+{/each} + + diff --git a/examples/cms-poc/frontend/src/routes/admin/pages/+page.svelte b/examples/cms-poc/frontend/src/routes/admin/pages/+page.svelte new file mode 100644 index 0000000..4ef3680 --- /dev/null +++ b/examples/cms-poc/frontend/src/routes/admin/pages/+page.svelte @@ -0,0 +1,53 @@ + + +

Pages

+

{msg}

+ +{#if editing} +
+ + + + + +

+
+{/if} + +
    + {#each pages as p} +
  • + {p.title} {p.status} + β€” view + + +
  • + {/each} +
+ + diff --git a/examples/cms-poc/frontend/src/routes/admin/posts/+page.svelte b/examples/cms-poc/frontend/src/routes/admin/posts/+page.svelte new file mode 100644 index 0000000..bd690a3 --- /dev/null +++ b/examples/cms-poc/frontend/src/routes/admin/posts/+page.svelte @@ -0,0 +1,97 @@ + + +

Posts

+

{msg}

+ +{#if editing} +
+

{editing.id ? 'Edit' : 'New'} post

+ + + + + + + + {#if editing.status === 'scheduled'} + + + {/if} +

+
+{/if} + + + + + {#each posts as p} + + + + + + + {/each} + +
TitleStatusTags
{p.title}{p.status}{(p.tags || []).join(', ')} + + +
+ + diff --git a/examples/cms-poc/frontend/src/routes/admin/users/+page.svelte b/examples/cms-poc/frontend/src/routes/admin/users/+page.svelte new file mode 100644 index 0000000..5cf35c4 --- /dev/null +++ b/examples/cms-poc/frontend/src/routes/admin/users/+page.svelte @@ -0,0 +1,47 @@ + + +

Users

+

{msg}

+ + + + {#each users as u} + + + + + + {/each} + +
EmailNameRoles
{u.email}{u.display_name || 'β€”'} + {#each ROLES as r} + + {/each} +
+ + diff --git a/examples/cms-poc/frontend/src/routes/admin/workflow/+page.svelte b/examples/cms-poc/frontend/src/routes/admin/workflow/+page.svelte new file mode 100644 index 0000000..a665887 --- /dev/null +++ b/examples/cms-poc/frontend/src/routes/admin/workflow/+page.svelte @@ -0,0 +1,148 @@ + + +

Editorial pipeline (PiCloud Workflow)

+

+ A durable DAG: validate β†’ (enrich βˆ₯ seo) β†’ publish β†’ finalize. Pick a draft and run it β€” + the nodes light up as each step completes. Publishing chains into the reader-notification pipeline. +

+ +
+ + + {#if overall}{overall}{/if} + {msg} +
+ +
+ + + + +
+ + diff --git a/examples/cms-poc/frontend/src/routes/login/+page.svelte b/examples/cms-poc/frontend/src/routes/login/+page.svelte new file mode 100644 index 0000000..90f9c8b --- /dev/null +++ b/examples/cms-poc/frontend/src/routes/login/+page.svelte @@ -0,0 +1,24 @@ + + +

Log in

+
+ + + + +

+ {#if error}

{error}

{/if} +
+

No account? Register as a reader.

diff --git a/examples/cms-poc/frontend/src/routes/pages/[slug]/+page.svelte b/examples/cms-poc/frontend/src/routes/pages/[slug]/+page.svelte new file mode 100644 index 0000000..4dc4d24 --- /dev/null +++ b/examples/cms-poc/frontend/src/routes/pages/[slug]/+page.svelte @@ -0,0 +1,23 @@ + + +{#if error} +

Page not found.

+{:else if pg} +

{pg.title}

+
{@html renderMarkdown(pg.body_md)}
+{:else} +

Loading…

+{/if} diff --git a/examples/cms-poc/frontend/src/routes/posts/[slug]/+page.svelte b/examples/cms-poc/frontend/src/routes/posts/[slug]/+page.svelte new file mode 100644 index 0000000..342c9d1 --- /dev/null +++ b/examples/cms-poc/frontend/src/routes/posts/[slug]/+page.svelte @@ -0,0 +1,110 @@ + + +{#if error} +

Post not found.

+ ← back +{:else if post} + ← all posts +

{post.title}

+

by {post.author_name} Β· {new Date(post.publish_at).toLocaleString()} Β· {post.views} views

+

{#each post.tags as t}#{t} {/each}

+ +
{@html renderMarkdown(post.body_md)}
+ +
+

Comments ({comments.length}) {#if live}● live{/if}

+ {#each comments as c} +
+ {@html c.author_name} + {new Date(c.created_at).toLocaleDateString()} + +

{@html c.body}

+
+ {/each} + +

Leave a comment

+
+ + + + + + +

+ {#if submitMsg}

{submitMsg}

{/if} +
+{:else} +

Loading…

+{/if} + + diff --git a/examples/cms-poc/frontend/src/routes/register/+page.svelte b/examples/cms-poc/frontend/src/routes/register/+page.svelte new file mode 100644 index 0000000..a18a965 --- /dev/null +++ b/examples/cms-poc/frontend/src/routes/register/+page.svelte @@ -0,0 +1,29 @@ + + +

Register as a reader

+
+ + + + + + +

+ {#if msg}

{msg}

{/if} + {#if error}

{error}

{/if} +
diff --git a/examples/cms-poc/frontend/svelte.config.js b/examples/cms-poc/frontend/svelte.config.js new file mode 100644 index 0000000..f33dcfc --- /dev/null +++ b/examples/cms-poc/frontend/svelte.config.js @@ -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; diff --git a/examples/cms-poc/frontend/vite.config.ts b/examples/cms-poc/frontend/vite.config.ts new file mode 100644 index 0000000..4a33309 --- /dev/null +++ b/examples/cms-poc/frontend/vite.config.ts @@ -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 + } + } + } +}); diff --git a/examples/cms-poc/picloud.dev.toml b/examples/cms-poc/picloud.dev.toml new file mode 100644 index 0000000..38cc171 --- /dev/null +++ b/examples/cms-poc/picloud.dev.toml @@ -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" diff --git a/examples/cms-poc/picloud.prod.toml b/examples/cms-poc/picloud.prod.toml new file mode 100644 index 0000000..c0bb82a --- /dev/null +++ b/examples/cms-poc/picloud.prod.toml @@ -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" diff --git a/examples/cms-poc/picloud.toml b/examples/cms-poc/picloud.toml new file mode 100644 index 0000000..0ca627b --- /dev/null +++ b/examples/cms-poc/picloud.toml @@ -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.] 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 ` (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 }}" } diff --git a/examples/cms-poc/run-server.sh b/examples/cms-poc/run-server.sh new file mode 100755 index 0000000..92c9681 --- /dev/null +++ b/examples/cms-poc/run-server.sh @@ -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 diff --git a/examples/cms-poc/scripts/auth/admin_user_roles.rhai b/examples/cms-poc/scripts/auth/admin_user_roles.rhai new file mode 100644 index 0000000..dcc2365 --- /dev/null +++ b/examples/cms-poc/scripts/auth/admin_user_roles.rhai @@ -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) } } diff --git a/examples/cms-poc/scripts/auth/admin_users_list.rhai b/examples/cms-poc/scripts/auth/admin_users_list.rhai new file mode 100644 index 0000000..c0f6cb6 --- /dev/null +++ b/examples/cms-poc/scripts/auth/admin_users_list.rhai @@ -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 } } diff --git a/examples/cms-poc/scripts/auth/auth.rhai b/examples/cms-poc/scripts/auth/auth.rhai new file mode 100644 index 0000000..c55e240 --- /dev/null +++ b/examples/cms-poc/scripts/auth/auth.rhai @@ -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 +} diff --git a/examples/cms-poc/scripts/auth/bootstrap.rhai b/examples/cms-poc/scripts/auth/bootstrap.rhai new file mode 100644 index 0000000..ea943aa --- /dev/null +++ b/examples/cms-poc/scripts/auth/bootstrap.rhai @@ -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"] } } diff --git a/examples/cms-poc/scripts/auth/login.rhai b/examples/cms-poc/scripts/auth/login.rhai new file mode 100644 index 0000000..1db3fe8 --- /dev/null +++ b/examples/cms-poc/scripts/auth/login.rhai @@ -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 } + } +} diff --git a/examples/cms-poc/scripts/auth/logout.rhai b/examples/cms-poc/scripts/auth/logout.rhai new file mode 100644 index 0000000..c0f6e06 --- /dev/null +++ b/examples/cms-poc/scripts/auth/logout.rhai @@ -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 } } diff --git a/examples/cms-poc/scripts/auth/me.rhai b/examples/cms-poc/scripts/auth/me.rhai new file mode 100644 index 0000000..922514a --- /dev/null +++ b/examples/cms-poc/scripts/auth/me.rhai @@ -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) } +} diff --git a/examples/cms-poc/scripts/auth/register.rhai b/examples/cms-poc/scripts/auth/register.rhai new file mode 100644 index 0000000..20dc348 --- /dev/null +++ b/examples/cms-poc/scripts/auth/register.rhai @@ -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"] } } diff --git a/examples/cms-poc/scripts/comments/comment_create.rhai b/examples/cms-poc/scripts/comments/comment_create.rhai new file mode 100644 index 0000000..5584fc8 --- /dev/null +++ b/examples/cms-poc/scripts/comments/comment_create.rhai @@ -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" } } diff --git a/examples/cms-poc/scripts/comments/comment_on_approve.rhai b/examples/cms-poc/scripts/comments/comment_on_approve.rhai new file mode 100644 index 0000000..fabcb71 --- /dev/null +++ b/examples/cms-poc/scripts/comments/comment_on_approve.rhai @@ -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 }); diff --git a/examples/cms-poc/scripts/comments/comment_sanitize.rhai b/examples/cms-poc/scripts/comments/comment_sanitize.rhai new file mode 100644 index 0000000..738667e --- /dev/null +++ b/examples/cms-poc/scripts/comments/comment_sanitize.rhai @@ -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: }; 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 } diff --git a/examples/cms-poc/scripts/comments/comments_admin.rhai b/examples/cms-poc/scripts/comments/comments_admin.rhai new file mode 100644 index 0000000..0969211 --- /dev/null +++ b/examples/cms-poc/scripts/comments/comments_admin.rhai @@ -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" } } diff --git a/examples/cms-poc/scripts/comments/comments_for_post.rhai b/examples/cms-poc/scripts/comments/comments_for_post.rhai new file mode 100644 index 0000000..c615984 --- /dev/null +++ b/examples/cms-poc/scripts/comments/comments_for_post.rhai @@ -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 } } diff --git a/examples/cms-poc/scripts/hello.rhai b/examples/cms-poc/scripts/hello.rhai new file mode 100644 index 0000000..341f654 --- /dev/null +++ b/examples/cms-poc/scripts/hello.rhai @@ -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, + } +} diff --git a/examples/cms-poc/scripts/media/media_get.rhai b/examples/cms-poc/scripts/media/media_get.rhai new file mode 100644 index 0000000..0782977 --- /dev/null +++ b/examples/cms-poc/scripts/media/media_get.rhai @@ -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 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), +} diff --git a/examples/cms-poc/scripts/media/media_upload.rhai b/examples/cms-poc/scripts/media/media_upload.rhai new file mode 100644 index 0000000..68d9cc4 --- /dev/null +++ b/examples/cms-poc/scripts/media/media_upload.rhai @@ -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() } } diff --git a/examples/cms-poc/scripts/notify/notify_drain.rhai b/examples/cms-poc/scripts/notify/notify_drain.rhai new file mode 100644 index 0000000..0220649 --- /dev/null +++ b/examples/cms-poc/scripts/notify/notify_drain.rhai @@ -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: "

" + title + "

" + + "

A new post was just published on the blog.

" + + "

Read it here

", + }); + sent += 1; + } catch(e) { + log::warn("notify email failed", #{ to: addr, err: "" + e }); + } +} +log::info("sent post notifications", #{ title: title, sent: sent }); diff --git a/examples/cms-poc/scripts/pages/pages.rhai b/examples/cms-poc/scripts/pages/pages.rhai new file mode 100644 index 0000000..de1c1ec --- /dev/null +++ b/examples/cms-poc/scripts/pages/pages.rhai @@ -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" } } diff --git a/examples/cms-poc/scripts/posts/post_create.rhai b/examples/cms-poc/scripts/posts/post_create.rhai new file mode 100644 index 0000000..3216a79 --- /dev/null +++ b/examples/cms-poc/scripts/posts/post_create.rhai @@ -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 } } diff --git a/examples/cms-poc/scripts/posts/post_delete.rhai b/examples/cms-poc/scripts/posts/post_delete.rhai new file mode 100644 index 0000000..5f59f84 --- /dev/null +++ b/examples/cms-poc/scripts/posts/post_delete.rhai @@ -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 } } diff --git a/examples/cms-poc/scripts/posts/post_get.rhai b/examples/cms-poc/scripts/posts/post_get.rhai new file mode 100644 index 0000000..bf36e9e --- /dev/null +++ b/examples/cms-poc/scripts/posts/post_get.rhai @@ -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 } diff --git a/examples/cms-poc/scripts/posts/post_update.rhai b/examples/cms-poc/scripts/posts/post_update.rhai new file mode 100644 index 0000000..6242d88 --- /dev/null +++ b/examples/cms-poc/scripts/posts/post_update.rhai @@ -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 } } diff --git a/examples/cms-poc/scripts/posts/posts_admin_list.rhai b/examples/cms-poc/scripts/posts/posts_admin_list.rhai new file mode 100644 index 0000000..b5a6bd6 --- /dev/null +++ b/examples/cms-poc/scripts/posts/posts_admin_list.rhai @@ -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 } } diff --git a/examples/cms-poc/scripts/posts/posts_list.rhai b/examples/cms-poc/scripts/posts/posts_list.rhai new file mode 100644 index 0000000..d4db812 --- /dev/null +++ b/examples/cms-poc/scripts/posts/posts_list.rhai @@ -0,0 +1,23 @@ +// GET /cms/posts β€” public list of PUBLISHED posts, newest first. +// Optional ?tag= 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 } } diff --git a/examples/cms-poc/scripts/posts/posts_on_publish.rhai b/examples/cms-poc/scripts/posts/posts_on_publish.rhai new file mode 100644 index 0000000..161237f --- /dev/null +++ b/examples/cms-poc/scripts/posts/posts_on_publish.rhai @@ -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 }); diff --git a/examples/cms-poc/scripts/posts/posts_scheduled_publish.rhai b/examples/cms-poc/scripts/posts/posts_scheduled_publish.rhai new file mode 100644 index 0000000..078cd2e --- /dev/null +++ b/examples/cms-poc/scripts/posts/posts_scheduled_publish.rhai @@ -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 }); } diff --git a/examples/cms-poc/scripts/tags/tags_list.rhai b/examples/cms-poc/scripts/tags/tags_list.rhai new file mode 100644 index 0000000..cd5a701 --- /dev/null +++ b/examples/cms-poc/scripts/tags/tags_list.rhai @@ -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 } } diff --git a/examples/cms-poc/scripts/util.rhai b/examples/cms-poc/scripts/util.rhai new file mode 100644 index 0000000..842daca --- /dev/null +++ b/examples/cms-poc/scripts/util.rhai @@ -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) +} diff --git a/examples/cms-poc/scripts/workflow/pipeline_dag.rhai b/examples/cms-poc/scripts/workflow/pipeline_dag.rhai new file mode 100644 index 0000000..d79c492 --- /dev/null +++ b/examples/cms-poc/scripts/workflow/pipeline_dag.rhai @@ -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"] }, + ] + } +} diff --git a/examples/cms-poc/scripts/workflow/pipeline_run.rhai b/examples/cms-poc/scripts/workflow/pipeline_run.rhai new file mode 100644 index 0000000..f25d90b --- /dev/null +++ b/examples/cms-poc/scripts/workflow/pipeline_run.rhai @@ -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 } } diff --git a/examples/cms-poc/scripts/workflow/pipeline_start.rhai b/examples/cms-poc/scripts/workflow/pipeline_start.rhai new file mode 100644 index 0000000..5641e37 --- /dev/null +++ b/examples/cms-poc/scripts/workflow/pipeline_start.rhai @@ -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 } } diff --git a/examples/cms-poc/scripts/workflow/wf_enrich.rhai b/examples/cms-poc/scripts/workflow/wf_enrich.rhai new file mode 100644 index 0000000..3ddd18a --- /dev/null +++ b/examples/cms-poc/scripts/workflow/wf_enrich.rhai @@ -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 } diff --git a/examples/cms-poc/scripts/workflow/wf_finalize.rhai b/examples/cms-poc/scripts/workflow/wf_finalize.rhai new file mode 100644 index 0000000..d2a3abf --- /dev/null +++ b/examples/cms-poc/scripts/workflow/wf_finalize.rhai @@ -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 } diff --git a/examples/cms-poc/scripts/workflow/wf_publish.rhai b/examples/cms-poc/scripts/workflow/wf_publish.rhai new file mode 100644 index 0000000..5c4a559 --- /dev/null +++ b/examples/cms-poc/scripts/workflow/wf_publish.rhai @@ -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 } diff --git a/examples/cms-poc/scripts/workflow/wf_seo.rhai b/examples/cms-poc/scripts/workflow/wf_seo.rhai new file mode 100644 index 0000000..01e8173 --- /dev/null +++ b/examples/cms-poc/scripts/workflow/wf_seo.rhai @@ -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 } diff --git a/examples/cms-poc/scripts/workflow/wf_validate.rhai b/examples/cms-poc/scripts/workflow/wf_validate.rhai new file mode 100644 index 0000000..08e57d1 --- /dev/null +++ b/examples/cms-poc/scripts/workflow/wf_validate.rhai @@ -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 }