docs(examples): add the CMS proof-of-concept dogfooding artifact
Some checks failed
CI / Rust — fmt, clippy, test (push) Failing after 39m53s
CI / Dashboard — check (push) Successful in 10m15s

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:
MechaCat02
2026-07-19 20:15:29 +02:00
parent 5682be366c
commit 813bc46640
68 changed files with 4623 additions and 0 deletions

View 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) } }

View 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 } }

View 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
}

View 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"] } }

View 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 }
}
}

View 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 } }

View 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) }
}

View 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"] } }

View 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" } }

View 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 });

View 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("&", "&amp;"); // must be first
body.replace("<", "&lt;");
body.replace(">", "&gt;");
body.replace("\"", "&quot;");
body.replace("'", "&#39;");
// also escape any HTML the display_name might carry
let an = v.author_name;
let name = if an == () { "anonymous" } else { "" + an };
name.replace("&", "&amp;");
name.replace("<", "&lt;");
name.replace(">", "&gt;");
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 }

View 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" } }

View 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 } }

View 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,
}
}

View 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),
}

View 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() } }

View 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 });

View 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" } }

View 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 } }

View 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 } }

View 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 }

View 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 } }

View 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 } }

View 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 } }

View 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 });

View 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 }); }

View 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 } }

View 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)
}

View 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"] },
]
}
}

View 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 } }

View 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 } }

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }