diff --git a/CLAUDE.md b/CLAUDE.md index 1c94fd2..3172589 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,7 +77,7 @@ This is an MCP (Model Context Protocol) platform. The AI is the primary interfac 2. **HTTP Gateway** (`servers/gateway/`) — Express server that wraps all MCP servers with Streamable HTTP + SSE transports + OAuth 2.1. Includes proxy layer for external MCP servers, **tool router** (`/router/mcp` — 7 tools instead of 49+), **AI chat gateway** (`/api/chat/*` — BYOAI with tool calling), public blog routes, Crow's Nest UI, peer relay, and setup page. Modularized into Express routers (`routes/mcp.js`, `routes/chat.js`, `routes/blog-public.js`, `routes/storage-http.js`, `dashboard/`). -3. **Crow's Nest** (`servers/gateway/dashboard/`) — Server-side rendered HTML control panel (the "Crow's Nest") with Dark Editorial design. Password auth, session cookies, panel registry. Built-in panels: Health, Messages (AI Chat + Peer Messages tabs), Contacts, Memory, Blog (with markdown preview), Podcasts (subscriber + player), Files, Extensions, Skills, Settings. Third-party panels via `~/.crow/panels/`. +3. **Crow's Nest** (`servers/gateway/dashboard/`) — Server-side rendered HTML control panel (the "Crow's Nest") with Dark Editorial design. Password auth, session cookies, panel registry. Built-in panels: Health, Messages (AI Chat + Peer Messages tabs), Contacts, Memory, Blog (with markdown preview), Podcasts (subscriber + player), Files, Extensions, Skills, Settings, **Fediverse Admin (F.14 — moderation queue + crosspost queue)**. Third-party panels via `~/.crow/panels/`. 4. **Skills** (`skills/`) — 30 markdown files that serve as behavioral prompts loaded by Claude. Not code — they define workflows, trigger patterns, and integration logic. diff --git a/servers/gateway/dashboard/index.js b/servers/gateway/dashboard/index.js index f40d7dd..cc2af6a 100644 --- a/servers/gateway/dashboard/index.js +++ b/servers/gateway/dashboard/index.js @@ -69,6 +69,7 @@ import skillsPanel from "./panels/skills.js"; import projectsPanel from "./panels/projects.js"; import settingsPanel from "./panels/settings.js"; import contactsPanel from "./panels/contacts.js"; +import fediversePanel from "./panels/fediverse.js"; import bundlesRouterFactory from "../routes/bundles.js"; /** @@ -92,6 +93,7 @@ export default function dashboardRouter(mcpAuthMiddleware) { registerPanel(skillsPanel); registerPanel(settingsPanel); registerPanel(contactsPanel); + registerPanel(fediversePanel); // Load third-party panels (async, non-blocking) loadExternalPanels().catch((err) => { @@ -438,6 +440,16 @@ export default function dashboardRouter(mcpAuthMiddleware) { // Mount bundles API (protected by dashboard auth above) router.use("/dashboard", bundlesRouterFactory()); + // F.14: Fediverse Admin action POSTs (confirm/reject moderation, cancel/retry crosspost) + router.post("/dashboard/fediverse/action", async (req, res) => { + const db = createDbClient(); + try { + await fediversePanel.handleAction(req, res, { db }); + } finally { + try { db.close(); } catch {} + } + }); + // Dashboard home — redirect to first visible panel router.get("/dashboard", (req, res) => { const visible = getVisiblePanels(); diff --git a/servers/gateway/dashboard/nav-registry.js b/servers/gateway/dashboard/nav-registry.js index ccea641..be2d20e 100644 --- a/servers/gateway/dashboard/nav-registry.js +++ b/servers/gateway/dashboard/nav-registry.js @@ -46,6 +46,7 @@ const CATEGORY_TO_GROUP = { "federated-social": "core", "federated-media": "media", "federated-comms": "core", + connections: "core", }; /** diff --git a/servers/gateway/dashboard/panels/fediverse.js b/servers/gateway/dashboard/panels/fediverse.js new file mode 100644 index 0000000..fbe19a9 --- /dev/null +++ b/servers/gateway/dashboard/panels/fediverse.js @@ -0,0 +1,366 @@ +/** + * F.14: Fediverse Admin panel. + * + * Two-tab view over the federated-bundles operational surface: + * • Moderation Queue — pending moderation_actions (F.11) from the + * federated bundles (gotosocial/funkwhale/pixelfed/lemmy/mastodon/ + * peertube). Operator confirms / rejects / views expired. + * • Crosspost Queue — queued/ready/manual/error entries from + * crosspost_log (F.12/F.13). Operator can cancel queued, re-drive + * manual ones (shows the transformed preview), or just view audit. + * + * Actions are POSTs from simple HTML forms — no client JS framework. + * Auth: standard dashboardAuth on the parent route. + */ + +import { t } from "../shared/i18n.js"; + +function escapeHtml(s) { + return String(s ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function fmtAgo(ts) { + if (!ts) return "—"; + const delta = Math.floor(Date.now() / 1000) - Number(ts); + if (delta < 60) return `${delta}s ago`; + if (delta < 3600) return `${Math.floor(delta / 60)}m ago`; + if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`; + return `${Math.floor(delta / 86400)}d ago`; +} + +function fmtFuture(ts) { + if (!ts) return "—"; + const delta = Number(ts) - Math.floor(Date.now() / 1000); + if (delta <= 0) return `ready`; + if (delta < 60) return `in ${delta}s`; + if (delta < 3600) return `in ${Math.floor(delta / 60)}m`; + return `in ${Math.floor(delta / 3600)}h`; +} + +async function loadModerationActions(db, filter) { + const clauses = []; + const args = []; + if (filter === "pending") clauses.push("status = 'pending'"); + else if (filter === "confirmed") clauses.push("status = 'confirmed'"); + else if (filter === "expired") clauses.push("status = 'expired'"); + else if (filter === "rejected") clauses.push("status = 'rejected'"); + // "all" → no filter + const sql = `SELECT id, bundle_id, action_type, payload_json, requested_by, requested_at, + expires_at, status, confirmed_by, confirmed_at, error + FROM moderation_actions + ${clauses.length ? "WHERE " + clauses.join(" AND ") : ""} + ORDER BY requested_at DESC LIMIT 100`; + const rows = await db.execute({ sql, args }); + return rows.rows.map((r) => ({ + id: Number(r.id), + bundle_id: r.bundle_id, + action_type: r.action_type, + payload: (() => { try { return JSON.parse(r.payload_json); } catch { return null; } })(), + requested_by: r.requested_by, + requested_at: Number(r.requested_at), + expires_at: Number(r.expires_at), + status: r.status, + confirmed_by: r.confirmed_by || null, + confirmed_at: r.confirmed_at ? Number(r.confirmed_at) : null, + error: r.error || null, + })); +} + +async function loadCrosspostLog(db, filter) { + const clauses = []; + const args = []; + if (filter && filter !== "all") { + clauses.push("status = ?"); + args.push(filter); + } + args.push(100); + const sql = `SELECT id, idempotency_key, source_app, source_post_id, target_app, transform, + status, target_post_id, scheduled_at, published_at, cancelled_at, error, + created_at, transformed_payload_json + FROM crosspost_log + ${clauses.length ? "WHERE " + clauses.join(" AND ") : ""} + ORDER BY created_at DESC LIMIT ?`; + const rows = await db.execute({ sql, args }); + return rows.rows.map((r) => { + let preview = null; + try { + const tp = JSON.parse(r.transformed_payload_json || "null"); + if (tp) preview = (tp.status || tp.caption || JSON.stringify(tp)).slice(0, 180); + } catch {} + return { + id: Number(r.id), + source_app: r.source_app, + source_post_id: r.source_post_id, + target_app: r.target_app, + transform: r.transform, + status: r.status, + target_post_id: r.target_post_id || null, + scheduled_at: Number(r.scheduled_at), + published_at: r.published_at ? Number(r.published_at) : null, + cancelled_at: r.cancelled_at ? Number(r.cancelled_at) : null, + error: r.error || null, + created_at: Number(r.created_at), + preview, + }; + }); +} + +function renderTabs(active) { + const tab = (id, label) => + `${label}`; + return `
${tab("moderation", "Moderation Queue")}${tab("crosspost", "Crosspost Queue")}
`; +} + +function renderModerationSection(lang, items, filter) { + const filters = ["pending", "confirmed", "expired", "rejected", "all"]; + const filterLinks = filters + .map((f) => `${f}`) + .join(""); + + const rows = items.length === 0 + ? `No entries in '${escapeHtml(filter)}'.` + : items.map((a) => { + const payloadSummary = a.payload ? Object.entries(a.payload) + .map(([k, v]) => `${escapeHtml(k)}: ${escapeHtml(String(v).slice(0, 120))}`) + .join("
") : "(no payload)"; + const actions = a.status === "pending" + ? `
+ + + + +
+
+ + + + +
` + : ""; + return ` + #${a.id} + +
${escapeHtml(a.bundle_id)}
+
${escapeHtml(a.action_type)}
+ + ${payloadSummary} + +
req: ${fmtAgo(a.requested_at)} by ${escapeHtml(a.requested_by)}
+
expires: ${fmtFuture(a.expires_at)}
+ ${a.confirmed_at ? `
confirmed: ${fmtAgo(a.confirmed_at)}
` : ""} + + ${escapeHtml(a.status)} + ${actions} + `; + }).join(""); + + return ` +
+
${filterLinks}
+ + + ${rows} +
#Bundle / ActionPayloadTimingStatusActions
+

Pending actions auto-expire after 72h via the F.13 GC sweeper. Confirming here records your approval but DOES NOT yet auto-fire the action against the federated app — a follow-up scheduler PR wires that end-to-end; for now, confirming + then invoking the bundle's own moderation verb by hand is the expected flow.

+
`; +} + +function renderCrosspostSection(lang, items, filter) { + const filters = ["queued", "ready", "published", "manual", "error", "cancelled", "all"]; + const filterLinks = filters + .map((f) => `${f}`) + .join(""); + + const rows = items.length === 0 + ? `No entries in '${escapeHtml(filter)}'.` + : items.map((c) => { + const actions = (c.status === "queued" || c.status === "ready") + ? `
+ + + + +
` + : c.status === "error" + ? `
+ + + + +
` + : ""; + const whenInfo = c.status === "queued" + ? `fires: ${fmtFuture(c.scheduled_at)}` + : c.status === "published" + ? `published: ${fmtAgo(c.published_at)}` + : c.status === "cancelled" + ? `cancelled: ${fmtAgo(c.cancelled_at)}` + : `scheduled: ${fmtAgo(c.scheduled_at)}`; + return ` + #${c.id} + +
${escapeHtml(c.transform || `${c.source_app}→${c.target_app}`)}
+
source: ${escapeHtml(c.source_app)}#${escapeHtml(c.source_post_id)}
+ ${c.target_post_id ? `
target: ${escapeHtml(c.target_app)}#${escapeHtml(c.target_post_id)}
` : ""} + + ${c.preview ? escapeHtml(c.preview) : "(no preview)"} + +
${escapeHtml(whenInfo)}
+
created: ${fmtAgo(c.created_at)}
+ ${c.error ? `
err: ${escapeHtml(c.error.slice(0, 120))}
` : ""} + + ${escapeHtml(c.status)} + ${actions} + `; + }).join(""); + + return ` +
+
${filterLinks}
+ + + ${rows} +
#RoutePreviewTimingStatusActions
+

The F.13 scheduler polls every 15s, auto-publishes ready/queued entries to mastodon/gotosocial/crow-blog, and marks media-heavy / context-specific targets as manual. Cancel a queued entry before scheduled_at arrives to prevent publication.

+
`; +} + +function style() { + return ``; +} + +export default { + id: "fediverse", + name: "Fediverse Admin", + icon: "globe", + route: "/dashboard/fediverse", + navOrder: 80, + category: "connections", + + async handler(req, res, { db, lang, layout }) { + const tab = (req.query.tab === "crosspost") ? "crosspost" : "moderation"; + const filter = String(req.query.filter || (tab === "moderation" ? "pending" : "queued")); + const flash = req.query.flash; + + let flashHtml = ""; + if (flash === "confirmed") flashHtml = `
Moderation action confirmed (still needs manual fire — see help text).
`; + else if (flash === "rejected") flashHtml = `
Moderation action rejected.
`; + else if (flash === "cancelled") flashHtml = `
Crosspost cancelled.
`; + else if (flash === "retried") flashHtml = `
Crosspost re-queued for another attempt.
`; + else if (flash && flash.startsWith("err_")) flashHtml = `
Error: ${escapeHtml(flash.slice(4))}
`; + + let section; + if (tab === "moderation") { + const items = await loadModerationActions(db, filter); + section = renderModerationSection(lang, items, filter); + } else { + const items = await loadCrosspostLog(db, filter); + section = renderCrosspostSection(lang, items, filter); + } + + const content = ` + ${style()} +
+

Fediverse Admin

+
Moderation queue + crosspost queue. Destructive actions from F.11 and queued crossposts from F.12/F.13 land here for operator review.
+ ${flashHtml} + ${renderTabs(tab)} + ${section} +
`; + return layout({ title: "Fediverse Admin", content }); + }, + + // POST action handler wired via the main dashboardRouter below + async handleAction(req, res, { db }) { + const { tab, action, id } = req.body || {}; + const idNum = Number(id); + if (!idNum) { res.redirect(`/dashboard/fediverse?tab=${tab || "moderation"}&flash=err_missing_id`); return; } + try { + if (action === "confirm_moderation") { + const now = Math.floor(Date.now() / 1000); + await db.execute({ + sql: "UPDATE moderation_actions SET status = 'confirmed', confirmed_by = ?, confirmed_at = ? WHERE id = ? AND status = 'pending'", + args: [String(req.user?.id || "operator"), now, idNum], + }); + res.redirect(`/dashboard/fediverse?tab=moderation&flash=confirmed`); + return; + } + if (action === "reject_moderation") { + const now = Math.floor(Date.now() / 1000); + await db.execute({ + sql: "UPDATE moderation_actions SET status = 'rejected', confirmed_by = ?, confirmed_at = ? WHERE id = ? AND status = 'pending'", + args: [String(req.user?.id || "operator"), now, idNum], + }); + res.redirect(`/dashboard/fediverse?tab=moderation&flash=rejected`); + return; + } + if (action === "cancel_crosspost") { + const now = Math.floor(Date.now() / 1000); + await db.execute({ + sql: "UPDATE crosspost_log SET status = 'cancelled', cancelled_at = ? WHERE id = ? AND status IN ('queued','ready')", + args: [now, idNum], + }); + res.redirect(`/dashboard/fediverse?tab=crosspost&flash=cancelled`); + return; + } + if (action === "retry_crosspost") { + const now = Math.floor(Date.now() / 1000); + await db.execute({ + sql: "UPDATE crosspost_log SET status = 'ready', error = NULL, scheduled_at = ? WHERE id = ? AND status = 'error'", + args: [now, idNum], + }); + res.redirect(`/dashboard/fediverse?tab=crosspost&flash=retried`); + return; + } + res.redirect(`/dashboard/fediverse?tab=${tab || "moderation"}&flash=err_unknown_action`); + } catch (err) { + const msg = encodeURIComponent(String(err.message || err).slice(0, 80)); + res.redirect(`/dashboard/fediverse?tab=${tab || "moderation"}&flash=err_${msg}`); + } + }, +};