/** * Forum module — Discourse cloud provisioner. * Deploy self-hosted Discourse forums on Hetzner VPS with Cloudflare DNS. */ import { Hono } from "hono"; import * as Automerge from "@automerge/automerge"; import { renderShell, renderExternalAppShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import { provisionInstance, destroyInstance } from "./lib/provisioner"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; import type { SyncServer } from '../../server/local-first/sync-server'; import { forumSchema, FORUM_DOC_ID, type ForumDoc, type ForumInstance, } from './schemas'; let _syncServer: SyncServer | null = null; const routes = new Hono(); // ── Helpers ── function ensureDoc(): ForumDoc { let doc = _syncServer!.getDoc(FORUM_DOC_ID); if (!doc) { doc = Automerge.change(Automerge.init(), 'init forum doc', (d) => { const init = forumSchema.init(); d.meta = init.meta; d.instances = {}; d.provisionLogs = {}; }); _syncServer!.setDoc(FORUM_DOC_ID, doc); } return doc; } // ── API: List instances ── routes.get("/api/instances", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims; try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const doc = ensureDoc(); const instances = Object.values(doc.instances).filter( (inst) => inst.userId === claims.sub && inst.status !== 'destroyed', ); // Sort by createdAt descending instances.sort((a, b) => b.createdAt - a.createdAt); return c.json({ instances }); }); // ── API: Create instance ── routes.post("/api/instances", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims; try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const body = await c.req.json<{ name: string; subdomain: string; region?: string; size?: string; admin_email: string; smtp_config?: Record; }>(); if (!body.name || !body.subdomain || !body.admin_email) { return c.json({ error: "name, subdomain, and admin_email are required" }, 400); } const domain = `${body.subdomain}.rforum.online`; // Check uniqueness const doc = ensureDoc(); const existing = Object.values(doc.instances).find((inst) => inst.domain === domain); if (existing) return c.json({ error: "Domain already taken" }, 409); const id = crypto.randomUUID(); const now = Date.now(); const instance: ForumInstance = { id, userId: claims.sub, name: body.name, domain, status: 'pending', errorMessage: '', discourseVersion: 'stable', provider: 'hetzner', vpsId: '', vpsIp: '', region: body.region || 'nbg1', size: body.size || 'cx22', adminEmail: body.admin_email, smtpConfig: body.smtp_config || {}, dnsRecordId: '', sslProvisioned: false, createdAt: now, updatedAt: now, provisionedAt: 0, destroyedAt: 0, }; _syncServer!.changeDoc(FORUM_DOC_ID, 'create instance', (d) => { d.instances[id] = instance; d.provisionLogs[id] = []; }); // Start provisioning asynchronously provisionInstance(_syncServer!, id).catch((e) => { console.error("[Forum] Provision failed:", e); }); return c.json({ instance }, 201); }); // ── API: Get instance detail ── routes.get("/api/instances/:id", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims; try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const doc = ensureDoc(); const instance = doc.instances[c.req.param("id")]; if (!instance || instance.userId !== claims.sub) return c.json({ error: "Instance not found" }, 404); const logs = doc.provisionLogs[instance.id] || []; return c.json({ instance, logs }); }); // ── API: Destroy instance ── routes.delete("/api/instances/:id", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims; try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const doc = ensureDoc(); const instance = doc.instances[c.req.param("id")]; if (!instance || instance.userId !== claims.sub) return c.json({ error: "Instance not found" }, 404); if (instance.status === "destroyed") return c.json({ error: "Already destroyed" }, 400); // Destroy asynchronously destroyInstance(_syncServer!, instance.id).catch((e) => { console.error("[Forum] Destroy failed:", e); }); return c.json({ message: "Destroying instance...", instance: { ...instance, status: "destroying" } }); }); // ── API: Get provision logs ── routes.get("/api/instances/:id/logs", async (c) => { const doc = ensureDoc(); const logs = doc.provisionLogs[c.req.param("id")] || []; return c.json({ logs }); }); // ── API: Health ── routes.get("/api/health", (c) => { return c.json({ status: "ok", service: "rforum" }); }); // ── Page route ── routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "demo"; const view = c.req.query("view"); if (view === "app") { return c.html(renderExternalAppShell({ title: `${spaceSlug} — Discourse | rSpace`, moduleId: "rforum", spaceSlug, modules: getModuleInfoList(), appUrl: "https://commons.rforum.online", appName: "Discourse", theme: "dark", })); } return c.html(renderShell({ title: `${spaceSlug} — Forum | rSpace`, moduleId: "rforum", spaceSlug, modules: getModuleInfoList(), theme: "dark", body: ` `, scripts: ``, styles: ``, })); }); export const forumModule: RSpaceModule = { id: "rforum", name: "rForum", icon: "💬", description: "Deploy and manage Discourse forums", scoping: { defaultScope: 'global', userConfigurable: true }, docSchemas: [{ pattern: 'global:forum:instances', description: 'Forum provisioning metadata', init: forumSchema.init }], routes, landingPage: renderLanding, standaloneDomain: "rforum.online", externalApp: { url: "https://commons.rforum.online", name: "Discourse" }, feeds: [ { id: "threads", name: "Threads", kind: "data", description: "Discussion threads and topics from provisioned Discourse instances", filterable: true, }, { id: "activity", name: "Activity", kind: "attention", description: "Forum engagement — new posts, replies, and participation metrics", }, ], acceptsFeeds: ["data", "governance"], outputPaths: [ { path: "threads", name: "Threads", icon: "💬", description: "Forum discussion threads" }, { path: "categories", name: "Categories", icon: "📂", description: "Forum categories and topics" }, ], async onInit(ctx) { _syncServer = ctx.syncServer; console.log("[Forum] Module initialized (Automerge-only, no PG)"); }, };