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

7
examples/cms-poc/frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
.svelte-kit/
build/
dist/
.env
.env.*
*.local

View File

@@ -0,0 +1,2 @@
fund=false
audit=false

1607
examples/cms-poc/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
{
"name": "cms-frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev --port 5173 --host",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.8.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"svelte": "^5.1.0",
"vite": "^5.4.0"
},
"dependencies": {
"@xyflow/svelte": "^1.6.2"
}
}

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PiCloud CMS</title>
%sveltekit.head%
</head>
<body>
<div style="max-width:820px;margin:0 auto;padding:0 16px">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,39 @@
// Thin fetch wrapper around the PiCloud CMS backend. The browser calls
// same-origin /cms/* (Vite proxies to :8081), so there's no CORS/preflight.
const TOKEN_KEY = 'cms_token';
export function getToken(): string | null {
if (typeof localStorage === 'undefined') return null;
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(t: string | null) {
if (typeof localStorage === 'undefined') return;
if (t) localStorage.setItem(TOKEN_KEY, t);
else localStorage.removeItem(TOKEN_KEY);
}
async function req(method: string, path: string, body?: unknown) {
const headers: Record<string, string> = {};
const t = getToken();
if (t) headers['Authorization'] = 'Bearer ' + t;
if (body !== undefined) headers['Content-Type'] = 'application/json';
const res = await fetch('/cms' + path, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined
});
const text = await res.text();
let data: any = null;
try { data = text ? JSON.parse(text) : null; } catch { data = text; }
if (!res.ok) {
throw Object.assign(new Error((data && data.error) || res.statusText), { status: res.status, data });
}
return data;
}
export const api = {
get: (p: string) => req('GET', p),
post: (p: string, b?: unknown) => req('POST', p, b),
put: (p: string, b?: unknown) => req('PUT', p, b),
del: (p: string) => req('DELETE', p)
};

View File

@@ -0,0 +1,27 @@
import { writable } from 'svelte/store';
import { api, getToken, setToken } from './api';
export const user = writable<any | null>(null);
export async function refreshUser() {
if (!getToken()) { user.set(null); return; }
try { user.set(await api.get('/auth/me')); }
catch { setToken(null); user.set(null); }
}
export async function login(email: string, password: string) {
const r = await api.post('/auth/login', { email, password });
setToken(r.token);
user.set(r.user);
return r;
}
export async function logout() {
try { await api.post('/auth/logout'); } catch { /* ignore */ }
setToken(null);
user.set(null);
}
export function hasRole(u: any, role: string): boolean {
return !!u && Array.isArray(u.roles) && u.roles.includes(role);
}

View File

@@ -0,0 +1,25 @@
// Tiny, safe-ish markdown renderer for post bodies. It ESCAPES all HTML first
// (so even a trusted author can't inject script), then applies a small set of
// inline/block transforms. Not a full CommonMark impl — enough for a PoC.
export function renderMarkdown(src: string): string {
if (!src) return '';
let s = src
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// headings
s = s.replace(/^### (.*)$/gm, '<h3>$1</h3>');
s = s.replace(/^## (.*)$/gm, '<h2>$1</h2>');
s = s.replace(/^# (.*)$/gm, '<h1>$1</h1>');
// bold / italic / code
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
s = s.replace(/\*(.+?)\*/g, '<em>$1</em>');
s = s.replace(/`(.+?)`/g, '<code>$1</code>');
// links [text](http...) — only http(s) URLs
s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" rel="noopener noreferrer">$1</a>');
// paragraphs from blank-line separated blocks (skip block-level tags)
return s
.split(/\n{2,}/)
.map((b) => (/^\s*<h[1-3]>/.test(b) ? b : '<p>' + b.replace(/\n/g, '<br>') + '</p>'))
.join('\n');
}

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import { onMount } from 'svelte';
import { user, refreshUser, logout, hasRole } from '$lib/auth';
import { goto } from '$app/navigation';
let { children } = $props();
onMount(refreshUser);
async function doLogout() {
await logout();
goto('/');
}
</script>
<header>
<nav>
<a href="/" class="brand">📝 PiCloud Blog</a>
<a href="/">Home</a>
<a href="/pages/about">About</a>
{#if $user && (hasRole($user, 'admin') || hasRole($user, 'author'))}
<a href="/admin">Dashboard</a>
{/if}
<span class="spacer"></span>
{#if $user}
<span class="who">{$user.display_name || $user.email} ({$user.roles.join(', ')})</span>
<button onclick={doLogout}>Log out</button>
{:else}
<a href="/login">Log in</a>
<a href="/register">Register</a>
{/if}
</nav>
</header>
<main>
{@render children()}
</main>
<footer>PiCloud CMS PoC — backend is 100% PiCloud Rhai scripts.</footer>
<style>
:global(body) { font-family: system-ui, sans-serif; color: #222; line-height: 1.5; }
header { border-bottom: 1px solid #eee; margin-bottom: 1.5rem; }
nav { display: flex; gap: 1rem; align-items: center; padding: 0.8rem 0; flex-wrap: wrap; }
nav a { text-decoration: none; color: #2563eb; }
nav .brand { font-weight: 700; color: #111; }
.spacer { flex: 1; }
.who { color: #666; font-size: 0.85rem; }
button { cursor: pointer; border: 1px solid #ccc; background: #f8f8f8; border-radius: 6px; padding: 0.3rem 0.7rem; }
footer { margin: 3rem 0 1rem; color: #999; font-size: 0.8rem; border-top: 1px solid #eee; padding-top: 1rem; }
:global(input), :global(textarea), :global(select) { font: inherit; padding: 0.4rem; border: 1px solid #ccc; border-radius: 6px; width: 100%; box-sizing: border-box; }
:global(.btn) { background: #2563eb; color: #fff; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; }
:global(.err) { color: #b91c1c; }
:global(.card) { border: 1px solid #eee; border-radius: 8px; padding: 1rem; margin: 0.8rem 0; }
:global(label) { display: block; margin: 0.6rem 0 0.2rem; font-size: 0.85rem; color: #555; }
</style>

View File

@@ -0,0 +1,3 @@
// SPA: no SSR (everything is client-side fetch against the proxied backend).
export const ssr = false;
export const prerender = false;

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { page } from '$app/stores';
let posts = $state<any[]>([]);
let tags = $state<any[]>([]);
let activeTag = $state<string | null>(null);
let loading = $state(true);
async function load(tag: string | null) {
loading = true;
activeTag = tag;
const q = tag ? '?tag=' + encodeURIComponent(tag) : '';
const [p, t] = await Promise.all([api.get('/posts' + q), api.get('/tags')]);
posts = p.posts;
tags = t.tags;
loading = false;
}
onMount(() => load(null));
</script>
<h1>Latest posts</h1>
{#if tags.length}
<div class="tagcloud">
<button class:active={!activeTag} onclick={() => load(null)}>all</button>
{#each tags as t}
<button class:active={activeTag === t.tag} onclick={() => load(t.tag)}>#{t.tag} ({t.count})</button>
{/each}
</div>
{/if}
{#if loading}
<p>Loading…</p>
{:else if posts.length === 0}
<p>No posts yet.</p>
{:else}
{#each posts as post}
<article class="card">
<h2><a href={'/posts/' + post.slug}>{post.title}</a></h2>
<p class="meta">by {post.author_name} · {new Date(post.publish_at).toLocaleDateString()}</p>
{#if post.excerpt}<p>{post.excerpt}</p>{/if}
<p class="tags">{#each post.tags as tg}<span>#{tg}</span> {/each}</p>
</article>
{/each}
{/if}
<style>
.tagcloud { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-bottom: 1rem; }
.tagcloud button { border: 1px solid #ddd; background: #fafafa; border-radius: 999px; padding: 0.2rem 0.7rem; cursor: pointer; font-size: 0.8rem; }
.tagcloud button.active { background: #2563eb; color: #fff; border-color: #2563eb; }
.meta { color: #888; font-size: 0.85rem; margin: 0.2rem 0; }
.tags span { color: #2563eb; font-size: 0.8rem; margin-right: 0.3rem; }
</style>

View File

@@ -0,0 +1,37 @@
<script lang="ts">
import { onMount } from 'svelte';
import { user, refreshUser, hasRole } from '$lib/auth';
import { goto } from '$app/navigation';
let { children } = $props();
let ready = $state(false);
onMount(async () => {
await refreshUser();
ready = true;
});
$effect(() => {
if (ready && (!$user || (!hasRole($user, 'admin') && !hasRole($user, 'author')))) {
goto('/login');
}
});
</script>
{#if ready && $user && (hasRole($user, 'admin') || hasRole($user, 'author'))}
<div class="adminnav">
<a href="/admin">Overview</a>
<a href="/admin/posts">Posts</a>
<a href="/admin/pages">Pages</a>
<a href="/admin/comments">Comments</a>
<a href="/admin/workflow">Pipeline</a>
{#if hasRole($user, 'admin')}<a href="/admin/users">Users</a>{/if}
</div>
{@render children()}
{:else}
<p>Checking access…</p>
{/if}
<style>
.adminnav { display: flex; gap: 1rem; padding: 0.6rem 0.8rem; background: #f3f4f6; border-radius: 8px; margin-bottom: 1rem; }
.adminnav a { text-decoration: none; color: #374151; font-weight: 500; }
</style>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { user, hasRole } from '$lib/auth';
let stats = $state({ posts: 0, pending: 0, users: 0 });
onMount(async () => {
const posts = (await api.get('/admin/posts')).posts;
stats.posts = posts.length;
try {
const pending = (await api.get('/admin/comments?status=pending')).comments;
stats.pending = pending.length;
} catch { /* authors can't list comments */ }
if (hasRole($user, 'admin')) {
try { stats.users = (await api.get('/admin/users')).users.length; } catch { /* */ }
}
});
</script>
<h1>Dashboard</h1>
<div class="grid">
<div class="card"><h2>{stats.posts}</h2><p>your posts</p><a href="/admin/posts">Manage →</a></div>
<div class="card"><h2>{stats.pending}</h2><p>pending comments</p><a href="/admin/comments">Moderate →</a></div>
{#if hasRole($user, 'admin')}
<div class="card"><h2>{stats.users}</h2><p>users</p><a href="/admin/users">Manage →</a></div>
{/if}
</div>
<style>
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 1rem; }
.card h2 { margin: 0; font-size: 2rem; }
.card p { margin: 0.2rem 0; color: #666; }
</style>

View File

@@ -0,0 +1,57 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
let comments = $state<any[]>([]);
let filter = $state('pending');
let msg = $state('');
async function load() {
const q = filter ? '?status=' + filter : '';
comments = (await api.get('/admin/comments' + q)).comments;
}
onMount(load);
async function setStatus(c: any, status: string) {
try { await api.put('/admin/comments/' + c.id, { status }); await load(); }
catch (e: any) { msg = e.message; }
}
async function del(c: any) {
if (!confirm('Delete comment?')) return;
try { await api.del('/admin/comments/' + c.id); await load(); }
catch (e: any) { msg = e.message; }
}
</script>
<h1>Comment moderation</h1>
<p>
Filter:
<select bind:value={filter} onchange={load} style="width:auto;display:inline-block">
<option value="pending">pending</option>
<option value="approved">approved</option>
<option value="spam">spam</option>
<option value="">all</option>
</select>
<span class="err">{msg}</span>
</p>
{#each comments as c}
<div class="card">
<p><strong>{@html c.author_name}</strong> on <a href={'/posts/' + c.post_slug}>{c.post_title}</a>
· <span class="meta">{new Date(c.created_at).toLocaleString()}</span>
· <span class="badge">{c.status}</span></p>
<!-- pre-escaped by the interceptor -->
<p>{@html c.body}</p>
<p>
{#if c.status !== 'approved'}<button class="btn" onclick={() => setStatus(c, 'approved')}>approve</button>{/if}
{#if c.status !== 'spam'}<button onclick={() => setStatus(c, 'spam')}>spam</button>{/if}
<button onclick={() => del(c)}>delete</button>
</p>
</div>
{:else}
<p>No comments.</p>
{/each}
<style>
.meta { color: #888; font-size: 0.85rem; }
.badge { background: #eef2ff; color: #3730a3; padding: 0.1rem 0.5rem; border-radius: 999px; font-size: 0.75rem; }
</style>

View File

@@ -0,0 +1,53 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
let pages = $state<any[]>([]);
let editing = $state<any | null>(null);
let msg = $state('');
const blank = () => ({ id: null, title: '', slug: '', body_md: '', status: 'draft' });
async function load() { pages = (await api.get('/admin/pages')).pages; }
onMount(load);
async function save(e: Event) {
e.preventDefault(); msg = '';
const b = { title: editing.title, slug: editing.slug, body_md: editing.body_md, status: editing.status };
try {
if (editing.id) await api.put('/admin/pages/' + editing.id, b);
else await api.post('/admin/pages', b);
editing = null; await load(); msg = 'Saved.';
} catch (err: any) { msg = 'Error: ' + err.message; }
}
async function del(p: any) {
if (!confirm('Delete page?')) return;
await api.del('/admin/pages/' + p.id); await load();
}
</script>
<h1>Pages</h1>
<p><button class="btn" onclick={() => (editing = blank())}>+ New page</button> {msg}</p>
{#if editing}
<form class="card" onsubmit={save}>
<label>Title</label><input bind:value={editing.title} required />
<label>Slug</label><input bind:value={editing.slug} />
<label>Body (markdown)</label><textarea bind:value={editing.body_md} rows="8"></textarea>
<label>Status</label>
<select bind:value={editing.status}><option value="draft">draft</option><option value="published">published</option></select>
<p><button class="btn" type="submit">Save</button> <button type="button" onclick={() => (editing = null)}>Cancel</button></p>
</form>
{/if}
<ul>
{#each pages as p}
<li>
<strong>{p.title}</strong> <span class="badge">{p.status}</span>
<a href={'/pages/' + p.slug}>view</a>
<button onclick={() => (editing = { ...p })}>edit</button>
<button onclick={() => del(p)}>del</button>
</li>
{/each}
</ul>
<style>
li { margin: 0.4rem 0; }
.badge { background: #f3f4f6; color: #555; padding: 0.1rem 0.5rem; border-radius: 999px; font-size: 0.75rem; }
</style>

View File

@@ -0,0 +1,97 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
let posts = $state<any[]>([]);
let editing = $state<any | null>(null);
let msg = $state('');
const blank = () => ({ id: null, title: '', slug: '', body_md: '', excerpt: '', tags: '', status: 'draft', publish_at: '' });
async function load() { posts = (await api.get('/admin/posts')).posts; }
onMount(load);
function newPost() { editing = blank(); }
function edit(p: any) {
editing = { ...p, tags: (p.tags || []).join(', '), publish_at: p.publish_at || '' };
}
async function save(e: Event) {
e.preventDefault();
msg = '';
const body: any = {
title: editing.title,
slug: editing.slug,
body_md: editing.body_md,
excerpt: editing.excerpt,
status: editing.status,
tags: editing.tags.split(',').map((s: string) => s.trim()).filter(Boolean)
};
if (editing.status === 'scheduled' && editing.publish_at) body.publish_at = editing.publish_at;
try {
if (editing.id) await api.put('/admin/posts/' + editing.id, body);
else await api.post('/admin/posts', body);
msg = 'Saved.';
editing = null;
await load();
} catch (err: any) { msg = 'Error: ' + err.message; }
}
async function del(p: any) {
if (!confirm('Delete "' + p.title + '"?')) return;
try { await api.del('/admin/posts/' + p.id); await load(); }
catch (e: any) { msg = 'Error: ' + e.message; }
}
</script>
<h1>Posts</h1>
<p><button class="btn" onclick={newPost}>+ New post</button> {msg}</p>
{#if editing}
<form class="card" onsubmit={save}>
<h3>{editing.id ? 'Edit' : 'New'} post</h3>
<label>Title</label><input bind:value={editing.title} required />
<label>Slug (optional)</label><input bind:value={editing.slug} />
<label>Excerpt</label><input bind:value={editing.excerpt} />
<label>Tags (comma separated)</label><input bind:value={editing.tags} />
<label>Body (markdown)</label><textarea bind:value={editing.body_md} rows="8"></textarea>
<label>Status</label>
<select bind:value={editing.status}>
<option value="draft">draft</option>
<option value="published">published</option>
<option value="scheduled">scheduled</option>
</select>
{#if editing.status === 'scheduled'}
<label>Publish at (ISO, e.g. 2026-07-18T09:00:00Z)</label>
<input bind:value={editing.publish_at} />
{/if}
<p><button class="btn" type="submit">Save</button> <button type="button" onclick={() => (editing = null)}>Cancel</button></p>
</form>
{/if}
<table>
<thead><tr><th>Title</th><th>Status</th><th>Tags</th><th></th></tr></thead>
<tbody>
{#each posts as p}
<tr>
<td>{p.title}</td>
<td><span class={'badge ' + p.status}>{p.status}</span></td>
<td>{(p.tags || []).join(', ')}</td>
<td>
<button onclick={() => edit(p)}>edit</button>
<button onclick={() => del(p)}>del</button>
</td>
</tr>
{/each}
</tbody>
</table>
<style>
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #eee; font-size: 0.9rem; }
.badge { padding: 0.1rem 0.5rem; border-radius: 999px; font-size: 0.75rem; }
.badge.published { background: #dcfce7; color: #166534; }
.badge.draft { background: #f3f4f6; color: #555; }
.badge.scheduled { background: #fef9c3; color: #854d0e; }
td button { margin-right: 0.3rem; }
</style>

View File

@@ -0,0 +1,47 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
let users = $state<any[]>([]);
let msg = $state('');
const ROLES = ['admin', 'author', 'reader'];
async function load() { users = (await api.get('/admin/users')).users; }
onMount(load);
async function toggle(u: any, role: string) {
const has = u.roles.includes(role);
try {
await api.put('/admin/users/' + u.id + '/roles', has ? { remove: [role] } : { add: [role] });
await load();
} catch (e: any) { msg = e.message; }
}
</script>
<h1>Users</h1>
<p class="err">{msg}</p>
<table>
<thead><tr><th>Email</th><th>Name</th><th>Roles</th></tr></thead>
<tbody>
{#each users as u}
<tr>
<td>{u.email}</td>
<td>{u.display_name || '—'}</td>
<td>
{#each ROLES as r}
<label class="chip">
<input type="checkbox" checked={u.roles.includes(r)} onchange={() => toggle(u, r)} />
{r}
</label>
{/each}
</td>
</tr>
{/each}
</tbody>
</table>
<style>
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #eee; font-size: 0.9rem; }
.chip { display: inline-flex; align-items: center; gap: 0.2rem; margin-right: 0.6rem; width: auto; font-size: 0.85rem; }
.chip input { width: auto; }
</style>

View File

@@ -0,0 +1,148 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { SvelteFlow, Background, Controls } from '@xyflow/svelte';
import '@xyflow/svelte/dist/style.css';
import { api } from '$lib/api';
let nodes = $state<any[]>([]);
let edges = $state<any[]>([]);
let dag = $state<any>(null);
let drafts = $state<any[]>([]);
let selected = $state<string>('');
let runId = $state<string | null>(null);
let overall = $state<string>('');
let msg = $state('');
let poll: any = null;
const STATUS_STYLE: Record<string, string> = {
pending: 'background:#f3f4f6;border:1px solid #d1d5db;color:#6b7280',
running: 'background:#dbeafe;border:2px solid #2563eb;color:#1e40af',
succeeded: 'background:#dcfce7;border:2px solid #16a34a;color:#166534',
failed: 'background:#fee2e2;border:2px solid #dc2626;color:#991b1b',
skipped: 'background:#fafafa;border:1px dashed #cbd5e1;color:#94a3b8'
};
// layer each node by longest dependency path, then spread within the layer
function layout(steps: any[]) {
const byName: Record<string, any> = {};
steps.forEach((s) => (byName[s.name] = s));
const layer: Record<string, number> = {};
const depth = (n: string): number => {
if (layer[n] != null) return layer[n];
const deps = byName[n].depends_on || [];
const d = deps.length ? 1 + Math.max(...deps.map(depth)) : 0;
return (layer[n] = d);
};
steps.forEach((s) => depth(s.name));
const layers: Record<number, string[]> = {};
steps.forEach((s) => (layers[layer[s.name]] ||= []).push(s.name));
const pos: Record<string, { x: number; y: number }> = {};
Object.entries(layers).forEach(([l, names]) => {
names.forEach((n, i) => {
pos[n] = { x: 120 + i * 240 - ((names.length - 1) * 240) / 2 + 200, y: +l * 130 };
});
});
return pos;
}
function buildGraph(steps: any[], statuses: Record<string, any> = {}) {
const pos = layout(steps);
nodes = steps.map((s) => {
const st = statuses[s.name]?.status || 'pending';
const sub = s.when ? `\nwhen: ${s.when}` : '';
return {
id: s.name,
data: { label: `${s.label}\n[${st}]${sub}` },
position: pos[s.name],
style: `${STATUS_STYLE[st] || STATUS_STYLE.pending};border-radius:8px;padding:8px 10px;width:190px;white-space:pre-line;font-size:12px;text-align:center`
};
});
edges = steps.flatMap((s) =>
(s.depends_on || []).map((d: string) => ({
id: `${d}-${s.name}`,
source: d,
target: s.name,
animated: statuses[s.name]?.status === 'running'
}))
);
}
async function loadDag() {
dag = await api.get('/admin/workflow');
buildGraph(dag.steps);
const posts = (await api.get('/admin/posts')).posts;
drafts = posts.filter((p: any) => p.status === 'draft');
if (drafts.length) selected = drafts[0].id;
}
async function run() {
if (!selected) return;
msg = 'starting…';
stopPoll();
const r = await api.post(`/admin/posts/${selected}/pipeline`, {});
runId = r.run_id;
overall = 'running';
msg = `run ${runId.slice(0, 8)}… started`;
poll = setInterval(tick, 700);
tick();
}
async function tick() {
if (!runId) return;
try {
const r = await api.get(`/admin/workflow/runs/${runId}`);
overall = r.overall.status;
buildGraph(dag.steps, r.steps);
if (overall !== 'running') {
stopPoll();
msg = `run ${overall}`;
loadDraftsSoon();
}
} catch (e: any) {
msg = e.message;
stopPoll();
}
}
async function loadDraftsSoon() {
const posts = (await api.get('/admin/posts')).posts;
drafts = posts.filter((p: any) => p.status === 'draft');
}
function stopPoll() { if (poll) { clearInterval(poll); poll = null; } }
onMount(loadDag);
onDestroy(stopPoll);
</script>
<h1>Editorial pipeline <span class="muted">(PiCloud Workflow)</span></h1>
<p class="muted">
A durable DAG: <code>validate → (enrich ∥ seo) → publish → finalize</code>. Pick a draft and run it —
the nodes light up as each step completes. Publishing chains into the reader-notification pipeline.
</p>
<div class="bar">
<select bind:value={selected} style="width:auto;max-width:320px">
{#if drafts.length === 0}<option value="">— no drafts (create one in Posts) —</option>{/if}
{#each drafts as d}<option value={d.id}>{d.title}</option>{/each}
</select>
<button class="btn" onclick={run} disabled={!selected || overall === 'running'}>▶ Run pipeline</button>
{#if overall}<span class="pill {overall}">{overall}</span>{/if}
<span class="muted">{msg}</span>
</div>
<div class="flow">
<SvelteFlow bind:nodes bind:edges fitView>
<Background />
<Controls />
</SvelteFlow>
</div>
<style>
.muted { color: #888; font-weight: 400; font-size: 0.9rem; }
.bar { display: flex; gap: 0.75rem; align-items: center; margin: 1rem 0; flex-wrap: wrap; }
.flow { height: 460px; border: 1px solid #e5e7eb; border-radius: 10px; overflow: hidden; }
.pill { padding: 0.15rem 0.6rem; border-radius: 999px; font-size: 0.8rem; text-transform: uppercase; }
.pill.running { background: #dbeafe; color: #1e40af; }
.pill.completed { background: #dcfce7; color: #166534; }
.pill.blocked { background: #fee2e2; color: #991b1b; }
code { background: #f3f4f6; padding: 0.05rem 0.3rem; border-radius: 4px; }
</style>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { login } from '$lib/auth';
import { goto } from '$app/navigation';
let email = $state('');
let password = $state('');
let error = $state('');
async function submit(e: Event) {
e.preventDefault();
error = '';
try { await login(email, password); goto('/'); }
catch (err: any) { error = err.message || 'login failed'; }
}
</script>
<h1>Log in</h1>
<form onsubmit={submit} style="max-width:360px">
<label>Email</label>
<input bind:value={email} type="email" required />
<label>Password</label>
<input bind:value={password} type="password" required />
<p><button class="btn" type="submit">Log in</button></p>
{#if error}<p class="err">{error}</p>{/if}
</form>
<p>No account? <a href="/register">Register as a reader</a>.</p>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { api } from '$lib/api';
import { renderMarkdown } from '$lib/markdown';
let pg = $state<any>(null);
let error = $state('');
const slug = $derived($page.params.slug);
async function load() {
try { pg = await api.get('/pages/' + slug); }
catch (e: any) { error = e.message; }
}
onMount(load);
</script>
{#if error}
<p class="err">Page not found.</p>
{:else if pg}
<h1>{pg.title}</h1>
<div>{@html renderMarkdown(pg.body_md)}</div>
{:else}
<p>Loading…</p>
{/if}

View File

@@ -0,0 +1,110 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { api } from '$lib/api';
import { renderMarkdown } from '$lib/markdown';
let post = $state<any>(null);
let comments = $state<any[]>([]);
let error = $state('');
let form = $state({ author_name: '', author_email: '', body: '' });
let submitMsg = $state('');
const slug = $derived($page.params.slug);
let live = $state(false);
let es: EventSource | null = null;
async function load() {
error = '';
try {
post = await api.get('/posts/' + slug);
comments = (await api.get('/posts/' + post.id + '/comments')).comments;
subscribeLive();
} catch (e: any) {
error = e.message || 'not found';
}
}
function subscribeLive() {
if (es) es.close();
// Public SSE feed of approved comments; filter to this post, dedupe by id-ish.
es = new EventSource('/realtime/topics/comments-feed');
es.onopen = () => (live = true);
es.onerror = () => (live = false);
es.onmessage = (ev) => {
try {
const m = JSON.parse(ev.data).message;
if (m && m.post_id === post.id) {
const exists = comments.some(
(c) => c.body === m.body && c.author_name === m.author_name && c.created_at === m.created_at
);
if (!exists) comments = [...comments, m];
}
} catch { /* ignore */ }
};
}
onMount(() => {
load();
return () => es?.close();
});
async function submitComment(e: Event) {
e.preventDefault();
submitMsg = '';
try {
await api.post('/posts/' + post.id + '/comments', form);
submitMsg = 'Thanks! Your comment is awaiting moderation.';
form = { author_name: '', author_email: '', body: '' };
} catch (e: any) {
submitMsg = 'Error: ' + (e.message || 'failed');
}
}
</script>
{#if error}
<p class="err">Post not found.</p>
<a href="/">← back</a>
{:else if post}
<a href="/">← all posts</a>
<h1>{post.title}</h1>
<p class="meta">by {post.author_name} · {new Date(post.publish_at).toLocaleString()} · {post.views} views</p>
<p class="tags">{#each post.tags as t}<a href={'/?tag=' + t}>#{t}</a> {/each}</p>
<!-- body_md is author-authored; renderMarkdown escapes HTML before formatting -->
<div class="body">{@html renderMarkdown(post.body_md)}</div>
<hr />
<h3>Comments ({comments.length}) {#if live}<span class="live">● live</span>{/if}</h3>
{#each comments as c}
<div class="card comment">
<strong>{@html c.author_name}</strong>
<span class="meta">{new Date(c.created_at).toLocaleDateString()}</span>
<!-- c.body is stored HTML-escaped by the interceptor; {@html} renders the
escaped entities as harmless literal text (no tag is formed). -->
<p>{@html c.body}</p>
</div>
{/each}
<h3>Leave a comment</h3>
<form onsubmit={submitComment}>
<label>Name</label>
<input bind:value={form.author_name} required />
<label>Email (optional, never shown publicly)</label>
<input bind:value={form.author_email} type="email" />
<label>Comment</label>
<textarea bind:value={form.body} rows="4" required></textarea>
<p><button class="btn" type="submit">Submit</button></p>
{#if submitMsg}<p>{submitMsg}</p>{/if}
</form>
{:else}
<p>Loading…</p>
{/if}
<style>
.meta { color: #888; font-size: 0.85rem; }
.tags a { color: #2563eb; font-size: 0.8rem; margin-right: 0.3rem; text-decoration: none; }
.body :global(p) { margin: 0.8rem 0; }
.comment strong { margin-right: 0.5rem; }
.live { color: #16a34a; font-size: 0.7rem; vertical-align: middle; }
</style>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { api } from '$lib/api';
let email = $state('');
let password = $state('');
let display_name = $state('');
let msg = $state('');
let error = $state('');
async function submit(e: Event) {
e.preventDefault();
msg = ''; error = '';
try {
await api.post('/auth/register', { email, password, display_name });
msg = 'Registered! You can now log in. (Readers get emailed when new posts publish.)';
} catch (err: any) { error = err.message || 'registration failed'; }
}
</script>
<h1>Register as a reader</h1>
<form onsubmit={submit} style="max-width:360px">
<label>Display name</label>
<input bind:value={display_name} />
<label>Email</label>
<input bind:value={email} type="email" required />
<label>Password (min 8 chars)</label>
<input bind:value={password} type="password" required />
<p><button class="btn" type="submit">Register</button></p>
{#if msg}<p>{msg}</p>{/if}
{#if error}<p class="err">{error}</p>{/if}
</form>

View File

@@ -0,0 +1,12 @@
import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
// SPA: prerender the shell, client-side route + fetch everything.
adapter: adapter({ fallback: 'index.html' }),
prerender: { entries: [] }
}
};
export default config;

View File

@@ -0,0 +1,23 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
// The browser calls same-origin /cms/* on :5173; Vite proxies to the PiCloud
// backend on :8081. This sidesteps PiCloud's lack of CORS (FINDINGS F-030) and
// avoids CORS preflight entirely (the request is same-origin to the browser).
export default defineConfig({
plugins: [sveltekit()],
server: {
port: 5173,
proxy: {
'/cms': {
target: 'http://localhost:8081',
changeOrigin: true
},
// SSE realtime stream (live comments)
'/realtime': {
target: 'http://localhost:8081',
changeOrigin: true
}
}
}
});