docs(examples): add the CMS proof-of-concept dogfooding artifact
A WordPress-inspired CMS whose entire backend is PiCloud Rhai scripts deployed with pic apply, plus a SvelteKit frontend. Built to dogfood the project tool, CLI, and SDK; exercises docs/kv/files/users, docs+queue+cron +pubsub triggers, the transactional-outbox notification chain, a docs before-interceptor, set_if CAS, SSE, per-app CORS, env overlays, and a durable Workflow (validate -> enrich || seo -> publish -> finalize) started with workflow::start and polled with workflow::run_status, visualized live with Svelte Flow. FINDINGS.md / SECURITY.md capture every gap, surprise, and security issue found (each tagged [PiCloud] vs [CMS]); the platform fixes for the actionable ones ship in the preceding commit. Also folds in the read-only `pic` allowlist entries added to .claude/settings.json during the session (fewer-permission-prompts). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
17
examples/cms-poc/scripts/auth/admin_user_roles.rhai
Normal file
17
examples/cms-poc/scripts/auth/admin_user_roles.rhai
Normal file
@@ -0,0 +1,17 @@
|
||||
// PUT /cms/admin/users/:id/roles — grant/revoke roles (admin only).
|
||||
// body: { "add": ["author"], "remove": ["reader"] }
|
||||
import "auth" as auth;
|
||||
let g = auth::guard(ctx, ["admin"]);
|
||||
if !g.ok { return g.response; }
|
||||
|
||||
let id = ctx.request.params.id;
|
||||
let target = users::get(id);
|
||||
if target == () { return #{ statusCode: 404, body: #{ error: "no such user" } }; }
|
||||
|
||||
let valid = ["admin", "author", "reader"];
|
||||
let b = ctx.request.body;
|
||||
if b != () {
|
||||
if b.add != () { for r in b.add { if valid.contains(r) { users::add_role(id, r); } } }
|
||||
if b.remove != () { for r in b.remove { if valid.contains(r) { users::remove_role(id, r); } } }
|
||||
}
|
||||
#{ statusCode: 200, body: #{ id: id, roles: auth::roles_of(target) } }
|
||||
17
examples/cms-poc/scripts/auth/admin_users_list.rhai
Normal file
17
examples/cms-poc/scripts/auth/admin_users_list.rhai
Normal file
@@ -0,0 +1,17 @@
|
||||
// GET /cms/admin/users — list app-users + their roles (admin only).
|
||||
import "auth" as auth;
|
||||
let g = auth::guard(ctx, ["admin"]);
|
||||
if !g.ok { return g.response; }
|
||||
|
||||
let res = users::list(#{ "$limit": 200 });
|
||||
let out = [];
|
||||
for u in res.users {
|
||||
out.push(#{
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
display_name: u.display_name,
|
||||
roles: auth::roles_of(u),
|
||||
created_at: u.created_at,
|
||||
});
|
||||
}
|
||||
#{ statusCode: 200, body: #{ users: out } }
|
||||
64
examples/cms-poc/scripts/auth/auth.rhai
Normal file
64
examples/cms-poc/scripts/auth/auth.rhai
Normal file
@@ -0,0 +1,64 @@
|
||||
// auth — shared module (kind=module). THE access boundary for the CMS.
|
||||
// A public route runs with full app authority (principal:None); every
|
||||
// protected endpoint MUST import this and call require_role/require_user
|
||||
// as its first statement. Imported as: import "auth" as auth;
|
||||
|
||||
// Extract a bearer token from the Authorization header, or () if absent.
|
||||
fn bearer_token(ctx) {
|
||||
let h = ctx.request.headers["authorization"];
|
||||
if h == () { return (); }
|
||||
let t = h;
|
||||
if t.starts_with("Bearer ") { t = t.sub_string(7); }
|
||||
else if t.starts_with("bearer ") { t = t.sub_string(7); }
|
||||
t.trim();
|
||||
if t == "" { return (); }
|
||||
t
|
||||
}
|
||||
|
||||
// Resolve the current app-user from the bearer token, or () if none/invalid.
|
||||
fn current_user(ctx) {
|
||||
let t = bearer_token(ctx);
|
||||
if t == () { return (); }
|
||||
try { users::verify(t) } catch(e) { () }
|
||||
}
|
||||
|
||||
// The list of CMS roles, most-privileged first.
|
||||
fn all_roles() { ["admin", "author", "reader"] }
|
||||
|
||||
// Collect the roles a user actually holds (for /me and UI gating).
|
||||
fn roles_of(user) {
|
||||
let out = [];
|
||||
for r in all_roles() { if users::has_role(user.id, r) { out.push(r); } }
|
||||
out
|
||||
}
|
||||
|
||||
fn has_any_role(user, roles) {
|
||||
for r in roles { if users::has_role(user.id, r) { return true; } }
|
||||
false
|
||||
}
|
||||
|
||||
// --- guard pattern -------------------------------------------------------
|
||||
// We do NOT `throw` an HTTP envelope: an uncaught throw surfaces as a 502
|
||||
// whose body leaks the app id + script internals (see FINDINGS F-011 /
|
||||
// SECURITY S-01). Instead a guard RETURNS { ok, user } on success or
|
||||
// { ok:false, response } on failure, and the caller does:
|
||||
// let g = auth::guard(ctx, ["admin"]);
|
||||
// if !g.ok { return g.response; }
|
||||
// let user = g.user;
|
||||
|
||||
fn guard_user(ctx) {
|
||||
let u = current_user(ctx);
|
||||
if u == () {
|
||||
return #{ ok: false, response: #{ statusCode: 401, body: #{ error: "unauthenticated" } } };
|
||||
}
|
||||
#{ ok: true, user: u }
|
||||
}
|
||||
|
||||
fn guard(ctx, roles) {
|
||||
let g = guard_user(ctx);
|
||||
if !g.ok { return g; }
|
||||
if !has_any_role(g.user, roles) {
|
||||
return #{ ok: false, response: #{ statusCode: 403, body: #{ error: "forbidden" } } };
|
||||
}
|
||||
g
|
||||
}
|
||||
25
examples/cms-poc/scripts/auth/bootstrap.rhai
Normal file
25
examples/cms-poc/scripts/auth/bootstrap.rhai
Normal file
@@ -0,0 +1,25 @@
|
||||
// POST /cms/auth/bootstrap — create the first admin/author account.
|
||||
// Gated by the `setup-token` secret (compared server-side; never echoed).
|
||||
// This exists because there is no CLI/API to grant an app-user a role —
|
||||
// role assignment is script-only (users::add_role). See FINDINGS.
|
||||
let b = ctx.request.body;
|
||||
if b == () || b.setup_token == () {
|
||||
return #{ statusCode: 400, body: #{ error: "setup_token required" } };
|
||||
}
|
||||
let want = secrets::get("setup-token");
|
||||
if want == () {
|
||||
return #{ statusCode: 500, body: #{ error: "server not configured for bootstrap" } };
|
||||
}
|
||||
if b.setup_token != want {
|
||||
return #{ statusCode: 403, body: #{ error: "bad setup token" } };
|
||||
}
|
||||
if b.email == () || b.password == () {
|
||||
return #{ statusCode: 400, body: #{ error: "email and password required" } };
|
||||
}
|
||||
if !users::email_available(b.email) {
|
||||
return #{ statusCode: 409, body: #{ error: "email already registered" } };
|
||||
}
|
||||
let u = users::create(#{ email: b.email, password: b.password, display_name: b.display_name });
|
||||
users::add_role(u.id, "admin");
|
||||
users::add_role(u.id, "author");
|
||||
#{ statusCode: 201, body: #{ id: u.id, email: u.email, roles: ["admin", "author"] } }
|
||||
23
examples/cms-poc/scripts/auth/login.rhai
Normal file
23
examples/cms-poc/scripts/auth/login.rhai
Normal file
@@ -0,0 +1,23 @@
|
||||
// POST /cms/auth/login — exchange email+password for a session token (public).
|
||||
// Guard the body type: since the platform fix (F-026) non-JSON bodies now reach
|
||||
// the script (text -> string, binary -> base64), so an endpoint expecting a JSON
|
||||
// object must type-check rather than assume a map (else it throws -> 502).
|
||||
let b = ctx.request.body;
|
||||
if type_of(b) != "map" || b.email == () || b.password == () {
|
||||
return #{ statusCode: 400, body: #{ error: "email and password required" } };
|
||||
}
|
||||
let token = ();
|
||||
try { token = users::login(b.email, b.password); } catch(e) { token = (); }
|
||||
if token == () {
|
||||
return #{ statusCode: 401, body: #{ error: "invalid credentials" } };
|
||||
}
|
||||
let u = users::verify(token);
|
||||
let roles = [];
|
||||
for r in ["admin", "author", "reader"] { if users::has_role(u.id, r) { roles.push(r); } }
|
||||
#{
|
||||
statusCode: 200,
|
||||
body: #{
|
||||
token: token,
|
||||
user: #{ id: u.id, email: u.email, display_name: u.display_name, roles: roles }
|
||||
}
|
||||
}
|
||||
5
examples/cms-poc/scripts/auth/logout.rhai
Normal file
5
examples/cms-poc/scripts/auth/logout.rhai
Normal file
@@ -0,0 +1,5 @@
|
||||
// POST /cms/auth/logout — invalidate the current session token server-side.
|
||||
import "auth" as auth;
|
||||
let t = auth::bearer_token(ctx);
|
||||
if t != () { try { users::logout(t); } catch(e) {} }
|
||||
#{ statusCode: 200, body: #{ ok: true } }
|
||||
9
examples/cms-poc/scripts/auth/me.rhai
Normal file
9
examples/cms-poc/scripts/auth/me.rhai
Normal file
@@ -0,0 +1,9 @@
|
||||
// GET /cms/auth/me — the current user + roles (401 if unauthenticated).
|
||||
import "auth" as auth;
|
||||
let g = auth::guard_user(ctx);
|
||||
if !g.ok { return g.response; }
|
||||
let u = g.user;
|
||||
#{
|
||||
statusCode: 200,
|
||||
body: #{ id: u.id, email: u.email, display_name: u.display_name, roles: auth::roles_of(u) }
|
||||
}
|
||||
44
examples/cms-poc/scripts/auth/register.rhai
Normal file
44
examples/cms-poc/scripts/auth/register.rhai
Normal file
@@ -0,0 +1,44 @@
|
||||
// POST /cms/auth/register — reader self-signup (public).
|
||||
// Rate-limited by client IP + email to blunt enumeration/abuse (the only
|
||||
// primitive PiCloud gives us for this is a kv counter).
|
||||
let b = ctx.request.body;
|
||||
if b == () || type_of(b) != "map" {
|
||||
return #{ statusCode: 400, body: #{ error: "expected JSON body" } };
|
||||
}
|
||||
let email = b.email;
|
||||
let password = b.password;
|
||||
let name = if b.display_name == () { () } else { b.display_name };
|
||||
|
||||
if email == () || password == () {
|
||||
return #{ statusCode: 400, body: #{ error: "email and password required" } };
|
||||
}
|
||||
if vars::get("signups-open") != "true" {
|
||||
return #{ statusCode: 403, body: #{ error: "signups are closed" } };
|
||||
}
|
||||
|
||||
// --- basic rate limit: max 5 registration attempts / hour / IP ---
|
||||
let ip = ctx.request.headers["x-forwarded-for"];
|
||||
if ip == () { ip = "local"; }
|
||||
let rl = kv::collection("ratelimit");
|
||||
let bucket = "register:" + ip;
|
||||
let cur = rl.get(bucket);
|
||||
let count = if cur == () { 0 } else { cur.count };
|
||||
if count >= 5 {
|
||||
return #{ statusCode: 429, body: #{ error: "too many attempts, try later" } };
|
||||
}
|
||||
rl.set(bucket, #{ count: count + 1, at: time::now() });
|
||||
|
||||
// email_available is the anon-safe probe (find_by_email requires a principal).
|
||||
if !users::email_available(email) {
|
||||
return #{ statusCode: 409, body: #{ error: "email already registered" } };
|
||||
}
|
||||
|
||||
let u = ();
|
||||
try {
|
||||
u = users::create(#{ email: email, password: password, display_name: name });
|
||||
} catch(e) {
|
||||
return #{ statusCode: 400, body: #{ error: "could not create user: " + e } };
|
||||
}
|
||||
users::add_role(u.id, "reader");
|
||||
|
||||
#{ statusCode: 201, body: #{ id: u.id, email: u.email, roles: ["reader"] } }
|
||||
39
examples/cms-poc/scripts/comments/comment_create.rhai
Normal file
39
examples/cms-poc/scripts/comments/comment_create.rhai
Normal file
@@ -0,0 +1,39 @@
|
||||
// POST /cms/posts/:id/comments — submit a comment (public, rate-limited).
|
||||
// The docs `before` interceptor (comment_sanitize) escapes the body and sets
|
||||
// the moderation status; this endpoint just validates + writes.
|
||||
let post_id = ctx.request.params.id;
|
||||
let b = ctx.request.body;
|
||||
if b == () || b.body == () || b.author_name == () {
|
||||
return #{ statusCode: 400, body: #{ error: "author_name and body required" } };
|
||||
}
|
||||
|
||||
// the post must exist and be published
|
||||
let posts = docs::collection("posts");
|
||||
let pd = posts.get(post_id);
|
||||
if pd == () || pd.data.status != "published" {
|
||||
return #{ statusCode: 404, body: #{ error: "post not found" } };
|
||||
}
|
||||
|
||||
// rate limit: max 10 comments / hour / IP
|
||||
let ip = ctx.request.headers["x-forwarded-for"];
|
||||
if ip == () { ip = "local"; }
|
||||
let rl = kv::collection("ratelimit");
|
||||
let bk = "comment:" + ip;
|
||||
let cur = rl.get(bk);
|
||||
let cnt = if cur == () { 0 } else { cur.count };
|
||||
if cnt >= 10 { return #{ statusCode: 429, body: #{ error: "slow down" } }; }
|
||||
rl.set(bk, #{ count: cnt + 1, at: time::now() });
|
||||
|
||||
let comments = docs::collection("comments");
|
||||
let data = #{
|
||||
post_id: post_id,
|
||||
post_slug: pd.data.slug,
|
||||
post_title: pd.data.title,
|
||||
author_name: b.author_name,
|
||||
author_email: if b.author_email == () { "" } else { b.author_email },
|
||||
body: b.body,
|
||||
status: "pending", // interceptor re-affirms/overrides
|
||||
created_at: time::now(),
|
||||
};
|
||||
let id = comments.create(data); // <- interceptor sanitizes + sets status here
|
||||
#{ statusCode: 201, body: #{ id: id, status: "submitted" } }
|
||||
20
examples/cms-poc/scripts/comments/comment_on_approve.rhai
Normal file
20
examples/cms-poc/scripts/comments/comment_on_approve.rhai
Normal file
@@ -0,0 +1,20 @@
|
||||
// comment_on_approve — docs TRIGGER on `comments` (update). When a comment
|
||||
// transitions INTO "approved", publish it to the public `comments-feed` topic
|
||||
// so post pages can show it live over SSE. (Comments are public once approved.)
|
||||
let ev = ctx.event.docs;
|
||||
if ev == () { return; }
|
||||
let data = ev.data;
|
||||
if data == () { return; }
|
||||
|
||||
let was = if ev.prev_data == () { () } else { ev.prev_data.status };
|
||||
let became_approved = data.status == "approved" && was != "approved";
|
||||
if !became_approved { return; }
|
||||
|
||||
pubsub::publish_durable("comments-feed", #{
|
||||
post_id: data.post_id,
|
||||
post_slug: data.post_slug,
|
||||
author_name: data.author_name, // already HTML-escaped at write time
|
||||
body: data.body, // already HTML-escaped at write time
|
||||
created_at: data.created_at,
|
||||
});
|
||||
log::info("published approved comment", #{ post: data.post_id });
|
||||
36
examples/cms-poc/scripts/comments/comment_sanitize.rhai
Normal file
36
examples/cms-poc/scripts/comments/comment_sanitize.rhai
Normal file
@@ -0,0 +1,36 @@
|
||||
// comment_sanitize — a docs `before`/`create` INTERCEPTOR.
|
||||
// Fires for EVERY docs create in the app (posts, pages, comments), so it must
|
||||
// branch on the collection and no-op on everything but comments (F-020).
|
||||
// Receives the op-context as its request body: { service, op, collection, key, value }.
|
||||
// Returns #{ allowed: true, data: <rewritten value> }; anything else DENIES.
|
||||
let p = ctx.request.body;
|
||||
if p == () || p.collection != "comments" {
|
||||
return #{ allowed: true }; // not a comment — pass through untouched
|
||||
}
|
||||
|
||||
let v = p.value;
|
||||
if v == () { return #{ allowed: true }; }
|
||||
|
||||
// --- sanitize the comment body: escape HTML so it can't inject markup ---
|
||||
let raw = v.body;
|
||||
let body = if raw == () { "" } else { "" + raw };
|
||||
body.replace("&", "&"); // must be first
|
||||
body.replace("<", "<");
|
||||
body.replace(">", ">");
|
||||
body.replace("\"", """);
|
||||
body.replace("'", "'");
|
||||
// also escape any HTML the display_name might carry
|
||||
let an = v.author_name;
|
||||
let name = if an == () { "anonymous" } else { "" + an };
|
||||
name.replace("&", "&");
|
||||
name.replace("<", "<");
|
||||
name.replace(">", ">");
|
||||
|
||||
v.body = body;
|
||||
v.author_name = name;
|
||||
|
||||
// --- moderation status from the site config var ---
|
||||
let mode = vars::get("comment-moderation");
|
||||
if mode == "auto" { v.status = "approved"; } else { v.status = "pending"; }
|
||||
|
||||
#{ allowed: true, data: v }
|
||||
41
examples/cms-poc/scripts/comments/comments_admin.rhai
Normal file
41
examples/cms-poc/scripts/comments/comments_admin.rhai
Normal file
@@ -0,0 +1,41 @@
|
||||
// Admin comment moderation, dispatched by method:
|
||||
// GET /cms/admin/comments?status=pending -> list (admin)
|
||||
// PUT /cms/admin/comments/:id { status } -> approve/spam (admin)
|
||||
// DELETE /cms/admin/comments/:id -> delete (admin)
|
||||
import "auth" as auth;
|
||||
import "util" as util;
|
||||
let g = auth::guard(ctx, ["admin"]);
|
||||
if !g.ok { return g.response; }
|
||||
|
||||
let comments = docs::collection("comments");
|
||||
let m = ctx.request.method;
|
||||
|
||||
if m == "GET" {
|
||||
let status = ctx.request.query.status;
|
||||
let filter = #{ "$sort": #{ created_at: -1 }, "$limit": 200 };
|
||||
if status != () { filter.status = status; }
|
||||
let rows = comments.find(filter);
|
||||
let out = [];
|
||||
for r in rows { out.push(util::shape_doc(r)); }
|
||||
return #{ statusCode: 200, body: #{ comments: out } };
|
||||
}
|
||||
|
||||
let id = ctx.request.params.id;
|
||||
let doc = comments.get(id);
|
||||
if doc == () { return #{ statusCode: 404, body: #{ error: "not found" } }; }
|
||||
|
||||
if m == "DELETE" {
|
||||
comments.delete(id);
|
||||
return #{ statusCode: 200, body: #{ ok: true } };
|
||||
}
|
||||
|
||||
if m == "PUT" {
|
||||
let b = ctx.request.body;
|
||||
let cur = doc.data;
|
||||
let valid = ["pending", "approved", "spam"];
|
||||
if b.status != () && valid.contains(b.status) { cur.status = b.status; }
|
||||
comments.update(id, cur); // status->approved fires comment_on_approve (SSE)
|
||||
return #{ statusCode: 200, body: #{ id: id, status: cur.status } };
|
||||
}
|
||||
|
||||
#{ statusCode: 405, body: #{ error: "method not allowed" } }
|
||||
17
examples/cms-poc/scripts/comments/comments_for_post.rhai
Normal file
17
examples/cms-poc/scripts/comments/comments_for_post.rhai
Normal file
@@ -0,0 +1,17 @@
|
||||
// GET /cms/posts/:id/comments — public list of APPROVED comments for a post.
|
||||
import "util" as util;
|
||||
let post_id = ctx.request.params.id;
|
||||
let comments = docs::collection("comments");
|
||||
let rows = comments.find(#{
|
||||
post_id: post_id,
|
||||
status: "approved",
|
||||
"$sort": #{ created_at: 1 },
|
||||
"$limit": 200,
|
||||
});
|
||||
let out = [];
|
||||
for r in rows {
|
||||
let c = util::shape_doc(r);
|
||||
c.remove("author_email"); // never expose commenter emails publicly
|
||||
out.push(c);
|
||||
}
|
||||
#{ statusCode: 200, body: #{ comments: out } }
|
||||
11
examples/cms-poc/scripts/hello.rhai
Normal file
11
examples/cms-poc/scripts/hello.rhai
Normal file
@@ -0,0 +1,11 @@
|
||||
// Skeleton smoke-test endpoint. Returns site config to prove vars resolve.
|
||||
// (Var KEYS are kebab-case strings; Rhai map keys must be valid identifiers.)
|
||||
#{
|
||||
statusCode: 200,
|
||||
body: #{
|
||||
ok: true,
|
||||
site_title: vars::get("site-title"),
|
||||
site_base_url: vars::get("site-base-url"),
|
||||
sdk: ctx.sdk_version,
|
||||
}
|
||||
}
|
||||
18
examples/cms-poc/scripts/media/media_get.rhai
Normal file
18
examples/cms-poc/scripts/media/media_get.rhai
Normal file
@@ -0,0 +1,18 @@
|
||||
// GET /cms/media/:id — serve the image as REAL binary bytes (public).
|
||||
// Since the platform fix (F-026), a response with a `body_base64` field returns
|
||||
// raw bytes with a script-set Content-Type, so <img src="/cms/media/:id"> works
|
||||
// directly (no more base64 data-URL workaround).
|
||||
let id = ctx.request.params.id;
|
||||
let media = files::collection("media");
|
||||
let meta = media.head(id);
|
||||
if meta == () { return #{ statusCode: 404, body: #{ error: "not found" } }; }
|
||||
let bytes = media.get(id);
|
||||
if bytes == () { return #{ statusCode: 404, body: #{ error: "not found" } }; }
|
||||
#{
|
||||
statusCode: 200,
|
||||
headers: #{
|
||||
"content-type": meta.content_type,
|
||||
"cache-control": "public, max-age=3600",
|
||||
},
|
||||
body_base64: base64::encode(bytes),
|
||||
}
|
||||
43
examples/cms-poc/scripts/media/media_upload.rhai
Normal file
43
examples/cms-poc/scripts/media/media_upload.rhai
Normal file
@@ -0,0 +1,43 @@
|
||||
// POST /cms/admin/media — upload an image (author+). Two ways in, since the
|
||||
// platform fix (F-026):
|
||||
// 1. JSON: { name, content_type, data_base64 }
|
||||
// 2. RAW BINARY: Content-Type: image/png, body = the bytes, ?name=file.png
|
||||
// (the platform hands a non-JSON binary body to the script as base64).
|
||||
// SVG is rejected (it can carry scripts -> stored XSS).
|
||||
import "auth" as auth;
|
||||
let g = auth::guard(ctx, ["admin", "author"]);
|
||||
if !g.ok { return g.response; }
|
||||
|
||||
let allowed = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
let name = ();
|
||||
let content_type = ();
|
||||
let bytes = ();
|
||||
|
||||
let b = ctx.request.body;
|
||||
if type_of(b) == "map" {
|
||||
// (1) JSON path
|
||||
if b.data_base64 == () || b.name == () || b.content_type == () {
|
||||
return #{ statusCode: 400, body: #{ error: "name, content_type, data_base64 required" } };
|
||||
}
|
||||
name = b.name;
|
||||
content_type = b.content_type;
|
||||
bytes = base64::decode(b.data_base64);
|
||||
} else if type_of(b) == "string" {
|
||||
// (2) raw binary path — body is the platform's base64 of the raw bytes
|
||||
let ct = ctx.request.headers["content-type"];
|
||||
if ct == () { return #{ statusCode: 400, body: #{ error: "content-type header required" } }; }
|
||||
content_type = ct;
|
||||
name = ctx.request.query["name"];
|
||||
if name == () { name = "upload"; }
|
||||
bytes = base64::decode(b);
|
||||
} else {
|
||||
return #{ statusCode: 400, body: #{ error: "empty body" } };
|
||||
}
|
||||
|
||||
if !allowed.contains(content_type) {
|
||||
return #{ statusCode: 415, body: #{ error: "unsupported content_type (images only, no svg)" } };
|
||||
}
|
||||
|
||||
let media = files::collection("media");
|
||||
let id = media.create(#{ name: name, content_type: content_type, data: bytes });
|
||||
#{ statusCode: 201, body: #{ id: id, name: name, content_type: content_type, size: bytes.len() } }
|
||||
28
examples/cms-poc/scripts/notify/notify_drain.rhai
Normal file
28
examples/cms-poc/scripts/notify/notify_drain.rhai
Normal file
@@ -0,0 +1,28 @@
|
||||
// notify_drain — queue TRIGGER on `post-notifications`. Sends one email per
|
||||
// reader in the batch via email::send_html (relayed to Mailpit in dev).
|
||||
let msg = ctx.event.queue.message;
|
||||
if msg == () { return; }
|
||||
|
||||
let from = vars::get("notify-from-email");
|
||||
let base = vars::get("site-base-url");
|
||||
let title = msg.title;
|
||||
let url = base + "/posts/" + msg.slug;
|
||||
|
||||
let sent = 0;
|
||||
for addr in msg.emails {
|
||||
try {
|
||||
email::send_html(#{
|
||||
to: addr,
|
||||
from: from,
|
||||
subject: "New post: " + title,
|
||||
text: "A new post was published: " + title + "\n\nRead it: " + url,
|
||||
html: "<h2>" + title + "</h2>"
|
||||
+ "<p>A new post was just published on the blog.</p>"
|
||||
+ "<p><a href=\"" + url + "\">Read it here</a></p>",
|
||||
});
|
||||
sent += 1;
|
||||
} catch(e) {
|
||||
log::warn("notify email failed", #{ to: addr, err: "" + e });
|
||||
}
|
||||
}
|
||||
log::info("sent post notifications", #{ title: title, sent: sent });
|
||||
96
examples/cms-poc/scripts/pages/pages.rhai
Normal file
96
examples/cms-poc/scripts/pages/pages.rhai
Normal file
@@ -0,0 +1,96 @@
|
||||
// pages — one script handling all page operations, dispatched by method+path.
|
||||
// Demonstrates a single script bound to several routes (method/param varies).
|
||||
// GET /cms/pages -> public list (published)
|
||||
// GET /cms/pages/:slug -> public get by slug
|
||||
// GET /cms/admin/pages -> admin list (all)
|
||||
// POST /cms/admin/pages -> create (admin)
|
||||
// PUT /cms/admin/pages/:id -> update (admin)
|
||||
// DELETE /cms/admin/pages/:id -> delete (admin)
|
||||
import "auth" as auth;
|
||||
import "util" as util;
|
||||
|
||||
let m = ctx.request.method;
|
||||
let path = ctx.request.path;
|
||||
let is_admin_path = path.contains("/cms/admin/");
|
||||
let pages = docs::collection("pages");
|
||||
let idx = kv::collection("slugs");
|
||||
|
||||
// ---- public reads ----
|
||||
if !is_admin_path && m == "GET" && ctx.request.params.slug != () {
|
||||
let id = idx.get("page:" + ctx.request.params.slug);
|
||||
if id == () { return #{ statusCode: 404, body: #{ error: "not found" } }; }
|
||||
let doc = pages.get(id);
|
||||
if doc == () { return #{ statusCode: 404, body: #{ error: "not found" } }; }
|
||||
let p = util::shape_doc(doc);
|
||||
if p.status != "published" { return #{ statusCode: 404, body: #{ error: "not found" } }; }
|
||||
return #{ statusCode: 200, body: p };
|
||||
}
|
||||
if !is_admin_path && m == "GET" {
|
||||
let rows = pages.find(#{ status: "published", "$sort": #{ title: 1 }, "$limit": 100 });
|
||||
let out = [];
|
||||
for r in rows { let p = util::shape_doc(r); p.remove("body_md"); out.push(p); }
|
||||
return #{ statusCode: 200, body: #{ pages: out } };
|
||||
}
|
||||
|
||||
// ---- everything else is admin ----
|
||||
let g = auth::guard(ctx, ["admin"]);
|
||||
if !g.ok { return g.response; }
|
||||
let user = g.user;
|
||||
|
||||
if m == "GET" {
|
||||
let rows = pages.find(#{ "$sort": #{ updated_at: -1 }, "$limit": 100 });
|
||||
let out = [];
|
||||
for r in rows { let p = util::shape_doc(r); p.remove("body_md"); out.push(p); }
|
||||
return #{ statusCode: 200, body: #{ pages: out } };
|
||||
}
|
||||
|
||||
if m == "POST" {
|
||||
let b = ctx.request.body;
|
||||
if b == () || b.title == () { return #{ statusCode: 400, body: #{ error: "title required" } }; }
|
||||
let slug = if b.slug == () { util::slugify(b.title) } else { util::slugify(b.slug) };
|
||||
if idx.has("page:" + slug) { slug = slug + "-" + random::string(4).to_lower(); }
|
||||
let data = #{
|
||||
title: b.title,
|
||||
slug: slug,
|
||||
body_md: if b.body_md == () { "" } else { b.body_md },
|
||||
status: if b.status == () { "draft" } else { b.status },
|
||||
author_id: user.id,
|
||||
author_name: user.display_name,
|
||||
};
|
||||
let id = pages.create(data);
|
||||
idx.set("page:" + slug, id);
|
||||
return #{ statusCode: 201, body: #{ id: id, slug: slug } };
|
||||
}
|
||||
|
||||
if m == "PUT" {
|
||||
let id = ctx.request.params.id;
|
||||
let doc = pages.get(id);
|
||||
if doc == () { return #{ statusCode: 404, body: #{ error: "not found" } }; }
|
||||
let cur = doc.data;
|
||||
let b = ctx.request.body;
|
||||
if b.title != () { cur.title = b.title; }
|
||||
if b.body_md != () { cur.body_md = b.body_md; }
|
||||
if b.status != () { cur.status = b.status; }
|
||||
if b.slug != () {
|
||||
let ns = util::slugify(b.slug);
|
||||
if ns != cur.slug {
|
||||
idx.delete("page:" + cur.slug);
|
||||
if idx.has("page:" + ns) { ns = ns + "-" + random::string(4).to_lower(); }
|
||||
idx.set("page:" + ns, id);
|
||||
cur.slug = ns;
|
||||
}
|
||||
}
|
||||
pages.update(id, cur);
|
||||
return #{ statusCode: 200, body: #{ id: id, slug: cur.slug } };
|
||||
}
|
||||
|
||||
if m == "DELETE" {
|
||||
let id = ctx.request.params.id;
|
||||
let doc = pages.get(id);
|
||||
if doc == () { return #{ statusCode: 404, body: #{ error: "not found" } }; }
|
||||
pages.delete(id);
|
||||
idx.delete("page:" + doc.data.slug);
|
||||
return #{ statusCode: 200, body: #{ ok: true } };
|
||||
}
|
||||
|
||||
#{ statusCode: 405, body: #{ error: "method not allowed" } }
|
||||
39
examples/cms-poc/scripts/posts/post_create.rhai
Normal file
39
examples/cms-poc/scripts/posts/post_create.rhai
Normal file
@@ -0,0 +1,39 @@
|
||||
// POST /cms/admin/posts — create a post (author+).
|
||||
import "auth" as auth;
|
||||
import "util" as util;
|
||||
let g = auth::guard(ctx, ["admin", "author"]);
|
||||
if !g.ok { return g.response; }
|
||||
let user = g.user;
|
||||
|
||||
let b = ctx.request.body;
|
||||
if b == () || b.title == () {
|
||||
return #{ statusCode: 400, body: #{ error: "title required" } };
|
||||
}
|
||||
|
||||
let posts = docs::collection("posts");
|
||||
let idx = kv::collection("slugs");
|
||||
let slug = if b.slug == () { util::slugify(b.title) } else { util::slugify(b.slug) };
|
||||
if idx.has("post:" + slug) {
|
||||
slug = slug + "-" + random::string(4).to_lower();
|
||||
}
|
||||
|
||||
let now = time::now();
|
||||
let status = if b.status == () { "draft" } else { b.status };
|
||||
let publish_at = ();
|
||||
if status == "published" { publish_at = now; }
|
||||
else if status == "scheduled" && b.publish_at != () { publish_at = b.publish_at; }
|
||||
|
||||
let data = #{
|
||||
title: b.title,
|
||||
slug: slug,
|
||||
body_md: if b.body_md == () { "" } else { b.body_md },
|
||||
excerpt: if b.excerpt == () { "" } else { b.excerpt },
|
||||
status: status,
|
||||
author_id: user.id,
|
||||
author_name: user.display_name,
|
||||
tags: if b.tags == () { [] } else { b.tags },
|
||||
publish_at: publish_at,
|
||||
};
|
||||
let id = posts.create(data);
|
||||
idx.set("post:" + slug, id);
|
||||
#{ statusCode: 201, body: #{ id: id, slug: slug, status: status } }
|
||||
13
examples/cms-poc/scripts/posts/post_delete.rhai
Normal file
13
examples/cms-poc/scripts/posts/post_delete.rhai
Normal file
@@ -0,0 +1,13 @@
|
||||
// DELETE /cms/admin/posts/:id — delete a post (admin only).
|
||||
import "auth" as auth;
|
||||
let g = auth::guard(ctx, ["admin"]);
|
||||
if !g.ok { return g.response; }
|
||||
|
||||
let id = ctx.request.params.id;
|
||||
let posts = docs::collection("posts");
|
||||
let doc = posts.get(id);
|
||||
if doc == () { return #{ statusCode: 404, body: #{ error: "not found" } }; }
|
||||
let slug = doc.data.slug;
|
||||
posts.delete(id);
|
||||
kv::collection("slugs").delete("post:" + slug);
|
||||
#{ statusCode: 200, body: #{ ok: true } }
|
||||
24
examples/cms-poc/scripts/posts/post_get.rhai
Normal file
24
examples/cms-poc/scripts/posts/post_get.rhai
Normal file
@@ -0,0 +1,24 @@
|
||||
// GET /cms/posts/:slug — public single published post (by slug).
|
||||
// Resolves slug -> id via the kv slug index, increments a CAS view counter.
|
||||
import "util" as util;
|
||||
let slug = ctx.request.params.slug;
|
||||
if slug == () { return #{ statusCode: 400, body: #{ error: "missing slug" } }; }
|
||||
|
||||
let idx = kv::collection("slugs");
|
||||
let id = idx.get("post:" + slug);
|
||||
if id == () { return #{ statusCode: 404, body: #{ error: "not found" } }; }
|
||||
|
||||
let posts = docs::collection("posts");
|
||||
let doc = posts.get(id);
|
||||
if doc == () { return #{ statusCode: 404, body: #{ error: "not found" } }; }
|
||||
|
||||
let p = util::shape_doc(doc);
|
||||
// A public reader may only see PUBLISHED posts — never a draft/scheduled one,
|
||||
// even if they guess the slug. (No platform per-row authz; enforced here.)
|
||||
if p.status != "published" {
|
||||
return #{ statusCode: 404, body: #{ error: "not found" } };
|
||||
}
|
||||
|
||||
let views = util::incr("views", "post:" + id);
|
||||
p.views = views;
|
||||
#{ statusCode: 200, body: p }
|
||||
44
examples/cms-poc/scripts/posts/post_update.rhai
Normal file
44
examples/cms-poc/scripts/posts/post_update.rhai
Normal file
@@ -0,0 +1,44 @@
|
||||
// PUT /cms/admin/posts/:id — update a post (author+ ; own-only unless admin).
|
||||
import "auth" as auth;
|
||||
import "util" as util;
|
||||
let g = auth::guard(ctx, ["admin", "author"]);
|
||||
if !g.ok { return g.response; }
|
||||
let user = g.user;
|
||||
|
||||
let id = ctx.request.params.id;
|
||||
let posts = docs::collection("posts");
|
||||
let doc = posts.get(id);
|
||||
if doc == () { return #{ statusCode: 404, body: #{ error: "not found" } }; }
|
||||
let cur = doc.data;
|
||||
|
||||
// IDOR guard: an author may edit only their OWN post; an admin edits any.
|
||||
let is_admin = users::has_role(user.id, "admin");
|
||||
if !is_admin && cur.author_id != user.id {
|
||||
return #{ statusCode: 403, body: #{ error: "not your post" } };
|
||||
}
|
||||
|
||||
let b = ctx.request.body;
|
||||
if b.title != () { cur.title = b.title; }
|
||||
if b.body_md != () { cur.body_md = b.body_md; }
|
||||
if b.excerpt != () { cur.excerpt = b.excerpt; }
|
||||
if b.tags != () { cur.tags = b.tags; }
|
||||
|
||||
let idx = kv::collection("slugs");
|
||||
if b.slug != () {
|
||||
let newslug = util::slugify(b.slug);
|
||||
if newslug != cur.slug {
|
||||
idx.delete("post:" + cur.slug);
|
||||
if idx.has("post:" + newslug) { newslug = newslug + "-" + random::string(4).to_lower(); }
|
||||
idx.set("post:" + newslug, id);
|
||||
cur.slug = newslug;
|
||||
}
|
||||
}
|
||||
|
||||
if b.status != () && b.status != cur.status {
|
||||
cur.status = b.status;
|
||||
if b.status == "published" && cur.publish_at == () { cur.publish_at = time::now(); }
|
||||
if b.status == "scheduled" && b.publish_at != () { cur.publish_at = b.publish_at; }
|
||||
}
|
||||
|
||||
posts.update(id, cur);
|
||||
#{ statusCode: 200, body: #{ id: id, slug: cur.slug, status: cur.status } }
|
||||
21
examples/cms-poc/scripts/posts/posts_admin_list.rhai
Normal file
21
examples/cms-poc/scripts/posts/posts_admin_list.rhai
Normal file
@@ -0,0 +1,21 @@
|
||||
// GET /cms/admin/posts — list posts for the dashboard (author+).
|
||||
// Admin sees all; an author sees only their own.
|
||||
import "auth" as auth;
|
||||
import "util" as util;
|
||||
let g = auth::guard(ctx, ["admin", "author"]);
|
||||
if !g.ok { return g.response; }
|
||||
let user = g.user;
|
||||
|
||||
let posts = docs::collection("posts");
|
||||
let is_admin = users::has_role(user.id, "admin");
|
||||
let filter = #{ "$sort": #{ updated_at: -1 }, "$limit": 100 };
|
||||
if !is_admin { filter.author_id = user.id; }
|
||||
|
||||
let rows = posts.find(filter);
|
||||
let out = [];
|
||||
for r in rows {
|
||||
let p = util::shape_doc(r);
|
||||
p.remove("body_md");
|
||||
out.push(p);
|
||||
}
|
||||
#{ statusCode: 200, body: #{ posts: out } }
|
||||
23
examples/cms-poc/scripts/posts/posts_list.rhai
Normal file
23
examples/cms-poc/scripts/posts/posts_list.rhai
Normal file
@@ -0,0 +1,23 @@
|
||||
// GET /cms/posts — public list of PUBLISHED posts, newest first.
|
||||
// Optional ?tag=<slug> filters by tag. Since the platform fix (F-015), the docs
|
||||
// filter DSL supports `$contains` (JSONB @>) for array-membership, so the tag
|
||||
// filter now runs IN THE DATABASE with real pagination — no over-fetch/in-script
|
||||
// scan needed.
|
||||
import "util" as util;
|
||||
let posts = docs::collection("posts");
|
||||
let per = util::to_int_or(vars::get("posts-per-page"), 10);
|
||||
let tag = ctx.request.query["tag"];
|
||||
|
||||
let filter = #{ status: "published", "$sort": #{ publish_at: -1 }, "$limit": per };
|
||||
if tag != () {
|
||||
filter.tags = #{ "$contains": tag };
|
||||
}
|
||||
|
||||
let rows = posts.find(filter);
|
||||
let out = [];
|
||||
for r in rows {
|
||||
let p = util::shape_doc(r);
|
||||
p.remove("body_md"); // list view: excerpt only
|
||||
out.push(p);
|
||||
}
|
||||
#{ statusCode: 200, body: #{ posts: out, tag: tag } }
|
||||
33
examples/cms-poc/scripts/posts/posts_on_publish.rhai
Normal file
33
examples/cms-poc/scripts/posts/posts_on_publish.rhai
Normal file
@@ -0,0 +1,33 @@
|
||||
// posts_on_publish — docs TRIGGER on the `posts` collection (create|update).
|
||||
// When a post transitions INTO "published", enqueue batched reader-notification
|
||||
// jobs onto the `post-notifications` queue (decoupling the burst from delivery,
|
||||
// and staying well under the per-execution emission ceiling via chunking).
|
||||
let ev = ctx.event.docs;
|
||||
if ev == () { return; }
|
||||
let data = ev.data;
|
||||
if data == () { return; }
|
||||
|
||||
let was = if ev.prev_data == () { () } else { ev.prev_data.status };
|
||||
let became_published = data.status == "published" && was != "published";
|
||||
if !became_published { return; }
|
||||
|
||||
// collect reader emails in chunks of 50
|
||||
let res = users::list(#{ "$limit": 500 });
|
||||
let batch = [];
|
||||
let total = 0;
|
||||
for u in res.users {
|
||||
if users::has_role(u.id, "reader") {
|
||||
batch.push(u.email);
|
||||
total += 1;
|
||||
if batch.len() >= 50 {
|
||||
queue::enqueue("post-notifications",
|
||||
#{ post_id: ev.id, title: data.title, slug: data.slug, emails: batch });
|
||||
batch = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
if batch.len() > 0 {
|
||||
queue::enqueue("post-notifications",
|
||||
#{ post_id: ev.id, title: data.title, slug: data.slug, emails: batch });
|
||||
}
|
||||
log::info("queued post notifications", #{ post: ev.id, title: data.title, readers: total });
|
||||
18
examples/cms-poc/scripts/posts/posts_scheduled_publish.rhai
Normal file
18
examples/cms-poc/scripts/posts/posts_scheduled_publish.rhai
Normal file
@@ -0,0 +1,18 @@
|
||||
// posts_scheduled_publish — cron TRIGGER (every minute). Flips due `scheduled`
|
||||
// posts to `published`, which itself fires posts_on_publish -> notifications
|
||||
// (a bounded trigger-fires-trigger chain).
|
||||
let posts = docs::collection("posts");
|
||||
let now = time::now();
|
||||
let due = posts.find(#{
|
||||
status: "scheduled",
|
||||
publish_at: #{ "$lte": now },
|
||||
"$limit": 100,
|
||||
});
|
||||
let n = 0;
|
||||
for r in due {
|
||||
let cur = r.data;
|
||||
cur.status = "published";
|
||||
posts.update(r.id, cur);
|
||||
n += 1;
|
||||
}
|
||||
if n > 0 { log::info("cron published scheduled posts", #{ count: n }); }
|
||||
15
examples/cms-poc/scripts/tags/tags_list.rhai
Normal file
15
examples/cms-poc/scripts/tags/tags_list.rhai
Normal file
@@ -0,0 +1,15 @@
|
||||
// GET /cms/tags — public tag cloud: distinct tags across published posts + counts.
|
||||
let posts = docs::collection("posts");
|
||||
let rows = posts.find(#{ status: "published", "$limit": 500 });
|
||||
let counts = #{};
|
||||
for r in rows {
|
||||
let tags = r.data.tags;
|
||||
if tags != () {
|
||||
for t in tags {
|
||||
if counts[t] == () { counts[t] = 1; } else { counts[t] = counts[t] + 1; }
|
||||
}
|
||||
}
|
||||
}
|
||||
let out = [];
|
||||
for k in counts.keys() { out.push(#{ tag: k, count: counts[k] }); }
|
||||
#{ statusCode: 200, body: #{ tags: out } }
|
||||
40
examples/cms-poc/scripts/util.rhai
Normal file
40
examples/cms-poc/scripts/util.rhai
Normal file
@@ -0,0 +1,40 @@
|
||||
// util — shared helpers (kind=module). import "util" as util;
|
||||
|
||||
// Turn a title into a URL slug: lowercase, non-alnum -> hyphen, trim hyphens.
|
||||
fn slugify(s) {
|
||||
let out = s.to_lower();
|
||||
out = regex::replace_all("[^a-z0-9]+", out, "-");
|
||||
out = regex::replace_all("(^-+|-+$)", out, "");
|
||||
if out == "" { out = "untitled"; }
|
||||
out
|
||||
}
|
||||
|
||||
// Flatten a docs envelope { id, data, created_at, updated_at } into a single
|
||||
// map (data fields + id/timestamps) for JSON responses.
|
||||
fn shape_doc(doc) {
|
||||
let d = doc.data;
|
||||
d.id = doc.id;
|
||||
d.created_at = doc.created_at;
|
||||
d.updated_at = doc.updated_at;
|
||||
d
|
||||
}
|
||||
|
||||
fn to_int_or(s, dflt) {
|
||||
if s == () { return dflt; }
|
||||
if type_of(s) == "i64" || type_of(s) == "int" { return s; }
|
||||
try { parse_int(s) } catch(e) { dflt }
|
||||
}
|
||||
|
||||
// Atomic KV counter increment via set_if (CAS), bounded retries.
|
||||
fn incr(coll_name, key) {
|
||||
let c = kv::collection(coll_name);
|
||||
let i = 0;
|
||||
while i < 8 {
|
||||
let cur = c.get(key);
|
||||
let n = if cur == () { 0 } else { cur };
|
||||
if c.set_if(key, cur, n + 1) { return n + 1; }
|
||||
i += 1;
|
||||
}
|
||||
// give up gracefully — a stale counter is not worth failing the request
|
||||
c.get(key)
|
||||
}
|
||||
18
examples/cms-poc/scripts/workflow/pipeline_dag.rhai
Normal file
18
examples/cms-poc/scripts/workflow/pipeline_dag.rhai
Normal file
@@ -0,0 +1,18 @@
|
||||
// GET /cms/admin/workflow — the editorial pipeline's DAG structure, for the
|
||||
// Svelte Flow visual editor (nodes + edges). Mirrors the [[workflows]] manifest.
|
||||
import "auth" as auth;
|
||||
let g = auth::guard(ctx, ["admin", "author"]);
|
||||
if !g.ok { return g.response; }
|
||||
#{
|
||||
statusCode: 200,
|
||||
body: #{
|
||||
name: "editorial_pipeline",
|
||||
steps: [
|
||||
#{ name: "validate", label: "Validate content", depends_on: [] },
|
||||
#{ name: "enrich", label: "Auto-excerpt + reading time", depends_on: ["validate"], when: "validate ok" },
|
||||
#{ name: "seo", label: "SEO check", depends_on: ["validate"], when: "validate ok" },
|
||||
#{ name: "publish", label: "Publish + notify readers", depends_on: ["enrich", "seo"], when: "validate ok" },
|
||||
#{ name: "finalize", label: "Finalize run", depends_on: ["publish"] },
|
||||
]
|
||||
}
|
||||
}
|
||||
41
examples/cms-poc/scripts/workflow/pipeline_run.rhai
Normal file
41
examples/cms-poc/scripts/workflow/pipeline_run.rhai
Normal file
@@ -0,0 +1,41 @@
|
||||
// GET /cms/admin/workflow/runs/:id — one run's overall + per-step status, read
|
||||
// straight from the platform via workflow::run_status (F-038). Replaces the
|
||||
// former app-side `wfruns` KV tracking — the SDK is app-scoped, so a run from
|
||||
// another app resolves to () (404) here.
|
||||
import "auth" as auth;
|
||||
let g = auth::guard(ctx, ["admin", "author"]);
|
||||
if !g.ok { return g.response; }
|
||||
|
||||
let run_id = ctx.request.params.id;
|
||||
let st = workflow::run_status(run_id);
|
||||
if st == () { return #{ statusCode: 404, body: #{ error: "no such run" } }; }
|
||||
|
||||
let engine = st.status; // pending|running|succeeded|failed
|
||||
let terminal = engine == "succeeded" || engine == "failed";
|
||||
|
||||
// Normalize per-step status for the UI. A DAG step absent from the run — its
|
||||
// `when` gate was false — shows as skipped once the run is terminal, else
|
||||
// pending; the engine's "ready" (queued) also maps to pending.
|
||||
let steps = #{};
|
||||
for name in ["validate", "enrich", "seo", "publish", "finalize"] {
|
||||
let s = st.steps[name];
|
||||
if s == () {
|
||||
s = if terminal { "skipped" } else { "pending" };
|
||||
} else if s == "ready" {
|
||||
s = "pending";
|
||||
}
|
||||
steps[name] = #{ status: s };
|
||||
}
|
||||
|
||||
// CMS-level overall: "blocked" when the gate stopped publish, else mirror the
|
||||
// engine's terminal state (skip != fail, so the engine itself reports
|
||||
// "succeeded" even when publish was gated out — F-039).
|
||||
let overall_status = if !terminal {
|
||||
"running"
|
||||
} else if steps.publish.status == "succeeded" {
|
||||
"completed"
|
||||
} else {
|
||||
"blocked"
|
||||
};
|
||||
|
||||
#{ statusCode: 200, body: #{ run_id: run_id, overall: #{ status: overall_status }, steps: steps } }
|
||||
21
examples/cms-poc/scripts/workflow/pipeline_start.rhai
Normal file
21
examples/cms-poc/scripts/workflow/pipeline_start.rhai
Normal file
@@ -0,0 +1,21 @@
|
||||
// POST /cms/admin/posts/:id/pipeline — run the editorial publish pipeline on a
|
||||
// post (author+, own-only unless admin). Starts the durable workflow and
|
||||
// returns its run id; the UI polls it via workflow::run_status (F-038).
|
||||
import "auth" as auth;
|
||||
let g = auth::guard(ctx, ["admin", "author"]);
|
||||
if !g.ok { return g.response; }
|
||||
let user = g.user;
|
||||
|
||||
let post_id = ctx.request.params.id;
|
||||
let post = docs::collection("posts").get(post_id);
|
||||
if post == () { return #{ statusCode: 404, body: #{ error: "post not found" } }; }
|
||||
|
||||
let is_admin = users::has_role(user.id, "admin");
|
||||
if !is_admin && post.data.author_id != user.id {
|
||||
return #{ statusCode: 403, body: #{ error: "not your post" } };
|
||||
}
|
||||
|
||||
// durable DAG; each step reads post_id from its input mapping
|
||||
let run_id = workflow::start("editorial_pipeline", #{ post_id: post_id });
|
||||
|
||||
#{ statusCode: 202, body: #{ run_id: run_id, workflow_run_id: run_id } }
|
||||
24
examples/cms-poc/scripts/workflow/wf_enrich.rhai
Normal file
24
examples/cms-poc/scripts/workflow/wf_enrich.rhai
Normal file
@@ -0,0 +1,24 @@
|
||||
// workflow step: enrich — auto-generate an excerpt (if missing) and a reading
|
||||
// time, and write them back onto the post. Runs in PARALLEL with `seo`.
|
||||
let b = ctx.request.body;
|
||||
let posts = docs::collection("posts");
|
||||
let post = posts.get(b.post_id);
|
||||
let d = post.data;
|
||||
let body = if d.body_md == () { "" } else { d.body_md };
|
||||
let words = if body == "" { 0 } else { body.split(" ").len() };
|
||||
let reading_time = (words / 200) + 1;
|
||||
|
||||
let excerpt = d.excerpt;
|
||||
if excerpt == () || excerpt == "" {
|
||||
let parts = body.split(" ");
|
||||
let n = if parts.len() < 24 { parts.len() } else { 24 };
|
||||
let ex = "";
|
||||
for i in 0..n { ex += parts[i] + " "; }
|
||||
ex.trim();
|
||||
excerpt = ex + "…";
|
||||
d.excerpt = excerpt;
|
||||
}
|
||||
d.reading_time = reading_time;
|
||||
posts.update(b.post_id, d);
|
||||
|
||||
#{ excerpt: excerpt, reading_time: reading_time }
|
||||
6
examples/cms-poc/scripts/workflow/wf_finalize.rhai
Normal file
6
examples/cms-poc/scripts/workflow/wf_finalize.rhai
Normal file
@@ -0,0 +1,6 @@
|
||||
// workflow step: finalize — terminal join of the DAG. Runs even when upstream
|
||||
// steps were SKIPPED (a skipped dep doesn't block dependents — F-039), so it
|
||||
// makes no assumption that `publish` ran. Overall completed-vs-blocked is
|
||||
// derived by the reader from `workflow::run_status` (F-038), not here.
|
||||
let b = ctx.request.body;
|
||||
#{ finalized: true, post_id: b.post_id }
|
||||
13
examples/cms-poc/scripts/workflow/wf_publish.rhai
Normal file
13
examples/cms-poc/scripts/workflow/wf_publish.rhai
Normal file
@@ -0,0 +1,13 @@
|
||||
// workflow step: publish — flip the post to published. This docs::update fires
|
||||
// the existing posts_on_publish trigger → queue → reader notification emails,
|
||||
// so the workflow chains into the rest of the CMS event graph.
|
||||
let b = ctx.request.body;
|
||||
let posts = docs::collection("posts");
|
||||
let post = posts.get(b.post_id);
|
||||
let d = post.data;
|
||||
if d.status != "published" {
|
||||
d.status = "published";
|
||||
if d.publish_at == () { d.publish_at = time::now(); }
|
||||
posts.update(b.post_id, d);
|
||||
}
|
||||
#{ published: true, slug: d.slug }
|
||||
13
examples/cms-poc/scripts/workflow/wf_seo.rhai
Normal file
13
examples/cms-poc/scripts/workflow/wf_seo.rhai
Normal file
@@ -0,0 +1,13 @@
|
||||
// workflow step: seo — score the post on a few SEO heuristics. Runs in PARALLEL
|
||||
// with `enrich`. Output { score, warnings } (advisory; doesn't block publish).
|
||||
let b = ctx.request.body;
|
||||
let post = docs::collection("posts").get(b.post_id);
|
||||
let d = post.data;
|
||||
let warnings = [];
|
||||
let score = 100;
|
||||
if d.tags == () || d.tags.len() == 0 { warnings.push("no tags"); score -= 30; }
|
||||
let tlen = if d.title == () { 0 } else { d.title.len() };
|
||||
if tlen < 10 { warnings.push("title is short (<10 chars)"); score -= 20; }
|
||||
if tlen > 70 { warnings.push("title is long (>70 chars)"); score -= 10; }
|
||||
if score < 0 { score = 0; }
|
||||
#{ score: score, warnings: warnings }
|
||||
14
examples/cms-poc/scripts/workflow/wf_validate.rhai
Normal file
14
examples/cms-poc/scripts/workflow/wf_validate.rhai
Normal file
@@ -0,0 +1,14 @@
|
||||
// workflow step: validate — check the post has a title + enough body.
|
||||
// Output { ok, word_count, issues } → gates the rest of the DAG via `when`.
|
||||
let b = ctx.request.body;
|
||||
let post = docs::collection("posts").get(b.post_id);
|
||||
if post == () {
|
||||
return #{ ok: false, issues: ["post not found"], word_count: 0 };
|
||||
}
|
||||
let d = post.data;
|
||||
let issues = [];
|
||||
if d.title == () || d.title == "" { issues.push("missing title"); }
|
||||
let body = if d.body_md == () { "" } else { d.body_md };
|
||||
let words = if body == "" { 0 } else { body.split(" ").len() };
|
||||
if words < 3 { issues.push("body too short"); }
|
||||
#{ ok: issues.len() == 0, word_count: words, issues: issues }
|
||||
Reference in New Issue
Block a user