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 `
| # | Bundle / Action | Payload | Timing | Status | Actions |
|---|
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.
+| # | Route | Preview | Timing | Status | Actions |
|---|
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.