# PiCloud CMS PoC — a WordPress-inspired CMS on PiCloud A proof-of-concept content-management system whose **entire backend is PiCloud Rhai scripts**, deployed declaratively with `pic apply`, plus a SvelteKit frontend. Built to **dogfood the PiCloud project tool, CLI, and SDK** — the gaps/surprises/issues found along the way are in [FINDINGS.md](FINDINGS.md) and the security audit is in [SECURITY.md](SECURITY.md). > This is a **test artifact**, not a production CMS. It deliberately exercises > as many PiCloud features as fit a blog, and records where they helped or hurt. ## What it does (WordPress-style) | Feature | PiCloud pieces used | |---|---| | Admin dashboard + admin/author users | `users::` app-users + roles (`admin`/`author`/`reader`), a shared `auth` **module** as the access boundary | | Post management + editor | `docs` collection `posts`, `kv` slug index, `kv::set_if` **CAS** view counter | | Page management + editor | `docs` collection `pages` (one method-dispatched script on many routes) | | Post comments + moderation | `docs` collection `comments`, a **docs `before` interceptor** that HTML-escapes + sets moderation status | | Reader users | `users::` register/login/verify, `kv` rate-limiter | | Reader email on new posts | `docs` trigger → **durable queue** → `email::send_html` → **Mailpit** | | Post tags | `docs` tag arrays + a tag-cloud endpoint | | Scheduled publishing | **cron** trigger flips `scheduled`→`published` (which re-fires the notify chain) | | Media uploads | `files` collection (base64-in-JSON, image allowlist) | | Live comments | `pubsub::publish_durable` → **realtime SSE** (public topic) → browser `EventSource` | | Editorial automation | **Workflow** (durable DAG: validate → enrich ∥ seo → publish → finalize) started with `workflow::start`, polled with `workflow::run_status`; live-visualized with **Svelte Flow** in the admin dashboard | | Config / secrets / envs | `vars` (+ `picloud.dev/prod.toml` **overlays**), `secrets` (`setup-token`) | | Deploy | `[project]` + `[app]`, declarative `pic plan/apply --dir --env`, `--prune` | ## Layout ``` picloud.toml # the whole backend: scripts, routes, triggers, interceptor, vars, secrets picloud.dev.toml # dev env overlay picloud.prod.toml # prod env overlay scripts/**/*.rhai # ~30 Rhai scripts (endpoints, modules, triggers, interceptor) run-server.sh # boots picloud on :8081 against the picloud_cms DB, relaying mail to Mailpit docker-compose.override.yml # Mailpit frontend/ # SvelteKit SPA (public site + admin dashboard) FINDINGS.md SECURITY.md ``` ## Run it ```sh # 0. infra: Postgres (repo compose) + Mailpit docker compose up -d postgres # from repo root docker run -d --name cms-mailpit -p 127.0.0.1:1025:1025 -p 127.0.0.1:8025:8025 \ -e MP_SMTP_AUTH_ACCEPT_ANY=true axllent/mailpit:latest docker exec picloud-postgres-1 psql -U picloud -d picloud -c "CREATE DATABASE picloud_cms" # 1. build + boot picloud (all-in-one) on :8081, relaying mail to Mailpit cargo build -p picloud bash examples/cms-poc/run-server.sh & # see the script for the exact env # 2. deploy the CMS (from examples/cms-poc/) cd examples/cms-poc pic login --url http://localhost:8081 # admin / admin pic apps create cms --name "PiCloud CMS" # apply can't create apps (FINDINGS F-006) pic apps domains rm default && pic apps domains add cms localhost # claim the host (F-009) echo -n "cms-setup-secret-123" | pic secrets set --app cms setup-token pic apply --dir . --env dev # 3. bootstrap the first admin (no CLI to grant app-user roles — FINDINGS F-012) curl -X POST localhost:8081/cms/auth/bootstrap -H 'Content-Type: application/json' \ -d '{"setup_token":"cms-setup-secret-123","email":"admin@cms.test","password":"adminpass1","display_name":"Site Admin"}' # 4. frontend cd frontend && npm install && npm run dev # http://localhost:5173 ``` Then: register a reader, write+publish a post as admin, watch the email land in Mailpit (http://localhost:8025), comment on the post, approve it, and see it appear live. ## Backend API (all under `/cms/`, since `/api/` is reserved — FINDINGS F-007) - Public: `GET /cms/posts`, `GET /cms/posts/:slug`, `GET /cms/posts/:id/comments`, `POST /cms/posts/:id/comments`, `GET /cms/pages`, `GET /cms/pages/:slug`, `GET /cms/tags`, `GET /cms/media/:id`, `POST /cms/auth/{register,login,logout}`, `GET /cms/auth/me` - Author+: `GET/POST /cms/admin/posts`, `PUT/DELETE /cms/admin/posts/:id`, `POST /cms/admin/media` - Admin: `/cms/admin/pages*`, `/cms/admin/comments*`, `/cms/admin/users*` ## Headlines from the audit - 🟢 **Strengths:** the event model (docs/queue/cron/pubsub triggers), the transactional-outbox notification chain, `set_if` CAS, the docs `before` interceptor for write-time sanitization, `users::` + roles, SSE, and the declarative `plan/apply/--prune/--env` loop all worked well. - 🟠 **Rough edges:** `pic apply` can't create an app (only groups); `/api/` is a reserved route prefix; `throw #{statusCode}` becomes a 502 that leaks script internals; the docs filter DSL can't match array membership; user routes are JSON-only (no form/binary bodies); no CORS; no CLI to grant app-user roles. - 🔴 **Security root cause:** a public route holds **full app authority** and there's **no per-row authz** — every check is app code — while uncaught errors return a **502 that leaks the app id + script structure**. Get one `guard()` wrong and it's a silent full bypass. See [SECURITY.md](SECURITY.md).