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>
65 lines
2.1 KiB
Plaintext
65 lines
2.1 KiB
Plaintext
// 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
|
|
}
|