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:
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