/** * rSpace Unified Server * * Hono-based HTTP router + Bun WebSocket handler. * Mounts module routes under /:space/:moduleId. * Preserves backward-compatible subdomain routing and /api/communities/* API. */ import { resolve } from "node:path"; import { Hono } from "hono"; import { cors } from "hono/cors"; import type { ServerWebSocket } from "bun"; import { addShapes, clearShapes, communityExists, createCommunity, forgetShape, rememberShape, generateSyncMessageForPeer, getDocumentData, loadCommunity, receiveSyncMessage, removePeerSyncState, updateShape, updateShapeFields, cascadePermissions, listCommunities, deleteCommunity, } from "./community-store"; import type { NestPermissions, SpaceRefFilter } from "./community-store"; import { ensureDemoCommunity } from "./seed-demo"; import { seedTemplateShapes, ensureTemplateSeeding } from "./seed-template"; // Campaign demo moved to rsocials module — see modules/rsocials/campaign-data.ts import type { SpaceVisibility } from "./community-store"; import { verifyEncryptIDToken, evaluateSpaceAccess, extractToken, authenticateWSUpgrade, } from "@encryptid/sdk/server"; import type { EncryptIDClaims, SpaceAuthConfig } from "@encryptid/sdk/server"; // ── Module system ── import { registerModule, getAllModules, getModuleInfoList, getModule } from "../shared/module"; import { canvasModule } from "../modules/rspace/mod"; import { booksModule } from "../modules/rbooks/mod"; import { pubsModule } from "../modules/rpubs/mod"; import { cartModule } from "../modules/rcart/mod"; import { swagModule } from "../modules/rswag/mod"; import { choicesModule } from "../modules/rchoices/mod"; import { flowsModule } from "../modules/rflows/mod"; import { filesModule } from "../modules/rfiles/mod"; import { forumModule } from "../modules/rforum/mod"; import { walletModule } from "../modules/rwallet/mod"; import { voteModule } from "../modules/rvote/mod"; import { notesModule } from "../modules/rnotes/mod"; import { mapsModule } from "../modules/rmaps/mod"; import { tasksModule } from "../modules/rtasks/mod"; import { tripsModule } from "../modules/rtrips/mod"; import { calModule } from "../modules/rcal/mod"; import { networkModule } from "../modules/rnetwork/mod"; import { tubeModule } from "../modules/rtube/mod"; import { inboxModule } from "../modules/rinbox/mod"; import { dataModule } from "../modules/rdata/mod"; import { splatModule } from "../modules/rsplat/mod"; import { photosModule } from "../modules/rphotos/mod"; import { socialsModule } from "../modules/rsocials/mod"; import { meetsModule } from "../modules/rmeets/mod"; // import { docsModule } from "../modules/rdocs/mod"; // import { designModule } from "../modules/rdesign/mod"; import { scheduleModule } from "../modules/rschedule/mod"; import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces"; import type { SpaceRoleString } from "./spaces"; import { renderShell, renderModuleLanding, renderSubPageInfo, renderOnboarding } from "./shell"; import { renderOutputListPage } from "./output-list"; import { renderMainLanding, renderSpaceDashboard } from "./landing"; import { fetchLandingPage } from "./landing-proxy"; import { syncServer } from "./sync-instance"; import { loadAllDocs } from "./local-first/doc-persistence"; import { backupRouter } from "./local-first/backup-routes"; import { oauthRouter } from "./oauth/index"; import { setNotionOAuthSyncServer } from "./oauth/notion"; import { setGoogleOAuthSyncServer } from "./oauth/google"; import { notificationRouter } from "./notification-routes"; import { registerUserConnection, unregisterUserConnection, notify } from "./notification-service"; import { SystemClock } from "./clock-service"; import type { ClockPayload } from "./clock-service"; import { miRoutes } from "./mi-routes"; // Register modules registerModule(canvasModule); registerModule(booksModule); registerModule(pubsModule); registerModule(cartModule); registerModule(swagModule); registerModule(choicesModule); registerModule(flowsModule); registerModule(filesModule); registerModule(forumModule); registerModule(walletModule); registerModule(voteModule); registerModule(notesModule); registerModule(mapsModule); registerModule(tasksModule); registerModule(tripsModule); registerModule(calModule); registerModule(networkModule); registerModule(tubeModule); registerModule(inboxModule); registerModule(dataModule); registerModule(splatModule); registerModule(photosModule); registerModule(socialsModule); // registerModule(docsModule); // placeholder — not yet an rApp // registerModule(designModule); // placeholder — not yet an rApp registerModule(scheduleModule); registerModule(meetsModule); // ── Config ── const PORT = Number(process.env.PORT) || 3000; const INTERNAL_API_KEY = process.env.INTERNAL_API_KEY || ""; const DIST_DIR = resolve(import.meta.dir, "../dist"); // ── Hono app ── const app = new Hono(); // CORS for API routes app.use("/api/*", cors()); // ── .well-known/webauthn (WebAuthn Related Origins) ── // Browsers enforce a 5 eTLD+1 limit. Only list domains where passkey // ceremonies happen directly. Must match encryptid/server.ts priority list. app.get("/.well-known/webauthn", (c) => { return c.json( { origins: [ "https://ridentity.online", // OIDC authorize + admin (eTLD+1 #1) "https://auth.ridentity.online", "https://rsocials.online", // Postiz ecosystem (eTLD+1 #2) "https://demo.rsocials.online", "https://socials.crypto-commons.org", // (eTLD+1 #3) "https://socials.p2pfoundation.net", // (eTLD+1 #4) "https://rwallet.online", // (eTLD+1 #5) ], }, 200, { "Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600", } ); }); // ── Analytics tracker (proxied from Umami — replaces rdata.online/collect.js) ── const UMAMI_URL = process.env.UMAMI_URL || "https://analytics.rspace.online"; app.get("/collect.js", async (c) => { try { const res = await fetch(`${UMAMI_URL}/script.js`, { signal: AbortSignal.timeout(5000) }); if (res.ok) { const script = await res.text(); return new Response(script, { headers: { "Content-Type": "application/javascript", "Cache-Control": "public, max-age=3600" }, }); } } catch {} return new Response("/* umami unavailable */", { headers: { "Content-Type": "application/javascript" }, }); }); // ── Serve generated files from /data/files/generated/ ── app.get("/data/files/generated/:filename", async (c) => { const filename = c.req.param("filename"); if (!filename || filename.includes("..") || filename.includes("/")) { return c.json({ error: "Invalid filename" }, 400); } const dir = resolve(process.env.FILES_DIR || "./data/files", "generated"); const filePath = resolve(dir, filename); const file = Bun.file(filePath); if (!(await file.exists())) return c.notFound(); const ext = filename.split(".").pop() || ""; const mimeMap: Record = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp" }; return new Response(file, { headers: { "Content-Type": mimeMap[ext] || "application/octet-stream", "Cache-Control": "public, max-age=86400" } }); }); // ── Link preview / unfurl API ── const linkPreviewCache = new Map(); app.get("/api/link-preview", async (c) => { const url = c.req.query("url"); if (!url) return c.json({ error: "Missing url parameter" }, 400); try { new URL(url); } catch { return c.json({ error: "Invalid URL" }, 400); } // Check cache (1 hour TTL) const cached = linkPreviewCache.get(url); if (cached && Date.now() - cached.fetchedAt < 3600_000) { return c.json(cached, 200, { "Cache-Control": "public, max-age=3600" }); } try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); const res = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0 (compatible; rSpace/1.0; +https://rspace.online)", "Accept": "text/html" }, signal: controller.signal, redirect: "follow", }); clearTimeout(timeout); if (!res.ok) return c.json({ error: "Fetch failed" }, 502); const contentType = res.headers.get("content-type") || ""; if (!contentType.includes("text/html")) { const domain = new URL(url).hostname.replace(/^www\./, ""); const result = { title: url, description: "", image: null, domain, fetchedAt: Date.now() }; linkPreviewCache.set(url, result); return c.json(result); } const html = await res.text(); const getMetaContent = (nameOrProp: string): string => { const re = new RegExp(`]*(?:name|property)=["']${nameOrProp}["'][^>]*content=["']([^"']*?)["']`, "i"); const re2 = new RegExp(`]*content=["']([^"']*?)["'][^>]*(?:name|property)=["']${nameOrProp}["']`, "i"); return re.exec(html)?.[1] || re2.exec(html)?.[1] || ""; }; const ogTitle = getMetaContent("og:title") || getMetaContent("twitter:title"); const titleMatch = html.match(/]*>([^<]*)<\/title>/i); const title = ogTitle || titleMatch?.[1]?.trim() || url; const description = getMetaContent("og:description") || getMetaContent("twitter:description") || getMetaContent("description"); let image = getMetaContent("og:image") || getMetaContent("twitter:image") || null; if (image && !image.startsWith("http")) { try { image = new URL(image, url).href; } catch { image = null; } } const domain = new URL(url).hostname.replace(/^www\./, ""); const result = { title, description, image, domain, fetchedAt: Date.now() }; linkPreviewCache.set(url, result); // Cap cache size if (linkPreviewCache.size > 500) { const oldest = [...linkPreviewCache.entries()].sort((a, b) => a[1].fetchedAt - b[1].fetchedAt); for (let i = 0; i < 100; i++) linkPreviewCache.delete(oldest[i][0]); } return c.json(result, 200, { "Cache-Control": "public, max-age=3600" }); } catch { return c.json({ error: "Failed to fetch URL" }, 502); } }); // ── Ecosystem manifest (self-declaration) ── // Serves rSpace's own manifest so other ecosystem apps can discover it app.get("/.well-known/rspace-manifest.json", (c) => { const modules = getModuleInfoList(); const shapes = modules.map((m) => ({ tagName: `folk-rapp`, name: m.name, description: m.description || "", defaults: { width: 500, height: 400 }, portDescriptors: [], eventDescriptors: [], config: { moduleId: m.id }, })); return c.json( { appId: "rspace", name: "rSpace", version: "1.0.0", icon: "🌐", description: "Local-first workspace for the r-ecosystem", homepage: "https://rspace.online", moduleUrl: "/assets/folk-rapp.js", color: "#6366f1", embeddingModes: ["trusted", "sandboxed"], shapes, minProtocolVersion: 1, }, 200, { "Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600", } ); }); // ── Ecosystem manifest proxy (CORS avoidance) ── // Fetches external app manifests server-side so canvas clients avoid CORS issues const ecosystemManifestCache = new Map(); app.get("/api/ecosystem/:appId/manifest", async (c) => { const appId = c.req.param("appId"); // Known ecosystem app origins const ECOSYSTEM_ORIGINS: Record = { rwallet: "https://rwallet.online", rvote: "https://rvote.online", rmaps: "https://rmaps.online", rsocials: "https://rsocials.online", }; const origin = ECOSYSTEM_ORIGINS[appId]; if (!origin) { return c.json({ error: "Unknown ecosystem app" }, 404); } // Check cache (1 hour TTL) const cached = ecosystemManifestCache.get(appId); if (cached && Date.now() - cached.fetchedAt < 3600_000) { return c.json(cached.data, 200, { "Cache-Control": "public, max-age=3600" }); } try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); const manifestUrl = `${origin}/.well-known/rspace-manifest.json`; const res = await fetch(manifestUrl, { headers: { "Accept": "application/json", "User-Agent": "rSpace-Ecosystem/1.0" }, signal: controller.signal, redirect: "error", // reject redirects — prevents allowlist bypass }); clearTimeout(timeout); if (!res.ok) return c.json({ error: "Failed to fetch manifest" }, 502); const data = await res.json(); // Resolve relative moduleUrl to absolute if (data.moduleUrl && !data.moduleUrl.startsWith("http")) { data.resolvedModuleUrl = `${origin}${data.moduleUrl.startsWith("/") ? "" : "/"}${data.moduleUrl}`; } else { data.resolvedModuleUrl = data.moduleUrl; } data.origin = origin; data.fetchedAt = Date.now(); ecosystemManifestCache.set(appId, { data, fetchedAt: Date.now() }); // Cap cache size if (ecosystemManifestCache.size > 50) { const oldest = [...ecosystemManifestCache.entries()].sort((a, b) => a[1].fetchedAt - b[1].fetchedAt); ecosystemManifestCache.delete(oldest[0][0]); } return c.json(data, 200, { "Cache-Control": "public, max-age=3600" }); } catch { return c.json({ error: "Failed to fetch manifest" }, 502); } }); // ── Ecosystem module proxy (CORS avoidance for JS modules) ── app.get("/api/ecosystem/:appId/module", async (c) => { const appId = c.req.param("appId"); // Fetch manifest first to get module URL const cached = ecosystemManifestCache.get(appId); if (!cached) { return c.json({ error: "Fetch manifest first via /api/ecosystem/:appId/manifest" }, 400); } const manifest = cached.data as Record; const moduleUrl = (manifest.resolvedModuleUrl || manifest.moduleUrl) as string; if (!moduleUrl) return c.json({ error: "No moduleUrl in manifest" }, 400); try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10_000); const res = await fetch(moduleUrl, { headers: { "Accept": "application/javascript", "User-Agent": "rSpace-Ecosystem/1.0" }, signal: controller.signal, redirect: "error", // reject redirects — prevents allowlist bypass }); clearTimeout(timeout); if (!res.ok) return c.json({ error: "Failed to fetch module" }, 502); const js = await res.text(); return new Response(js, { headers: { "Content-Type": "application/javascript", "Cache-Control": "public, max-age=86400", "Access-Control-Allow-Origin": "*", }, }); } catch { return c.json({ error: "Failed to fetch module" }, 502); } }); // ── Space registry API ── app.route("/api/spaces", spaces); // ── Backup API (encrypted blob storage) ── app.route("/api/backup", backupRouter); // ── OAuth API (Notion, Google integrations) ── app.route("/api/oauth", oauthRouter); // ── Notifications API ── app.route("/api/notifications", notificationRouter); // ── MI — AI assistant endpoints ── app.route("/api/mi", miRoutes); // ── Existing /api/communities/* routes (backward compatible) ── /** Resolve a community slug to SpaceAuthConfig for the SDK guard */ async function getSpaceConfig(slug: string): Promise { let doc = getDocumentData(slug); if (!doc) { await loadCommunity(slug); doc = getDocumentData(slug); } if (!doc) return null; return { spaceSlug: slug, visibility: (doc.meta.visibility || "public") as SpaceVisibility, ownerDID: doc.meta.ownerDID || undefined, app: "rspace", }; } // Demo reset rate limiter let lastDemoReset = 0; const DEMO_RESET_COOLDOWN = 5 * 60 * 1000; // POST /api/communities — create community (deprecated, use POST /api/spaces) app.post("/api/communities", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required to create a community" }, 401); let claims: EncryptIDClaims; try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid or expired authentication token" }, 401); } const body = await c.req.json<{ name?: string; slug?: string; visibility?: SpaceVisibility }>(); const { name, slug, visibility = "private" } = body; const validVisibilities: SpaceVisibility[] = ["public", "permissioned", "private"]; if (visibility && !validVisibilities.includes(visibility)) return c.json({ error: "Invalid visibility" }, 400); const result = await createSpace({ name: name || "", slug: slug || "", ownerDID: claims.sub, visibility, source: 'api', }); if (!result.ok) return c.json({ error: result.error }, result.status); c.header("Deprecation", "true"); c.header("Link", "; rel=\"successor-version\""); return c.json({ url: `https://${result.slug}.rspace.online`, slug: result.slug, name: result.name, visibility: result.visibility, ownerDID: result.ownerDID }, 201); }); // POST /api/internal/provision — auth-free, called by rSpace Registry app.post("/api/internal/provision", async (c) => { const body = await c.req.json<{ space?: string; description?: string; public?: boolean }>(); const space = body.space?.trim(); if (!space) return c.json({ error: "Missing space name" }, 400); if (await communityExists(space)) { return c.json({ status: "exists", slug: space }); } const result = await createSpace({ name: space.charAt(0).toUpperCase() + space.slice(1), slug: space, ownerDID: `did:system:${space}`, visibility: "public", source: 'internal', }); if (!result.ok) return c.json({ error: result.error }, result.status); return c.json({ status: "created", slug: space }, 201); }); // POST /api/communities/demo/reset app.post("/api/communities/demo/reset", async (c) => { const now = Date.now(); if (now - lastDemoReset < DEMO_RESET_COOLDOWN) { const remaining = Math.ceil((DEMO_RESET_COOLDOWN - (now - lastDemoReset)) / 1000); return c.json({ error: `Demo reset on cooldown. Try again in ${remaining}s` }, 429); } lastDemoReset = now; await loadCommunity("demo"); clearShapes("demo"); await ensureDemoCommunity(); broadcastAutomergeSync("demo"); broadcastJsonSnapshot("demo"); return c.json({ ok: true, message: "Demo community reset to seed data" }); }); // Campaign demo reset removed — campaign is now at /:space/rsocials/campaign // GET /api/communities/:slug/shapes app.get("/api/communities/:slug/shapes", async (c) => { const slug = c.req.param("slug"); const token = extractToken(c.req.raw.headers); const access = await evaluateSpaceAccess(slug, token, "GET", { getSpaceConfig }); if (!access.allowed) return c.json({ error: access.reason }, access.claims ? 403 : 401); await loadCommunity(slug); const data = getDocumentData(slug); if (!data) return c.json({ error: "Community not found" }, 404); return c.json({ shapes: data.shapes || {} }); }); // POST /api/communities/:slug/shapes app.post("/api/communities/:slug/shapes", async (c) => { const slug = c.req.param("slug"); const internalKey = c.req.header("X-Internal-Key"); const isInternalCall = INTERNAL_API_KEY && internalKey === INTERNAL_API_KEY; if (!isInternalCall) { const token = extractToken(c.req.raw.headers); const access = await evaluateSpaceAccess(slug, token, "POST", { getSpaceConfig }); if (!access.allowed) return c.json({ error: access.reason }, access.claims ? 403 : 401); if (access.readOnly) return c.json({ error: "Write access required to add shapes" }, 403); } await loadCommunity(slug); const data = getDocumentData(slug); if (!data) return c.json({ error: "Community not found" }, 404); const body = await c.req.json<{ shapes?: Record[] }>(); if (!body.shapes || !Array.isArray(body.shapes) || body.shapes.length === 0) { return c.json({ error: "shapes array is required and must not be empty" }, 400); } const ids = addShapes(slug, body.shapes); broadcastAutomergeSync(slug); broadcastJsonSnapshot(slug); return c.json({ ok: true, ids }, 201); }); // PATCH /api/communities/:slug/shapes/:shapeId app.patch("/api/communities/:slug/shapes/:shapeId", async (c) => { const slug = c.req.param("slug"); const shapeId = c.req.param("shapeId"); const internalKey = c.req.header("X-Internal-Key"); const isInternalCall = INTERNAL_API_KEY && internalKey === INTERNAL_API_KEY; if (!isInternalCall) { const token = extractToken(c.req.raw.headers); const access = await evaluateSpaceAccess(slug, token, "PATCH", { getSpaceConfig }); if (!access.allowed) return c.json({ error: access.reason }, access.claims ? 403 : 401); } await loadCommunity(slug); const body = await c.req.json>(); const updated = updateShapeFields(slug, shapeId, body); if (!updated) return c.json({ error: "Shape not found" }, 404); broadcastAutomergeSync(slug); broadcastJsonSnapshot(slug); return c.json({ ok: true }); }); // GET /api/communities/:slug — community info app.get("/api/communities/:slug", async (c) => { const slug = c.req.param("slug"); const token = extractToken(c.req.raw.headers); const access = await evaluateSpaceAccess(slug, token, "GET", { getSpaceConfig }); if (!access.allowed) return c.json({ error: access.reason }, access.claims ? 403 : 401); let data = getDocumentData(slug); if (!data) { await loadCommunity(slug); data = getDocumentData(slug); } if (!data) return c.json({ error: "Community not found" }, 404); return c.json({ meta: data.meta, readOnly: access.readOnly }); }); // ── Module info API (for app switcher) ── app.get("/api/modules", (c) => { return c.json({ modules: getModuleInfoList() }); }); // ── Module landing HTML API (for info popover) ── app.get("/api/modules/:moduleId/landing", (c) => { const moduleId = c.req.param("moduleId"); const mod = getModule(moduleId); if (!mod) return c.json({ error: "Module not found" }, 404); const html = mod.landingPage ? mod.landingPage() : `

${mod.description || "No description available."}

`; return c.json({ html }); }); // ── x402 test endpoint (no auth, payment-gated only) ── import { setupX402FromEnv } from "../shared/x402/hono-middleware"; const x402Test = setupX402FromEnv({ description: "x402 test endpoint", resource: "/api/x402-test" }); app.post("/api/x402-test", async (c) => { if (x402Test) { const result = await new Promise((resolve) => { x402Test(c, async () => { resolve(null); }).then((res) => { if (res instanceof Response) resolve(res); }); }); if (result) return result; } return c.json({ ok: true, message: "Payment received!", timestamp: new Date().toISOString() }); }); // ── Creative tools API endpoints ── const FAL_KEY = process.env.FAL_KEY || ""; const GEMINI_API_KEY = process.env.GEMINI_API_KEY || ""; const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434"; // ── Image helpers ── /** Read a /data/files/generated/... path from disk → base64 */ async function readFileAsBase64(serverPath: string): Promise { const filename = serverPath.split("/").pop(); if (!filename) throw new Error("Invalid path"); const dir = resolve(process.env.FILES_DIR || "./data/files", "generated"); const file = Bun.file(resolve(dir, filename)); if (!(await file.exists())) throw new Error("File not found: " + serverPath); const buf = await file.arrayBuffer(); return Buffer.from(buf).toString("base64"); } /** Save a data:image/... URL to disk → return server-relative URL */ async function saveDataUrlToDisk(dataUrl: string, prefix: string): Promise { const match = dataUrl.match(/^data:image\/(\w+);base64,(.+)$/); if (!match) throw new Error("Invalid data URL"); const ext = match[1] === "jpeg" ? "jpg" : match[1]; const b64 = match[2]; const filename = `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.${ext}`; const dir = resolve(process.env.FILES_DIR || "./data/files", "generated"); await Bun.write(resolve(dir, filename), Buffer.from(b64, "base64")); return `/data/files/generated/${filename}`; } // Image generation via fal.ai Flux Pro app.post("/api/image-gen", async (c) => { if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); const { prompt, style } = await c.req.json(); if (!prompt) return c.json({ error: "prompt required" }, 400); const stylePrompts: Record = { illustration: "digital illustration style, ", photorealistic: "photorealistic, high detail, ", painting: "oil painting style, artistic, ", sketch: "pencil sketch style, hand-drawn, ", "punk-zine": "punk zine aesthetic, cut-and-paste collage, bold contrast, ", }; const styledPrompt = (stylePrompts[style] || "") + prompt; const res = await fetch("https://fal.run/fal-ai/flux-pro/v1.1", { method: "POST", headers: { Authorization: `Key ${FAL_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ prompt: styledPrompt, image_size: "landscape_4_3", num_images: 1, safety_tolerance: "2", }), }); if (!res.ok) { const err = await res.text(); console.error("[image-gen] fal.ai error:", err); return c.json({ error: "Image generation failed" }, 502); } const data = await res.json(); const imageUrl = data.images?.[0]?.url || data.output?.url; if (!imageUrl) return c.json({ error: "No image returned" }, 502); return c.json({ url: imageUrl, image_url: imageUrl }); }); // Upload image (data URL → disk) app.post("/api/image-upload", async (c) => { const { image } = await c.req.json(); if (!image) return c.json({ error: "image required" }, 400); try { const url = await saveDataUrlToDisk(image, "upload"); return c.json({ url }); } catch (e: any) { console.error("[image-upload]", e.message); return c.json({ error: e.message }, 400); } }); // Analyze style from reference images via Gemini vision app.post("/api/image-gen/analyze-style", async (c) => { if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503); const { images, context } = await c.req.json(); if (!images?.length) return c.json({ error: "images required" }, 400); const { GoogleGenerativeAI } = await import("@google/generative-ai"); const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash", generationConfig: { responseMimeType: "application/json" }, }); // Build multimodal parts const parts: any[] = []; for (const img of images) { let b64: string, mimeType: string; if (img.startsWith("data:")) { const match = img.match(/^data:(image\/\w+);base64,(.+)$/); if (!match) continue; mimeType = match[1]; b64 = match[2]; } else { // Server path b64 = await readFileAsBase64(img); mimeType = img.endsWith(".png") ? "image/png" : "image/jpeg"; } parts.push({ inlineData: { data: b64, mimeType } }); } parts.push({ text: `Analyze the visual style of these reference images. ${context ? `Context: ${context}` : ""} Return JSON with these fields: { "style_description": "A detailed paragraph describing the overall visual style", "color_palette": ["#hex1", "#hex2", ...up to 6 dominant colors], "style_keywords": ["keyword1", "keyword2", ...up to 8 keywords], "brand_prompt_prefix": "A concise prompt prefix (under 50 words) that can be prepended to any image generation prompt to reproduce this style" } Focus on: color palette, textures, composition patterns, mood, typography style, illustration approach.`, }); try { const result = await model.generateContent({ contents: [{ role: "user", parts }] }); const text = result.response.text(); const parsed = JSON.parse(text); return c.json(parsed); } catch (e: any) { console.error("[analyze-style] error:", e.message); return c.json({ error: "Style analysis failed" }, 502); } }); // img2img generation via fal.ai or Gemini app.post("/api/image-gen/img2img", async (c) => { const { prompt, source_image, reference_images, provider = "fal", strength = 0.85, style, model: modelOverride } = await c.req.json(); if (!prompt) return c.json({ error: "prompt required" }, 400); if (!source_image) return c.json({ error: "source_image required" }, 400); const fullPrompt = (style ? style + " " : "") + prompt; if (provider === "fal") { if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); try { // Save source to disk if data URL let sourceUrl: string; if (source_image.startsWith("data:")) { const path = await saveDataUrlToDisk(source_image, "img2img-src"); sourceUrl = `https://rspace.online${path}`; } else if (source_image.startsWith("/")) { sourceUrl = `https://rspace.online${source_image}`; } else { sourceUrl = source_image; } let falEndpoint: string; let falBody: any; if (reference_images?.length) { // Multi-reference edit mode const imageUrls: string[] = [sourceUrl]; for (const ref of reference_images) { if (ref.startsWith("data:")) { const path = await saveDataUrlToDisk(ref, "img2img-ref"); imageUrls.push(`https://rspace.online${path}`); } else if (ref.startsWith("/")) { imageUrls.push(`https://rspace.online${ref}`); } else { imageUrls.push(ref); } } falEndpoint = "https://fal.run/fal-ai/flux-2/edit"; falBody = { image_urls: imageUrls, prompt: fullPrompt }; } else { // Single source img2img falEndpoint = "https://fal.run/fal-ai/flux/dev/image-to-image"; falBody = { image_url: sourceUrl, prompt: fullPrompt, strength, num_inference_steps: 28 }; } const res = await fetch(falEndpoint, { method: "POST", headers: { Authorization: `Key ${FAL_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify(falBody), }); if (!res.ok) { const err = await res.text(); console.error("[img2img] fal.ai error:", err); return c.json({ error: "img2img generation failed" }, 502); } const data = await res.json(); const resultUrl = data.images?.[0]?.url || data.output?.url; if (!resultUrl) return c.json({ error: "No image returned" }, 502); // Download and save locally const imgRes = await fetch(resultUrl); if (!imgRes.ok) return c.json({ url: resultUrl, image_url: resultUrl }); const imgBuf = await imgRes.arrayBuffer(); const filename = `img2img-${Date.now()}.png`; const dir = resolve(process.env.FILES_DIR || "./data/files", "generated"); await Bun.write(resolve(dir, filename), Buffer.from(imgBuf)); const localUrl = `/data/files/generated/${filename}`; return c.json({ url: localUrl, image_url: localUrl }); } catch (e: any) { console.error("[img2img] fal error:", e.message); return c.json({ error: "img2img generation failed" }, 502); } } if (provider === "gemini") { if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503); const { GoogleGenAI } = await import("@google/genai"); const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY }); const geminiModelMap: Record = { "gemini-flash": "gemini-2.5-flash-image", "gemini-pro": "gemini-3-pro-image-preview", "gemini-flash-2": "gemini-3.1-flash-image-preview", }; const modelName = geminiModelMap[modelOverride || "gemini-flash"] || "gemini-2.5-flash-image"; try { // Build multimodal parts const parts: any[] = [{ text: fullPrompt }]; // Add source image const addImage = async (img: string) => { let b64: string, mimeType: string; if (img.startsWith("data:")) { const match = img.match(/^data:(image\/\w+);base64,(.+)$/); if (!match) return; mimeType = match[1]; b64 = match[2]; } else { b64 = await readFileAsBase64(img); mimeType = img.endsWith(".png") ? "image/png" : "image/jpeg"; } parts.push({ inlineData: { data: b64, mimeType } }); }; await addImage(source_image); if (reference_images?.length) { for (const ref of reference_images) await addImage(ref); } const result = await ai.models.generateContent({ model: modelName, contents: [{ role: "user", parts }], config: { responseModalities: ["Text", "Image"] }, }); const resParts = result.candidates?.[0]?.content?.parts || []; for (const part of resParts) { if ((part as any).inlineData) { const { data: b64, mimeType } = (part as any).inlineData; const ext = mimeType?.includes("png") ? "png" : "jpg"; const filename = `img2img-gemini-${Date.now()}.${ext}`; const dir = resolve(process.env.FILES_DIR || "./data/files", "generated"); await Bun.write(resolve(dir, filename), Buffer.from(b64, "base64")); const url = `/data/files/generated/${filename}`; return c.json({ url, image_url: url }); } } return c.json({ error: "No image in Gemini response" }, 502); } catch (e: any) { console.error("[img2img] Gemini error:", e.message); return c.json({ error: "Gemini img2img failed" }, 502); } } return c.json({ error: `Unknown provider: ${provider}` }, 400); }); // Text-to-video via fal.ai WAN 2.1 app.post("/api/video-gen/t2v", async (c) => { if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); const { prompt, duration } = await c.req.json(); if (!prompt) return c.json({ error: "prompt required" }, 400); const res = await fetch("https://fal.run/fal-ai/wan/v2.1", { method: "POST", headers: { Authorization: `Key ${FAL_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ prompt, num_frames: duration === "5s" ? 81 : 49, resolution: "480p", }), }); if (!res.ok) { const err = await res.text(); console.error("[video-gen/t2v] fal.ai error:", err); return c.json({ error: "Video generation failed" }, 502); } const data = await res.json(); const videoUrl = data.video?.url || data.output?.url; if (!videoUrl) return c.json({ error: "No video returned" }, 502); return c.json({ url: videoUrl, video_url: videoUrl }); }); // Image-to-video via fal.ai Kling app.post("/api/video-gen/i2v", async (c) => { if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); const { image, prompt, duration } = await c.req.json(); if (!image) return c.json({ error: "image required" }, 400); const res = await fetch("https://fal.run/fal-ai/kling-video/v1/standard/image-to-video", { method: "POST", headers: { Authorization: `Key ${FAL_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ image_url: image, prompt: prompt || "", duration: duration === "5s" ? "5" : "5", }), }); if (!res.ok) { const err = await res.text(); console.error("[video-gen/i2v] fal.ai error:", err); return c.json({ error: "Video generation failed" }, 502); } const data = await res.json(); const videoUrl = data.video?.url || data.output?.url; if (!videoUrl) return c.json({ error: "No video returned" }, 502); return c.json({ url: videoUrl, video_url: videoUrl }); }); // Image-to-3D via fal.ai Trellis app.post("/api/3d-gen", async (c) => { if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); const { image_url } = await c.req.json(); if (!image_url) return c.json({ error: "image_url required" }, 400); try { const res = await fetch("https://fal.run/fal-ai/trellis", { method: "POST", headers: { Authorization: `Key ${FAL_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ image_url }), }); if (!res.ok) { const err = await res.text(); console.error("[3d-gen] fal.ai error:", err); return c.json({ error: "3D generation failed" }, 502); } const data = await res.json(); // Trellis returns glb_url and/or model_mesh — download the GLB const modelUrl = data.glb_url || data.model_mesh?.url || data.output?.url; if (!modelUrl) return c.json({ error: "No 3D model returned" }, 502); // Download the model file const modelRes = await fetch(modelUrl); if (!modelRes.ok) return c.json({ error: "Failed to download model" }, 502); const modelBuf = await modelRes.arrayBuffer(); const ext = modelUrl.includes(".ply") ? "ply" : "glb"; const filename = `splat-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.${ext}`; const dir = resolve(process.env.FILES_DIR || "./data/files", "generated"); await Bun.write(resolve(dir, filename), modelBuf); return c.json({ url: `/data/files/generated/${filename}`, format: ext }); } catch (e: any) { console.error("[3d-gen] error:", e.message); return c.json({ error: "3D generation failed" }, 502); } }); // Blender 3D generation via LLM + RunPod const RUNPOD_API_KEY = process.env.RUNPOD_API_KEY || ""; app.post("/api/blender-gen", async (c) => { if (!RUNPOD_API_KEY) return c.json({ error: "RUNPOD_API_KEY not configured" }, 503); const { prompt } = await c.req.json(); if (!prompt) return c.json({ error: "prompt required" }, 400); // Step 1: Generate Blender Python script via LLM const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434"; let script = ""; try { const llmRes = await fetch(`${OLLAMA_URL}/api/generate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: process.env.OLLAMA_MODEL || "llama3.1", prompt: `Generate a Blender Python script that creates: ${prompt}\n\nThe script should:\n- Import bpy\n- Clear the default scene\n- Create the described objects with materials\n- Set up basic lighting and camera\n- Render to /tmp/render.png at 1024x1024\n\nOnly output the Python code, no explanations.`, stream: false, }), }); if (llmRes.ok) { const llmData = await llmRes.json(); script = llmData.response || ""; // Extract code block if wrapped in markdown const codeMatch = script.match(/```python\n([\s\S]*?)```/); if (codeMatch) script = codeMatch[1]; } } catch (e) { console.error("[blender-gen] LLM error:", e); } if (!script) { return c.json({ error: "Failed to generate Blender script" }, 502); } // Step 2: Execute on RunPod (headless Blender) try { const runpodRes = await fetch("https://api.runpod.ai/v2/blender/runsync", { method: "POST", headers: { Authorization: `Bearer ${RUNPOD_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ input: { script, render: true, }, }), }); if (!runpodRes.ok) { // Return just the script if RunPod fails return c.json({ script, error_detail: "RunPod execution failed" }); } const runpodData = await runpodRes.json(); return c.json({ render_url: runpodData.output?.render_url || null, script, blend_url: runpodData.output?.blend_url || null, }); } catch (e) { // Return the script even if RunPod is unavailable return c.json({ script, error_detail: "RunPod unavailable" }); } }); // KiCAD PCB design — REST-to-MCP bridge const KICAD_MCP_URL = process.env.KICAD_MCP_URL || "http://localhost:3001"; app.post("/api/kicad/:action", async (c) => { const action = c.req.param("action"); const body = await c.req.json(); const validActions = [ "create_project", "add_schematic_component", "add_schematic_connection", "export_svg", "run_drc", "export_gerber", "export_bom", "export_pdf", "search_symbols", "search_footprints", "place_component", ]; if (!validActions.includes(action)) { return c.json({ error: `Unknown action: ${action}` }, 400); } try { const mcpRes = await fetch(`${KICAD_MCP_URL}/call-tool`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: action, arguments: body, }), }); if (!mcpRes.ok) { const err = await mcpRes.text(); console.error(`[kicad/${action}] MCP error:`, err); return c.json({ error: `KiCAD action failed: ${action}` }, 502); } const data = await mcpRes.json(); return c.json(data); } catch (e) { console.error(`[kicad/${action}] Connection error:`, e); return c.json({ error: "KiCAD MCP server not available" }, 503); } }); // FreeCAD parametric CAD — REST-to-MCP bridge const FREECAD_MCP_URL = process.env.FREECAD_MCP_URL || "http://localhost:3002"; app.post("/api/freecad/:action", async (c) => { const action = c.req.param("action"); const body = await c.req.json(); const validActions = [ "generate", "export_step", "export_stl", "update_parameters", ]; if (!validActions.includes(action)) { return c.json({ error: `Unknown action: ${action}` }, 400); } try { const mcpRes = await fetch(`${FREECAD_MCP_URL}/call-tool`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: action, arguments: body, }), }); if (!mcpRes.ok) { const err = await mcpRes.text(); console.error(`[freecad/${action}] MCP error:`, err); return c.json({ error: `FreeCAD action failed: ${action}` }, 502); } const data = await mcpRes.json(); return c.json(data); } catch (e) { console.error(`[freecad/${action}] Connection error:`, e); return c.json({ error: "FreeCAD MCP server not available" }, 503); } }); // ── Multi-provider prompt endpoint ── const GEMINI_MODELS: Record = { "gemini-flash": "gemini-2.5-flash", "gemini-pro": "gemini-2.5-pro", }; const OLLAMA_MODELS: Record = { "llama3.2": "llama3.2:3b", "llama3.1": "llama3.1:8b", "qwen2.5-coder": "qwen2.5-coder:7b", "mistral-small": "mistral-small:24b", }; app.post("/api/prompt", async (c) => { const { messages, model = "gemini-flash" } = await c.req.json(); if (!messages?.length) return c.json({ error: "messages required" }, 400); // Determine provider if (GEMINI_MODELS[model]) { if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503); const { GoogleGenerativeAI } = await import("@google/generative-ai"); const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); const geminiModel = genAI.getGenerativeModel({ model: GEMINI_MODELS[model] }); // Convert chat messages to Gemini contents format (with optional images) const contents = messages.map((m: { role: string; content: string; images?: string[] }) => { const parts: any[] = [{ text: m.content }]; if (m.images?.length) { for (const img of m.images) { const match = img.match(/^data:(image\/\w+);base64,(.+)$/); if (match) { parts.push({ inlineData: { data: match[2], mimeType: match[1] } }); } } } return { role: m.role === "assistant" ? "model" : "user", parts }; }); try { const result = await geminiModel.generateContent({ contents }); const text = result.response.text(); return c.json({ content: text }); } catch (e: any) { console.error("[prompt] Gemini error:", e.message); return c.json({ error: "Gemini request failed" }, 502); } } if (OLLAMA_MODELS[model]) { try { const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: OLLAMA_MODELS[model], messages: messages.map((m: { role: string; content: string }) => ({ role: m.role, content: m.content, })), stream: false, }), }); if (!ollamaRes.ok) { const err = await ollamaRes.text(); console.error("[prompt] Ollama error:", err); return c.json({ error: "Ollama request failed" }, 502); } const data = await ollamaRes.json(); return c.json({ content: data.message?.content || "" }); } catch (e: any) { console.error("[prompt] Ollama unreachable:", e.message); return c.json({ error: "Ollama unreachable" }, 503); } } return c.json({ error: `Unknown model: ${model}` }, 400); }); // ── Gemini image generation ── app.post("/api/gemini/image", async (c) => { if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503); const { prompt, style, aspect_ratio, reference_images } = await c.req.json(); if (!prompt) return c.json({ error: "prompt required" }, 400); const styleHints: Record = { photorealistic: "photorealistic, high detail, natural lighting, ", illustration: "digital illustration, clean lines, vibrant colors, ", painting: "oil painting style, brushstrokes visible, painterly, ", sketch: "pencil sketch, hand-drawn, line art, ", "punk-zine": "punk zine aesthetic, xerox texture, high contrast, DIY, rough edges, ", collage: "cut-and-paste collage, mixed media, layered paper textures, ", vintage: "vintage aesthetic, retro colors, aged paper texture, ", minimalist: "minimalist design, simple shapes, limited color palette, ", }; const enhancedPrompt = (styleHints[style] || "") + prompt; const { GoogleGenAI } = await import("@google/genai"); const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY }); // Build multimodal contents if reference images provided let contentsPayload: any = enhancedPrompt; if (reference_images?.length) { const parts: any[] = [{ text: enhancedPrompt + "\n\nUse these reference images as style guidance:" }]; for (const ref of reference_images) { let b64: string, mimeType: string; if (ref.startsWith("data:")) { const match = ref.match(/^data:(image\/\w+);base64,(.+)$/); if (!match) continue; mimeType = match[1]; b64 = match[2]; } else { b64 = await readFileAsBase64(ref); mimeType = ref.endsWith(".png") ? "image/png" : "image/jpeg"; } parts.push({ inlineData: { data: b64, mimeType } }); } contentsPayload = [{ role: "user", parts }]; } const models = ["gemini-2.5-flash-image", "imagen-3.0-generate-002"]; for (const modelName of models) { try { if (modelName.startsWith("gemini")) { const result = await ai.models.generateContent({ model: modelName, contents: contentsPayload, config: { responseModalities: ["Text", "Image"] }, }); const parts = result.candidates?.[0]?.content?.parts || []; for (const part of parts) { if ((part as any).inlineData) { const { data: b64, mimeType } = (part as any).inlineData; const ext = mimeType?.includes("png") ? "png" : "jpg"; const filename = `gemini-${Date.now()}.${ext}`; const dir = resolve(process.env.FILES_DIR || "./data/files", "generated"); await Bun.write(resolve(dir, filename), Buffer.from(b64, "base64")); const url = `/data/files/generated/${filename}`; return c.json({ url, image_url: url }); } } } else { // Imagen API const result = await ai.models.generateImages({ model: modelName, prompt: enhancedPrompt, config: { numberOfImages: 1, aspectRatio: aspect_ratio || "3:4", }, }); const img = (result as any).generatedImages?.[0]; if (img?.image?.imageBytes) { const filename = `imagen-${Date.now()}.png`; const dir = resolve(process.env.FILES_DIR || "./data/files", "generated"); await Bun.write(resolve(dir, filename), Buffer.from(img.image.imageBytes, "base64")); const url = `/data/files/generated/${filename}`; return c.json({ url, image_url: url }); } } } catch (e: any) { console.error(`[gemini/image] ${modelName} error:`, e.message); continue; } } return c.json({ error: "All Gemini image models failed" }, 502); }); // ── Zine generation endpoints ── const ZINE_STYLES: Record = { "punk-zine": "Xerox texture, high contrast B&W, DIY collage, hand-drawn typography", mycelial: "Organic networks, spore patterns, earth tones, fungal textures, interconnected webs", minimal: "Clean lines, white space, modern sans-serif, subtle gradients", collage: "Layered imagery, mixed media textures, vintage photographs", retro: "1970s aesthetic, earth tones, groovy typography, halftone patterns", academic: "Diagram-heavy, annotated illustrations, infographic elements", }; const ZINE_TONES: Record = { rebellious: "Defiant, anti-establishment, punk energy", regenerative: "Hopeful, nature-inspired, healing, systems-thinking", playful: "Fun, whimsical, approachable", informative: "Educational, clear, accessible", poetic: "Lyrical, metaphorical, evocative", }; app.post("/api/zine/outline", async (c) => { if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503); const { topic, style = "punk-zine", tone = "informative", pageCount = 8 } = await c.req.json(); if (!topic) return c.json({ error: "topic required" }, 400); const { GoogleGenerativeAI } = await import("@google/generative-ai"); const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); const model = genAI.getGenerativeModel({ model: "gemini-2.5-pro" }); const systemPrompt = `You are a zine designer creating a ${pageCount}-page MycroZine. Style: ${ZINE_STYLES[style] || style} Tone: ${ZINE_TONES[tone] || tone} Create a structured outline for a zine about: "${topic}" Each page has sections that will be generated independently. Return ONLY valid JSON (no markdown fences): { "pages": [ { "pageNumber": 1, "type": "cover", "title": "...", "sections": [ { "id": "headline", "type": "text", "content": "Main headline text" }, { "id": "subhead", "type": "text", "content": "Subtitle or tagline" }, { "id": "hero", "type": "image", "imagePrompt": "Detailed image description for cover art" } ], "hashtags": ["#tag1"] }, { "pageNumber": 2, "type": "content", "title": "...", "sections": [ { "id": "heading", "type": "text", "content": "Section heading" }, { "id": "body", "type": "text", "content": "Main body text (2-3 paragraphs)" }, { "id": "pullquote", "type": "text", "content": "A punchy pull quote" }, { "id": "visual", "type": "image", "imagePrompt": "Detailed description for page illustration" } ], "hashtags": ["#tag"] } ] } Rules: - Page 1 is always "cover" type with headline, subhead, hero image - Pages 2-${pageCount - 1} are "content" with heading, body, pullquote, visual - Page ${pageCount} is "cta" (call-to-action) with heading, body, action, visual - Each image prompt should be highly specific, matching the ${style} style - Text content should match the ${tone} tone - Make body text substantive (2-3 short paragraphs each)`; try { const result = await model.generateContent(systemPrompt); const text = result.response.text(); // Parse JSON (strip markdown fences if present) const jsonStr = text.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim(); const outline = JSON.parse(jsonStr); return c.json(outline); } catch (e: any) { console.error("[zine/outline] error:", e.message); return c.json({ error: "Failed to generate outline" }, 502); } }); app.post("/api/zine/page", async (c) => { if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503); const { outline, style = "punk-zine", tone = "informative" } = await c.req.json(); if (!outline?.pageNumber) return c.json({ error: "outline required with pageNumber" }, 400); // Find all image sections and generate them const imageSections = (outline.sections || []).filter((s: any) => s.type === "image"); const generatedImages: Record = {}; const { GoogleGenAI } = await import("@google/genai"); const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY }); for (const section of imageSections) { const styleDesc = ZINE_STYLES[style] || style; const toneDesc = ZINE_TONES[tone] || tone; const enhancedPrompt = `${styleDesc} style, ${toneDesc} mood. ${section.imagePrompt}. For a zine page about "${outline.title}".`; try { const result = await ai.models.generateContent({ model: "gemini-2.5-flash-image", contents: enhancedPrompt, config: { responseModalities: ["Text", "Image"] }, }); const parts = result.candidates?.[0]?.content?.parts || []; for (const part of parts) { if ((part as any).inlineData) { const { data: b64, mimeType } = (part as any).inlineData; const ext = mimeType?.includes("png") ? "png" : "jpg"; const filename = `zine-p${outline.pageNumber}-${section.id}-${Date.now()}.${ext}`; const dir = resolve(process.env.FILES_DIR || "./data/files", "generated"); await Bun.write(resolve(dir, filename), Buffer.from(b64, "base64")); generatedImages[section.id] = `/data/files/generated/${filename}`; break; } } } catch (e: any) { console.error(`[zine/page] Image gen failed for ${section.id}:`, e.message); } } return c.json({ pageNumber: outline.pageNumber, images: generatedImages, outline, }); }); app.post("/api/zine/regenerate-section", async (c) => { if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503); const { section, pageTitle, style = "punk-zine", tone = "informative", feedback = "" } = await c.req.json(); if (!section?.id) return c.json({ error: "section with id required" }, 400); if (section.type === "image") { // Regenerate image with feedback const { GoogleGenAI } = await import("@google/genai"); const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY }); const styleDesc = ZINE_STYLES[style] || style; const toneDesc = ZINE_TONES[tone] || tone; const enhancedPrompt = `${styleDesc} style, ${toneDesc} mood. ${section.imagePrompt}. For a zine page about "${pageTitle}".${feedback ? ` User feedback: ${feedback}` : ""}`; try { const result = await ai.models.generateContent({ model: "gemini-2.5-flash-image", contents: enhancedPrompt, config: { responseModalities: ["Text", "Image"] }, }); const parts = result.candidates?.[0]?.content?.parts || []; for (const part of parts) { if ((part as any).inlineData) { const { data: b64, mimeType } = (part as any).inlineData; const ext = mimeType?.includes("png") ? "png" : "jpg"; const filename = `zine-regen-${section.id}-${Date.now()}.${ext}`; const dir = resolve(process.env.FILES_DIR || "./data/files", "generated"); await Bun.write(resolve(dir, filename), Buffer.from(b64, "base64")); return c.json({ sectionId: section.id, type: "image", url: `/data/files/generated/${filename}` }); } } return c.json({ error: "No image generated" }, 502); } catch (e: any) { console.error("[zine/regenerate-section] image error:", e.message); return c.json({ error: "Image regeneration failed" }, 502); } } if (section.type === "text") { // Regenerate text via Gemini const { GoogleGenerativeAI } = await import("@google/generative-ai"); const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" }); const toneDesc = ZINE_TONES[tone] || tone; const prompt = `Rewrite this zine text section. Section type: "${section.id}". Page title: "${pageTitle}". Tone: ${toneDesc}. Current text: "${section.content}" ${feedback ? `User feedback: ${feedback}` : "Make it punchier and more engaging."} Return ONLY the new text, no quotes or explanation.`; try { const result = await model.generateContent(prompt); return c.json({ sectionId: section.id, type: "text", content: result.response.text().trim() }); } catch (e: any) { console.error("[zine/regenerate-section] text error:", e.message); return c.json({ error: "Text regeneration failed" }, 502); } } return c.json({ error: `Unknown section type: ${section.type}` }, 400); }); // ── Auto-provision personal space ── app.post("/api/spaces/auto-provision", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims: EncryptIDClaims; try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid or expired token" }, 401); } const username = claims.username?.toLowerCase(); if (!username || !/^[a-z0-9][a-z0-9-]*$/.test(username)) { return c.json({ error: "Username not suitable for space slug" }, 400); } if (await communityExists(username)) { return c.json({ status: "exists", slug: username }); } const result = await createSpace({ name: `${claims.username}'s Space`, slug: username, ownerDID: claims.sub, visibility: "private", source: 'auto-provision', }); if (!result.ok) return c.json({ error: result.error }, result.status); return c.json({ status: "created", slug: username }, 201); }); // ── Inject space visibility into HTML responses ── // Replaces the default data-space-visibility="public" rendered by renderShell // with the actual visibility from the space config, so the client-side access gate // can block content for private spaces when no session exists. app.use("/:space/*", async (c, next) => { await next(); const ct = c.res.headers.get("content-type"); if (!ct?.includes("text/html")) return; const space = c.req.param("space"); if (!space || space === "api" || space.includes(".")) return; const config = await getSpaceConfig(space); const vis = config?.visibility || "public"; if (vis === "public") return; const html = await c.res.text(); c.res = new Response( html.replace( 'data-space-visibility="public"', `data-space-visibility="${vis}"`, ), { status: c.res.status, headers: c.res.headers }, ); }); // ── Template seeding route: /:space/:moduleId/template ── // Triggers template seeding for empty spaces, then redirects to the module page. app.get("/:space/:moduleId/template", async (c) => { const space = c.req.param("space"); const moduleId = c.req.param("moduleId"); if (!space || space === "api" || space.includes(".")) return c.notFound(); try { await loadCommunity(space); const seeded = seedTemplateShapes(space); if (seeded) { broadcastAutomergeSync(space); broadcastJsonSnapshot(space); } // Seed module-specific demo data (calendar events, notes, tasks, etc.) const mod = getModule(moduleId); if (mod?.seedTemplate) mod.seedTemplate(space); } catch (e) { console.error(`[Template] On-demand seed failed for "${space}":`, e); } return c.redirect(`/${space}/${moduleId}`, 302); }); // ── Empty-state detection for onboarding ── import type { RSpaceModule } from "../shared/module"; import { resolveDataSpace } from "../shared/scope-resolver"; function moduleHasData(space: string, mod: RSpaceModule): boolean { if (space === "demo") return true; // demo always has data if (!mod.docSchemas || mod.docSchemas.length === 0) return true; // no schemas = can't detect for (const schema of mod.docSchemas) { if (!schema.pattern.includes('{space}')) return true; // global module, always show const prefix = schema.pattern.replace('{space}', space).split(':').slice(0, 3).join(':'); if (syncServer.hasDocsWithPrefix(prefix)) return true; } return false; } // ── Mount module routes under /:space/:moduleId ── // Enforce enabledModules: if a space has an explicit list, only those modules route. // The 'rspace' (canvas) module is always allowed as the core module. for (const mod of getAllModules()) { app.use(`/:space/${mod.id}/*`, async (c, next) => { const space = c.req.param("space"); if (!space || space === "api" || space.includes(".")) return next(); // Check enabled modules (skip for core rspace module) const doc = getDocumentData(space); if (mod.id !== "rspace") { if (doc?.meta?.enabledModules && !doc.meta.enabledModules.includes(mod.id)) { return c.json({ error: "Module not enabled for this space" }, 404); } } // Resolve effective data space (global vs space-scoped) const overrides = doc?.meta?.moduleScopeOverrides ?? null; const effectiveSpace = resolveDataSpace(mod.id, space, overrides); c.set("effectiveSpace" as any, effectiveSpace); // Resolve caller's role for write-method blocking const method = c.req.method; if (!mod.publicWrite && (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE")) { const token = extractToken(c.req.raw.headers); let claims: EncryptIDClaims | null = null; if (token) { try { claims = await verifyEncryptIDToken(token); } catch {} } const resolved = await resolveCallerRole(space, claims); if (resolved) { c.set("spaceRole" as any, resolved.role); c.set("isOwner" as any, resolved.isOwner); if (resolved.role === "viewer") { return c.json({ error: "Write access required" }, 403); } } } return next(); }); // Onboarding: show landing page for empty modules (root page only) if (mod.id !== "rspace") { app.use(`/:space/${mod.id}`, async (c, next) => { const space = c.req.param("space"); if (!space || space === "demo" || space === "api" || space.includes(".")) return next(); if (c.req.method !== "GET") return next(); const path = c.req.path; const root = `/${space}/${mod.id}`; if (path !== root && path !== root + '/') return next(); if (!moduleHasData(space, mod)) { return c.html(renderOnboarding({ moduleId: mod.id, moduleName: mod.name, moduleIcon: mod.icon, moduleDescription: mod.description, spaceSlug: space, modules: getModuleInfoList(), landingHTML: mod.landingPage?.(), })); } return next(); }); } app.route(`/:space/${mod.id}`, mod.routes); // Auto-mount browsable output list pages if (mod.outputPaths) { for (const op of mod.outputPaths) { app.get(`/:space/${mod.id}/${op.path}`, (c) => { return c.html(renderOutputListPage( c.req.param("space"), mod, op, getModuleInfoList() )); }); } } } // ── Page routes ── // Landing page: rspace.online/ → server-rendered main landing app.get("/", (c) => { return c.html(renderMainLanding(getModuleInfoList())); }); // About/info page (full landing content) app.get("/about", async (c) => { const file = Bun.file(resolve(DIST_DIR, "index.html")); if (await file.exists()) { return new Response(file, { headers: { "Content-Type": "text/html" } }); } return c.text("rSpace", 200); }); // Create new space page app.get("/create-space", async (c) => { const file = Bun.file(resolve(DIST_DIR, "create-space.html")); if (await file.exists()) { return new Response(file, { headers: { "Content-Type": "text/html" } }); } return c.text("Create space", 200); }); // Legacy redirect app.get("/new", (c) => c.redirect("/create-space", 301)); // ── Admin dashboard & API ── const ADMIN_DIDS = (process.env.ADMIN_DIDS || "").split(",").filter(Boolean); function isAdminDID(did: string | undefined): boolean { return !!did && ADMIN_DIDS.includes(did); } // Serve admin HTML page app.get("/admin", async (c) => { const file = Bun.file(resolve(DIST_DIR, "admin.html")); if (await file.exists()) { return new Response(file, { headers: { "Content-Type": "text/html" } }); } return c.text("Admin", 200); }); // Admin data endpoint (GET) — returns spaces list + modules app.get("/admin-data", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims: EncryptIDClaims; try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } if (!isAdminDID(claims.sub)) return c.json({ error: "Admin access required" }, 403); const STORAGE_DIR = process.env.STORAGE_DIR || "./data/communities"; const slugs = await listCommunities(); const spacesList = []; for (const slug of slugs) { await loadCommunity(slug); const data = getDocumentData(slug); if (!data?.meta) continue; const shapes = data.shapes || {}; const members = data.members || {}; const shapeCount = Object.keys(shapes).length; const memberCount = Object.keys(members).length; let fileSizeBytes = 0; try { const { stat } = await import("node:fs/promises"); const s = await stat(`${STORAGE_DIR}/${slug}.automerge`); fileSizeBytes = s.size; } catch { try { const { stat } = await import("node:fs/promises"); const s = await stat(`${STORAGE_DIR}/${slug}.json`); fileSizeBytes = s.size; } catch { /* not on disk yet */ } } const shapeTypes: Record = {}; for (const shape of Object.values(shapes)) { const t = (shape as Record).type as string || "unknown"; shapeTypes[t] = (shapeTypes[t] || 0) + 1; } spacesList.push({ slug: data.meta.slug, name: data.meta.name, visibility: data.meta.visibility || "public", createdAt: data.meta.createdAt, ownerDID: data.meta.ownerDID, shapeCount, memberCount, fileSizeBytes, shapeTypes, }); } spacesList.sort((a, b) => { const da = a.createdAt ? new Date(a.createdAt).getTime() : 0; const db = b.createdAt ? new Date(b.createdAt).getTime() : 0; return db - da; }); return c.json({ spaces: spacesList, total: spacesList.length, modules: getModuleInfoList(), }); }); // Admin action endpoint (POST) — mutations (delete space, etc.) app.post("/admin-action", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims: EncryptIDClaims; try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } if (!isAdminDID(claims.sub)) return c.json({ error: "Admin access required" }, 403); const body = await c.req.json(); const { action, slug } = body; if (action === "delete-space" && slug) { await loadCommunity(slug); const data = getDocumentData(slug); if (!data) return c.json({ error: "Space not found" }, 404); const deleteCtx = { spaceSlug: slug, ownerDID: data.meta?.ownerDID ?? null, enabledModules: data.meta?.enabledModules || getAllModules().map(m => m.id), syncServer, }; for (const mod of getAllModules()) { if (mod.onSpaceDelete) { try { await mod.onSpaceDelete(deleteCtx); } catch (e) { console.error(`[Admin] Module ${mod.id} onSpaceDelete failed:`, e); } } } // Clean up EncryptID space_members try { await fetch(`https://auth.rspace.online/api/admin/spaces/${slug}/members`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` }, }); } catch (e) { console.error(`[Admin] Failed to clean EncryptID space_members for ${slug}:`, e); } await deleteCommunity(slug); return c.json({ ok: true, message: `Space "${slug}" deleted by admin` }); } return c.json({ error: "Unknown action" }, 400); }); // Space root: /:space → space dashboard app.get("/:space", (c) => { const space = c.req.param("space"); // Don't serve dashboard for static file paths if (space.includes(".")) return c.notFound(); return c.html(renderSpaceDashboard(space, getModuleInfoList())); }); // ── WebSocket types ── interface WSData { communitySlug: string; peerId: string; claims: EncryptIDClaims | null; readOnly: boolean; spaceRole: 'viewer' | 'member' | 'moderator' | 'admin' | null; mode: "automerge" | "json"; // Nest context: set when a folk-canvas shape connects to a nested space nestFrom?: string; // slug of the parent space that contains the nest nestPermissions?: NestPermissions; // effective permissions for this nested view nestFilter?: SpaceRefFilter; // shape filter applied to this nested view } // Track connected clients per community const communityClients = new Map>>(); // Track announced peer info per community: slug → serverPeerId → { clientPeerId, username, color } const peerAnnouncements = new Map>(); function generatePeerId(): string { return `peer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } function getClient(slug: string, peerId: string): ServerWebSocket | undefined { return communityClients.get(slug)?.get(peerId); } function broadcastJsonSnapshot(slug: string, excludePeerId?: string): void { const clients = communityClients.get(slug); if (!clients) return; const docData = getDocumentData(slug); if (!docData) return; const allShapes = docData.shapes || {}; // Pre-build the unfiltered message (most clients use this) let unfilteredMsg: string | null = null; for (const [clientPeerId, client] of clients) { if (client.data.mode === "json" && clientPeerId !== excludePeerId && client.readyState === WebSocket.OPEN) { // Apply nest filter for nested connections if (client.data.nestFilter) { const filtered: typeof allShapes = {}; for (const [id, shape] of Object.entries(allShapes)) { if (client.data.nestFilter.shapeTypes && !client.data.nestFilter.shapeTypes.includes(shape.type)) continue; if (client.data.nestFilter.shapeIds && !client.data.nestFilter.shapeIds.includes(id)) continue; filtered[id] = shape; } client.send(JSON.stringify({ type: "snapshot", shapes: filtered })); } else { if (!unfilteredMsg) unfilteredMsg = JSON.stringify({ type: "snapshot", shapes: allShapes }); client.send(unfilteredMsg); } } } } function broadcastAutomergeSync(slug: string, excludePeerId?: string): void { const clients = communityClients.get(slug); if (!clients) return; for (const [clientPeerId, client] of clients) { if (client.data.mode === "automerge" && clientPeerId !== excludePeerId && client.readyState === WebSocket.OPEN) { const syncMsg = generateSyncMessageForPeer(slug, clientPeerId); if (syncMsg) { client.send(JSON.stringify({ type: "sync", data: Array.from(syncMsg) })); } } } } // ── System Clock ── /** Broadcast a clock event to all connected clients across all communities. */ function broadcastClockEvent(channel: string, payload: ClockPayload): void { const msg = JSON.stringify({ type: "clock", channel, payload }); for (const [_slug, clients] of communityClients) { for (const [_peerId, client] of clients) { if (client.readyState === WebSocket.OPEN) { try { client.send(msg); } catch { // Ignore send errors on closing sockets } } } } } const systemClock = new SystemClock(broadcastClockEvent); systemClock.start(); // ── Subdomain parsing (backward compat) ── const RESERVED_SUBDOMAINS = ["www", "rspace", "create", "new", "start", "auth"]; function getSubdomain(host: string | null): string | null { if (!host) return null; if (host.includes("localhost") || host.includes("127.0.0.1")) return null; const parts = host.split("."); if (parts.length >= 3 && parts.slice(-2).join(".") === "rspace.online") { const sub = parts[0]; if (!RESERVED_SUBDOMAINS.includes(sub)) return sub; } return null; } // ── Auto-provision helpers ── /** Extract JWT from Authorization header, then eid_token cookie, then encryptid_token cookie */ function extractTokenFromRequest(req: Request): string | null { const auth = req.headers.get("authorization"); if (auth?.startsWith("Bearer ")) return auth.slice(7); const cookie = req.headers.get("cookie"); if (cookie) { const eidMatch = cookie.match(/(?:^|;\s*)eid_token=([^;]*)/); if (eidMatch) return decodeURIComponent(eidMatch[1]); const encMatch = cookie.match(/(?:^|;\s*)encryptid_token=([^;]*)/); if (encMatch) return decodeURIComponent(encMatch[1]); } return null; } /** Base64-decode JWT payload to extract username (cheap pre-check, no crypto) */ function decodeJWTUsername(token: string): string | null { try { const parts = token.split("."); if (parts.length < 2) return null; const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); const pad = "=".repeat((4 - (b64.length % 4)) % 4); const payload = JSON.parse(atob(b64 + pad)); return payload.username?.toLowerCase() || null; } catch { return null; } } /** Tracks slugs currently being provisioned to prevent concurrent double-creation */ const provisioningInProgress = new Set(); // ── Static file serving ── function getContentType(path: string): string { if (path.endsWith(".html")) return "text/html"; if (path.endsWith(".js")) return "application/javascript"; if (path.endsWith(".css")) return "text/css"; if (path.endsWith(".json")) return "application/json"; if (path.endsWith(".svg")) return "image/svg+xml"; if (path.endsWith(".wasm")) return "application/wasm"; if (path.endsWith(".png")) return "image/png"; if (path.endsWith(".jpg") || path.endsWith(".jpeg")) return "image/jpeg"; if (path.endsWith(".gif")) return "image/gif"; if (path.endsWith(".ico")) return "image/x-icon"; return "application/octet-stream"; } async function serveStatic(path: string, url?: URL): Promise { const filePath = resolve(DIST_DIR, path); const file = Bun.file(filePath); if (await file.exists()) { const headers: Record = { "Content-Type": getContentType(path) }; if (url?.searchParams.has("v")) { headers["Cache-Control"] = "public, max-age=31536000, immutable"; } else if (path.endsWith(".html")) { // HTML must revalidate so browsers pick up new hashed JS/CSS references headers["Cache-Control"] = "no-cache"; } else if (path.startsWith("assets/")) { // Vite content-hashed assets are safe to cache long-term headers["Cache-Control"] = "public, max-age=31536000, immutable"; } return new Response(file, { headers }); } return null; } // ── Standalone domain → module lookup ── const domainToModule = new Map(); for (const mod of getAllModules()) { if (mod.standaloneDomain) { domainToModule.set(mod.standaloneDomain, mod.id); } } // ── Bun.serve: WebSocket + fetch delegation ── const server = Bun.serve({ port: PORT, async fetch(req, server) { const url = new URL(req.url); const host = req.headers.get("host"); const hostClean = host?.split(":")[0] || ""; const subdomain = getSubdomain(host); // ── Standalone domain → 301 redirect to rspace.online ── // Check both bare domain and subdomain variants (e.g. rnotes.online, alice.rnotes.online) let standaloneModuleId = domainToModule.get(hostClean); let standaloneSub: string | null = null; if (!standaloneModuleId) { // Check if this is a subdomain of a standalone domain (e.g. alice.rnotes.online) const hostParts = hostClean.split("."); if (hostParts.length >= 3) { const baseDomain = hostParts.slice(-2).join("."); const candidate = domainToModule.get(baseDomain); if (candidate) { standaloneModuleId = candidate; standaloneSub = hostParts.slice(0, -2).join("."); } } } if (standaloneModuleId) { // Self-fetch detection: landing proxy uses this User-Agent; // return 404 to break circular fetch so the generic fallback is used if (req.headers.get("user-agent") === "rSpace-Proxy/1.0") { return new Response("Not found", { status: 404 }); } // Determine the space and remaining path const pathParts = url.pathname.split("/").filter(Boolean); let space = standaloneSub || null; // subdomain on standalone domain = space let remainingPath = ""; if (pathParts.length > 0 && !pathParts[0].includes(".") && pathParts[0] !== "api" && pathParts[0] !== "ws") { // First path segment is the space (if no subdomain already set it) if (!space) { space = pathParts[0]; remainingPath = pathParts.length > 1 ? "/" + pathParts.slice(1).join("/") : ""; } else { remainingPath = url.pathname; } } else { remainingPath = url.pathname === "/" ? "" : url.pathname; } // Build redirect URL let redirectUrl: string; if (space) { // Space-qualified: alice.rnotes.online/path or rnotes.online/alice/path // → alice.rspace.online/rnotes/path redirectUrl = `https://${space}.rspace.online/${standaloneModuleId}${remainingPath}`; } else { // No space: rnotes.online/ or rnotes.online/api/... // → rspace.online/rnotes or rspace.online/rnotes/api/... redirectUrl = `https://rspace.online/${standaloneModuleId}${remainingPath}`; } if (url.search) redirectUrl += url.search; return Response.redirect(redirectUrl, 301); } // ── WebSocket upgrade ── if (url.pathname.startsWith("/ws/")) { const communitySlug = url.pathname.split("/")[2]; if (communitySlug) { const spaceConfig = await getSpaceConfig(communitySlug); const claims = await authenticateWSUpgrade(req); let readOnly = false; let spaceRole: WSData['spaceRole'] = null; if (spaceConfig) { const vis = spaceConfig.visibility; if (vis === "permissioned" || vis === "private") { if (!claims) return new Response("Authentication required", { status: 401 }); } else if (vis === "public") { readOnly = !claims; } } // Resolve the caller's space role await loadCommunity(communitySlug); const spaceData = getDocumentData(communitySlug); if (spaceData) { if (claims && spaceData.meta.ownerDID === claims.sub) { spaceRole = 'admin'; } else if (claims && spaceData.members?.[claims.sub]) { spaceRole = spaceData.members[claims.sub].role; } else { // Non-member defaults by visibility const vis = spaceConfig?.visibility; if (vis === 'public' && claims) spaceRole = 'member'; else if (vis === 'permissioned' && claims) spaceRole = 'viewer'; else if (claims) spaceRole = 'viewer'; else spaceRole = 'viewer'; // anonymous } } // Enforce read-only for viewers if (spaceRole === 'viewer') { readOnly = true; } const peerId = generatePeerId(); const mode = url.searchParams.get("mode") === "json" ? "json" : "automerge"; // Nest context: if connecting from a parent space via folk-canvas const nestFrom = url.searchParams.get("nest-from") || undefined; let nestPermissions: NestPermissions | undefined; let nestFilter: SpaceRefFilter | undefined; if (nestFrom) { await loadCommunity(nestFrom); const parentData = getDocumentData(nestFrom); if (parentData?.nestedSpaces) { // Find the SpaceRef in the parent that points to this space const matchingRef = Object.values(parentData.nestedSpaces) .find(ref => ref.sourceSlug === communitySlug); if (matchingRef) { nestPermissions = matchingRef.permissions; nestFilter = matchingRef.filter; // If nest doesn't allow writes, force readOnly if (!matchingRef.permissions.write) { readOnly = true; } } } } const upgraded = server.upgrade(req, { data: { communitySlug, peerId, claims, readOnly, spaceRole, mode, nestFrom, nestPermissions, nestFilter } as WSData, }); if (upgraded) return undefined; } return new Response("WebSocket upgrade failed", { status: 400 }); } // ── Explicit page routes (before Hono, to avoid /:space catch-all) ── if (url.pathname === "/admin") { const adminHtml = await serveStatic("admin.html"); if (adminHtml) return adminHtml; } // ── Static assets (before Hono routing) ── if (url.pathname !== "/" && !url.pathname.startsWith("/api/") && !url.pathname.startsWith("/ws/")) { const assetPath = url.pathname.slice(1); // Serve files with extensions directly if (assetPath.includes(".")) { const staticResponse = await serveStatic(assetPath, url); if (staticResponse) return staticResponse; } } // ── Subdomain routing: {space}.rspace.online/{moduleId}/... ── if (subdomain) { // ── Auto-provision personal space on first visit ── // Fast path: communityExists is an in-memory Map check for cached spaces. if (!(await communityExists(subdomain))) { const token = extractTokenFromRequest(req); if (token) { const jwtUsername = decodeJWTUsername(token); if (jwtUsername === subdomain && !provisioningInProgress.has(subdomain)) { provisioningInProgress.add(subdomain); try { const claims = await verifyEncryptIDToken(token); const username = claims.username?.toLowerCase(); if (username === subdomain && !(await communityExists(subdomain))) { await createSpace({ name: `${claims.username}'s Space`, slug: subdomain, ownerDID: claims.sub, visibility: "private", source: 'subdomain', }); } } catch (e) { console.error(`[AutoProvision] Token verification failed for ${subdomain}:`, e); } finally { provisioningInProgress.delete(subdomain); } } } } const pathSegments = url.pathname.split("/").filter(Boolean); // Root: show space dashboard if (pathSegments.length === 0) { return new Response(renderSpaceDashboard(subdomain, getModuleInfoList()), { headers: { "Content-Type": "text/html" }, }); } // Global routes pass through without subdomain prefix if ( url.pathname.startsWith("/api/") || url.pathname.startsWith("/data/") || url.pathname.startsWith("/.well-known/") || url.pathname === "/about" || url.pathname === "/admin" || url.pathname === "/create-space" || url.pathname === "/new" ) { return app.fetch(req); } // Static assets (paths with file extensions) pass through if (pathSegments[0].includes(".")) { return app.fetch(req); } // Template seeding: /{moduleId}/template → seed + redirect if (pathSegments.length >= 2 && pathSegments[pathSegments.length - 1] === "template") { const moduleId = pathSegments[0].toLowerCase(); try { await loadCommunity(subdomain); const seeded = seedTemplateShapes(subdomain); if (seeded) { broadcastAutomergeSync(subdomain); broadcastJsonSnapshot(subdomain); } // Seed module-specific demo data const mod = getModule(moduleId); if (mod?.seedTemplate) mod.seedTemplate(subdomain); } catch (e) { console.error(`[Template] On-demand seed failed for "${subdomain}":`, e); } const withoutTemplate = pathSegments.slice(0, -1).join("/"); return Response.redirect(`${url.protocol}//${url.host}/${withoutTemplate}`, 302); } // Demo route: /{moduleId}/demo → rewrite to /demo/{moduleId} if (pathSegments.length >= 2 && pathSegments[pathSegments.length - 1] === "demo") { const moduleId = pathSegments[0].toLowerCase(); const mod = getModule(moduleId); if (mod) { const rewrittenPath = `/demo/${moduleId}`; const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`); return app.fetch(new Request(rewrittenUrl, req)); } } // If first segment already matches the subdomain (space slug), // pass through directly — URL already has /{space}/... prefix // e.g. demo.rspace.online/demo/rcart/pay/123 → /demo/rcart/pay/123 if (pathSegments[0].toLowerCase() === subdomain) { const rewrittenUrl = new URL(url.pathname + url.search, `http://localhost:${PORT}`); return app.fetch(new Request(rewrittenUrl, req)); } // Normalize module ID to lowercase (rTrips → rtrips) const normalizedPath = "/" + pathSegments.map((seg, i) => i === 0 ? seg.toLowerCase() : seg ).join("/"); // Rewrite: /{moduleId}/... → /{space}/{moduleId}/... // e.g. demo.rspace.online/vote/api/polls → /demo/vote/api/polls const rewrittenPath = `/${subdomain}${normalizedPath}`; const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`); const rewrittenReq = new Request(rewrittenUrl, req); return app.fetch(rewrittenReq); } // ── Bare-domain routing: rspace.online/{...} ── if (!subdomain && hostClean.includes("rspace.online")) { const pathSegments = url.pathname.split("/").filter(Boolean); if (pathSegments.length >= 1) { const firstSegment = pathSegments[0].toLowerCase(); const allModules = getAllModules(); const knownModuleIds = new Set(allModules.map((m) => m.id)); const mod = allModules.find((m) => m.id === firstSegment); if (mod) { // rspace.online/{moduleId} → landing page if (pathSegments.length === 1) { // 1. Check for inline rich landing page if (mod.landingPage) { const html = renderModuleLanding({ module: mod, modules: getModuleInfoList(), bodyHTML: mod.landingPage(), }); return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } }); } // 2. Try proxying the rich standalone landing page const proxyHtml = await fetchLandingPage(mod, getModuleInfoList()); if (proxyHtml) { return new Response(proxyHtml, { headers: { "Content-Type": "text/html; charset=utf-8" }, }); } // 3. Fallback to generic landing page const html = renderModuleLanding({ module: mod, modules: getModuleInfoList(), }); return new Response(html, { headers: { "Content-Type": "text/html" } }); } // rspace.online/{moduleId}/sub-path const secondSegment = pathSegments[1]?.toLowerCase(); // 1. API routes always pass through to demo if (secondSegment === "api") { const normalizedPath = "/" + [firstSegment, ...pathSegments.slice(1)].join("/"); const rewrittenPath = `/demo${normalizedPath}`; const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`); return app.fetch(new Request(rewrittenUrl, req)); } // 2. If sub-path matches a subPageInfo → render info page const subPageInfo = mod.subPageInfos?.find(sp => sp.path === secondSegment); if (subPageInfo) { const html = renderSubPageInfo({ subPage: subPageInfo, module: mod, modules: getModuleInfoList(), }); return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } }); } // 3. Fallback: rewrite to demo space (backward compat) const normalizedPath = "/" + [firstSegment, ...pathSegments.slice(1)].join("/"); const rewrittenPath = `/demo${normalizedPath}`; const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`); const rewrittenReq = new Request(rewrittenUrl, req); return app.fetch(rewrittenReq); } // rspace.online/{space}/{...} → redirect to {space}.rspace.online/{...} // (space is not a module ID — it's a space slug, canonicalize to subdomain) // Skip redirect for known server paths (api, admin, etc.) const serverPaths = new Set(["api", "admin", "admin-data", "admin-action", ".well-known"]); if (!knownModuleIds.has(firstSegment) && !serverPaths.has(firstSegment) && pathSegments.length >= 2) { const space = firstSegment; const rest = "/" + pathSegments.slice(1).join("/"); const baseDomain = hostClean.replace(/^www\./, ""); return Response.redirect( `${url.protocol}//${space}.${baseDomain}${rest}${url.search}`, 301 ); } } } // ── Hono handles everything else ── const response = await app.fetch(req); // If Hono returns 404, try serving canvas.html as SPA fallback // But only for paths that don't match a known module route if (response.status === 404 && !url.pathname.startsWith("/api/")) { const parts = url.pathname.split("/").filter(Boolean); // Check if this is under a known module — if so, the module's 404 is authoritative const knownModuleIds = getAllModules().map((m) => m.id); const isModulePath = parts.length >= 2 && knownModuleIds.includes(parts[1].toLowerCase()); if (!isModulePath && parts.length >= 1 && !parts[0].includes(".")) { // Not a module path — could be a canvas SPA route, try fallback const canvasHtml = await serveStatic("canvas.html"); if (canvasHtml) return canvasHtml; const indexHtml = await serveStatic("index.html"); if (indexHtml) return indexHtml; } } return response; }, // ── WebSocket handlers (unchanged) ── websocket: { open(ws: ServerWebSocket) { const { communitySlug, peerId, mode, nestFrom, nestFilter, claims } = ws.data; if (!communityClients.has(communitySlug)) { communityClients.set(communitySlug, new Map()); } communityClients.get(communitySlug)!.set(peerId, ws); // Register with DocSyncManager for multi-doc sync syncServer.addPeer(peerId, ws, claims ? { sub: claims.sub, username: claims.username } : undefined); // Register for notification delivery if (claims?.sub) registerUserConnection(claims.sub, ws); const nestLabel = nestFrom ? ` (nested from ${nestFrom})` : ""; console.log(`[WS] Client ${peerId} connected to ${communitySlug} (mode: ${mode})${nestLabel}`); loadCommunity(communitySlug).then((doc) => { if (!doc) return; // Guard: peer may have disconnected during async load if (ws.readyState !== WebSocket.OPEN) return; if (mode === "json") { const docData = getDocumentData(communitySlug); if (docData) { let shapes = docData.shapes || {}; // Apply nest filter if this is a nested connection if (nestFilter) { const filtered: typeof shapes = {}; for (const [id, shape] of Object.entries(shapes)) { if (nestFilter.shapeTypes && !nestFilter.shapeTypes.includes(shape.type)) continue; if (nestFilter.shapeIds && !nestFilter.shapeIds.includes(id)) continue; filtered[id] = shape; } shapes = filtered; } ws.send(JSON.stringify({ type: "snapshot", shapes })); } } else { const syncMessage = generateSyncMessageForPeer(communitySlug, peerId); if (syncMessage) { ws.send(JSON.stringify({ type: "sync", data: Array.from(syncMessage) })); } } }); }, message(ws: ServerWebSocket, message: string | Buffer) { const { communitySlug, peerId } = ws.data; try { const msg = JSON.parse(message.toString()); // ── DocSyncManager protocol (messages with docId) ── if (msg.type === "subscribe" || msg.type === "unsubscribe" || msg.type === "awareness") { if (msg.docIds || msg.docId) { syncServer.handleMessage(peerId, message.toString()); return; } } if (msg.type === "sync" && msg.docId) { // New protocol: sync with docId → route to SyncServer syncServer.handleMessage(peerId, message.toString()); return; } // ── Legacy canvas protocol (no docId) ── if (msg.type === "sync" && Array.isArray(msg.data)) { if (ws.data.readOnly) { ws.send(JSON.stringify({ type: "error", message: "Authentication required to edit this space" })); return; } const syncMessage = new Uint8Array(msg.data); const result = receiveSyncMessage(communitySlug, peerId, syncMessage); if (result.response) { ws.send(JSON.stringify({ type: "sync", data: Array.from(result.response) })); } for (const [targetPeerId, targetMessage] of result.broadcastToPeers) { const targetClient = getClient(communitySlug, targetPeerId); if (targetClient && targetClient.data.mode === "automerge" && targetClient.readyState === WebSocket.OPEN) { targetClient.send(JSON.stringify({ type: "sync", data: Array.from(targetMessage) })); } } if (result.broadcastToPeers.size > 0) { broadcastJsonSnapshot(communitySlug, peerId); } } else if (msg.type === "ping") { ws.send(JSON.stringify({ type: "pong", timestamp: msg.timestamp })); } else if (msg.type === "presence") { const clients = communityClients.get(communitySlug); if (clients) { const presenceMsg = JSON.stringify({ type: "presence", peerId, ...msg }); for (const [clientPeerId, client] of clients) { if (clientPeerId !== peerId && client.readyState === WebSocket.OPEN) { client.send(presenceMsg); } } } } else if (msg.type === "announce") { // Client announcing its identity for the people-online panel const { peerId: clientPeerId, username, color } = msg; if (!clientPeerId || !username) return; let slugAnnouncements = peerAnnouncements.get(communitySlug); if (!slugAnnouncements) { slugAnnouncements = new Map(); peerAnnouncements.set(communitySlug, slugAnnouncements); } slugAnnouncements.set(peerId, { clientPeerId, username, color }); // Send full peer list back to the announcing client const peerList = Array.from(slugAnnouncements.values()); ws.send(JSON.stringify({ type: "peer-list", peers: peerList })); // Broadcast peer-joined to all other clients const joinedMsg = JSON.stringify({ type: "peer-joined", peerId: clientPeerId, username, color }); const clients2 = communityClients.get(communitySlug); if (clients2) { for (const [cPeerId, client] of clients2) { if (cPeerId !== peerId && client.readyState === WebSocket.OPEN) { client.send(joinedMsg); } } } } else if (msg.type === "ping-user") { // Relay a "come here" ping to a specific peer const { targetPeerId: targetClientPeerId, viewport } = msg; const slugAnnouncements = peerAnnouncements.get(communitySlug); const senderInfo = slugAnnouncements?.get(peerId); if (!slugAnnouncements || !senderInfo) return; // Find the server peerId that matches the target client peerId const clients3 = communityClients.get(communitySlug); if (clients3) { for (const [serverPid, _client] of clients3) { const ann = slugAnnouncements.get(serverPid); if (ann && ann.clientPeerId === targetClientPeerId && _client.readyState === WebSocket.OPEN) { _client.send(JSON.stringify({ type: "ping-user", fromPeerId: senderInfo.clientPeerId, fromUsername: senderInfo.username, viewport, })); // Persist ping as a notification const targetDid = _client.data.claims?.sub; if (targetDid && ws.data.claims?.sub) { notify({ userDid: targetDid, category: 'social', eventType: 'ping_user', title: `${senderInfo.username} pinged you in "${communitySlug}"`, spaceSlug: communitySlug, actorDid: ws.data.claims.sub, actorUsername: senderInfo.username, actionUrl: `/${communitySlug}/rspace`, expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24h }).catch(() => {}); } break; } } } } else if (msg.type === "update" && msg.id && msg.data) { if (ws.data.readOnly) { ws.send(JSON.stringify({ type: "error", message: "Authentication required to edit" })); return; } // Nest-level: check addShapes for new shapes if (ws.data.nestPermissions) { const existingDoc = getDocumentData(communitySlug); const isNewShape = existingDoc && !existingDoc.shapes?.[msg.id]; if (isNewShape && !ws.data.nestPermissions.addShapes) { ws.send(JSON.stringify({ type: "error", message: "Nest permissions do not allow adding shapes" })); return; } } updateShape(communitySlug, msg.id, msg.data); broadcastJsonSnapshot(communitySlug, peerId); broadcastAutomergeSync(communitySlug, peerId); } else if (msg.type === "delete" && msg.id) { if (ws.data.readOnly) { ws.send(JSON.stringify({ type: "error", message: "Authentication required to delete" })); return; } if (ws.data.nestPermissions && !ws.data.nestPermissions.deleteShapes) { ws.send(JSON.stringify({ type: "error", message: "Nest permissions do not allow deleting shapes" })); return; } forgetShape(communitySlug, msg.id, ws.data.claims?.sub); broadcastJsonSnapshot(communitySlug, peerId); broadcastAutomergeSync(communitySlug, peerId); } else if (msg.type === "forget" && msg.id) { if (ws.data.readOnly) { ws.send(JSON.stringify({ type: "error", message: "Authentication required to forget" })); return; } if (ws.data.nestPermissions && !ws.data.nestPermissions.deleteShapes) { ws.send(JSON.stringify({ type: "error", message: "Nest permissions do not allow forgetting shapes" })); return; } forgetShape(communitySlug, msg.id, ws.data.claims?.sub); broadcastJsonSnapshot(communitySlug, peerId); broadcastAutomergeSync(communitySlug, peerId); } else if (msg.type === "remember" && msg.id) { if (ws.data.readOnly) { ws.send(JSON.stringify({ type: "error", message: "Authentication required to remember" })); return; } rememberShape(communitySlug, msg.id); broadcastJsonSnapshot(communitySlug, peerId); broadcastAutomergeSync(communitySlug, peerId); } } catch (e) { console.error("[WS] Failed to parse message:", e); } }, close(ws: ServerWebSocket) { const { communitySlug, peerId, claims } = ws.data; // Unregister from notification delivery if (claims?.sub) unregisterUserConnection(claims.sub, ws); // Broadcast peer-left before cleanup const slugAnnouncements = peerAnnouncements.get(communitySlug); if (slugAnnouncements) { const ann = slugAnnouncements.get(peerId); if (ann) { const leftMsg = JSON.stringify({ type: "peer-left", peerId: ann.clientPeerId }); const clients = communityClients.get(communitySlug); if (clients) { for (const [cPeerId, client] of clients) { if (cPeerId !== peerId && client.readyState === WebSocket.OPEN) { client.send(leftMsg); } } } slugAnnouncements.delete(peerId); if (slugAnnouncements.size === 0) peerAnnouncements.delete(communitySlug); } } const clients = communityClients.get(communitySlug); if (clients) { clients.delete(peerId); if (clients.size === 0) communityClients.delete(communitySlug); } removePeerSyncState(communitySlug, peerId); syncServer.removePeer(peerId); console.log(`[WS] Client ${peerId} disconnected from ${communitySlug}`); }, }, }); // ── Startup ── // Call onInit for each module that defines it (schema registration, DB init, etc.) (async () => { for (const mod of getAllModules()) { if (mod.onInit) { try { await mod.onInit({ syncServer }); console.log(`[Init] ${mod.name} initialized`); } catch (e) { console.error(`[Init] ${mod.name} failed:`, e); } } } // Pass syncServer to OAuth handlers setNotionOAuthSyncServer(syncServer); setGoogleOAuthSyncServer(syncServer); })(); // Ensure generated files directory exists import { mkdirSync } from "node:fs"; try { mkdirSync(resolve(process.env.FILES_DIR || "./data/files", "generated"), { recursive: true }); } catch {} ensureDemoCommunity().then(() => console.log("[Demo] Demo community ready")).catch((e) => console.error("[Demo] Failed:", e)); loadAllDocs(syncServer) .then(() => { ensureTemplateSeeding(); // Seed all modules' demo data so /demo routes always have content for (const mod of getAllModules()) { if (mod.seedTemplate) { try { mod.seedTemplate("demo"); } catch { /* already seeded */ } } } console.log("[Demo] All module demo data seeded"); }) .catch((e) => console.error("[DocStore] Startup load failed:", e)); // Restore relay mode for encrypted spaces (async () => { try { const slugs = await listCommunities(); let relayCount = 0; for (const slug of slugs) { await loadCommunity(slug); const data = getDocumentData(slug); if (data?.meta?.encrypted) { syncServer.setRelayOnly(slug, true); relayCount++; } } if (relayCount > 0) { console.log(`[Encryption] ${relayCount} space(s) set to relay mode (encrypted)`); } } catch (e) { console.error("[Encryption] Failed to restore relay modes:", e); } })(); console.log(`rSpace unified server running on http://localhost:${PORT}`); console.log(`Modules: ${getAllModules().map((m) => `${m.icon} ${m.name}`).join(", ")}`);