rspace-online/server/index.ts

4708 lines
172 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 sharp from "sharp";
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,
updateSpaceMeta,
setMember,
} from "./community-store";
import type { NestPermissions, SpaceRefFilter } from "./community-store";
import { ensureDemoCommunity } from "./seed-demo";
import { seedTemplateShapes } from "./seed-template";
// Campaign demo moved to rsocials module — see modules/rsocials/campaign-data.ts
import type { SpaceVisibility } from "./community-store";
import {
evaluateSpaceAccess,
authenticateWSUpgrade,
} from "@encryptid/sdk/server";
import type { SpaceAuthConfig } from "@encryptid/sdk/server";
import { verifyToken, extractToken } from "./auth";
import type { EncryptIDClaims } from "./auth";
import { createMcpRouter } from "./mcp-server";
const spaceAuthOpts = () => ({
getSpaceConfig,
...(process.env.JWT_SECRET ? { secret: process.env.JWT_SECRET } : {}),
});
// ── 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 { checklistCheckRoutes, checklistApiRoutes } from "../modules/rtasks/checklist-routes";
import { magicLinkRoutes } from "./magic-link/routes";
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 { chatsModule } from "../modules/rchats/mod";
import { agentsModule } from "../modules/ragents/mod";
import { docsModule } from "../modules/rdocs/mod";
import { designModule } from "../modules/rdesign/mod";
import { scheduleModule } from "../modules/rschedule/mod";
import { bnbModule } from "../modules/rbnb/mod";
import { vnbModule } from "../modules/rvnb/mod";
import { crowdsurfModule } from "../modules/crowdsurf/mod";
import { timeModule } from "../modules/rtime/mod";
import { govModule } from "../modules/rgov/mod";
import { sheetsModule } from "../modules/rsheets/mod";
import { exchangeModule } from "../modules/rexchange/mod";
import { auctionsModule } from "../modules/rauctions/mod";
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
import type { SpaceRoleString } from "./spaces";
import { renderShell, renderSubPageInfo, renderModuleLanding, renderOnboarding, setFragmentMode } from "./shell";
import { renderOutputListPage } from "./output-list";
import { renderMainLanding, renderSpaceDashboard } from "./landing";
import { syncServer } from "./sync-instance";
import { loadAllDocs } from "./local-first/doc-persistence";
import { backupRouter } from "./local-first/backup-routes";
import { ipfsRouter } from "./ipfs-routes";
import { isIPFSEnabled, pinToIPFS } from "./ipfs";
import { oauthRouter, setOAuthStatusSyncServer } from "./oauth/index";
import { setNotionOAuthSyncServer } from "./oauth/notion";
import { setGoogleOAuthSyncServer } from "./oauth/google";
import { setClickUpOAuthSyncServer } from "./oauth/clickup";
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";
import { bugReportRouter } from "./bug-report-routes";
import { createSecurityMiddleware, mcpGuard } from "./security";
// ── Process-level error safety net (prevent crash on unhandled socket errors) ──
process.on('uncaughtException', (err) => {
console.error('[FATAL] Uncaught exception (swallowed):', err.message);
// Don't exit — keep the server running
});
process.on('unhandledRejection', (reason) => {
console.error('[FATAL] Unhandled rejection (swallowed):', reason);
});
// Register modules (order determines app-switcher menu position)
registerModule(canvasModule);
registerModule(pubsModule);
registerModule(cartModule);
registerModule(swagModule);
registerModule(choicesModule);
registerModule(flowsModule);
registerModule(filesModule);
registerModule(walletModule);
registerModule(voteModule);
registerModule(notesModule);
registerModule(mapsModule);
registerModule(tasksModule);
registerModule(calModule);
registerModule(networkModule);
registerModule(inboxModule);
registerModule(dataModule);
registerModule(splatModule);
registerModule(photosModule);
registerModule(socialsModule);
registerModule(scheduleModule);
registerModule(meetsModule);
registerModule(chatsModule);
registerModule(agentsModule);
registerModule(bnbModule);
registerModule(vnbModule);
registerModule(crowdsurfModule);
registerModule(timeModule);
registerModule(govModule); // Governance decision circuits
registerModule(exchangeModule); // P2P crypto/fiat exchange
registerModule(auctionsModule); // Community auctions with USDC
registerModule(designModule); // Scribus DTP + AI design agent
// De-emphasized modules (bottom of menu)
registerModule(forumModule);
registerModule(tubeModule);
registerModule(tripsModule);
registerModule(booksModule);
registerModule(sheetsModule);
registerModule(docsModule); // Full TipTap editor (split from rNotes)
// ── 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 ──
type AppEnv = { Variables: { isSubdomain: boolean } };
const app = new Hono<AppEnv>();
// Detect subdomain routing and set context flag
app.use("*", async (c, next) => {
const host = c.req.header("host")?.split(":")[0] || "";
const parts = host.split(".");
const isSubdomain = parts.length >= 3 && parts.slice(-2).join(".") === "rspace.online"
&& !["www", "rspace", "create", "new", "start", "auth"].includes(parts[0]);
c.set("isSubdomain", isSubdomain);
await next();
});
// CORS for API routes — restrict to known origins
app.use("/api/*", cors({
origin: (origin) => {
if (!origin) return origin; // server-to-server (no Origin header) — allow
const allowed = [
"rspace.online", "ridentity.online", "rsocials.online",
"rwallet.online", "rvote.online", "rmaps.online",
];
try {
const host = new URL(origin).hostname;
if (allowed.some((d) => host === d || host.endsWith(`.${d}`))) return origin;
// Allow localhost for dev
if (host === "localhost" || host === "127.0.0.1") return origin;
} catch {}
return ""; // deny
},
credentials: true,
}));
// Security middleware — UA filtering + tiered rate limiting
app.use("/api/*", createSecurityMiddleware());
// ── .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", // EncryptID domain (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)
],
},
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/ and /api/files/generated/ ──
// The /api/ route avoids Cloudflare/Traefik redirecting /data/ paths
const GENERATED_MIME: Record<string, string> = {
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp",
glb: "model/gltf-binary", gltf: "model/gltf+json",
step: "application/step", stp: "application/step",
stl: "application/sla", fcstd: "application/octet-stream",
svg: "image/svg+xml", pdf: "application/pdf",
};
function serveGeneratedFile(c: any) {
// Support both flat files and subdirectory paths (e.g. freecad-xxx/model.step)
const filename = c.req.param("filename");
const subdir = c.req.param("subdir");
const relPath = subdir ? `${subdir}/${filename}` : filename;
if (!relPath || relPath.includes("..")) {
return c.json({ error: "Invalid filename" }, 400);
}
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
const filePath = resolve(dir, relPath);
// Ensure resolved path stays within generated dir
if (!filePath.startsWith(dir)) return c.json({ error: "Invalid path" }, 400);
const file = Bun.file(filePath);
return file.exists().then((exists: boolean) => {
if (!exists) return c.notFound();
const ext = filePath.split(".").pop()?.toLowerCase() || "";
return new Response(file, { headers: { "Content-Type": GENERATED_MIME[ext] || "application/octet-stream", "Cache-Control": "public, max-age=86400" } });
});
}
app.get("/data/files/generated/:filename", serveGeneratedFile);
app.get("/data/files/generated/:subdir/:filename", serveGeneratedFile);
app.get("/api/files/generated/:filename", serveGeneratedFile);
app.get("/api/files/generated/:subdir/:filename", serveGeneratedFile);
// ── IPFS background pinning for generated files ──
/** In-memory cache of filePath → CID. Populated from .cid sidecar files on startup. */
const generatedCidCache = new Map<string, string>();
/** Load existing .cid sidecar files into cache on startup. */
async function loadGeneratedCids() {
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
try {
const { readdir, readFile } = await import("node:fs/promises");
const files = await readdir(dir);
for (const f of files) {
if (!f.endsWith(".cid")) continue;
try {
const cid = (await readFile(resolve(dir, f), "utf-8")).trim();
const originalFile = f.replace(/\.cid$/, "");
generatedCidCache.set(originalFile, cid);
} catch {}
}
if (generatedCidCache.size > 0) {
console.log(`[ipfs] Loaded ${generatedCidCache.size} CID sidecar files`);
}
} catch {}
}
loadGeneratedCids();
/**
* Pin a generated file to IPFS in the background.
* Writes a .cid sidecar file alongside the original.
* Safe to call fire-and-forget — failures are logged and swallowed.
*/
async function pinGeneratedFile(filePath: string, filename: string) {
if (!isIPFSEnabled()) return;
if (generatedCidCache.has(filename)) return;
try {
const file = Bun.file(filePath);
const buf = new Uint8Array(await file.arrayBuffer());
const cid = await pinToIPFS(buf, filename);
generatedCidCache.set(filename, cid);
const { writeFile } = await import("node:fs/promises");
await writeFile(`${filePath}.cid`, cid);
console.log(`[ipfs] Pinned ${filename}${cid}`);
} catch (err: any) {
console.warn(`[ipfs] Pin failed for ${filename} (non-fatal):`, err.message);
}
}
// ── Link preview / unfurl API ──
const linkPreviewCache = new Map<string, { title: string; description: string; image: string | null; domain: string; fetchedAt: number }>();
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(`<meta[^>]*(?:name|property)=["']${nameOrProp}["'][^>]*content=["']([^"']*?)["']`, "i");
const re2 = new RegExp(`<meta[^>]*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[^>]*>([^<]*)<\/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 (e) {
console.error("[link-preview] fetch error:", url, e instanceof Error ? e.message : e);
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<string, { data: unknown; fetchedAt: number }>();
app.get("/api/ecosystem/:appId/manifest", async (c) => {
const appId = c.req.param("appId");
// Known ecosystem app origins
const ECOSYSTEM_ORIGINS: Record<string, string> = {
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<string, unknown>;
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);
// ── IPFS API (pinning + gateway proxy) ──
app.route("/api/ipfs", ipfsRouter);
// ── 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);
// ── Email Checklist (top-level, bypasses space auth) ──
app.route("/rtasks/check", checklistCheckRoutes);
app.route("/api/rtasks", checklistApiRoutes);
// ── Dashboard summary API ──
import { dashboardRoutes } from "./dashboard-routes";
app.route("/api", dashboardRoutes);
// ── Bug Report API ──
app.route("/api/bug-report", bugReportRouter);
// ── MCP Server (Model Context Protocol) ──
app.use("/api/mcp/*", mcpGuard);
app.route("/api/mcp", createMcpRouter(syncServer));
// ── Magic Link Responses (top-level, bypasses space auth) ──
app.route("/respond", magicLinkRoutes);
// ── Same-origin auth proxy (avoids cross-origin issues on Safari) ──
const ENCRYPTID_INTERNAL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000";
const proxyToEncryptid = async (c: any) => {
const targetUrl = `${ENCRYPTID_INTERNAL}${c.req.path}${new URL(c.req.url).search}`;
const headers = new Headers(c.req.raw.headers);
headers.delete("host");
try {
const res = await fetch(targetUrl, {
method: c.req.method,
headers,
body: c.req.method !== "GET" && c.req.method !== "HEAD" ? c.req.raw.body : undefined,
// @ts-ignore duplex needed for streaming request bodies
duplex: "half",
});
return new Response(res.body, { status: res.status, headers: res.headers });
} catch (e: any) {
return c.json({ error: "EncryptID service unavailable" }, 502);
}
};
app.all("/api/auth/*", proxyToEncryptid);
app.all("/api/register/*", proxyToEncryptid);
app.all("/api/account/*", proxyToEncryptid);
app.all("/api/session/*", proxyToEncryptid);
app.all("/api/guardians/*", proxyToEncryptid);
app.all("/api/guardians", proxyToEncryptid);
app.all("/api/user/*", proxyToEncryptid);
app.all("/api/device-link/*", proxyToEncryptid);
app.all("/api/recovery/*", proxyToEncryptid);
// ── EncryptID proxy (forward /encryptid/* to encryptid container) ──
app.all("/encryptid/*", async (c) => {
const targetUrl = `${ENCRYPTID_INTERNAL}${c.req.path}${new URL(c.req.url).search}`;
const headers = new Headers(c.req.raw.headers);
headers.delete("host");
try {
const res = await fetch(targetUrl, {
method: c.req.method,
headers,
body: c.req.method !== "GET" && c.req.method !== "HEAD" ? c.req.raw.body : undefined,
// @ts-ignore duplex needed for streaming request bodies
duplex: "half",
});
return new Response(res.body, { status: res.status, headers: res.headers });
} catch (e: any) {
return c.json({ error: "EncryptID service unavailable" }, 502);
}
});
// ── User API proxy (forward /api/user/* and /api/users/* to EncryptID) ──
app.all("/api/user/*", async (c) => {
const targetUrl = `${ENCRYPTID_INTERNAL}${c.req.path}${new URL(c.req.url).search}`;
const headers = new Headers(c.req.raw.headers);
headers.delete("host");
try {
const res = await fetch(targetUrl, {
method: c.req.method,
headers,
body: c.req.method !== "GET" && c.req.method !== "HEAD" ? c.req.raw.body : undefined,
// @ts-ignore duplex needed for streaming request bodies
duplex: "half",
});
return new Response(res.body, { status: res.status, headers: res.headers });
} catch (e: any) {
return c.json({ error: "EncryptID service unavailable" }, 502);
}
});
app.all("/api/users/*", async (c) => {
const targetUrl = `${ENCRYPTID_INTERNAL}${c.req.path}${new URL(c.req.url).search}`;
const headers = new Headers(c.req.raw.headers);
headers.delete("host");
try {
const res = await fetch(targetUrl, {
method: c.req.method,
headers,
body: c.req.method !== "GET" && c.req.method !== "HEAD" ? c.req.raw.body : undefined,
// @ts-ignore duplex needed for streaming request bodies
duplex: "half",
});
return new Response(res.body, { status: res.status, headers: res.headers });
} catch (e: any) {
return c.json({ error: "EncryptID service unavailable" }, 502);
}
});
// ── Existing /api/communities/* routes (backward compatible) ──
/** Resolve a community slug to SpaceAuthConfig for the SDK guard */
async function getSpaceConfig(slug: string): Promise<SpaceAuthConfig | null> {
let doc = getDocumentData(slug);
if (!doc) {
await loadCommunity(slug);
doc = getDocumentData(slug);
}
if (!doc) return null;
let vis = (doc.meta.visibility || "private") as SpaceVisibility;
if (slug === "demo") vis = "public";
return {
spaceSlug: slug,
visibility: vis,
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 verifyToken(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", "</api/spaces>; 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/internal/sync-space-member — called by EncryptID after identity invite claim
// Syncs a member from EncryptID's space_members (PostgreSQL) to the Automerge doc
app.post("/api/internal/sync-space-member", async (c) => {
const body = await c.req.json<{ spaceSlug: string; userDid: string; role: string; username?: string }>();
if (!body.spaceSlug || !body.userDid || !body.role) {
return c.json({ error: "spaceSlug, userDid, and role are required" }, 400);
}
try {
await loadCommunity(body.spaceSlug);
setMember(body.spaceSlug, body.userDid, body.role as any, body.username);
return c.json({ ok: true });
} catch (err: any) {
console.error("sync-space-member error:", err.message);
return c.json({ error: "Failed to sync member" }, 500);
}
});
// POST /api/internal/mint-crdt — called by onramp-service after fiat payment confirmed
app.post("/api/internal/mint-crdt", async (c) => {
const internalKey = c.req.header("X-Internal-Key");
if (!INTERNAL_API_KEY || internalKey !== INTERNAL_API_KEY) {
return c.json({ error: "Unauthorized" }, 401);
}
const body = await c.req.json<{
did?: string;
label?: string;
amountDecimal?: string;
txHash?: string;
network?: string;
}>();
const { did, label, amountDecimal, txHash, network } = body;
if (!did || !amountDecimal || !txHash || !network) {
return c.json({ error: "did, amountDecimal, txHash, and network are required" }, 400);
}
const { mintFromOnChain } = await import("./token-service");
const success = mintFromOnChain(did, label || "Unknown", amountDecimal, txHash, network);
if (!success) {
// mintFromOnChain returns false for duplicates or invalid amounts — both are idempotent
return c.json({ ok: false, reason: "already minted or invalid amount" });
}
return c.json({ ok: true, minted: amountDecimal, did, txHash });
});
// POST /api/internal/escrow-burn — called by offramp-service to escrow cUSDC before payout
app.post("/api/internal/escrow-burn", async (c) => {
const internalKey = c.req.header("X-Internal-Key");
if (!INTERNAL_API_KEY || internalKey !== INTERNAL_API_KEY) {
return c.json({ error: "Unauthorized" }, 401);
}
const body = await c.req.json<{
did?: string;
label?: string;
amount?: number;
offRampId?: string;
}>();
if (!body.did || !body.amount || !body.offRampId) {
return c.json({ error: "did, amount, and offRampId are required" }, 400);
}
const { burnTokensEscrow, getTokenDoc, getBalance } = await import("./token-service");
const doc = getTokenDoc("cusdc");
if (!doc) return c.json({ error: "cUSDC token not found" }, 500);
const balance = getBalance(doc, body.did);
if (balance < body.amount) {
return c.json({ error: `Insufficient balance: ${balance} < ${body.amount}` }, 400);
}
const ok = burnTokensEscrow(
"cusdc", body.did, body.label || "", body.amount, body.offRampId,
`Off-ramp withdrawal: ${body.offRampId}`,
);
if (!ok) return c.json({ error: "Escrow burn failed" }, 500);
return c.json({ ok: true, escrowed: body.amount, offRampId: body.offRampId });
});
// POST /api/internal/confirm-offramp — called by offramp-service after payout confirmed/failed
app.post("/api/internal/confirm-offramp", async (c) => {
const internalKey = c.req.header("X-Internal-Key");
if (!INTERNAL_API_KEY || internalKey !== INTERNAL_API_KEY) {
return c.json({ error: "Unauthorized" }, 401);
}
const body = await c.req.json<{
offRampId?: string;
status?: "confirmed" | "reversed";
}>();
if (!body.offRampId || !body.status) {
return c.json({ error: "offRampId and status ('confirmed' | 'reversed') required" }, 400);
}
const { confirmBurn, reverseBurn } = await import("./token-service");
if (body.status === "confirmed") {
const ok = confirmBurn("cusdc", body.offRampId);
return c.json({ ok, action: "confirmed" });
} else {
const ok = reverseBurn("cusdc", body.offRampId);
return c.json({ ok, action: "reversed" });
}
});
// 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", spaceAuthOpts());
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", spaceAuthOpts());
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<string, unknown>[] }>();
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", spaceAuthOpts());
if (!access.allowed) return c.json({ error: access.reason }, access.claims ? 403 : 401);
}
await loadCommunity(slug);
const body = await c.req.json<Record<string, unknown>>();
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/space-access/:slug — lightweight membership check for client-side gate
app.get("/api/space-access/:slug", async (c) => {
const slug = c.req.param("slug");
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ access: false, reason: "not-authenticated" });
let claims: EncryptIDClaims | null = null;
try { claims = await verifyToken(token); } catch {}
if (!claims) return c.json({ access: false, reason: "not-authenticated" });
const config = await getSpaceConfig(slug);
const vis = config?.visibility || "private";
const resolved = await resolveCallerRole(slug, claims);
if (vis === "private" && resolved && !resolved.isOwner && resolved.role === "viewer") {
return c.json({ access: false, reason: "not-member", visibility: vis, role: resolved.role });
}
return c.json({
access: true,
visibility: vis,
role: resolved?.role || "viewer",
isOwner: resolved?.isOwner || false,
});
});
// 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", spaceAuthOpts());
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() : `<p>${mod.description || "No description available."}</p>`;
return c.json({ html, icon: mod.icon || "", name: mod.name || moduleId });
});
// ── Comment Pin API ──
import { listAllUsersWithTrust, listSpaceMembers } from "../src/encryptid/db";
// Space members for @mention autocomplete
app.get("/:space/api/space-members", async (c) => {
const space = c.req.param("space");
try {
const users = await listAllUsersWithTrust(space);
return c.json({
members: users.map((u) => ({
did: u.did,
username: u.username,
displayName: u.displayName,
})),
});
} catch {
return c.json({ members: [] });
}
});
// Space-wide comment notification (all members)
app.post("/:space/api/comment-pins/notify", async (c) => {
const space = c.req.param("space");
try {
const body = await c.req.json();
const { pinId, authorDid, authorName, text, pinIndex, isReply, mentionedDids, moduleId } = body;
if (!pinId || !authorDid) {
return c.json({ error: "Missing fields" }, 400);
}
const effectiveModule = moduleId || "rspace";
const isModuleComment = effectiveModule !== "rspace";
const members = await listSpaceMembers(space);
const excludeDids = new Set<string>([authorDid, ...(mentionedDids || [])]);
const title = isReply
? `${authorName || "Someone"} replied to a comment`
: `New comment from ${authorName || "Someone"}`;
const preview = text ? text.slice(0, 120) + (text.length > 120 ? "..." : "") : "";
// In-app + push notifications for all members (excluding author and @mentioned)
await Promise.all(
members
.filter((m) => !excludeDids.has(m.userDID))
.map((m) =>
notify({
userDid: m.userDID,
category: "module",
eventType: isModuleComment ? "module_comment" : "canvas_comment",
title,
body: preview || `Comment pin #${pinIndex || "?"} in ${space}`,
spaceSlug: space,
moduleId: effectiveModule,
actionUrl: `/${effectiveModule}#pin-${pinId}`,
actorDid: authorDid,
actorUsername: authorName,
}),
),
);
// Email notification to all space members (fire-and-forget)
import("../modules/rinbox/agent-notify")
.then(({ sendSpaceNotification }) => {
sendSpaceNotification(
space,
title,
`<p>${preview}</p><p><a href="https://${space}.rspace.online/${effectiveModule}#pin-${pinId}">View comment</a></p>`,
);
})
.catch(() => {});
return c.json({ ok: true });
} catch (err) {
console.error("[comment-pins] notify-all error:", err);
return c.json({ error: "Failed to send notifications" }, 500);
}
});
// Mention notification
app.post("/:space/api/comment-pins/notify-mention", async (c) => {
const space = c.req.param("space");
try {
const body = await c.req.json();
const { pinId, authorDid, authorName, mentionedDids, pinIndex, moduleId } = body;
if (!pinId || !authorDid || !mentionedDids?.length) {
return c.json({ error: "Missing fields" }, 400);
}
const effectiveModule = moduleId || "rspace";
const isModuleComment = effectiveModule !== "rspace";
for (const did of mentionedDids) {
await notify({
userDid: did,
category: "module",
eventType: isModuleComment ? "module_mention" : "canvas_mention",
title: `${authorName} mentioned you in a comment`,
body: `Comment pin #${pinIndex || "?"} in ${space}`,
spaceSlug: space,
moduleId: effectiveModule,
actionUrl: `/${effectiveModule}#pin-${pinId}`,
actorDid: authorDid,
actorUsername: authorName,
});
}
return c.json({ ok: true });
} catch (err) {
console.error("[comment-pins] notify error:", err);
return c.json({ error: "Failed to send notification" }, 500);
}
});
// ── x402 test endpoint (payment-gated, supports on-chain + CRDT) ──
import { setupX402FromEnv } from "../shared/x402/hono-middleware";
import { setTokenVerifier } from "../shared/x402/crdt-scheme";
import { getBalance, getTokenDoc, transferTokens, mintFromOnChain } from "./token-service";
// Wire EncryptID JWT verifier into CRDT scheme
setTokenVerifier(async (token: string) => {
const claims = await verifyToken(token);
return { sub: claims.sub, did: claims.did as string | undefined, username: claims.username };
});
const x402Test = setupX402FromEnv({
description: "x402 test endpoint",
resource: "/api/x402-test",
crdtPayment: {
tokenId: "cusdc",
amount: 10_000, // 0.01 cUSDC (6 decimals)
payToDid: "did:key:treasury-main-rspace-dao-2026",
payToLabel: "DAO Treasury",
getBalance,
getTokenDoc,
transferTokens,
},
onPaymentSettled: async ({ paymentHeader, context }) => {
// Direction 1: mint cUSDC after on-chain USDC payment
try {
const token = extractToken(context.req.raw.headers);
if (!token) {
console.warn("[x402 bridge] No JWT — skipping cUSDC mint (on-chain payment still valid)");
return;
}
const claims = await verifyToken(token);
const did = (claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`;
const label = claims.username || did;
const amount = process.env.X402_UPLOAD_PRICE || "0.01";
const network = process.env.X402_NETWORK || "eip155:84532";
// Use payment header hash as pseudo-txHash for idempotency
const txHash = `x402-${Buffer.from(paymentHeader).toString("base64url").slice(0, 40)}`;
mintFromOnChain(did, label, amount, txHash, network);
} catch (e) {
console.warn("[x402 bridge] cUSDC mint failed (non-fatal):", e);
}
},
});
app.post("/api/x402-test", async (c) => {
if (x402Test) {
const result = await new Promise<Response | null>((resolve) => {
x402Test(c, async () => { resolve(null); }).then((res) => {
if (res instanceof Response) resolve(res);
});
});
if (result) return result;
}
const scheme = c.get("x402Scheme") || "none";
return c.json({ ok: true, scheme, 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";
const SPLAT_NOTIFY_EMAIL = process.env.SPLAT_NOTIFY_EMAIL || "";
const SITE_URL = process.env.SITE_URL || "https://rspace.online";
// ── 3D Gen job queue ──
import { createTransport } from "nodemailer";
interface Gen3DJob {
id: string;
status: "pending" | "processing" | "complete" | "failed";
imageUrl: string;
resultUrl?: string;
resultFormat?: string;
error?: string;
createdAt: number;
completedAt?: number;
emailSent?: boolean;
title?: string;
}
const gen3dJobs = new Map<string, Gen3DJob>();
// Clean up old jobs every 30 minutes (keep for 24h)
setInterval(() => {
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
for (const [id, job] of gen3dJobs) {
if (job.createdAt < cutoff) gen3dJobs.delete(id);
}
}, 30 * 60 * 1000);
// ── Video generation job queue (async to avoid Cloudflare timeouts) ──
interface VideoGenJob {
id: string;
status: "pending" | "processing" | "complete" | "failed";
type: "t2v" | "i2v";
prompt: string;
sourceImage?: string;
resultUrl?: string;
error?: string;
createdAt: number;
completedAt?: number;
queuePosition?: number;
falStatus?: string;
model?: string;
duration?: string;
resolution?: string;
aspectRatio?: string;
generateAudio?: boolean;
}
const videoGenJobs = new Map<string, VideoGenJob>();
// Clean up old video jobs every 30 minutes (keep for 6h)
setInterval(() => {
const cutoff = Date.now() - 6 * 60 * 60 * 1000;
for (const [id, job] of videoGenJobs) {
if (job.createdAt < cutoff) videoGenJobs.delete(id);
}
}, 30 * 60 * 1000);
async function processVideoGenJob(job: VideoGenJob) {
job.status = "processing";
const falHeaders = { Authorization: `Key ${FAL_KEY}`, "Content-Type": "application/json" };
try {
let MODEL: string;
let body: Record<string, any>;
const m = job.model || (job.type === "i2v" ? "kling" : "wan");
switch (m) {
case "seedance":
case "seedance-fast": {
const fast = m === "seedance-fast" ? "/fast" : "";
const mode = job.type === "i2v" ? "image-to-video" : "text-to-video";
MODEL = `bytedance/seedance-2.0${fast}/${mode}`;
body = {
prompt: job.prompt,
duration: job.duration || "5",
resolution: job.resolution || "480p",
aspect_ratio: job.aspectRatio || "16:9",
generate_audio: job.generateAudio ?? false,
};
if (job.type === "i2v" && job.sourceImage) {
body.image_url = job.sourceImage;
}
break;
}
case "kling":
MODEL = "fal-ai/kling-video/v1/standard/image-to-video";
body = { image_url: job.sourceImage, prompt: job.prompt || "", duration: 5, aspect_ratio: "16:9" };
break;
case "wan":
default:
MODEL = "fal-ai/wan-t2v";
body = { prompt: job.prompt, num_frames: 81, resolution: "480p" };
break;
}
// Submit to fal.ai queue
const submitRes = await fetch(`https://queue.fal.run/${MODEL}`, {
method: "POST",
headers: falHeaders,
body: JSON.stringify(body),
});
if (!submitRes.ok) {
const errText = await submitRes.text();
console.error(`[video-gen] fal.ai submit error (${job.type}):`, submitRes.status, errText);
job.status = "failed";
job.error = "Video generation failed to start";
job.completedAt = Date.now();
return;
}
const submitData = await submitRes.json() as { request_id: string; status_url?: string; response_url?: string };
const request_id = submitData.request_id;
const statusPollUrl = submitData.status_url || `https://queue.fal.run/${MODEL}/requests/${request_id}/status`;
let responseUrl = submitData.response_url || "";
console.log(`[video-gen] Job ${job.id} submitted (model=${MODEL}, reqId=${request_id})`);
// Poll for completion (up to 10 min)
const deadline = Date.now() + 600_000;
let completed = false;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 3000));
const statusRes = await fetch(statusPollUrl, { headers: falHeaders });
if (!statusRes.ok) {
console.log(`[video-gen] Poll ${job.id}: status HTTP ${statusRes.status}`);
continue;
}
const statusData = await statusRes.json() as { status: string; response_url?: string; queue_position?: number };
console.log(`[video-gen] Poll ${job.id}: status=${statusData.status}`);
job.falStatus = statusData.status;
job.queuePosition = statusData.queue_position;
if (statusData.response_url) responseUrl = statusData.response_url;
if (statusData.status === "COMPLETED") { completed = true; break; }
if (statusData.status === "FAILED") {
job.status = "failed";
job.error = "Video generation failed on fal.ai";
job.completedAt = Date.now();
return;
}
}
if (!completed) {
job.status = "failed";
job.error = "Video generation timed out";
job.completedAt = Date.now();
return;
}
// Fetch result
const resultUrl = responseUrl || `https://queue.fal.run/${MODEL}/requests/${request_id}`;
const resultRes = await fetch(resultUrl, { headers: falHeaders });
if (!resultRes.ok) {
job.status = "failed";
job.error = "Failed to retrieve video";
job.completedAt = Date.now();
return;
}
const data = await resultRes.json();
const videoUrl = data.video?.url || data.output?.url;
if (!videoUrl) {
console.error(`[video-gen] No video URL in response:`, JSON.stringify(data).slice(0, 500));
job.status = "failed";
job.error = "No video returned";
job.completedAt = Date.now();
return;
}
job.status = "complete";
job.resultUrl = videoUrl;
job.completedAt = Date.now();
console.log(`[video-gen] Job ${job.id} complete: ${videoUrl}`);
} catch (e: any) {
console.error("[video-gen] error:", e.message);
job.status = "failed";
job.error = "Video generation failed";
job.completedAt = Date.now();
}
}
let splatMailTransport: ReturnType<typeof createTransport> | null = null;
if (process.env.SMTP_PASS) {
splatMailTransport = createTransport({
host: process.env.SMTP_HOST || "mail.rmail.online",
port: Number(process.env.SMTP_PORT) || 587,
secure: Number(process.env.SMTP_PORT) === 465,
tls: { rejectUnauthorized: false },
auth: {
user: process.env.SMTP_USER || "noreply@rmail.online",
pass: process.env.SMTP_PASS,
},
});
}
async function sendSplatEmail(job: Gen3DJob) {
if (!splatMailTransport || !SPLAT_NOTIFY_EMAIL) return;
const downloadUrl = `${SITE_URL}${job.resultUrl}`;
const title = job.title || "3D Model";
try {
await splatMailTransport.sendMail({
from: process.env.SMTP_FROM || "rSplat <noreply@rmail.online>",
to: SPLAT_NOTIFY_EMAIL,
subject: `Your 3D splat "${title}" is ready — rSplat`,
html: `
<div style="font-family:system-ui,sans-serif;max-width:600px;margin:0 auto;padding:24px;">
<h2 style="color:#818cf8;">Your 3D model is ready!</h2>
<p>Your AI-generated 3D model <strong>"${title}"</strong> has finished processing.</p>
<p style="margin:24px 0;">
<a href="${downloadUrl}" style="display:inline-block;padding:12px 24px;background:#818cf8;color:#fff;text-decoration:none;border-radius:8px;font-weight:600;">
View &amp; Download
</a>
</p>
<p>Or save it to your gallery at <a href="${SITE_URL}/rsplat">${SITE_URL}/rsplat</a></p>
<p style="color:#64748b;font-size:13px;margin-top:32px;">Generated by rSplat on rSpace</p>
</div>
`,
});
job.emailSent = true;
console.log(`[3d-gen] Email sent to ${SPLAT_NOTIFY_EMAIL} for job ${job.id}`);
} catch (e: any) {
console.error(`[3d-gen] Failed to send email:`, e.message);
}
}
async function process3DGenJob(job: Gen3DJob) {
job.status = "processing";
const falHeaders = { Authorization: `Key ${FAL_KEY}`, "Content-Type": "application/json" };
const MODEL = "fal-ai/hunyuan3d-v21";
try {
// 1. Resize staged image with sharp (max 1024px) for reliable fal.ai processing
let imageInput = job.imageUrl;
const stagedMatch = job.imageUrl.match(/\/(?:api\/files|data\/files)\/generated\/([^?#]+)/);
if (stagedMatch) {
try {
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
const srcPath = resolve(dir, stagedMatch[1]);
const file = Bun.file(srcPath);
if (await file.exists()) {
const resizedBuf = await sharp(srcPath)
.resize(1024, 1024, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 90 })
.toBuffer();
const resizedName = `stage-resized-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.jpg`;
await Bun.write(resolve(dir, resizedName), resizedBuf);
// Use public HTTPS URL for the resized image
imageInput = `${PUBLIC_ORIGIN}/api/files/generated/${resizedName}`;
console.log(`[3d-gen] Resized image: ${Math.round(resizedBuf.length / 1024)}KB → ${imageInput}`);
}
} catch (e: any) {
console.warn(`[3d-gen] Resize failed, using original URL:`, e.message);
}
}
// 2. Submit to fal.ai queue
const submitRes = await fetch(`https://queue.fal.run/${MODEL}`, {
method: "POST",
headers: falHeaders,
body: JSON.stringify({ input_image_url: imageInput, textured_mesh: true, octree_resolution: 256 }),
});
if (!submitRes.ok) {
const errText = await submitRes.text();
console.error("[3d-gen] fal.ai submit error:", submitRes.status, errText);
let detail = "3D generation failed to start";
try {
const parsed = JSON.parse(errText);
if (parsed.detail) {
detail = typeof parsed.detail === "string" ? parsed.detail
: Array.isArray(parsed.detail) ? parsed.detail[0]?.msg || detail
: detail;
}
} catch {}
job.status = "failed";
job.error = detail;
job.completedAt = Date.now();
return;
}
const { request_id } = await submitRes.json() as { request_id: string };
// 3. Poll for completion (up to 8 min — Hunyuan3D with textures can take 3-5 min)
const deadline = Date.now() + 480_000;
let responseUrl = "";
let completed = false;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 3000));
const statusRes = await fetch(
`https://queue.fal.run/${MODEL}/requests/${request_id}/status`,
{ headers: falHeaders },
);
if (!statusRes.ok) continue;
const statusData = await statusRes.json() as { status: string; response_url?: string };
console.log(`[3d-gen] Poll ${job.id}: status=${statusData.status}`);
if (statusData.response_url) responseUrl = statusData.response_url;
if (statusData.status === "COMPLETED") { completed = true; break; }
if (statusData.status === "FAILED") {
job.status = "failed";
job.error = "3D generation failed on fal.ai";
job.completedAt = Date.now();
return;
}
}
if (!completed) {
job.status = "failed";
job.error = "3D generation timed out";
job.completedAt = Date.now();
return;
}
// 4. Fetch result using response_url from status (with fallback)
const resultUrl = responseUrl || `https://queue.fal.run/${MODEL}/requests/${request_id}`;
console.log(`[3d-gen] Fetching result from: ${resultUrl}`);
let resultRes = await fetch(resultUrl, { headers: falHeaders });
if (!resultRes.ok) {
console.warn(`[3d-gen] Result fetch failed (status=${resultRes.status}), retrying in 3s...`);
const errBody = await resultRes.text().catch(() => "");
console.warn(`[3d-gen] Result error body:`, errBody);
await new Promise((r) => setTimeout(r, 3000));
resultRes = await fetch(resultUrl, { headers: falHeaders });
}
if (!resultRes.ok) {
const errBody = await resultRes.text().catch(() => "");
console.error(`[3d-gen] Result fetch failed after retry (status=${resultRes.status}):`, errBody);
let detail = "Failed to retrieve 3D model";
try {
const parsed = JSON.parse(errBody);
if (parsed.detail) {
detail = Array.isArray(parsed.detail) ? parsed.detail[0]?.msg || detail
: typeof parsed.detail === "string" ? parsed.detail : detail;
}
} catch {}
job.status = "failed";
job.error = detail;
job.completedAt = Date.now();
return;
}
const data = await resultRes.json();
console.log(`[3d-gen] Result keys for ${job.id}:`, Object.keys(data));
const modelUrl = data.model_glb?.url || data.model_glb_pbr?.url || data.mesh?.url;
if (!modelUrl) {
console.error(`[3d-gen] No model URL found in response:`, JSON.stringify(data).slice(0, 500));
job.status = "failed";
job.error = "No 3D model returned";
job.completedAt = Date.now();
return;
}
// 5. Download and save
const modelRes = await fetch(modelUrl);
if (!modelRes.ok) {
job.status = "failed";
job.error = "Failed to download model";
job.completedAt = Date.now();
return;
}
const modelBuf = await modelRes.arrayBuffer();
const filename = `splat-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.glb`;
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
await Bun.write(resolve(dir, filename), modelBuf);
pinGeneratedFile(resolve(dir, filename), filename);
job.status = "complete";
job.resultUrl = `/data/files/generated/${filename}`;
job.resultFormat = "glb";
job.completedAt = Date.now();
console.log(`[3d-gen] Job ${job.id} complete: ${job.resultUrl}`);
// Send email notification
sendSplatEmail(job).catch(() => {});
} catch (e: any) {
console.error("[3d-gen] error:", e.message);
job.status = "failed";
job.error = "3D generation failed";
job.completedAt = Date.now();
}
}
// ── Image helpers ──
/** Read a /data/files/generated/... path from disk → base64 */
async function readFileAsBase64(serverPath: string): Promise<string> {
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<string> {
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"));
pinGeneratedFile(resolve(dir, filename), filename);
return `/data/files/generated/${filename}`;
}
// Image generation via fal.ai Flux Pro (delegates to shared helper)
app.post("/api/image-gen", async (c) => {
const { prompt, style } = await c.req.json();
if (!prompt) return c.json({ error: "prompt required" }, 400);
const { generateImageViaFal } = await import("./mi-media");
const result = await generateImageViaFal(prompt, style);
if (!result.ok) return c.json({ error: result.error }, 502);
return c.json({ url: result.url, image_url: result.url });
});
// 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);
}
});
// MakeReal: sketch-to-HTML via Gemini vision
app.post("/api/makereal", async (c) => {
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
const { sketch_image, prompt, framework = "html" } = await c.req.json();
if (!sketch_image) return c.json({ error: "sketch_image 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" });
// Extract base64 from data URL
const match = sketch_image.match(/^data:(image\/\w+);base64,(.+)$/);
if (!match) return c.json({ error: "Invalid image data URL" }, 400);
const frameworkInstructions: Record<string, string> = {
html: "Use plain HTML and CSS only. No external dependencies.",
tailwind: "Use Tailwind CSS via CDN (<script src=\"https://cdn.tailwindcss.com\"></script>). Use Tailwind utility classes for all styling.",
react: "Use React via CDN (react, react-dom, babel-standalone). Include a single <script type=\"text/babel\"> block with the React component.",
};
const systemPrompt = `You are an expert frontend developer. Convert the wireframe sketch into a complete, functional HTML page.
Rules:
- Return ONLY the HTML code, no markdown fences, no explanation
- ${frameworkInstructions[framework] || frameworkInstructions.html}
- Make it visually polished with proper spacing, colors, and typography
- Include all styles inline or in a <style> block — the page must be fully self-contained
- Make interactive elements functional (buttons, inputs, toggles should work)
- Use modern CSS (flexbox/grid) for layout
- Match the layout and structure shown in the wireframe sketch as closely as possible`;
const userPrompt = prompt || "Convert this wireframe into a working UI";
try {
const result = await model.generateContent({
contents: [{
role: "user",
parts: [
{ inlineData: { data: match[2], mimeType: match[1] } },
{ text: `${systemPrompt}\n\nUser request: ${userPrompt}` },
],
}],
});
let htmlContent = result.response.text();
// Strip markdown fences if present
htmlContent = htmlContent.replace(/^```html?\s*\n?/i, "").replace(/\n?```\s*$/i, "").trim();
// Save to generated files
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
const filename = `makereal-${Date.now()}.html`;
await Bun.write(resolve(dir, filename), htmlContent);
return c.json({ html: htmlContent, url: `/data/files/generated/${filename}` });
} catch (e: any) {
console.error("[makereal] error:", e.message);
return c.json({ error: "HTML generation 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));
pinGeneratedFile(resolve(dir, filename), filename);
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<string, string> = {
"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"));
pinGeneratedFile(resolve(dir, filename), filename);
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 (async job queue to avoid Cloudflare timeouts)
app.post("/api/video-gen/t2v", async (c) => {
if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
const { prompt, model, duration, resolution, aspect_ratio, generate_audio } = await c.req.json();
if (!prompt) return c.json({ error: "prompt required" }, 400);
const jobId = crypto.randomUUID();
const job: VideoGenJob = {
id: jobId, status: "pending", type: "t2v",
prompt, createdAt: Date.now(),
model: model || "wan",
duration, resolution, aspectRatio: aspect_ratio, generateAudio: generate_audio,
};
videoGenJobs.set(jobId, job);
processVideoGenJob(job);
return c.json({ job_id: jobId, status: "pending" });
});
// Image-to-video via fal.ai Kling (async job queue)
app.post("/api/video-gen/i2v", async (c) => {
if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
const { image, prompt, model, duration, resolution, aspect_ratio, generate_audio } = await c.req.json();
if (!image) return c.json({ error: "image required" }, 400);
// Stage the source image if it's a data URL
let imageUrl = image;
if (image.startsWith("data:")) {
const url = await saveDataUrlToDisk(image, "vid-src");
imageUrl = publicUrl(c, url);
} else if (image.startsWith("/")) {
imageUrl = publicUrl(c, image);
}
const jobId = crypto.randomUUID();
const job: VideoGenJob = {
id: jobId, status: "pending", type: "i2v",
prompt: prompt || "", sourceImage: imageUrl, createdAt: Date.now(),
model: model || "kling",
duration, resolution, aspectRatio: aspect_ratio, generateAudio: generate_audio,
};
videoGenJobs.set(jobId, job);
processVideoGenJob(job);
return c.json({ job_id: jobId, status: "pending" });
});
// Poll video generation job status
app.get("/api/video-gen/:jobId", async (c) => {
const jobId = c.req.param("jobId");
const job = videoGenJobs.get(jobId);
if (!job) return c.json({ error: "Job not found" }, 404);
const response: Record<string, any> = {
job_id: job.id, status: job.status, created_at: job.createdAt,
fal_status: job.falStatus, queue_position: job.queuePosition,
};
if (job.status === "complete") {
response.url = job.resultUrl;
response.video_url = job.resultUrl;
response.completed_at = job.completedAt;
} else if (job.status === "failed") {
response.error = job.error;
response.completed_at = job.completedAt;
}
return c.json(response);
});
// Stage image for 3D generation (binary upload → HTTPS URL for fal.ai)
const PUBLIC_ORIGIN = process.env.PUBLIC_ORIGIN || "https://rspace.online";
/** Build public URL using the request's Host header so subdomains (jeff.rspace.online) work */
function publicUrl(c: any, path: string): string {
const host = c.req.header("host") || new URL(PUBLIC_ORIGIN).host;
// Always use https — site is behind Cloudflare/Traefik, x-forwarded-proto may be "http" internally
return `https://${host}${path}`;
}
app.post("/api/image-stage", async (c) => {
const ct = c.req.header("content-type") || "";
console.log("[image-stage] Content-Type:", ct);
let formData: FormData;
try {
formData = await c.req.formData();
} catch (e: any) {
console.error("[image-stage] formData parse failed:", e.message, "| Content-Type:", ct);
// Fallback: try reading raw body as binary if it's not multipart
if (ct.startsWith("application/octet-stream") || !ct.includes("multipart")) {
const buf = Buffer.from(await c.req.arrayBuffer());
if (!buf.length) return c.json({ error: "Empty body" }, 400);
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
const filename = `stage-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.jpg`;
await Bun.write(resolve(dir, filename), buf);
const url = publicUrl(c, `/api/files/generated/${filename}`);
console.log("[image-stage] Staged (binary fallback):", url);
return c.json({ url });
}
return c.json({ error: "Invalid upload format. Expected multipart/form-data." }, 400);
}
const file = formData.get("file") as File | null;
if (!file) return c.json({ error: "file required" }, 400);
// HEIC rejection
const ext = file.name.split(".").pop()?.toLowerCase() || "";
if (ext === "heic" || ext === "heif" || file.type === "image/heic" || file.type === "image/heif") {
return c.json({ error: "HEIC files are not supported. Please convert to JPEG or PNG first." }, 400);
}
// Validate type
const validTypes = ["image/jpeg", "image/png", "image/webp"];
const validExts = ["jpg", "jpeg", "png", "webp"];
if (!validTypes.includes(file.type) && !validExts.includes(ext)) {
return c.json({ error: "Only JPEG, PNG, and WebP images are supported" }, 400);
}
// 15MB limit
if (file.size > 15 * 1024 * 1024) {
return c.json({ error: "Image too large. Maximum 15MB." }, 400);
}
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
const outExt = ext === "png" ? "png" : ext === "webp" ? "webp" : "jpg";
const filename = `stage-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.${outExt}`;
const buf = Buffer.from(await file.arrayBuffer());
await Bun.write(resolve(dir, filename), buf);
const url = publicUrl(c, `/api/files/generated/${filename}`);
console.log("[image-stage] Staged:", url, `(${buf.length} bytes)`);
return c.json({ url });
});
// Image-to-3D via fal.ai Hunyuan3D v2.1 (async job queue)
app.post("/api/3d-gen", async (c) => {
if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
const { image_url, title, email } = await c.req.json();
if (!image_url) return c.json({ error: "image_url required" }, 400);
const jobId = crypto.randomUUID();
const job: Gen3DJob = {
id: jobId,
status: "pending",
imageUrl: image_url,
createdAt: Date.now(),
title: title || "Untitled",
};
gen3dJobs.set(jobId, job);
// Process in background — no await, returns immediately
process3DGenJob(job);
return c.json({ job_id: jobId, status: "pending" });
});
// Poll job status
app.get("/api/3d-gen/:jobId", async (c) => {
const jobId = c.req.param("jobId");
const job = gen3dJobs.get(jobId);
if (!job) return c.json({ error: "Job not found" }, 404);
const response: Record<string, any> = {
job_id: job.id,
status: job.status,
created_at: job.createdAt,
};
if (job.status === "complete") {
response.url = job.resultUrl;
response.format = job.resultFormat;
response.completed_at = job.completedAt;
response.email_sent = job.emailSent || false;
response.source_image = job.imageUrl;
} else if (job.status === "failed") {
response.error = job.error;
response.completed_at = job.completedAt;
}
return c.json(response);
});
// Blender 3D generation via LLM + headless worker sidecar
const BLENDER_WORKER_URL = process.env.BLENDER_WORKER_URL || "http://blender-worker:8810";
app.get("/api/blender-gen/health", async (c) => {
const issues: string[] = [];
const warnings: string[] = [];
if (!GEMINI_API_KEY) issues.push("GEMINI_API_KEY not configured");
const running = await isSidecarRunning("blender-worker");
if (!running) warnings.push("blender-worker stopped (will start on demand)");
else {
try {
const res = await fetch(`${BLENDER_WORKER_URL}/health`, { signal: AbortSignal.timeout(3000) });
if (!res.ok) warnings.push("blender-worker unhealthy");
} catch {
warnings.push("blender-worker unreachable");
}
}
return c.json({ available: issues.length === 0, issues, warnings });
});
app.post("/api/blender-gen", async (c) => {
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_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 Gemini Flash
let script = "";
try {
const { GoogleGenerativeAI } = await import("@google/generative-ai");
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
const model = genAI.getGenerativeModel({ model: "gemini-2.0-flash" });
const result = await model.generateContent(`Generate a Blender Python script that creates: ${prompt}
The script should:
- Import bpy
- Clear the default scene (delete all default objects)
- Create the described objects with materials and colors
- Set up basic lighting (sun + area light) and camera positioned to frame the scene
- Use Cycles render engine with CPU device: bpy.context.scene.render.engine = "CYCLES" and bpy.context.scene.cycles.device = "CPU" and bpy.context.scene.cycles.samples = 64 and bpy.context.scene.cycles.use_denoising = False
- Render to /tmp/render.png at 1024x1024 with bpy.context.scene.render.image_settings.file_format = "PNG"
Output ONLY the Python code, no explanations or comments outside the code.`);
const text = result.response.text();
const codeMatch = text.match(/```(?:python)?\n([\s\S]*?)```/);
script = codeMatch ? codeMatch[1].trim() : text.trim();
} catch (e) {
console.error("[blender-gen] Gemini error:", e);
}
if (!script) {
return c.json({ error: "Failed to generate Blender script" }, 502);
}
// Step 2: Start sidecar on demand, execute on blender-worker
try {
await ensureSidecar("blender-worker");
const workerRes = await fetch(`${BLENDER_WORKER_URL}/render`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ script }),
signal: AbortSignal.timeout(95_000), // 95s — worker has 90s internal timeout
});
const data = await workerRes.json() as {
success?: boolean;
render_url?: string;
error?: string;
stdout?: string;
stderr?: string;
};
markSidecarUsed("blender-worker");
if (data.success && data.render_url) {
return c.json({ script, render_url: data.render_url });
}
// Worker ran but render failed — return script + error details
return c.json({
script,
render_url: null,
error_detail: data.error || "Render failed",
});
} catch (e) {
console.error("[blender-gen] worker error:", e);
// Worker unreachable — return script only
return c.json({ script, render_url: null, error_detail: "blender-worker unavailable" });
}
});
// ── Design Agent (Scribus DTP via Gemini tool loop) ──
const SCRIBUS_BRIDGE_URL = process.env.SCRIBUS_BRIDGE_URL || "http://scribus-novnc:8765";
const SCRIBUS_BRIDGE_SECRET = process.env.SCRIBUS_BRIDGE_SECRET || "";
const SCRIBUS_TOOLS = [
{
name: "new_document",
description: "Create a new Scribus document with given page size and margins",
parameters: {
type: "object",
properties: {
width: { type: "number", description: "Page width in points (default 595 = A4)" },
height: { type: "number", description: "Page height in points (default 842 = A4)" },
margins: { type: "number", description: "Margin in points (default 40)" },
pages: { type: "number", description: "Number of pages (default 1)" },
unit: { type: "string", description: "Unit: pt, mm, in (default pt)" },
},
},
},
{
name: "add_text_frame",
description: "Add a text frame to the document",
parameters: {
type: "object",
properties: {
x: { type: "number", description: "X position in points" },
y: { type: "number", description: "Y position in points" },
width: { type: "number", description: "Frame width in points" },
height: { type: "number", description: "Frame height in points" },
text: { type: "string", description: "Text content" },
fontSize: { type: "number", description: "Font size in points (default 12)" },
fontName: { type: "string", description: "Font name (default: Arial Regular)" },
name: { type: "string", description: "Unique frame name" },
},
required: ["x", "y", "width", "height", "text", "name"],
},
},
{
name: "add_image_frame",
description: "Add an image frame to the document",
parameters: {
type: "object",
properties: {
x: { type: "number", description: "X position" },
y: { type: "number", description: "Y position" },
width: { type: "number", description: "Frame width" },
height: { type: "number", description: "Frame height" },
imagePath: { type: "string", description: "Path to image file inside container" },
name: { type: "string", description: "Unique frame name" },
},
required: ["x", "y", "width", "height", "name"],
},
},
{
name: "add_shape",
description: "Add a rectangle or ellipse shape to the document",
parameters: {
type: "object",
properties: {
x: { type: "number", description: "X position" },
y: { type: "number", description: "Y position" },
width: { type: "number", description: "Shape width" },
height: { type: "number", description: "Shape height" },
shapeType: { type: "string", description: "Shape type: rectangle or ellipse" },
fillColor: { type: "string", description: "Fill color as hex (e.g. #ff0000)" },
name: { type: "string", description: "Unique frame name" },
},
required: ["x", "y", "width", "height", "name"],
},
},
{
name: "set_background_color",
description: "Set the page background color",
parameters: {
type: "object",
properties: {
color: { type: "string", description: "Hex color (e.g. #ffffff)" },
},
required: ["color"],
},
},
{
name: "move_frame",
description: "Move a frame to a new position",
parameters: {
type: "object",
properties: {
name: { type: "string", description: "Frame name" },
x: { type: "number", description: "New X position" },
y: { type: "number", description: "New Y position" },
relative: { type: "boolean", description: "If true, move relative to current position" },
},
required: ["name", "x", "y"],
},
},
{
name: "delete_frame",
description: "Delete a frame by name",
parameters: {
type: "object",
properties: {
name: { type: "string", description: "Frame name to delete" },
},
required: ["name"],
},
},
{
name: "save_as_sla",
description: "Save the document as a Scribus .sla file",
parameters: {
type: "object",
properties: {
space: { type: "string", description: "Space/project name for organizing files" },
filename: { type: "string", description: "Filename without extension" },
},
required: ["space", "filename"],
},
},
{
name: "get_doc_state",
description: "Get current document state including all frames and their properties",
parameters: { type: "object", properties: {} },
},
];
const SCRIBUS_SYSTEM_PROMPT = `You are a Scribus desktop publishing design assistant. You create professional print-ready layouts by calling Scribus tools step by step.
Available tools control a live Scribus document. Follow this workflow:
1. new_document — Create the page (default A4: 595×842 pt, or specify custom size)
2. set_background_color — Set page background if needed
3. add_text_frame — Add titles, headings, body text
4. add_shape — Add decorative shapes, dividers, color blocks
5. add_image_frame — Add image placeholders
6. get_doc_state — Verify the layout
7. save_as_sla — Save the final document
Design principles:
- Use clear visual hierarchy: large bold titles, medium subheads, smaller body text
- Leave generous margins (40+ pt) and whitespace
- Use shapes as backgrounds or accent elements behind text
- For flyers: big headline at top, key info in middle, call-to-action at bottom
- For posters: dramatic title, supporting image area, details below
- For cards: centered content, decorative border shapes
- Name every frame descriptively (e.g. "title", "subtitle", "hero-bg", "body-text")
- Standard A4 is 595×842 pt, US Letter is 612×792 pt
- Font sizes: titles 36-60pt, subtitles 18-24pt, body 10-14pt`;
async function callScribusBridge(action: string, args: Record<string, any> = {}): Promise<any> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (SCRIBUS_BRIDGE_SECRET) headers["X-Bridge-Secret"] = SCRIBUS_BRIDGE_SECRET;
const res = await fetch(`${SCRIBUS_BRIDGE_URL}/api/scribus/command`, {
method: "POST",
headers,
body: JSON.stringify({ action, args }),
signal: AbortSignal.timeout(30_000),
});
return res.json();
}
app.post("/api/design-agent", async (c) => {
const { brief, space } = await c.req.json();
if (!brief) return c.json({ error: "brief is required" }, 400);
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (event: any) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
};
try {
// 1. Start Scribus sidecar
send({ action: "starting_scribus", status: "Starting Scribus container..." });
await ensureSidecar("scribus-novnc");
// 2. Health check
try {
const healthRes = await fetch(`${SCRIBUS_BRIDGE_URL}/health`, { signal: AbortSignal.timeout(10_000) });
const health = await healthRes.json() as any;
if (!health.ok) {
send({ action: "error", error: "Scribus bridge unhealthy" });
controller.close();
return;
}
} catch (e) {
send({ action: "error", error: `Scribus bridge unreachable: ${e instanceof Error ? e.message : e}` });
controller.close();
return;
}
send({ action: "scribus_ready", status: "Scribus ready" });
// 3. Initialize Gemini Flash with Scribus tools
const { GoogleGenerativeAI } = await import("@google/generative-ai");
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
const model = genAI.getGenerativeModel({
model: "gemini-2.5-flash",
systemInstruction: SCRIBUS_SYSTEM_PROMPT,
generationConfig: { temperature: 0.3 },
tools: [{ functionDeclarations: SCRIBUS_TOOLS as any }],
});
// 4. Agent loop
const maxTurns = 15;
const deadline = Date.now() + 90_000;
let contents: any[] = [
{ role: "user", parts: [{ text: `Design brief: ${brief}\n\nSpace/project: ${space || "demo"}\nCreate this design now using the available Scribus tools.` }] },
];
for (let turn = 0; turn < maxTurns; turn++) {
if (Date.now() > deadline) {
send({ action: "thinking", status: "Deadline reached, finishing up..." });
break;
}
send({ action: "thinking", status: `Planning step ${turn + 1}...` });
const result = await model.generateContent({ contents });
const candidate = result.response.candidates?.[0];
if (!candidate) break;
const parts = candidate.content?.parts || [];
const fnCalls = parts.filter((p: any) => p.functionCall);
if (fnCalls.length === 0) {
// Model is done — extract final text
const finalText = parts.filter((p: any) => p.text).map((p: any) => p.text).join("\n");
if (finalText) send({ action: "thinking", status: finalText });
break;
}
// Execute each function call
const fnResponseParts: any[] = [];
for (const part of fnCalls) {
const fc = part.functionCall!;
const toolName = fc.name;
const toolArgs = fc.args || {};
send({ action: "executing", tool: toolName, status: `Executing ${toolName}` });
console.log(`[design-agent] Turn ${turn + 1}: ${toolName}(${JSON.stringify(toolArgs).slice(0, 200)})`);
let toolResult: any;
try {
toolResult = await callScribusBridge(toolName, toolArgs);
} catch (err) {
toolResult = { error: err instanceof Error ? err.message : String(err) };
}
send({ action: "tool_result", tool: toolName, result: toolResult });
fnResponseParts.push({
functionResponse: {
name: toolName,
response: { result: JSON.stringify(toolResult) },
},
});
}
contents.push({ role: "model", parts });
contents.push({ role: "user", parts: fnResponseParts });
}
// 5. Get final document state
let docState: any = null;
try {
const stateRes = await fetch(`${SCRIBUS_BRIDGE_URL}/api/scribus/state`, {
headers: SCRIBUS_BRIDGE_SECRET ? { "X-Bridge-Secret": SCRIBUS_BRIDGE_SECRET } : {},
signal: AbortSignal.timeout(10_000),
});
docState = await stateRes.json();
} catch (e) {
console.warn("[design-agent] Failed to get final state:", e);
}
send({ action: "done", status: "Design complete", state: docState });
markSidecarUsed("scribus-novnc");
} catch (e) {
console.error("[design-agent] error:", e);
send({ action: "error", error: e instanceof Error ? e.message : String(e) });
}
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
});
// ── ASCII Art Generation (proxies to ascii-art service on rspace-internal) ──
const ASCII_ART_URL = process.env.ASCII_ART_URL || "http://ascii-art:8000";
app.post("/api/ascii-gen", async (c) => {
const body = await c.req.json();
const { prompt, width, height, palette, output_format } = body;
if (!prompt) return c.json({ error: "prompt required" }, 400);
try {
const res = await fetch(`${ASCII_ART_URL}/api/pattern`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt,
width: width || 80,
height: height || 40,
palette: palette || "classic",
output_format: output_format || "html",
}),
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) {
const err = await res.text();
return c.json({ error: `ASCII art service error: ${err}` }, res.status as any);
}
// Service returns raw HTML — wrap in JSON for the client
const htmlContent = await res.text();
// Strip HTML tags to get plain text version
const textContent = htmlContent.replace(/<[^>]*>/g, "").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
return c.json({ html: htmlContent, text: textContent });
} catch (e: any) {
console.error("[ascii-gen] error:", e);
return c.json({ error: `ASCII art service unavailable: ${e.message}` }, 502);
}
});
app.post("/api/ascii-gen/render", async (c) => {
try {
const res = await fetch(`${ASCII_ART_URL}/api/render`, {
method: "POST",
headers: { "Content-Type": c.req.header("Content-Type") || "application/json" },
body: await c.req.arrayBuffer(),
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) {
const err = await res.text();
return c.json({ error: `ASCII render error: ${err}` }, res.status as any);
}
const htmlContent = await res.text();
const textContent = htmlContent.replace(/<[^>]*>/g, "").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
return c.json({ html: htmlContent, text: textContent });
} catch (e: any) {
console.error("[ascii-gen] render error:", e);
return c.json({ error: `ASCII art service unavailable: ${e.message}` }, 502);
}
});
// KiCAD PCB design — MCP StreamableHTTP bridge (sidecar container)
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { runCadAgentLoop, assembleKicadResult, assembleFreecadResult, KICAD_SYSTEM_PROMPT, FREECAD_SYSTEM_PROMPT } from "./cad-orchestrator";
import { ensureSidecar, markSidecarUsed, isSidecarRunning, startIdleWatcher } from "./sidecar-manager";
const KICAD_MCP_URL = process.env.KICAD_MCP_URL || "http://kicad-mcp:8809/mcp";
let kicadClient: Client | null = null;
async function getKicadClient(): Promise<Client> {
await ensureSidecar("kicad-mcp");
if (kicadClient) return kicadClient;
const transport = new StreamableHTTPClientTransport(new URL(KICAD_MCP_URL));
const client = new Client({ name: "rspace-kicad-bridge", version: "1.0.0" });
transport.onclose = () => { kicadClient = null; };
transport.onerror = () => { kicadClient = null; };
await client.connect(transport);
kicadClient = client;
return client;
}
app.get("/api/kicad/health", async (c) => {
try {
const running = await isSidecarRunning("kicad-mcp");
if (!running) return c.json({ available: false, status: "stopped (starts on demand)" });
const client = await getKicadClient();
const tools = await client.listTools();
return c.json({ available: true, tools: tools.tools.length });
} catch (e) {
return c.json({ available: false, error: e instanceof Error ? e.message : "Connection failed" });
}
});
// KiCAD AI-orchestrated design generation
app.post("/api/kicad/generate", async (c) => {
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
const { prompt, components } = await c.req.json();
if (!prompt) return c.json({ error: "prompt required" }, 400);
const enrichedPrompt = components?.length
? `${prompt}\n\nKey components to use: ${components.join(", ")}`
: prompt;
try {
const client = await getKicadClient();
const orch = await runCadAgentLoop(client, KICAD_SYSTEM_PROMPT, enrichedPrompt, GEMINI_API_KEY);
const result = assembleKicadResult(orch);
markSidecarUsed("kicad-mcp");
return c.json({
schematic_svg: result.schematicSvg,
board_svg: result.boardSvg,
gerber_url: result.gerberUrl,
bom_url: result.bomUrl,
pdf_url: result.pdfUrl,
drc: result.drcResults,
summary: result.summary,
});
} catch (e) {
console.error("[kicad/generate] error:", e);
kicadClient = null;
return c.json({ error: e instanceof Error ? e.message : "KiCAD generation failed" }, 502);
}
});
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 client = await getKicadClient();
const result = await client.callTool({ name: action, arguments: body });
// Extract text content and parse as JSON if possible
const content = result.content as Array<{ type: string; text?: string }>;
const textContent = content
?.filter((c) => c.type === "text")
.map((c) => c.text)
.join("\n");
try {
return c.json(JSON.parse(textContent || "{}"));
} catch {
return c.json({ result: textContent });
}
} catch (e) {
console.error(`[kicad/${action}] MCP error:`, e);
kicadClient = null;
return c.json({ error: "KiCAD MCP server not available" }, 503);
}
});
// FreeCAD parametric CAD — MCP StreamableHTTP bridge (sidecar container)
const FREECAD_MCP_URL = process.env.FREECAD_MCP_URL || "http://freecad-mcp:8808/mcp";
let freecadClient: Client | null = null;
async function getFreecadClient(): Promise<Client> {
await ensureSidecar("freecad-mcp");
if (freecadClient) return freecadClient;
const transport = new StreamableHTTPClientTransport(new URL(FREECAD_MCP_URL));
const client = new Client({ name: "rspace-freecad-bridge", version: "1.0.0" });
transport.onclose = () => { freecadClient = null; };
transport.onerror = () => { freecadClient = null; };
await client.connect(transport);
freecadClient = client;
return client;
}
app.get("/api/freecad/health", async (c) => {
try {
const running = await isSidecarRunning("freecad-mcp");
if (!running) return c.json({ available: false, status: "stopped (starts on demand)" });
const client = await getFreecadClient();
const tools = await client.listTools();
return c.json({ available: true, tools: tools.tools.length });
} catch (e) {
return c.json({ available: false, error: e instanceof Error ? e.message : "Connection failed" });
}
});
// FreeCAD AI-orchestrated CAD generation
app.post("/api/freecad/generate", async (c) => {
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
const { prompt } = await c.req.json();
if (!prompt) return c.json({ error: "prompt required" }, 400);
try {
const client = await getFreecadClient();
const orch = await runCadAgentLoop(client, FREECAD_SYSTEM_PROMPT, prompt, GEMINI_API_KEY);
const result = assembleFreecadResult(orch);
markSidecarUsed("freecad-mcp");
return c.json({
preview_url: result.previewUrl,
step_url: result.stepUrl,
stl_url: result.stlUrl,
summary: result.summary,
});
} catch (e) {
console.error("[freecad/generate] error:", e);
freecadClient = null;
return c.json({ error: e instanceof Error ? e.message : "FreeCAD generation failed" }, 502);
}
});
app.post("/api/freecad/:action", async (c) => {
const action = c.req.param("action");
const body = await c.req.json();
const validActions = [
"generate", "create_box", "create_cylinder", "create_sphere",
"boolean_operation", "save_document", "list_objects", "execute_script",
"export_step", "export_stl", "update_parameters",
];
if (!validActions.includes(action)) {
return c.json({ error: `Unknown action: ${action}` }, 400);
}
try {
const client = await getFreecadClient();
const result = await client.callTool({ name: action, arguments: body });
const fcContent = result.content as Array<{ type: string; text?: string }>;
const textContent = fcContent
?.filter((c) => c.type === "text")
.map((c) => c.text)
.join("\n");
try {
return c.json(JSON.parse(textContent || "{}"));
} catch {
return c.json({ result: textContent });
}
} catch (e) {
console.error(`[freecad/${action}] MCP error:`, e);
freecadClient = null;
return c.json({ error: "FreeCAD MCP server not available" }, 503);
}
});
// ── Multi-provider prompt endpoint ──
const GEMINI_MODELS: Record<string, string> = {
"gemini-flash": "gemini-2.5-flash",
"gemini-pro": "gemini-2.5-pro",
};
const OLLAMA_MODELS: Record<string, string> = {
"llama3.2": "llama3.2:3b",
"llama3.1": "llama3.1:8b",
"qwen2.5-coder": "qwen2.5-coder:7b",
"mistral-small": "mistral-small:24b",
};
const CANVAS_TOOLS_SYSTEM_PROMPT = `You are a helpful AI assistant in rSpace, a collaborative canvas workspace.
When the user asks to create, show, display, or visualize something, use the available tools to spawn shapes on the canvas.
After creating shapes, give a brief summary of what you placed. Only create shapes directly relevant to the request.
For text-only questions (explanations, coding help, math), respond with text — don't create shapes unless asked.`;
app.post("/api/prompt", async (c) => {
const { messages, model = "gemini-flash", useTools = false, systemPrompt, enabledModules } = 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);
// Build model config with optional tools
const modelConfig: any = { model: GEMINI_MODELS[model] };
if (useTools) {
const { getToolsForModules } = await import("../lib/canvas-tools");
const tools = getToolsForModules(enabledModules ?? null);
modelConfig.tools = [{ functionDeclarations: tools.map(t => t.declaration) }];
modelConfig.systemInstruction = systemPrompt || CANVAS_TOOLS_SYSTEM_PROMPT;
}
const geminiModel = genAI.getGenerativeModel(modelConfig);
// 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 {
if (!useTools) {
const result = await geminiModel.generateContent({ contents });
const text = result.response.text();
return c.json({ content: text });
}
// Multi-turn tool loop (max 5 rounds)
const toolCalls: { name: string; args: Record<string, any>; label: string }[] = [];
const { findTool } = await import("../lib/canvas-tools");
let loopContents = [...contents];
for (let turn = 0; turn < 5; turn++) {
const result = await geminiModel.generateContent({ contents: loopContents });
const response = result.response;
const candidate = response.candidates?.[0];
if (!candidate) break;
const parts = candidate.content?.parts || [];
const fnCalls = parts.filter((p: any) => p.functionCall);
if (fnCalls.length === 0) {
// No more tool calls — extract final text
const text = response.text();
return c.json({ content: text, toolCalls });
}
// Record tool calls and build function responses
const fnResponseParts: any[] = [];
for (const part of fnCalls) {
const fc = part.functionCall!;
const tool = findTool(fc.name);
const label = tool?.actionLabel(fc.args) || fc.name;
toolCalls.push({ name: fc.name, args: fc.args, label });
fnResponseParts.push({
functionResponse: {
name: fc.name,
response: { success: true, message: `${label} — shape will be created on the canvas.` },
},
});
}
// Append model turn + function responses for next iteration
loopContents.push({ role: "model", parts });
loopContents.push({ role: "user", parts: fnResponseParts });
}
// Exhausted loop — return what we have
return c.json({ content: "I've set up the requested items on your canvas.", toolCalls });
} catch (e: any) {
console.error("[prompt] Gemini error:", e.message);
return c.json({ error: "Gemini request failed" }, 502);
}
}
if (OLLAMA_MODELS[model]) {
try {
await ensureSidecar("ollama");
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();
markSidecarUsed("ollama");
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);
});
// ── Trip AI Prompt (executes trip tools server-side) ──
app.post("/api/trips/ai-prompt", async (c) => {
const { messages, model = "gemini-flash", systemPrompt } = await c.req.json();
if (!messages?.length) return c.json({ error: "messages required" }, 400);
if (!GEMINI_MODELS[model]) return c.json({ error: `Unsupported model: ${model}` }, 400);
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);
// Combine canvas tools (create_destination, etc.) + trip tools (search_flights, etc.)
const { CANVAS_TOOL_DECLARATIONS } = await import("../lib/canvas-tools");
const { TRIP_TOOL_DECLARATIONS, findTripTool } = await import("../lib/trip-ai-tools");
const { findTool } = await import("../lib/canvas-tools");
const allDeclarations = [...CANVAS_TOOL_DECLARATIONS, ...TRIP_TOOL_DECLARATIONS];
const geminiModel = genAI.getGenerativeModel({
model: GEMINI_MODELS[model],
tools: [{ functionDeclarations: allDeclarations as any }],
systemInstruction: systemPrompt || "You are a travel planning assistant.",
});
const contents = messages.map((m: { role: string; content: string }) => ({
role: m.role === "assistant" ? "model" : "user",
parts: [{ text: m.content }],
}));
const OSRM_URL = process.env.OSRM_URL || "http://osrm-backend:5000";
try {
const toolCalls: { name: string; args: Record<string, any>; label: string }[] = [];
const toolResults: Record<string, any> = {};
let loopContents = [...contents];
for (let turn = 0; turn < 5; turn++) {
const result = await geminiModel.generateContent({ contents: loopContents });
const response = result.response;
const candidate = response.candidates?.[0];
if (!candidate) break;
const parts = candidate.content?.parts || [];
const fnCalls = parts.filter((p: any) => p.functionCall);
if (fnCalls.length === 0) {
const text = response.text();
return c.json({ content: text, toolCalls, toolResults });
}
const fnResponseParts: any[] = [];
for (const part of fnCalls) {
const fc = part.functionCall!;
const tripTool = findTripTool(fc.name);
if (tripTool) {
// Execute server-side and return real data
const data = await tripTool.execute(fc.args, { osrmUrl: OSRM_URL });
const label = tripTool.actionLabel(fc.args);
toolCalls.push({ name: fc.name, args: fc.args, label });
toolResults[`${fc.name}_${toolCalls.length}`] = data;
fnResponseParts.push({
functionResponse: { name: fc.name, response: data },
});
} else {
// Canvas tool — acknowledge only
const canvasTool = findTool(fc.name);
const label = canvasTool?.actionLabel(fc.args) || fc.name;
toolCalls.push({ name: fc.name, args: fc.args, label });
fnResponseParts.push({
functionResponse: {
name: fc.name,
response: { success: true, message: `${label} — item will be created.` },
},
});
}
}
loopContents.push({ role: "model", parts });
loopContents.push({ role: "user", parts: fnResponseParts });
}
return c.json({ content: "I've set up the requested items.", toolCalls, toolResults });
} catch (e: any) {
console.error("[trips/ai-prompt] Gemini error:", e.message);
return c.json({ error: "Gemini request failed" }, 502);
}
});
// ── 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<string, string> = {
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"));
pinGeneratedFile(resolve(dir, filename), filename);
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"));
pinGeneratedFile(resolve(dir, filename), filename);
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<string, string> = {
"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<string, string> = {
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<string, string> = {};
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"));
pinGeneratedFile(resolve(dir, filename), filename);
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"));
pinGeneratedFile(resolve(dir, filename), filename);
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 verifyToken(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 || "private";
if (vis === "private") return; // Shell already defaults to private
const html = await c.res.text();
c.res = new Response(
html.replace(
'data-space-visibility="private"',
`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);
}
const redirectPath = c.get("isSubdomain") ? `/${moduleId}` : `/${space}/${moduleId}`;
return c.redirect(redirectPath, 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 {
// Always show the module's own UI — let each module handle its empty state.
// Previously demo returned true while live spaces checked for CRDT docs,
// causing live spaces to show a generic onboarding page instead of the module.
return true;
}
// ── 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)
await loadCommunity(space);
const doc = getDocumentData(space);
if (mod.id !== "rspace") {
if (doc?.meta?.enabledModules && !doc.meta.enabledModules.includes(mod.id)) {
// Redirect browser navigations to space root; return JSON error for API calls
const accept = c.req.header("Accept") || "";
if (accept.includes("text/html")) {
return c.redirect(`https://${space}.rspace.online/`);
}
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", effectiveSpace);
// ── Access control for private/permissioned spaces (API requests only) ──
// HTML page navigations are gated client-side (tokens live in localStorage,
// not cookies, so the server can't check auth on browser navigations).
// The WebSocket gate prevents data sync for non-members regardless.
const vis = doc?.meta?.visibility || "private";
const accept = c.req.header("Accept") || "";
const isHtmlRequest = accept.includes("text/html");
// Exempt public-facing endpoints that are designed for unauthenticated users
const pathname = new URL(c.req.url).pathname;
const isPublicEndpoint = pathname.endsWith("/api/flows/user-onramp")
|| pathname.endsWith("/api/onramp/config")
|| pathname.endsWith("/api/transak/config")
|| pathname.endsWith("/api/transak/webhook")
|| pathname.endsWith("/api/coinbase/webhook")
|| pathname.endsWith("/api/ramp/webhook")
|| pathname.includes("/rcart/api/payments")
|| pathname.includes("/rcart/pay/")
|| pathname.includes("/rwallet/api/")
|| pathname.includes("/rdesign/api/")
|| pathname.includes("/rtasks/api/")
|| (c.req.method === "GET" && pathname.includes("/rvote/api/"));
if (!isHtmlRequest && !isPublicEndpoint && (vis === "private" || vis === "permissioned")) {
const token = extractToken(c.req.raw.headers);
if (!token) {
return c.json({ error: "Authentication required" }, 401);
}
let claims: EncryptIDClaims | null = null;
try { claims = await verifyToken(token); } catch {}
if (!claims) {
return c.json({ error: "Authentication required" }, 401);
}
if (vis === "private") {
const resolved = await resolveCallerRole(space, claims);
if (resolved) {
c.set("spaceRole", resolved.role);
c.set("isOwner", resolved.isOwner);
}
if (!resolved || (!resolved.isOwner && resolved.role === "viewer")) {
return c.json({ error: "You don't have access to this space" }, 403);
}
}
}
// Resolve caller's role for write-method blocking
const method = c.req.method;
if (!mod.publicWrite && (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE")) {
// Skip if already resolved above for private spaces
if (!c.get("spaceRole")) {
const token = extractToken(c.req.raw.headers);
let claims: EncryptIDClaims | null = null;
if (token) {
try { claims = await verifyToken(token); } catch {}
}
const resolved = await resolveCallerRole(space, claims);
if (resolved) {
c.set("spaceRole", resolved.role);
c.set("isOwner", resolved.isOwner);
if (resolved.role === "viewer") {
return c.json({ error: "Write access required" }, 403);
}
}
} else {
const role = c.get("spaceRole") as SpaceRoleString | undefined;
if (role === "viewer") {
return c.json({ error: "Write access required" }, 403);
}
}
}
return next();
});
// Onboarding + enabledModules check for root path (root page only)
if (mod.id !== "rspace") {
app.use(`/:space/${mod.id}`, async (c, next) => {
const space = c.req.param("space");
if (!space || space === "api" || space.includes(".")) return next();
// Block disabled modules (same check as sub-path middleware)
await loadCommunity(space);
const spaceDoc = getDocumentData(space);
if (spaceDoc?.meta?.enabledModules && !spaceDoc.meta.enabledModules.includes(mod.id)) {
const accept = c.req.header("Accept") || "";
if (accept.includes("text/html")) {
const redir = c.get("isSubdomain") ? "/rspace" : `/${space}/rspace`;
return c.redirect(redir);
}
return c.json({ error: "Module not enabled for this space" }, 404);
}
if (space === "demo") 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?.(),
onboardingActions: mod.onboardingActions,
}));
}
return next();
});
}
// Fragment mode: signal renderShell to return lightweight JSON instead of full shell HTML.
// Used by TabCache for fast tab switching — skips the entire 2000-line shell template.
app.use(`/:space/${mod.id}`, async (c, next) => {
if (c.req.method !== "GET" || !c.req.query("fragment")) return next();
// Set the global flag so renderShell returns JSON fragment
setFragmentMode(true);
await next();
// renderShell already returned JSON — fix the content-type header
if (c.res.headers.get("content-type")?.includes("text/html")) {
const body = await c.res.text();
c.res = new Response(body, {
status: c.res.status,
headers: { "content-type": "application/json" },
});
}
});
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) => {
c.header("Cache-Control", "no-cache, no-store, must-revalidate");
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 verifyToken(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<string, number> = {};
for (const shape of Object.values(shapes)) {
const t = (shape as Record<string, unknown>).type as string || "unknown";
shapeTypes[t] = (shapeTypes[t] || 0) + 1;
}
spacesList.push({
slug: data.meta.slug,
name: data.meta.name,
visibility: data.meta.visibility || "private",
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 verifyToken(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
// Access control for private spaces is handled client-side (tokens in localStorage)
// and enforced at the WebSocket layer (prevents data sync for non-members).
app.get("/:space", (c) => {
const space = c.req.param("space");
// Don't serve dashboard for static file paths
if (space.includes(".")) return c.notFound();
// On production bare domain, this route is unreachable (caught by subdomain redirect above).
// On localhost/dev: redirect authenticated users to canvas, show dashboard for guests.
const token = extractToken(c.req.raw.headers);
if (token) {
return c.redirect(`/${space}/rspace`, 302);
}
// Demo space: show dashboard; all others: redirect to main landing
if (space === "demo") {
return c.html(renderSpaceDashboard(space, getModuleInfoList()));
}
return c.redirect("/", 302);
});
// ── 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
// Anonymous connection tracking
anonIp?: string; // set for anonymous connections, used for cleanup on close
}
// Track connected clients per community
const communityClients = new Map<string, Map<string, ServerWebSocket<WSData>>>();
// Track anonymous WebSocket connections per IP (max 3 per IP)
const wsAnonConnectionsByIP = new Map<string, number>();
const MAX_ANON_WS_PER_IP = 3;
// Track announced peer info per community: slug → serverPeerId → { clientPeerId, username, color }
const peerAnnouncements = new Map<string, Map<string, { clientPeerId: string; username: string; color: string }>>();
function generatePeerId(): string {
return `peer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
function getClient(slug: string, peerId: string): ServerWebSocket<WSData> | 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<string>();
// ── 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<Response | null> {
const filePath = resolve(DIST_DIR, path);
const file = Bun.file(filePath);
if (await file.exists()) {
const headers: Record<string, string> = { "Content-Type": getContentType(path) };
if (url?.searchParams.has("v")) {
headers["Cache-Control"] = "public, max-age=31536000, immutable";
} else if (path.endsWith(".html") || path === "sw.js" || path === "manifest.json") {
// HTML, service worker, and manifest must revalidate every time
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;
}
// ── Module ID aliases (plural/misspelling → canonical) ──
const MODULE_ALIASES: Record<string, string> = { rsheet: "rsheets", auctions: "rauctions" };
function resolveModuleAlias(id: string): string { return MODULE_ALIASES[id] ?? id; }
// ── Standalone domain → module lookup ──
const domainToModule = new Map<string, string>();
for (const mod of getAllModules()) {
if (mod.standaloneDomain) {
domainToModule.set(mod.standaloneDomain, mod.id);
}
}
// ── Bun.serve: WebSocket + fetch delegation ──
const server = Bun.serve<WSData>({
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);
// TLS is terminated by Cloudflare/Traefik — url.protocol is always http: internally
const proto = hostClean.includes("rspace.online") || hostClean.includes(".online") ? "https:" : url.protocol;
// ── 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, 302);
}
// ── WebSocket upgrade ──
if (url.pathname.startsWith("/ws/")) {
const communitySlug = url.pathname.split("/")[2];
if (communitySlug) {
const spaceConfig = await getSpaceConfig(communitySlug);
const wsAuthOpts = process.env.JWT_SECRET ? { secret: process.env.JWT_SECRET } : {};
const claims = await authenticateWSUpgrade(req, wsAuthOpts);
let readOnly = false;
let spaceRole: WSData['spaceRole'] = null;
// Load doc early so we can check membership for private spaces
await loadCommunity(communitySlug);
const spaceData = getDocumentData(communitySlug);
if (spaceConfig) {
const vis = spaceConfig.visibility;
if (vis === "permissioned" || vis === "private") {
if (!claims) return new Response("Authentication required", { status: 401 });
}
// Reject non-members of private spaces
if (vis === "private" && claims && spaceData) {
const callerDid = (claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`;
const isOwner = spaceData.meta?.ownerDID === claims.sub || spaceData.meta?.ownerDID === callerDid;
let isMember = spaceData.members?.[claims.sub] || spaceData.members?.[callerDid];
// Fallback: check EncryptID space_members (handles identity-invite claims not yet synced to Automerge)
if (!isOwner && !isMember) {
try {
const memberRes = await fetch(`${ENCRYPTID_INTERNAL}/api/spaces/${encodeURIComponent(communitySlug)}/members/${encodeURIComponent(callerDid)}`);
if (memberRes.ok) {
const memberData = await memberRes.json() as { role: string; userDID: string };
// Sync to Automerge so future connections don't need the fallback
setMember(communitySlug, callerDid, memberData.role as any, (claims as any).username);
isMember = { did: callerDid, role: memberData.role as "admin" | "viewer" | "member" | "moderator", joinedAt: Date.now() };
}
} catch {}
}
if (!isOwner && !isMember) {
return new Response("You don't have access to this space", { status: 403 });
}
}
if (vis === "public") {
readOnly = !claims;
}
}
// Resolve the caller's space role (re-read doc in case setMember was called during fallback sync)
const spaceDataFresh = getDocumentData(communitySlug);
if (spaceDataFresh || spaceData) {
const sd = spaceDataFresh || spaceData;
const callerDid = claims ? ((claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`) : undefined;
if (claims && sd!.meta.ownerDID === claims.sub) {
spaceRole = 'admin';
} else if (claims && (sd!.members?.[claims.sub]?.role || (callerDid && sd!.members?.[callerDid]?.role))) {
spaceRole = sd!.members?.[claims.sub]?.role || sd!.members?.[callerDid!]?.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;
}
}
}
}
// Anonymous WebSocket connection cap (max 3 per IP)
let anonIp: string | undefined;
if (!claims) {
const wsIp = req.headers.get("cf-connecting-ip")
|| req.headers.get("x-forwarded-for")?.split(",")[0].trim()
|| "unknown";
const current = wsAnonConnectionsByIP.get(wsIp) || 0;
if (current >= MAX_ANON_WS_PER_IP) {
return new Response("Too many anonymous connections", { status: 429 });
}
wsAnonConnectionsByIP.set(wsIp, current + 1);
anonIp = wsIp;
}
const upgraded = server.upgrade(req, {
data: { communitySlug, peerId, claims, readOnly, spaceRole, mode, nestFrom, nestPermissions, nestFilter, anonIp } as WSData,
});
if (upgraded) return undefined;
// Upgrade failed — roll back anon counter
if (anonIp) {
const count = wsAnonConnectionsByIP.get(anonIp) || 1;
if (count <= 1) wsAnonConnectionsByIP.delete(anonIp);
else wsAnonConnectionsByIP.set(anonIp, count - 1);
}
}
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 verifyToken(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: redirect authenticated users straight to rSpace canvas
// (avoids rendering space dashboard just to JS-redirect, eliminating
// the flash of different header + 2-3 redirect chain)
if (pathSegments.length === 0) {
const token = extractTokenFromRequest(req);
if (token) {
return Response.redirect(`${proto}//${url.host}/rspace`, 302);
}
// Demo space: show dashboard; all others: redirect to main landing
if (subdomain === "demo") {
return new Response(renderSpaceDashboard(subdomain, getModuleInfoList()), {
headers: { "Content-Type": "text/html" },
});
}
return Response.redirect(`${proto}//rspace.online/`, 302);
}
// Global routes pass through without subdomain prefix
if (
url.pathname.startsWith("/api/") ||
url.pathname.startsWith("/data/") ||
url.pathname.startsWith("/encryptid/") ||
url.pathname.startsWith("/rtasks/check/") ||
url.pathname.startsWith("/respond/") ||
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(`${proto}//${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 matches the subdomain (space slug), the URL has a
// redundant /{space}/... prefix. For HTML navigation, redirect to strip
// it so the address bar never shows the space slug. For API/fetch
// requests, silently rewrite to avoid breaking client-side fetches.
// e.g. demo.rspace.online/demo/rspace → 302 → demo.rspace.online/rspace
if (pathSegments[0].toLowerCase() === subdomain) {
const accept = req.headers.get("Accept") || "";
if (accept.includes("text/html") && !url.pathname.includes("/api/")) {
const cleanPath = "/" + pathSegments.slice(1).join("/") + url.search;
return Response.redirect(`https://${host}${cleanPath}`, 302);
}
// API/fetch — rewrite internally
const rewrittenUrl = new URL(url.pathname + url.search, `http://localhost:${PORT}`);
return app.fetch(new Request(rewrittenUrl, req));
}
// Block disabled modules before rewriting — redirect to space root
const firstModId = resolveModuleAlias(pathSegments[0].toLowerCase());
if (firstModId !== "rspace") {
await loadCommunity(subdomain);
const spaceDoc = getDocumentData(subdomain);
if (spaceDoc?.meta?.enabledModules && !spaceDoc.meta.enabledModules.includes(firstModId)) {
return Response.redirect(`https://${subdomain}.rspace.online/`, 302);
}
}
// Normalize module ID to lowercase + resolve aliases (rTrips → rtrips, rsheet → rsheets)
const normalizedPath = "/" + pathSegments.map((seg, i) =>
i === 0 ? resolveModuleAlias(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);
const subdomainResponse = await app.fetch(rewrittenReq);
// If 404 on an HTML request for a known module sub-path, serve the
// module shell so client-side SPA routing can handle it
if (subdomainResponse.status === 404 && pathSegments.length >= 2) {
const accept = req.headers.get("Accept") || "";
if (accept.includes("text/html") && !normalizedPath.includes("/api/")) {
const moduleRoot = `/${subdomain}/${resolveModuleAlias(pathSegments[0].toLowerCase())}`;
const shellUrl = new URL(moduleRoot, `http://localhost:${PORT}`);
const shellResponse = await app.fetch(new Request(shellUrl, {
headers: req.headers,
method: "GET",
}));
if (shellResponse.status === 200) return shellResponse;
}
}
return subdomainResponse;
}
// ── Bare-domain routing: rspace.online/{...} ──
// Only match canonical bare domain, not stacked subdomains like rspace.rspace.online
if (!subdomain && (hostClean === "rspace.online" || hostClean === "www.rspace.online")) {
// Top-level routes that must bypass module rewriting
if (url.pathname.startsWith("/rtasks/check/") || url.pathname.startsWith("/respond/")) {
return app.fetch(req);
}
const pathSegments = url.pathname.split("/").filter(Boolean);
if (pathSegments.length >= 1) {
const firstSegment = resolveModuleAlias(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} → show module landing page (or fallback to demo shell)
if (pathSegments.length === 1) {
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" } });
}
const rewrittenPath = `/demo/${firstSegment}`;
const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`);
return app.fetch(new Request(rewrittenUrl, req));
}
// 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} or rspace.online/{space}/{...} → redirect to subdomain
// (space is not a module ID — it's a space slug)
// Skip for known server paths (api, admin, etc.)
const serverPaths = new Set(["api", "admin", "admin-data", "admin-action", "modules", ".well-known", "about", "create-space", "new", "discover"]);
if (!knownModuleIds.has(firstSegment) && !serverPaths.has(firstSegment)) {
const space = firstSegment;
if (pathSegments.length >= 2) {
const secondSeg = pathSegments[1]?.toLowerCase();
const isApiCall = secondSeg === "api" || pathSegments.some((s, i) => i >= 1 && s === "api");
if (isApiCall) {
// API calls: rewrite internally (avoid redirect + mixed-content)
const rewrittenUrl = new URL(url.pathname + url.search, `http://localhost:${PORT}`);
return app.fetch(new Request(rewrittenUrl, req));
}
// Page navigation: redirect to canonical subdomain URL
const rest = "/" + pathSegments.slice(1).join("/");
return Response.redirect(
`${proto}//${space}.rspace.online${rest}${url.search}`, 301
);
}
// Single segment: rspace.online/{space} → {space}.rspace.online/
return Response.redirect(`${proto}//${space}.rspace.online/${url.search}`, 301);
}
}
}
// ── Hono handles everything else ──
const response = await app.fetch(req);
// If Hono returns 404, serve the module shell for client-side routing
// (module sub-paths like /demo/rcal/settings are handled by the SPA router)
if (response.status === 404 && !url.pathname.startsWith("/api/")) {
const accept = req.headers.get("Accept") || "";
const parts = url.pathname.split("/").filter(Boolean);
if (accept.includes("text/html") && parts.length >= 1 && !parts[0].includes(".")) {
const knownModuleIds = getAllModules().map((m) => m.id);
// Subdomain requests: URL is /{moduleId}/... (space is the subdomain)
// Bare-domain requests: URL is /{space}/{moduleId}/...
const moduleId = subdomain
? resolveModuleAlias(parts[0].toLowerCase())
: parts.length >= 2 ? parts[1].toLowerCase() : null;
const space = subdomain || parts[0];
if (moduleId && knownModuleIds.includes(moduleId)) {
// Module sub-path: rewrite to the module root so the shell renders
// and the client-side SPA router handles the sub-path
const moduleRoot = `/${space}/${moduleId}`;
const rewrittenUrl = new URL(moduleRoot, `http://localhost:${PORT}`);
const shellResponse = await app.fetch(new Request(rewrittenUrl, {
headers: req.headers,
method: "GET",
}));
if (shellResponse.status === 200) return shellResponse;
}
// Non-module path — try canvas/index SPA 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<WSData>) {
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<WSData>, 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;
}
// ── Yjs collaboration relay (pure relay — server doesn't parse Yjs binary) ──
if (msg.type === "yjs-sync" || msg.type === "yjs-awareness") {
const clients = communityClients.get(communitySlug);
if (clients) {
const relay = JSON.stringify({ ...msg, peerId });
for (const [cid, client] of clients) {
if (cid !== peerId && client.readyState === WebSocket.OPEN) {
client.send(relay);
}
}
}
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" || msg.type === "presence-leave") {
const clients = communityClients.get(communitySlug);
if (clients) {
const presenceMsg = JSON.stringify({ ...msg, peerId });
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: `/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<WSData>) {
const { communitySlug, peerId, claims } = ws.data;
// Decrement anonymous WS connection counter
if (ws.data.anonIp) {
const count = wsAnonConnectionsByIP.get(ws.data.anonIp) || 1;
if (count <= 1) wsAnonConnectionsByIP.delete(ws.data.anonIp);
else wsAnonConnectionsByIP.set(ws.data.anonIp, count - 1);
}
// 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 ──
// Ensure generated files directory exists
import { mkdirSync } from "node:fs";
try { mkdirSync(resolve(process.env.FILES_DIR || "./data/files", "generated"), { recursive: true }); } catch {}
import { initTokenService, seedCUSDC } from "./token-service";
// IMPORTANT: Load persisted docs FIRST, then run module onInit + seeding.
// Previously onInit ran before loadAllDocs, causing seed functions to see
// empty docs and re-create demo data that users had already deleted.
ensureDemoCommunity().then(() => console.log("[Demo] Demo community ready")).catch((e) => console.error("[Demo] Failed:", e));
loadAllDocs(syncServer)
.then(async () => {
// Now that persisted docs are loaded, init modules (which may seed)
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);
setClickUpOAuthSyncServer(syncServer);
setOAuthStatusSyncServer(syncServer);
// 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");
// Initialize CRDT token service and seed cUSDC
initTokenService(syncServer);
try {
await seedCUSDC();
} catch (e) {
console.error("[TokenService] Seed failed:", e);
}
// Provision Mailcow forwarding aliases for all existing spaces
const ENCRYPTID_INTERNAL_FOR_ALIAS = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000";
try {
const slugs = await listCommunities();
let count = 0;
for (const slug of slugs) {
if (slug === "demo") continue;
try {
await fetch(`${ENCRYPTID_INTERNAL_FOR_ALIAS}/api/internal/spaces/${slug}/alias`, { method: "POST" });
count++;
} catch { /* encryptid not ready yet, will provision on next restart */ }
}
if (count > 0) console.log(`[SpaceAlias] Provisioned ${count} space aliases`);
} catch (e) {
console.error("[SpaceAlias] Startup provisioning failed:", e);
}
})
.catch((e) => console.error("[DocStore] Startup load failed:", e));
// Restore relay mode for encrypted spaces + one-shot visibility fixes
(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)`);
}
// One-shot: fix spaces that should be permissioned
const fixPermissioned = ["clf", "bcrg"];
for (const slug of fixPermissioned) {
const data = getDocumentData(slug);
if (data && data.meta?.visibility !== "permissioned") {
updateSpaceMeta(slug, { visibility: "permissioned" });
console.log(`[VisFix] Set ${slug} to permissioned (was: ${data.meta?.visibility})`);
}
}
} 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(", ")}`);
// Start sidecar lifecycle manager — stops idle containers after 5min
startIdleWatcher();