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