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>
527 lines
12 KiB
TOML
527 lines
12 KiB
TOML
## PiCloud CMS PoC — declarative manifest.
|
|
## Deploy: pic apply --env dev (from this directory, after `pic login`).
|
|
## Everything (scripts, routes, triggers, interceptors, config) lives here.
|
|
|
|
[project]
|
|
slug = "cms"
|
|
name = "PiCloud CMS PoC"
|
|
|
|
[app]
|
|
slug = "cms"
|
|
name = "PiCloud CMS"
|
|
description = "A WordPress-inspired CMS whose backend is 100% PiCloud Rhai scripts."
|
|
|
|
# ------------------------------------------------------------------ config
|
|
# NOTE: env-specific values (e.g. site_base_url) live in the overlay files
|
|
# picloud.dev.toml / picloud.prod.toml, NOT in inline [vars.<env>] sections
|
|
# (an inline [vars.dev] here would parse as a var literally NAMED "dev"). See
|
|
# FINDINGS F-005.
|
|
# NOTE: var KEYS must be kebab-case ([a-z0-9-], no underscores) — see FINDINGS F-008.
|
|
[vars]
|
|
site-title = "My PiCloud Blog"
|
|
posts-per-page = "10"
|
|
comment-moderation = "manual" # "manual" | "auto"
|
|
notify-from-email = "blog@example.com"
|
|
signups-open = "true"
|
|
|
|
# ------------------------------------------------------------------ secrets
|
|
# Values are pushed out-of-band via `pic secrets set <name>` (never in the
|
|
# manifest). `setup-token` gates the one-time admin bootstrap endpoint.
|
|
[secrets]
|
|
names = ["setup-token"]
|
|
|
|
# ------------------------------------------------------------------ scripts
|
|
[[scripts]]
|
|
name = "hello"
|
|
file = "scripts/hello.rhai"
|
|
kind = "endpoint"
|
|
description = "Skeleton smoke-test endpoint."
|
|
|
|
# --- auth (shared module + endpoints) ---
|
|
[[scripts]]
|
|
name = "auth"
|
|
file = "scripts/auth/auth.rhai"
|
|
kind = "module"
|
|
description = "Role-check helper module — the CMS access boundary."
|
|
|
|
[[scripts]]
|
|
name = "auth_register"
|
|
file = "scripts/auth/register.rhai"
|
|
description = "Reader self-signup."
|
|
|
|
[[scripts]]
|
|
name = "auth_login"
|
|
file = "scripts/auth/login.rhai"
|
|
description = "Email+password -> session token."
|
|
|
|
[[scripts]]
|
|
name = "auth_logout"
|
|
file = "scripts/auth/logout.rhai"
|
|
description = "Invalidate the current session."
|
|
|
|
[[scripts]]
|
|
name = "auth_me"
|
|
file = "scripts/auth/me.rhai"
|
|
description = "Current user + roles."
|
|
|
|
[[scripts]]
|
|
name = "auth_bootstrap"
|
|
file = "scripts/auth/bootstrap.rhai"
|
|
description = "One-time first-admin creation (setup-token gated)."
|
|
|
|
[[scripts]]
|
|
name = "admin_users_list"
|
|
file = "scripts/auth/admin_users_list.rhai"
|
|
description = "List app-users + roles (admin)."
|
|
|
|
[[scripts]]
|
|
name = "admin_user_roles"
|
|
file = "scripts/auth/admin_user_roles.rhai"
|
|
description = "Grant/revoke user roles (admin)."
|
|
|
|
# --- shared util module ---
|
|
[[scripts]]
|
|
name = "util"
|
|
file = "scripts/util.rhai"
|
|
kind = "module"
|
|
description = "slugify / doc-shaping / CAS counter helpers."
|
|
|
|
# --- posts ---
|
|
[[scripts]]
|
|
name = "posts_list"
|
|
file = "scripts/posts/posts_list.rhai"
|
|
description = "Public list of published posts."
|
|
|
|
[[scripts]]
|
|
name = "post_get"
|
|
file = "scripts/posts/post_get.rhai"
|
|
description = "Public single post by slug (+ view counter)."
|
|
|
|
[[scripts]]
|
|
name = "posts_admin_list"
|
|
file = "scripts/posts/posts_admin_list.rhai"
|
|
description = "Dashboard post list (author+)."
|
|
|
|
[[scripts]]
|
|
name = "post_create"
|
|
file = "scripts/posts/post_create.rhai"
|
|
description = "Create a post (author+)."
|
|
|
|
[[scripts]]
|
|
name = "post_update"
|
|
file = "scripts/posts/post_update.rhai"
|
|
description = "Update a post (own-only unless admin)."
|
|
|
|
[[scripts]]
|
|
name = "post_delete"
|
|
file = "scripts/posts/post_delete.rhai"
|
|
description = "Delete a post (admin)."
|
|
|
|
# --- pages (one script, method-dispatched) ---
|
|
[[scripts]]
|
|
name = "pages"
|
|
file = "scripts/pages/pages.rhai"
|
|
description = "Page CRUD (public reads + admin writes)."
|
|
|
|
# --- comments ---
|
|
[[scripts]]
|
|
name = "comment_sanitize"
|
|
file = "scripts/comments/comment_sanitize.rhai"
|
|
description = "docs before-interceptor: escape HTML + set moderation status."
|
|
|
|
[[scripts]]
|
|
name = "comment_create"
|
|
file = "scripts/comments/comment_create.rhai"
|
|
description = "Submit a comment (public, rate-limited)."
|
|
|
|
[[scripts]]
|
|
name = "comments_for_post"
|
|
file = "scripts/comments/comments_for_post.rhai"
|
|
description = "Public approved comments for a post."
|
|
|
|
[[scripts]]
|
|
name = "comments_admin"
|
|
file = "scripts/comments/comments_admin.rhai"
|
|
description = "Comment moderation (admin)."
|
|
|
|
[[scripts]]
|
|
name = "comment_on_approve"
|
|
file = "scripts/comments/comment_on_approve.rhai"
|
|
description = "docs trigger: publish approved comment to SSE feed."
|
|
|
|
# --- notifications ---
|
|
[[scripts]]
|
|
name = "posts_on_publish"
|
|
file = "scripts/posts/posts_on_publish.rhai"
|
|
description = "docs trigger: enqueue reader notifications on publish."
|
|
|
|
[[scripts]]
|
|
name = "notify_drain"
|
|
file = "scripts/notify/notify_drain.rhai"
|
|
description = "queue trigger: send notification emails."
|
|
|
|
[[scripts]]
|
|
name = "posts_scheduled_publish"
|
|
file = "scripts/posts/posts_scheduled_publish.rhai"
|
|
description = "cron trigger: publish due scheduled posts."
|
|
|
|
# --- tags ---
|
|
[[scripts]]
|
|
name = "tags_list"
|
|
file = "scripts/tags/tags_list.rhai"
|
|
description = "Public tag cloud (distinct tags + counts)."
|
|
|
|
# --- media ---
|
|
[[scripts]]
|
|
name = "media_upload"
|
|
file = "scripts/media/media_upload.rhai"
|
|
description = "Upload an image (author+, base64)."
|
|
|
|
[[scripts]]
|
|
name = "media_get"
|
|
file = "scripts/media/media_get.rhai"
|
|
description = "Fetch an image as base64 (public)."
|
|
|
|
# --- workflow: editorial publish pipeline ---
|
|
[[scripts]]
|
|
name = "wf_validate"
|
|
file = "scripts/workflow/wf_validate.rhai"
|
|
description = "Workflow step: validate content."
|
|
|
|
[[scripts]]
|
|
name = "wf_enrich"
|
|
file = "scripts/workflow/wf_enrich.rhai"
|
|
description = "Workflow step: auto-excerpt + reading time."
|
|
|
|
[[scripts]]
|
|
name = "wf_seo"
|
|
file = "scripts/workflow/wf_seo.rhai"
|
|
description = "Workflow step: SEO scoring."
|
|
|
|
[[scripts]]
|
|
name = "wf_publish"
|
|
file = "scripts/workflow/wf_publish.rhai"
|
|
description = "Workflow step: publish (chains into notifications)."
|
|
|
|
[[scripts]]
|
|
name = "wf_finalize"
|
|
file = "scripts/workflow/wf_finalize.rhai"
|
|
description = "Workflow step: finalize run."
|
|
|
|
[[scripts]]
|
|
name = "pipeline_start"
|
|
file = "scripts/workflow/pipeline_start.rhai"
|
|
description = "Start the editorial pipeline on a post (author+)."
|
|
|
|
[[scripts]]
|
|
name = "pipeline_dag"
|
|
file = "scripts/workflow/pipeline_dag.rhai"
|
|
description = "Return the pipeline DAG for the visual editor."
|
|
|
|
[[scripts]]
|
|
name = "pipeline_run"
|
|
file = "scripts/workflow/pipeline_run.rhai"
|
|
description = "Return a run's live per-step status."
|
|
|
|
# ------------------------------------------------------------------ routes
|
|
[[routes]]
|
|
script = "hello"
|
|
method = "GET"
|
|
host_kind = "any"
|
|
path_kind = "exact"
|
|
path = "/cms/hello"
|
|
dispatch_mode = "sync"
|
|
|
|
[[routes]]
|
|
script = "auth_register"
|
|
method = "POST"
|
|
host_kind = "any"
|
|
path_kind = "exact"
|
|
path = "/cms/auth/register"
|
|
|
|
[[routes]]
|
|
script = "auth_login"
|
|
method = "POST"
|
|
host_kind = "any"
|
|
path_kind = "exact"
|
|
path = "/cms/auth/login"
|
|
|
|
[[routes]]
|
|
script = "auth_logout"
|
|
method = "POST"
|
|
host_kind = "any"
|
|
path_kind = "exact"
|
|
path = "/cms/auth/logout"
|
|
|
|
[[routes]]
|
|
script = "auth_me"
|
|
method = "GET"
|
|
host_kind = "any"
|
|
path_kind = "exact"
|
|
path = "/cms/auth/me"
|
|
|
|
[[routes]]
|
|
script = "auth_bootstrap"
|
|
method = "POST"
|
|
host_kind = "any"
|
|
path_kind = "exact"
|
|
path = "/cms/auth/bootstrap"
|
|
|
|
# --- admin user management ---
|
|
[[routes]]
|
|
script = "admin_users_list"
|
|
method = "GET"
|
|
host_kind = "any"
|
|
path_kind = "exact"
|
|
path = "/cms/admin/users"
|
|
|
|
[[routes]]
|
|
script = "admin_user_roles"
|
|
method = "PUT"
|
|
host_kind = "any"
|
|
path_kind = "param"
|
|
path = "/cms/admin/users/:id/roles"
|
|
|
|
# --- posts (public) ---
|
|
[[routes]]
|
|
script = "posts_list"
|
|
method = "GET"
|
|
host_kind = "any"
|
|
path_kind = "exact"
|
|
path = "/cms/posts"
|
|
|
|
[[routes]]
|
|
script = "post_get"
|
|
method = "GET"
|
|
host_kind = "any"
|
|
path_kind = "param"
|
|
path = "/cms/posts/:slug"
|
|
|
|
# --- posts (admin) ---
|
|
[[routes]]
|
|
script = "posts_admin_list"
|
|
method = "GET"
|
|
host_kind = "any"
|
|
path_kind = "exact"
|
|
path = "/cms/admin/posts"
|
|
|
|
[[routes]]
|
|
script = "post_create"
|
|
method = "POST"
|
|
host_kind = "any"
|
|
path_kind = "exact"
|
|
path = "/cms/admin/posts"
|
|
|
|
[[routes]]
|
|
script = "post_update"
|
|
method = "PUT"
|
|
host_kind = "any"
|
|
path_kind = "param"
|
|
path = "/cms/admin/posts/:id"
|
|
|
|
[[routes]]
|
|
script = "post_delete"
|
|
method = "DELETE"
|
|
host_kind = "any"
|
|
path_kind = "param"
|
|
path = "/cms/admin/posts/:id"
|
|
|
|
# --- pages ---
|
|
[[routes]]
|
|
script = "pages"
|
|
method = "GET"
|
|
host_kind = "any"
|
|
path_kind = "exact"
|
|
path = "/cms/pages"
|
|
|
|
[[routes]]
|
|
script = "pages"
|
|
method = "GET"
|
|
host_kind = "any"
|
|
path_kind = "param"
|
|
path = "/cms/pages/:slug"
|
|
|
|
[[routes]]
|
|
script = "pages"
|
|
method = "GET"
|
|
host_kind = "any"
|
|
path_kind = "exact"
|
|
path = "/cms/admin/pages"
|
|
|
|
[[routes]]
|
|
script = "pages"
|
|
method = "POST"
|
|
host_kind = "any"
|
|
path_kind = "exact"
|
|
path = "/cms/admin/pages"
|
|
|
|
[[routes]]
|
|
script = "pages"
|
|
method = "PUT"
|
|
host_kind = "any"
|
|
path_kind = "param"
|
|
path = "/cms/admin/pages/:id"
|
|
|
|
[[routes]]
|
|
script = "pages"
|
|
method = "DELETE"
|
|
host_kind = "any"
|
|
path_kind = "param"
|
|
path = "/cms/admin/pages/:id"
|
|
|
|
# --- comments ---
|
|
[[routes]]
|
|
script = "comment_create"
|
|
method = "POST"
|
|
host_kind = "any"
|
|
path_kind = "param"
|
|
path = "/cms/posts/:id/comments"
|
|
|
|
[[routes]]
|
|
script = "comments_for_post"
|
|
method = "GET"
|
|
host_kind = "any"
|
|
path_kind = "param"
|
|
path = "/cms/posts/:id/comments"
|
|
|
|
[[routes]]
|
|
script = "comments_admin"
|
|
method = "GET"
|
|
host_kind = "any"
|
|
path_kind = "exact"
|
|
path = "/cms/admin/comments"
|
|
|
|
[[routes]]
|
|
script = "comments_admin"
|
|
method = "PUT"
|
|
host_kind = "any"
|
|
path_kind = "param"
|
|
path = "/cms/admin/comments/:id"
|
|
|
|
[[routes]]
|
|
script = "comments_admin"
|
|
method = "DELETE"
|
|
host_kind = "any"
|
|
path_kind = "param"
|
|
path = "/cms/admin/comments/:id"
|
|
|
|
# --- tags ---
|
|
[[routes]]
|
|
script = "tags_list"
|
|
method = "GET"
|
|
host_kind = "any"
|
|
path_kind = "exact"
|
|
path = "/cms/tags"
|
|
|
|
# --- media ---
|
|
[[routes]]
|
|
script = "media_upload"
|
|
method = "POST"
|
|
host_kind = "any"
|
|
path_kind = "exact"
|
|
path = "/cms/admin/media"
|
|
|
|
[[routes]]
|
|
script = "media_get"
|
|
method = "GET"
|
|
host_kind = "any"
|
|
path_kind = "param"
|
|
path = "/cms/media/:id"
|
|
|
|
# --- workflow ---
|
|
[[routes]]
|
|
script = "pipeline_start"
|
|
method = "POST"
|
|
host_kind = "any"
|
|
path_kind = "param"
|
|
path = "/cms/admin/posts/:id/pipeline"
|
|
|
|
[[routes]]
|
|
script = "pipeline_dag"
|
|
method = "GET"
|
|
host_kind = "any"
|
|
path_kind = "exact"
|
|
path = "/cms/admin/workflow"
|
|
|
|
[[routes]]
|
|
script = "pipeline_run"
|
|
method = "GET"
|
|
host_kind = "any"
|
|
path_kind = "param"
|
|
path = "/cms/admin/workflow/runs/:id"
|
|
|
|
# ------------------------------------------------------------------ triggers
|
|
# docs trigger: fire posts_on_publish when a post is created/updated.
|
|
[[triggers.docs]]
|
|
script = "posts_on_publish"
|
|
collection_glob = "posts"
|
|
ops = ["create", "update"]
|
|
retry_max_attempts = 3
|
|
|
|
# docs trigger: publish an approved comment to the SSE feed.
|
|
[[triggers.docs]]
|
|
script = "comment_on_approve"
|
|
collection_glob = "comments"
|
|
ops = ["update"]
|
|
retry_max_attempts = 2
|
|
|
|
# queue trigger: drain post-notifications, send emails.
|
|
[[triggers.queue]]
|
|
script = "notify_drain"
|
|
queue_name = "post-notifications"
|
|
retry_max_attempts = 5
|
|
|
|
# cron trigger: publish due scheduled posts every minute (6-field: sec min hr ...).
|
|
[[triggers.cron]]
|
|
script = "posts_scheduled_publish"
|
|
schedule = "0 * * * * *"
|
|
timezone = "UTC"
|
|
|
|
# ------------------------------------------------------------------ interceptors
|
|
[[interceptors]]
|
|
script = "comment_sanitize"
|
|
service = "docs"
|
|
ops = ["create"]
|
|
phase = "before"
|
|
timeout_ms = 500
|
|
|
|
# ------------------------------------------------------------------ workflows
|
|
# Editorial publish pipeline (durable DAG). validate → (enrich ∥ seo) → publish
|
|
# → finalize. Each step reads post_id from its input mapping; `when` gates the
|
|
# branch on validation passing. Per-step progress is read back via the platform
|
|
# `workflow::run_status` SDK (F-038) — no app-side run tracking needed.
|
|
[[workflows]]
|
|
name = "editorial_pipeline"
|
|
|
|
[[workflows.steps]]
|
|
name = "validate"
|
|
function = "wf_validate"
|
|
input = { post_id = "{{ input.post_id }}" }
|
|
|
|
[[workflows.steps]]
|
|
name = "enrich"
|
|
function = "wf_enrich"
|
|
depends_on = ["validate"]
|
|
when = "steps.validate.output.ok == true"
|
|
input = { post_id = "{{ input.post_id }}" }
|
|
|
|
[[workflows.steps]]
|
|
name = "seo"
|
|
function = "wf_seo"
|
|
depends_on = ["validate"]
|
|
when = "steps.validate.output.ok == true"
|
|
input = { post_id = "{{ input.post_id }}" }
|
|
|
|
[[workflows.steps]]
|
|
name = "publish"
|
|
function = "wf_publish"
|
|
depends_on = ["enrich", "seo"]
|
|
when = "steps.validate.output.ok == true"
|
|
input = { post_id = "{{ input.post_id }}" }
|
|
|
|
[[workflows.steps]]
|
|
name = "finalize"
|
|
function = "wf_finalize"
|
|
depends_on = ["publish"]
|
|
input = { post_id = "{{ input.post_id }}" }
|