rspace-online/server/index.ts

2729 lines
97 KiB
TypeScript

/**
* rSpace Unified Server
*
* Hono-based HTTP router + Bun WebSocket handler.
* Mounts module routes under /:space/:moduleId.
* Preserves backward-compatible subdomain routing and /api/communities/* API.
*/
import { resolve } from "node:path";
import { Hono } from "hono";
import { cors } from "hono/cors";
import type { ServerWebSocket } from "bun";
import {
addShapes,
clearShapes,
communityExists,
createCommunity,
forgetShape,
rememberShape,
generateSyncMessageForPeer,
getDocumentData,
loadCommunity,
receiveSyncMessage,
removePeerSyncState,
updateShape,
updateShapeFields,
cascadePermissions,
listCommunities,
deleteCommunity,
} from "./community-store";
import type { NestPermissions, SpaceRefFilter } from "./community-store";
import { ensureDemoCommunity } from "./seed-demo";
import { seedTemplateShapes, ensureTemplateSeeding } from "./seed-template";
// Campaign demo moved to rsocials module — see modules/rsocials/campaign-data.ts
import type { SpaceVisibility } from "./community-store";
import {
verifyEncryptIDToken,
evaluateSpaceAccess,
extractToken,
authenticateWSUpgrade,
} from "@encryptid/sdk/server";
import type { EncryptIDClaims, SpaceAuthConfig } from "@encryptid/sdk/server";
// ── Module system ──
import { registerModule, getAllModules, getModuleInfoList, getModule } from "../shared/module";
import { canvasModule } from "../modules/rspace/mod";
import { booksModule } from "../modules/rbooks/mod";
import { pubsModule } from "../modules/rpubs/mod";
import { cartModule } from "../modules/rcart/mod";
import { swagModule } from "../modules/rswag/mod";
import { choicesModule } from "../modules/rchoices/mod";
import { flowsModule } from "../modules/rflows/mod";
import { filesModule } from "../modules/rfiles/mod";
import { forumModule } from "../modules/rforum/mod";
import { walletModule } from "../modules/rwallet/mod";
import { voteModule } from "../modules/rvote/mod";
import { notesModule } from "../modules/rnotes/mod";
import { mapsModule } from "../modules/rmaps/mod";
import { tasksModule } from "../modules/rtasks/mod";
import { tripsModule } from "../modules/rtrips/mod";
import { calModule } from "../modules/rcal/mod";
import { networkModule } from "../modules/rnetwork/mod";
import { tubeModule } from "../modules/rtube/mod";
import { inboxModule } from "../modules/rinbox/mod";
import { dataModule } from "../modules/rdata/mod";
import { splatModule } from "../modules/rsplat/mod";
import { photosModule } from "../modules/rphotos/mod";
import { socialsModule } from "../modules/rsocials/mod";
import { meetsModule } from "../modules/rmeets/mod";
// import { docsModule } from "../modules/rdocs/mod";
// import { designModule } from "../modules/rdesign/mod";
import { scheduleModule } from "../modules/rschedule/mod";
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
import type { SpaceRoleString } from "./spaces";
import { renderShell, renderModuleLanding, renderSubPageInfo, renderOnboarding } from "./shell";
import { renderOutputListPage } from "./output-list";
import { renderMainLanding, renderSpaceDashboard } from "./landing";
import { fetchLandingPage } from "./landing-proxy";
import { syncServer } from "./sync-instance";
import { loadAllDocs } from "./local-first/doc-persistence";
import { backupRouter } from "./local-first/backup-routes";
import { oauthRouter } from "./oauth/index";
import { setNotionOAuthSyncServer } from "./oauth/notion";
import { setGoogleOAuthSyncServer } from "./oauth/google";
import { notificationRouter } from "./notification-routes";
import { registerUserConnection, unregisterUserConnection, notify } from "./notification-service";
import { SystemClock } from "./clock-service";
import type { ClockPayload } from "./clock-service";
import { miRoutes } from "./mi-routes";
// Register modules
registerModule(canvasModule);
registerModule(booksModule);
registerModule(pubsModule);
registerModule(cartModule);
registerModule(swagModule);
registerModule(choicesModule);
registerModule(flowsModule);
registerModule(filesModule);
registerModule(forumModule);
registerModule(walletModule);
registerModule(voteModule);
registerModule(notesModule);
registerModule(mapsModule);
registerModule(tasksModule);
registerModule(tripsModule);
registerModule(calModule);
registerModule(networkModule);
registerModule(tubeModule);
registerModule(inboxModule);
registerModule(dataModule);
registerModule(splatModule);
registerModule(photosModule);
registerModule(socialsModule);
// registerModule(docsModule); // placeholder — not yet an rApp
// registerModule(designModule); // placeholder — not yet an rApp
registerModule(scheduleModule);
registerModule(meetsModule);
// ── Config ──
const PORT = Number(process.env.PORT) || 3000;
const INTERNAL_API_KEY = process.env.INTERNAL_API_KEY || "";
const DIST_DIR = resolve(import.meta.dir, "../dist");
// ── Hono app ──
const app = new Hono();
// CORS for API routes
app.use("/api/*", cors());
// ── .well-known/webauthn (WebAuthn Related Origins) ──
// Browsers enforce a 5 eTLD+1 limit. Only list domains where passkey
// ceremonies happen directly. Must match encryptid/server.ts priority list.
app.get("/.well-known/webauthn", (c) => {
return c.json(
{
origins: [
"https://ridentity.online", // OIDC authorize + admin (eTLD+1 #1)
"https://auth.ridentity.online",
"https://rsocials.online", // Postiz ecosystem (eTLD+1 #2)
"https://demo.rsocials.online",
"https://socials.crypto-commons.org", // (eTLD+1 #3)
"https://socials.p2pfoundation.net", // (eTLD+1 #4)
"https://rwallet.online", // (eTLD+1 #5)
],
},
200,
{
"Access-Control-Allow-Origin": "*",
"Cache-Control": "public, max-age=3600",
}
);
});
// ── Serve generated files from /data/files/generated/ ──
app.get("/data/files/generated/:filename", async (c) => {
const filename = c.req.param("filename");
if (!filename || filename.includes("..") || filename.includes("/")) {
return c.json({ error: "Invalid filename" }, 400);
}
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
const filePath = resolve(dir, filename);
const file = Bun.file(filePath);
if (!(await file.exists())) return c.notFound();
const ext = filename.split(".").pop() || "";
const mimeMap: Record<string, string> = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp" };
return new Response(file, { headers: { "Content-Type": mimeMap[ext] || "application/octet-stream", "Cache-Control": "public, max-age=86400" } });
});
// ── Link preview / unfurl API ──
const linkPreviewCache = new Map<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 {
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: "follow",
});
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: "follow",
});
clearTimeout(timeout);
if (!res.ok) return c.json({ error: "Failed to fetch module" }, 502);
const js = await res.text();
return new Response(js, {
headers: {
"Content-Type": "application/javascript",
"Cache-Control": "public, max-age=86400",
"Access-Control-Allow-Origin": "*",
},
});
} catch {
return c.json({ error: "Failed to fetch module" }, 502);
}
});
// ── Space registry API ──
app.route("/api/spaces", spaces);
// ── Backup API (encrypted blob storage) ──
app.route("/api/backup", backupRouter);
// ── OAuth API (Notion, Google integrations) ──
app.route("/api/oauth", oauthRouter);
// ── Notifications API ──
app.route("/api/notifications", notificationRouter);
// ── MI — AI assistant endpoints ──
app.route("/api/mi", miRoutes);
// ── Existing /api/communities/* routes (backward compatible) ──
/** Resolve a community slug to SpaceAuthConfig for the SDK guard */
async function getSpaceConfig(slug: string): Promise<SpaceAuthConfig | null> {
let doc = getDocumentData(slug);
if (!doc) {
await loadCommunity(slug);
doc = getDocumentData(slug);
}
if (!doc) return null;
return {
spaceSlug: slug,
visibility: (doc.meta.visibility || "public") as SpaceVisibility,
ownerDID: doc.meta.ownerDID || undefined,
app: "rspace",
};
}
// Demo reset rate limiter
let lastDemoReset = 0;
const DEMO_RESET_COOLDOWN = 5 * 60 * 1000;
// POST /api/communities — create community (deprecated, use POST /api/spaces)
app.post("/api/communities", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required to create a community" }, 401);
let claims: EncryptIDClaims;
try {
claims = await verifyEncryptIDToken(token);
} catch {
return c.json({ error: "Invalid or expired authentication token" }, 401);
}
const body = await c.req.json<{ name?: string; slug?: string; visibility?: SpaceVisibility }>();
const { name, slug, visibility = "private" } = body;
const validVisibilities: SpaceVisibility[] = ["public", "permissioned", "private"];
if (visibility && !validVisibilities.includes(visibility)) return c.json({ error: "Invalid visibility" }, 400);
const result = await createSpace({
name: name || "", slug: slug || "", ownerDID: claims.sub, visibility, source: 'api',
});
if (!result.ok) return c.json({ error: result.error }, result.status);
c.header("Deprecation", "true");
c.header("Link", "</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/communities/demo/reset
app.post("/api/communities/demo/reset", async (c) => {
const now = Date.now();
if (now - lastDemoReset < DEMO_RESET_COOLDOWN) {
const remaining = Math.ceil((DEMO_RESET_COOLDOWN - (now - lastDemoReset)) / 1000);
return c.json({ error: `Demo reset on cooldown. Try again in ${remaining}s` }, 429);
}
lastDemoReset = now;
await loadCommunity("demo");
clearShapes("demo");
await ensureDemoCommunity();
broadcastAutomergeSync("demo");
broadcastJsonSnapshot("demo");
return c.json({ ok: true, message: "Demo community reset to seed data" });
});
// Campaign demo reset removed — campaign is now at /:space/rsocials/campaign
// GET /api/communities/:slug/shapes
app.get("/api/communities/:slug/shapes", async (c) => {
const slug = c.req.param("slug");
const token = extractToken(c.req.raw.headers);
const access = await evaluateSpaceAccess(slug, token, "GET", { getSpaceConfig });
if (!access.allowed) return c.json({ error: access.reason }, access.claims ? 403 : 401);
await loadCommunity(slug);
const data = getDocumentData(slug);
if (!data) return c.json({ error: "Community not found" }, 404);
return c.json({ shapes: data.shapes || {} });
});
// POST /api/communities/:slug/shapes
app.post("/api/communities/:slug/shapes", async (c) => {
const slug = c.req.param("slug");
const internalKey = c.req.header("X-Internal-Key");
const isInternalCall = INTERNAL_API_KEY && internalKey === INTERNAL_API_KEY;
if (!isInternalCall) {
const token = extractToken(c.req.raw.headers);
const access = await evaluateSpaceAccess(slug, token, "POST", { getSpaceConfig });
if (!access.allowed) return c.json({ error: access.reason }, access.claims ? 403 : 401);
if (access.readOnly) return c.json({ error: "Write access required to add shapes" }, 403);
}
await loadCommunity(slug);
const data = getDocumentData(slug);
if (!data) return c.json({ error: "Community not found" }, 404);
const body = await c.req.json<{ shapes?: Record<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", { getSpaceConfig });
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/communities/:slug — community info
app.get("/api/communities/:slug", async (c) => {
const slug = c.req.param("slug");
const token = extractToken(c.req.raw.headers);
const access = await evaluateSpaceAccess(slug, token, "GET", { getSpaceConfig });
if (!access.allowed) return c.json({ error: access.reason }, access.claims ? 403 : 401);
let data = getDocumentData(slug);
if (!data) {
await loadCommunity(slug);
data = getDocumentData(slug);
}
if (!data) return c.json({ error: "Community not found" }, 404);
return c.json({ meta: data.meta, readOnly: access.readOnly });
});
// ── Module info API (for app switcher) ──
app.get("/api/modules", (c) => {
return c.json({ modules: getModuleInfoList() });
});
// ── Module landing HTML API (for info popover) ──
app.get("/api/modules/:moduleId/landing", (c) => {
const moduleId = c.req.param("moduleId");
const mod = getModule(moduleId);
if (!mod) return c.json({ error: "Module not found" }, 404);
const html = mod.landingPage ? mod.landingPage() : `<p>${mod.description || "No description available."}</p>`;
return c.json({ html });
});
// ── x402 test endpoint (no auth, payment-gated only) ──
import { setupX402FromEnv } from "../shared/x402/hono-middleware";
const x402Test = setupX402FromEnv({ description: "x402 test endpoint", resource: "/api/x402-test" });
app.post("/api/x402-test", async (c) => {
if (x402Test) {
const result = await new Promise<Response | null>((resolve) => {
x402Test(c, async () => { resolve(null); }).then((res) => {
if (res instanceof Response) resolve(res);
});
});
if (result) return result;
}
return c.json({ ok: true, message: "Payment received!", timestamp: new Date().toISOString() });
});
// ── Creative tools API endpoints ──
const FAL_KEY = process.env.FAL_KEY || "";
const GEMINI_API_KEY = process.env.GEMINI_API_KEY || "";
const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434";
// ── Image helpers ──
/** Read a /data/files/generated/... path from disk → base64 */
async function readFileAsBase64(serverPath: string): Promise<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"));
return `/data/files/generated/${filename}`;
}
// Image generation via fal.ai Flux Pro
app.post("/api/image-gen", async (c) => {
if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
const { prompt, style } = await c.req.json();
if (!prompt) return c.json({ error: "prompt required" }, 400);
const stylePrompts: Record<string, string> = {
illustration: "digital illustration style, ",
photorealistic: "photorealistic, high detail, ",
painting: "oil painting style, artistic, ",
sketch: "pencil sketch style, hand-drawn, ",
"punk-zine": "punk zine aesthetic, cut-and-paste collage, bold contrast, ",
};
const styledPrompt = (stylePrompts[style] || "") + prompt;
const res = await fetch("https://fal.run/fal-ai/flux-pro/v1.1", {
method: "POST",
headers: {
Authorization: `Key ${FAL_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
prompt: styledPrompt,
image_size: "landscape_4_3",
num_images: 1,
safety_tolerance: "2",
}),
});
if (!res.ok) {
const err = await res.text();
console.error("[image-gen] fal.ai error:", err);
return c.json({ error: "Image generation failed" }, 502);
}
const data = await res.json();
const imageUrl = data.images?.[0]?.url || data.output?.url;
if (!imageUrl) return c.json({ error: "No image returned" }, 502);
return c.json({ url: imageUrl, image_url: imageUrl });
});
// Upload image (data URL → disk)
app.post("/api/image-upload", async (c) => {
const { image } = await c.req.json();
if (!image) return c.json({ error: "image required" }, 400);
try {
const url = await saveDataUrlToDisk(image, "upload");
return c.json({ url });
} catch (e: any) {
console.error("[image-upload]", e.message);
return c.json({ error: e.message }, 400);
}
});
// Analyze style from reference images via Gemini vision
app.post("/api/image-gen/analyze-style", async (c) => {
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
const { images, context } = await c.req.json();
if (!images?.length) return c.json({ error: "images required" }, 400);
const { GoogleGenerativeAI } = await import("@google/generative-ai");
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
const model = genAI.getGenerativeModel({
model: "gemini-2.5-flash",
generationConfig: { responseMimeType: "application/json" },
});
// Build multimodal parts
const parts: any[] = [];
for (const img of images) {
let b64: string, mimeType: string;
if (img.startsWith("data:")) {
const match = img.match(/^data:(image\/\w+);base64,(.+)$/);
if (!match) continue;
mimeType = match[1];
b64 = match[2];
} else {
// Server path
b64 = await readFileAsBase64(img);
mimeType = img.endsWith(".png") ? "image/png" : "image/jpeg";
}
parts.push({ inlineData: { data: b64, mimeType } });
}
parts.push({
text: `Analyze the visual style of these reference images. ${context ? `Context: ${context}` : ""}
Return JSON with these fields:
{
"style_description": "A detailed paragraph describing the overall visual style",
"color_palette": ["#hex1", "#hex2", ...up to 6 dominant colors],
"style_keywords": ["keyword1", "keyword2", ...up to 8 keywords],
"brand_prompt_prefix": "A concise prompt prefix (under 50 words) that can be prepended to any image generation prompt to reproduce this style"
}
Focus on: color palette, textures, composition patterns, mood, typography style, illustration approach.`,
});
try {
const result = await model.generateContent({ contents: [{ role: "user", parts }] });
const text = result.response.text();
const parsed = JSON.parse(text);
return c.json(parsed);
} catch (e: any) {
console.error("[analyze-style] error:", e.message);
return c.json({ error: "Style analysis failed" }, 502);
}
});
// img2img generation via fal.ai or Gemini
app.post("/api/image-gen/img2img", async (c) => {
const { prompt, source_image, reference_images, provider = "fal", strength = 0.85, style, model: modelOverride } = await c.req.json();
if (!prompt) return c.json({ error: "prompt required" }, 400);
if (!source_image) return c.json({ error: "source_image required" }, 400);
const fullPrompt = (style ? style + " " : "") + prompt;
if (provider === "fal") {
if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
try {
// Save source to disk if data URL
let sourceUrl: string;
if (source_image.startsWith("data:")) {
const path = await saveDataUrlToDisk(source_image, "img2img-src");
sourceUrl = `https://rspace.online${path}`;
} else if (source_image.startsWith("/")) {
sourceUrl = `https://rspace.online${source_image}`;
} else {
sourceUrl = source_image;
}
let falEndpoint: string;
let falBody: any;
if (reference_images?.length) {
// Multi-reference edit mode
const imageUrls: string[] = [sourceUrl];
for (const ref of reference_images) {
if (ref.startsWith("data:")) {
const path = await saveDataUrlToDisk(ref, "img2img-ref");
imageUrls.push(`https://rspace.online${path}`);
} else if (ref.startsWith("/")) {
imageUrls.push(`https://rspace.online${ref}`);
} else {
imageUrls.push(ref);
}
}
falEndpoint = "https://fal.run/fal-ai/flux-2/edit";
falBody = { image_urls: imageUrls, prompt: fullPrompt };
} else {
// Single source img2img
falEndpoint = "https://fal.run/fal-ai/flux/dev/image-to-image";
falBody = { image_url: sourceUrl, prompt: fullPrompt, strength, num_inference_steps: 28 };
}
const res = await fetch(falEndpoint, {
method: "POST",
headers: { Authorization: `Key ${FAL_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(falBody),
});
if (!res.ok) {
const err = await res.text();
console.error("[img2img] fal.ai error:", err);
return c.json({ error: "img2img generation failed" }, 502);
}
const data = await res.json();
const resultUrl = data.images?.[0]?.url || data.output?.url;
if (!resultUrl) return c.json({ error: "No image returned" }, 502);
// Download and save locally
const imgRes = await fetch(resultUrl);
if (!imgRes.ok) return c.json({ url: resultUrl, image_url: resultUrl });
const imgBuf = await imgRes.arrayBuffer();
const filename = `img2img-${Date.now()}.png`;
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
await Bun.write(resolve(dir, filename), Buffer.from(imgBuf));
const localUrl = `/data/files/generated/${filename}`;
return c.json({ url: localUrl, image_url: localUrl });
} catch (e: any) {
console.error("[img2img] fal error:", e.message);
return c.json({ error: "img2img generation failed" }, 502);
}
}
if (provider === "gemini") {
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
const { GoogleGenAI } = await import("@google/genai");
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
const geminiModelMap: Record<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"));
const url = `/data/files/generated/${filename}`;
return c.json({ url, image_url: url });
}
}
return c.json({ error: "No image in Gemini response" }, 502);
} catch (e: any) {
console.error("[img2img] Gemini error:", e.message);
return c.json({ error: "Gemini img2img failed" }, 502);
}
}
return c.json({ error: `Unknown provider: ${provider}` }, 400);
});
// Text-to-video via fal.ai WAN 2.1
app.post("/api/video-gen/t2v", async (c) => {
if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
const { prompt, duration } = await c.req.json();
if (!prompt) return c.json({ error: "prompt required" }, 400);
const res = await fetch("https://fal.run/fal-ai/wan/v2.1", {
method: "POST",
headers: {
Authorization: `Key ${FAL_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
prompt,
num_frames: duration === "5s" ? 81 : 49,
resolution: "480p",
}),
});
if (!res.ok) {
const err = await res.text();
console.error("[video-gen/t2v] fal.ai error:", err);
return c.json({ error: "Video generation failed" }, 502);
}
const data = await res.json();
const videoUrl = data.video?.url || data.output?.url;
if (!videoUrl) return c.json({ error: "No video returned" }, 502);
return c.json({ url: videoUrl, video_url: videoUrl });
});
// Image-to-video via fal.ai Kling
app.post("/api/video-gen/i2v", async (c) => {
if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
const { image, prompt, duration } = await c.req.json();
if (!image) return c.json({ error: "image required" }, 400);
const res = await fetch("https://fal.run/fal-ai/kling-video/v1/standard/image-to-video", {
method: "POST",
headers: {
Authorization: `Key ${FAL_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
image_url: image,
prompt: prompt || "",
duration: duration === "5s" ? "5" : "5",
}),
});
if (!res.ok) {
const err = await res.text();
console.error("[video-gen/i2v] fal.ai error:", err);
return c.json({ error: "Video generation failed" }, 502);
}
const data = await res.json();
const videoUrl = data.video?.url || data.output?.url;
if (!videoUrl) return c.json({ error: "No video returned" }, 502);
return c.json({ url: videoUrl, video_url: videoUrl });
});
// Image-to-3D via fal.ai Trellis
app.post("/api/3d-gen", async (c) => {
if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
const { image_url } = await c.req.json();
if (!image_url) return c.json({ error: "image_url required" }, 400);
try {
const res = await fetch("https://fal.run/fal-ai/trellis", {
method: "POST",
headers: {
Authorization: `Key ${FAL_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ image_url }),
});
if (!res.ok) {
const err = await res.text();
console.error("[3d-gen] fal.ai error:", err);
return c.json({ error: "3D generation failed" }, 502);
}
const data = await res.json();
// Trellis returns glb_url and/or model_mesh — download the GLB
const modelUrl = data.glb_url || data.model_mesh?.url || data.output?.url;
if (!modelUrl) return c.json({ error: "No 3D model returned" }, 502);
// Download the model file
const modelRes = await fetch(modelUrl);
if (!modelRes.ok) return c.json({ error: "Failed to download model" }, 502);
const modelBuf = await modelRes.arrayBuffer();
const ext = modelUrl.includes(".ply") ? "ply" : "glb";
const filename = `splat-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.${ext}`;
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
await Bun.write(resolve(dir, filename), modelBuf);
return c.json({ url: `/data/files/generated/${filename}`, format: ext });
} catch (e: any) {
console.error("[3d-gen] error:", e.message);
return c.json({ error: "3D generation failed" }, 502);
}
});
// Blender 3D generation via LLM + RunPod
const RUNPOD_API_KEY = process.env.RUNPOD_API_KEY || "";
app.post("/api/blender-gen", async (c) => {
if (!RUNPOD_API_KEY) return c.json({ error: "RUNPOD_API_KEY not configured" }, 503);
const { prompt } = await c.req.json();
if (!prompt) return c.json({ error: "prompt required" }, 400);
// Step 1: Generate Blender Python script via LLM
const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434";
let script = "";
try {
const llmRes = await fetch(`${OLLAMA_URL}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: process.env.OLLAMA_MODEL || "llama3.1",
prompt: `Generate a Blender Python script that creates: ${prompt}\n\nThe script should:\n- Import bpy\n- Clear the default scene\n- Create the described objects with materials\n- Set up basic lighting and camera\n- Render to /tmp/render.png at 1024x1024\n\nOnly output the Python code, no explanations.`,
stream: false,
}),
});
if (llmRes.ok) {
const llmData = await llmRes.json();
script = llmData.response || "";
// Extract code block if wrapped in markdown
const codeMatch = script.match(/```python\n([\s\S]*?)```/);
if (codeMatch) script = codeMatch[1];
}
} catch (e) {
console.error("[blender-gen] LLM error:", e);
}
if (!script) {
return c.json({ error: "Failed to generate Blender script" }, 502);
}
// Step 2: Execute on RunPod (headless Blender)
try {
const runpodRes = await fetch("https://api.runpod.ai/v2/blender/runsync", {
method: "POST",
headers: {
Authorization: `Bearer ${RUNPOD_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
input: {
script,
render: true,
},
}),
});
if (!runpodRes.ok) {
// Return just the script if RunPod fails
return c.json({ script, error_detail: "RunPod execution failed" });
}
const runpodData = await runpodRes.json();
return c.json({
render_url: runpodData.output?.render_url || null,
script,
blend_url: runpodData.output?.blend_url || null,
});
} catch (e) {
// Return the script even if RunPod is unavailable
return c.json({ script, error_detail: "RunPod unavailable" });
}
});
// KiCAD PCB design — REST-to-MCP bridge
const KICAD_MCP_URL = process.env.KICAD_MCP_URL || "http://localhost:3001";
app.post("/api/kicad/:action", async (c) => {
const action = c.req.param("action");
const body = await c.req.json();
const validActions = [
"create_project", "add_schematic_component", "add_schematic_connection",
"export_svg", "run_drc", "export_gerber", "export_bom", "export_pdf",
"search_symbols", "search_footprints", "place_component",
];
if (!validActions.includes(action)) {
return c.json({ error: `Unknown action: ${action}` }, 400);
}
try {
const mcpRes = await fetch(`${KICAD_MCP_URL}/call-tool`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: action,
arguments: body,
}),
});
if (!mcpRes.ok) {
const err = await mcpRes.text();
console.error(`[kicad/${action}] MCP error:`, err);
return c.json({ error: `KiCAD action failed: ${action}` }, 502);
}
const data = await mcpRes.json();
return c.json(data);
} catch (e) {
console.error(`[kicad/${action}] Connection error:`, e);
return c.json({ error: "KiCAD MCP server not available" }, 503);
}
});
// FreeCAD parametric CAD — REST-to-MCP bridge
const FREECAD_MCP_URL = process.env.FREECAD_MCP_URL || "http://localhost:3002";
app.post("/api/freecad/:action", async (c) => {
const action = c.req.param("action");
const body = await c.req.json();
const validActions = [
"generate", "export_step", "export_stl", "update_parameters",
];
if (!validActions.includes(action)) {
return c.json({ error: `Unknown action: ${action}` }, 400);
}
try {
const mcpRes = await fetch(`${FREECAD_MCP_URL}/call-tool`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: action,
arguments: body,
}),
});
if (!mcpRes.ok) {
const err = await mcpRes.text();
console.error(`[freecad/${action}] MCP error:`, err);
return c.json({ error: `FreeCAD action failed: ${action}` }, 502);
}
const data = await mcpRes.json();
return c.json(data);
} catch (e) {
console.error(`[freecad/${action}] Connection error:`, e);
return c.json({ error: "FreeCAD MCP server not available" }, 503);
}
});
// ── Multi-provider prompt endpoint ──
const GEMINI_MODELS: Record<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",
};
app.post("/api/prompt", async (c) => {
const { messages, model = "gemini-flash" } = await c.req.json();
if (!messages?.length) return c.json({ error: "messages required" }, 400);
// Determine provider
if (GEMINI_MODELS[model]) {
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
const { GoogleGenerativeAI } = await import("@google/generative-ai");
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
const geminiModel = genAI.getGenerativeModel({ model: GEMINI_MODELS[model] });
// Convert chat messages to Gemini contents format (with optional images)
const contents = messages.map((m: { role: string; content: string; images?: string[] }) => {
const parts: any[] = [{ text: m.content }];
if (m.images?.length) {
for (const img of m.images) {
const match = img.match(/^data:(image\/\w+);base64,(.+)$/);
if (match) {
parts.push({ inlineData: { data: match[2], mimeType: match[1] } });
}
}
}
return { role: m.role === "assistant" ? "model" : "user", parts };
});
try {
const result = await geminiModel.generateContent({ contents });
const text = result.response.text();
return c.json({ content: text });
} catch (e: any) {
console.error("[prompt] Gemini error:", e.message);
return c.json({ error: "Gemini request failed" }, 502);
}
}
if (OLLAMA_MODELS[model]) {
try {
const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: OLLAMA_MODELS[model],
messages: messages.map((m: { role: string; content: string }) => ({
role: m.role,
content: m.content,
})),
stream: false,
}),
});
if (!ollamaRes.ok) {
const err = await ollamaRes.text();
console.error("[prompt] Ollama error:", err);
return c.json({ error: "Ollama request failed" }, 502);
}
const data = await ollamaRes.json();
return c.json({ content: data.message?.content || "" });
} catch (e: any) {
console.error("[prompt] Ollama unreachable:", e.message);
return c.json({ error: "Ollama unreachable" }, 503);
}
}
return c.json({ error: `Unknown model: ${model}` }, 400);
});
// ── Gemini image generation ──
app.post("/api/gemini/image", async (c) => {
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
const { prompt, style, aspect_ratio, reference_images } = await c.req.json();
if (!prompt) return c.json({ error: "prompt required" }, 400);
const styleHints: Record<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"));
const url = `/data/files/generated/${filename}`;
return c.json({ url, image_url: url });
}
}
} else {
// Imagen API
const result = await ai.models.generateImages({
model: modelName,
prompt: enhancedPrompt,
config: {
numberOfImages: 1,
aspectRatio: aspect_ratio || "3:4",
},
});
const img = (result as any).generatedImages?.[0];
if (img?.image?.imageBytes) {
const filename = `imagen-${Date.now()}.png`;
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
await Bun.write(resolve(dir, filename), Buffer.from(img.image.imageBytes, "base64"));
const url = `/data/files/generated/${filename}`;
return c.json({ url, image_url: url });
}
}
} catch (e: any) {
console.error(`[gemini/image] ${modelName} error:`, e.message);
continue;
}
}
return c.json({ error: "All Gemini image models failed" }, 502);
});
// ── Zine generation endpoints ──
const ZINE_STYLES: Record<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"));
generatedImages[section.id] = `/data/files/generated/${filename}`;
break;
}
}
} catch (e: any) {
console.error(`[zine/page] Image gen failed for ${section.id}:`, e.message);
}
}
return c.json({
pageNumber: outline.pageNumber,
images: generatedImages,
outline,
});
});
app.post("/api/zine/regenerate-section", async (c) => {
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
const { section, pageTitle, style = "punk-zine", tone = "informative", feedback = "" } = await c.req.json();
if (!section?.id) return c.json({ error: "section with id required" }, 400);
if (section.type === "image") {
// Regenerate image with feedback
const { GoogleGenAI } = await import("@google/genai");
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
const styleDesc = ZINE_STYLES[style] || style;
const toneDesc = ZINE_TONES[tone] || tone;
const enhancedPrompt = `${styleDesc} style, ${toneDesc} mood. ${section.imagePrompt}. For a zine page about "${pageTitle}".${feedback ? ` User feedback: ${feedback}` : ""}`;
try {
const result = await ai.models.generateContent({
model: "gemini-2.5-flash-image",
contents: enhancedPrompt,
config: { responseModalities: ["Text", "Image"] },
});
const parts = result.candidates?.[0]?.content?.parts || [];
for (const part of parts) {
if ((part as any).inlineData) {
const { data: b64, mimeType } = (part as any).inlineData;
const ext = mimeType?.includes("png") ? "png" : "jpg";
const filename = `zine-regen-${section.id}-${Date.now()}.${ext}`;
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
await Bun.write(resolve(dir, filename), Buffer.from(b64, "base64"));
return c.json({ sectionId: section.id, type: "image", url: `/data/files/generated/${filename}` });
}
}
return c.json({ error: "No image generated" }, 502);
} catch (e: any) {
console.error("[zine/regenerate-section] image error:", e.message);
return c.json({ error: "Image regeneration failed" }, 502);
}
}
if (section.type === "text") {
// Regenerate text via Gemini
const { GoogleGenerativeAI } = await import("@google/generative-ai");
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" });
const toneDesc = ZINE_TONES[tone] || tone;
const prompt = `Rewrite this zine text section. Section type: "${section.id}". Page title: "${pageTitle}". Tone: ${toneDesc}.
Current text: "${section.content}"
${feedback ? `User feedback: ${feedback}` : "Make it punchier and more engaging."}
Return ONLY the new text, no quotes or explanation.`;
try {
const result = await model.generateContent(prompt);
return c.json({ sectionId: section.id, type: "text", content: result.response.text().trim() });
} catch (e: any) {
console.error("[zine/regenerate-section] text error:", e.message);
return c.json({ error: "Text regeneration failed" }, 502);
}
}
return c.json({ error: `Unknown section type: ${section.type}` }, 400);
});
// ── Auto-provision personal space ──
app.post("/api/spaces/auto-provision", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims: EncryptIDClaims;
try {
claims = await verifyEncryptIDToken(token);
} catch {
return c.json({ error: "Invalid or expired token" }, 401);
}
const username = claims.username?.toLowerCase();
if (!username || !/^[a-z0-9][a-z0-9-]*$/.test(username)) {
return c.json({ error: "Username not suitable for space slug" }, 400);
}
if (await communityExists(username)) {
return c.json({ status: "exists", slug: username });
}
const result = await createSpace({
name: `${claims.username}'s Space`,
slug: username,
ownerDID: claims.sub,
visibility: "private",
source: 'auto-provision',
});
if (!result.ok) return c.json({ error: result.error }, result.status);
return c.json({ status: "created", slug: username }, 201);
});
// ── Inject space visibility into HTML responses ──
// Replaces the default data-space-visibility="public" rendered by renderShell
// with the actual visibility from the space config, so the client-side access gate
// can block content for private spaces when no session exists.
app.use("/:space/*", async (c, next) => {
await next();
const ct = c.res.headers.get("content-type");
if (!ct?.includes("text/html")) return;
const space = c.req.param("space");
if (!space || space === "api" || space.includes(".")) return;
const config = await getSpaceConfig(space);
const vis = config?.visibility || "public";
if (vis === "public") return;
const html = await c.res.text();
c.res = new Response(
html.replace(
'data-space-visibility="public"',
`data-space-visibility="${vis}"`,
),
{ status: c.res.status, headers: c.res.headers },
);
});
// ── Template seeding route: /:space/:moduleId/template ──
// Triggers template seeding for empty spaces, then redirects to the module page.
app.get("/:space/:moduleId/template", async (c) => {
const space = c.req.param("space");
const moduleId = c.req.param("moduleId");
if (!space || space === "api" || space.includes(".")) return c.notFound();
try {
await loadCommunity(space);
const seeded = seedTemplateShapes(space);
if (seeded) {
broadcastAutomergeSync(space);
broadcastJsonSnapshot(space);
}
// Seed module-specific demo data (calendar events, notes, tasks, etc.)
const mod = getModule(moduleId);
if (mod?.seedTemplate) mod.seedTemplate(space);
} catch (e) {
console.error(`[Template] On-demand seed failed for "${space}":`, e);
}
return c.redirect(`/${space}/${moduleId}`, 302);
});
// ── Empty-state detection for onboarding ──
import type { RSpaceModule } from "../shared/module";
import { resolveDataSpace } from "../shared/scope-resolver";
function moduleHasData(space: string, mod: RSpaceModule): boolean {
if (space === "demo") return true; // demo always has data
if (!mod.docSchemas || mod.docSchemas.length === 0) return true; // no schemas = can't detect
for (const schema of mod.docSchemas) {
if (!schema.pattern.includes('{space}')) return true; // global module, always show
const prefix = schema.pattern.replace('{space}', space).split(':').slice(0, 3).join(':');
if (syncServer.hasDocsWithPrefix(prefix)) return true;
}
return false;
}
// ── Mount module routes under /:space/:moduleId ──
// Enforce enabledModules: if a space has an explicit list, only those modules route.
// The 'rspace' (canvas) module is always allowed as the core module.
for (const mod of getAllModules()) {
app.use(`/:space/${mod.id}/*`, async (c, next) => {
const space = c.req.param("space");
if (!space || space === "api" || space.includes(".")) return next();
// Check enabled modules (skip for core rspace module)
const doc = getDocumentData(space);
if (mod.id !== "rspace") {
if (doc?.meta?.enabledModules && !doc.meta.enabledModules.includes(mod.id)) {
return c.json({ error: "Module not enabled for this space" }, 404);
}
}
// Resolve effective data space (global vs space-scoped)
const overrides = doc?.meta?.moduleScopeOverrides ?? null;
const effectiveSpace = resolveDataSpace(mod.id, space, overrides);
c.set("effectiveSpace" as any, effectiveSpace);
// Resolve caller's role for write-method blocking
const method = c.req.method;
if (!mod.publicWrite && (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE")) {
const token = extractToken(c.req.raw.headers);
let claims: EncryptIDClaims | null = null;
if (token) {
try { claims = await verifyEncryptIDToken(token); } catch {}
}
const resolved = await resolveCallerRole(space, claims);
if (resolved) {
c.set("spaceRole" as any, resolved.role);
c.set("isOwner" as any, resolved.isOwner);
if (resolved.role === "viewer") {
return c.json({ error: "Write access required" }, 403);
}
}
}
return next();
});
// Onboarding: show landing page for empty modules (root page only)
if (mod.id !== "rspace") {
app.use(`/:space/${mod.id}`, async (c, next) => {
const space = c.req.param("space");
if (!space || space === "demo" || space === "api" || space.includes(".")) return next();
if (c.req.method !== "GET") return next();
const path = c.req.path;
const root = `/${space}/${mod.id}`;
if (path !== root && path !== root + '/') return next();
if (!moduleHasData(space, mod)) {
return c.html(renderOnboarding({
moduleId: mod.id,
moduleName: mod.name,
moduleIcon: mod.icon,
moduleDescription: mod.description,
spaceSlug: space,
modules: getModuleInfoList(),
landingHTML: mod.landingPage?.(),
}));
}
return next();
});
}
app.route(`/:space/${mod.id}`, mod.routes);
// Auto-mount browsable output list pages
if (mod.outputPaths) {
for (const op of mod.outputPaths) {
app.get(`/:space/${mod.id}/${op.path}`, (c) => {
return c.html(renderOutputListPage(
c.req.param("space"), mod, op, getModuleInfoList()
));
});
}
}
}
// ── Page routes ──
// Landing page: rspace.online/ → server-rendered main landing
app.get("/", (c) => {
return c.html(renderMainLanding(getModuleInfoList()));
});
// About/info page (full landing content)
app.get("/about", async (c) => {
const file = Bun.file(resolve(DIST_DIR, "index.html"));
if (await file.exists()) {
return new Response(file, { headers: { "Content-Type": "text/html" } });
}
return c.text("rSpace", 200);
});
// Create new space page
app.get("/create-space", async (c) => {
const file = Bun.file(resolve(DIST_DIR, "create-space.html"));
if (await file.exists()) {
return new Response(file, { headers: { "Content-Type": "text/html" } });
}
return c.text("Create space", 200);
});
// Legacy redirect
app.get("/new", (c) => c.redirect("/create-space", 301));
// ── Admin dashboard & API ──
const ADMIN_DIDS = (process.env.ADMIN_DIDS || "").split(",").filter(Boolean);
function isAdminDID(did: string | undefined): boolean {
return !!did && ADMIN_DIDS.includes(did);
}
// Serve admin HTML page
app.get("/admin", async (c) => {
const file = Bun.file(resolve(DIST_DIR, "admin.html"));
if (await file.exists()) {
return new Response(file, { headers: { "Content-Type": "text/html" } });
}
return c.text("Admin", 200);
});
// Admin data endpoint (GET) — returns spaces list + modules
app.get("/admin-data", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims: EncryptIDClaims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
if (!isAdminDID(claims.sub)) return c.json({ error: "Admin access required" }, 403);
const STORAGE_DIR = process.env.STORAGE_DIR || "./data/communities";
const slugs = await listCommunities();
const spacesList = [];
for (const slug of slugs) {
await loadCommunity(slug);
const data = getDocumentData(slug);
if (!data?.meta) continue;
const shapes = data.shapes || {};
const members = data.members || {};
const shapeCount = Object.keys(shapes).length;
const memberCount = Object.keys(members).length;
let fileSizeBytes = 0;
try {
const { stat } = await import("node:fs/promises");
const s = await stat(`${STORAGE_DIR}/${slug}.automerge`);
fileSizeBytes = s.size;
} catch {
try {
const { stat } = await import("node:fs/promises");
const s = await stat(`${STORAGE_DIR}/${slug}.json`);
fileSizeBytes = s.size;
} catch { /* not on disk yet */ }
}
const shapeTypes: Record<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 || "public",
createdAt: data.meta.createdAt,
ownerDID: data.meta.ownerDID,
shapeCount,
memberCount,
fileSizeBytes,
shapeTypes,
});
}
spacesList.sort((a, b) => {
const da = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const db = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return db - da;
});
return c.json({
spaces: spacesList,
total: spacesList.length,
modules: getModuleInfoList(),
});
});
// Admin action endpoint (POST) — mutations (delete space, etc.)
app.post("/admin-action", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims: EncryptIDClaims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
if (!isAdminDID(claims.sub)) return c.json({ error: "Admin access required" }, 403);
const body = await c.req.json();
const { action, slug } = body;
if (action === "delete-space" && slug) {
await loadCommunity(slug);
const data = getDocumentData(slug);
if (!data) return c.json({ error: "Space not found" }, 404);
const deleteCtx = {
spaceSlug: slug,
ownerDID: data.meta?.ownerDID ?? null,
enabledModules: data.meta?.enabledModules || getAllModules().map(m => m.id),
syncServer,
};
for (const mod of getAllModules()) {
if (mod.onSpaceDelete) {
try { await mod.onSpaceDelete(deleteCtx); } catch (e) {
console.error(`[Admin] Module ${mod.id} onSpaceDelete failed:`, e);
}
}
}
// Clean up EncryptID space_members
try {
await fetch(`https://auth.rspace.online/api/admin/spaces/${slug}/members`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
} catch (e) {
console.error(`[Admin] Failed to clean EncryptID space_members for ${slug}:`, e);
}
await deleteCommunity(slug);
return c.json({ ok: true, message: `Space "${slug}" deleted by admin` });
}
return c.json({ error: "Unknown action" }, 400);
});
// Space root: /:space → space dashboard
app.get("/:space", (c) => {
const space = c.req.param("space");
// Don't serve dashboard for static file paths
if (space.includes(".")) return c.notFound();
return c.html(renderSpaceDashboard(space, getModuleInfoList()));
});
// ── WebSocket types ──
interface WSData {
communitySlug: string;
peerId: string;
claims: EncryptIDClaims | null;
readOnly: boolean;
spaceRole: 'viewer' | 'member' | 'moderator' | 'admin' | null;
mode: "automerge" | "json";
// Nest context: set when a folk-canvas shape connects to a nested space
nestFrom?: string; // slug of the parent space that contains the nest
nestPermissions?: NestPermissions; // effective permissions for this nested view
nestFilter?: SpaceRefFilter; // shape filter applied to this nested view
}
// Track connected clients per community
const communityClients = new Map<string, Map<string, ServerWebSocket<WSData>>>();
// 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")) {
// HTML must revalidate so browsers pick up new hashed JS/CSS references
headers["Cache-Control"] = "no-cache";
} else if (path.startsWith("assets/")) {
// Vite content-hashed assets are safe to cache long-term
headers["Cache-Control"] = "public, max-age=31536000, immutable";
}
return new Response(file, { headers });
}
return null;
}
// ── Standalone domain → module lookup ──
const domainToModule = new Map<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);
// ── Standalone domain → 301 redirect to rspace.online ──
// Check both bare domain and subdomain variants (e.g. rnotes.online, alice.rnotes.online)
let standaloneModuleId = domainToModule.get(hostClean);
let standaloneSub: string | null = null;
if (!standaloneModuleId) {
// Check if this is a subdomain of a standalone domain (e.g. alice.rnotes.online)
const hostParts = hostClean.split(".");
if (hostParts.length >= 3) {
const baseDomain = hostParts.slice(-2).join(".");
const candidate = domainToModule.get(baseDomain);
if (candidate) {
standaloneModuleId = candidate;
standaloneSub = hostParts.slice(0, -2).join(".");
}
}
}
if (standaloneModuleId) {
// Self-fetch detection: landing proxy uses this User-Agent;
// return 404 to break circular fetch so the generic fallback is used
if (req.headers.get("user-agent") === "rSpace-Proxy/1.0") {
return new Response("Not found", { status: 404 });
}
// Determine the space and remaining path
const pathParts = url.pathname.split("/").filter(Boolean);
let space = standaloneSub || null; // subdomain on standalone domain = space
let remainingPath = "";
if (pathParts.length > 0 && !pathParts[0].includes(".") &&
pathParts[0] !== "api" && pathParts[0] !== "ws") {
// First path segment is the space (if no subdomain already set it)
if (!space) {
space = pathParts[0];
remainingPath = pathParts.length > 1 ? "/" + pathParts.slice(1).join("/") : "";
} else {
remainingPath = url.pathname;
}
} else {
remainingPath = url.pathname === "/" ? "" : url.pathname;
}
// Build redirect URL
let redirectUrl: string;
if (space) {
// Space-qualified: alice.rnotes.online/path or rnotes.online/alice/path
// → alice.rspace.online/rnotes/path
redirectUrl = `https://${space}.rspace.online/${standaloneModuleId}${remainingPath}`;
} else {
// No space: rnotes.online/ or rnotes.online/api/...
// → rspace.online/rnotes or rspace.online/rnotes/api/...
redirectUrl = `https://rspace.online/${standaloneModuleId}${remainingPath}`;
}
if (url.search) redirectUrl += url.search;
return Response.redirect(redirectUrl, 301);
}
// ── WebSocket upgrade ──
if (url.pathname.startsWith("/ws/")) {
const communitySlug = url.pathname.split("/")[2];
if (communitySlug) {
const spaceConfig = await getSpaceConfig(communitySlug);
const claims = await authenticateWSUpgrade(req);
let readOnly = false;
let spaceRole: WSData['spaceRole'] = null;
if (spaceConfig) {
const vis = spaceConfig.visibility;
if (vis === "permissioned" || vis === "private") {
if (!claims) return new Response("Authentication required", { status: 401 });
} else if (vis === "public") {
readOnly = !claims;
}
}
// Resolve the caller's space role
await loadCommunity(communitySlug);
const spaceData = getDocumentData(communitySlug);
if (spaceData) {
if (claims && spaceData.meta.ownerDID === claims.sub) {
spaceRole = 'admin';
} else if (claims && spaceData.members?.[claims.sub]) {
spaceRole = spaceData.members[claims.sub].role;
} else {
// Non-member defaults by visibility
const vis = spaceConfig?.visibility;
if (vis === 'public' && claims) spaceRole = 'member';
else if (vis === 'permissioned' && claims) spaceRole = 'viewer';
else if (claims) spaceRole = 'viewer';
else spaceRole = 'viewer'; // anonymous
}
}
// Enforce read-only for viewers
if (spaceRole === 'viewer') {
readOnly = true;
}
const peerId = generatePeerId();
const mode = url.searchParams.get("mode") === "json" ? "json" : "automerge";
// Nest context: if connecting from a parent space via folk-canvas
const nestFrom = url.searchParams.get("nest-from") || undefined;
let nestPermissions: NestPermissions | undefined;
let nestFilter: SpaceRefFilter | undefined;
if (nestFrom) {
await loadCommunity(nestFrom);
const parentData = getDocumentData(nestFrom);
if (parentData?.nestedSpaces) {
// Find the SpaceRef in the parent that points to this space
const matchingRef = Object.values(parentData.nestedSpaces)
.find(ref => ref.sourceSlug === communitySlug);
if (matchingRef) {
nestPermissions = matchingRef.permissions;
nestFilter = matchingRef.filter;
// If nest doesn't allow writes, force readOnly
if (!matchingRef.permissions.write) {
readOnly = true;
}
}
}
}
const upgraded = server.upgrade(req, {
data: { communitySlug, peerId, claims, readOnly, spaceRole, mode, nestFrom, nestPermissions, nestFilter } as WSData,
});
if (upgraded) return undefined;
}
return new Response("WebSocket upgrade failed", { status: 400 });
}
// ── Explicit page routes (before Hono, to avoid /:space catch-all) ──
if (url.pathname === "/admin") {
const adminHtml = await serveStatic("admin.html");
if (adminHtml) return adminHtml;
}
// ── Static assets (before Hono routing) ──
if (url.pathname !== "/" && !url.pathname.startsWith("/api/") && !url.pathname.startsWith("/ws/")) {
const assetPath = url.pathname.slice(1);
// Serve files with extensions directly
if (assetPath.includes(".")) {
const staticResponse = await serveStatic(assetPath, url);
if (staticResponse) return staticResponse;
}
}
// ── Subdomain routing: {space}.rspace.online/{moduleId}/... ──
if (subdomain) {
// ── Auto-provision personal space on first visit ──
// Fast path: communityExists is an in-memory Map check for cached spaces.
if (!(await communityExists(subdomain))) {
const token = extractTokenFromRequest(req);
if (token) {
const jwtUsername = decodeJWTUsername(token);
if (jwtUsername === subdomain && !provisioningInProgress.has(subdomain)) {
provisioningInProgress.add(subdomain);
try {
const claims = await verifyEncryptIDToken(token);
const username = claims.username?.toLowerCase();
if (username === subdomain && !(await communityExists(subdomain))) {
await createSpace({
name: `${claims.username}'s Space`,
slug: subdomain,
ownerDID: claims.sub,
visibility: "private",
source: 'subdomain',
});
}
} catch (e) {
console.error(`[AutoProvision] Token verification failed for ${subdomain}:`, e);
} finally {
provisioningInProgress.delete(subdomain);
}
}
}
}
const pathSegments = url.pathname.split("/").filter(Boolean);
// Root: show space dashboard
if (pathSegments.length === 0) {
return new Response(renderSpaceDashboard(subdomain, getModuleInfoList()), {
headers: { "Content-Type": "text/html" },
});
}
// Global routes pass through without subdomain prefix
if (
url.pathname.startsWith("/api/") ||
url.pathname.startsWith("/data/") ||
url.pathname.startsWith("/.well-known/") ||
url.pathname === "/about" ||
url.pathname === "/admin" ||
url.pathname === "/create-space" ||
url.pathname === "/new"
) {
return app.fetch(req);
}
// Static assets (paths with file extensions) pass through
if (pathSegments[0].includes(".")) {
return app.fetch(req);
}
// Template seeding: /{moduleId}/template → seed + redirect
if (pathSegments.length >= 2 && pathSegments[pathSegments.length - 1] === "template") {
const moduleId = pathSegments[0].toLowerCase();
try {
await loadCommunity(subdomain);
const seeded = seedTemplateShapes(subdomain);
if (seeded) {
broadcastAutomergeSync(subdomain);
broadcastJsonSnapshot(subdomain);
}
// Seed module-specific demo data
const mod = getModule(moduleId);
if (mod?.seedTemplate) mod.seedTemplate(subdomain);
} catch (e) {
console.error(`[Template] On-demand seed failed for "${subdomain}":`, e);
}
const withoutTemplate = pathSegments.slice(0, -1).join("/");
return Response.redirect(`${url.protocol}//${url.host}/${withoutTemplate}`, 302);
}
// Demo route: /{moduleId}/demo → rewrite to /demo/{moduleId}
if (pathSegments.length >= 2 && pathSegments[pathSegments.length - 1] === "demo") {
const moduleId = pathSegments[0].toLowerCase();
const mod = getModule(moduleId);
if (mod) {
const rewrittenPath = `/demo/${moduleId}`;
const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`);
return app.fetch(new Request(rewrittenUrl, req));
}
}
// If first segment already matches the subdomain (space slug),
// pass through directly — URL already has /{space}/... prefix
// e.g. demo.rspace.online/demo/rcart/pay/123 → /demo/rcart/pay/123
if (pathSegments[0].toLowerCase() === subdomain) {
const rewrittenUrl = new URL(url.pathname + url.search, `http://localhost:${PORT}`);
return app.fetch(new Request(rewrittenUrl, req));
}
// Normalize module ID to lowercase (rTrips → rtrips)
const normalizedPath = "/" + pathSegments.map((seg, i) =>
i === 0 ? seg.toLowerCase() : seg
).join("/");
// Rewrite: /{moduleId}/... → /{space}/{moduleId}/...
// e.g. demo.rspace.online/vote/api/polls → /demo/vote/api/polls
const rewrittenPath = `/${subdomain}${normalizedPath}`;
const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`);
const rewrittenReq = new Request(rewrittenUrl, req);
return app.fetch(rewrittenReq);
}
// ── Bare-domain routing: rspace.online/{...} ──
if (!subdomain && hostClean.includes("rspace.online")) {
const pathSegments = url.pathname.split("/").filter(Boolean);
if (pathSegments.length >= 1) {
const firstSegment = pathSegments[0].toLowerCase();
const allModules = getAllModules();
const knownModuleIds = new Set(allModules.map((m) => m.id));
const mod = allModules.find((m) => m.id === firstSegment);
if (mod) {
// rspace.online/{moduleId} → landing page
if (pathSegments.length === 1) {
// 1. Check for inline rich landing page
if (mod.landingPage) {
const html = renderModuleLanding({
module: mod,
modules: getModuleInfoList(),
bodyHTML: mod.landingPage(),
});
return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
}
// 2. Try proxying the rich standalone landing page
const proxyHtml = await fetchLandingPage(mod, getModuleInfoList());
if (proxyHtml) {
return new Response(proxyHtml, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
// 3. Fallback to generic landing page
const html = renderModuleLanding({
module: mod,
modules: getModuleInfoList(),
});
return new Response(html, { headers: { "Content-Type": "text/html" } });
}
// rspace.online/{moduleId}/sub-path
const secondSegment = pathSegments[1]?.toLowerCase();
// 1. API routes always pass through to demo
if (secondSegment === "api") {
const normalizedPath = "/" + [firstSegment, ...pathSegments.slice(1)].join("/");
const rewrittenPath = `/demo${normalizedPath}`;
const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`);
return app.fetch(new Request(rewrittenUrl, req));
}
// 2. If sub-path matches a subPageInfo → render info page
const subPageInfo = mod.subPageInfos?.find(sp => sp.path === secondSegment);
if (subPageInfo) {
const html = renderSubPageInfo({
subPage: subPageInfo,
module: mod,
modules: getModuleInfoList(),
});
return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
}
// 3. Fallback: rewrite to demo space (backward compat)
const normalizedPath = "/" + [firstSegment, ...pathSegments.slice(1)].join("/");
const rewrittenPath = `/demo${normalizedPath}`;
const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`);
const rewrittenReq = new Request(rewrittenUrl, req);
return app.fetch(rewrittenReq);
}
// rspace.online/{space}/{...} → redirect to {space}.rspace.online/{...}
// (space is not a module ID — it's a space slug, canonicalize to subdomain)
// Skip redirect for known server paths (api, admin, etc.)
const serverPaths = new Set(["api", "admin", "admin-data", "admin-action", ".well-known"]);
if (!knownModuleIds.has(firstSegment) && !serverPaths.has(firstSegment) && pathSegments.length >= 2) {
const space = firstSegment;
const rest = "/" + pathSegments.slice(1).join("/");
const baseDomain = hostClean.replace(/^www\./, "");
return Response.redirect(
`${url.protocol}//${space}.${baseDomain}${rest}${url.search}`, 301
);
}
}
}
// ── Hono handles everything else ──
const response = await app.fetch(req);
// If Hono returns 404, try serving canvas.html as SPA fallback
// But only for paths that don't match a known module route
if (response.status === 404 && !url.pathname.startsWith("/api/")) {
const parts = url.pathname.split("/").filter(Boolean);
// Check if this is under a known module — if so, the module's 404 is authoritative
const knownModuleIds = getAllModules().map((m) => m.id);
const isModulePath = parts.length >= 2 && knownModuleIds.includes(parts[1].toLowerCase());
if (!isModulePath && parts.length >= 1 && !parts[0].includes(".")) {
// Not a module path — could be a canvas SPA route, try fallback
const canvasHtml = await serveStatic("canvas.html");
if (canvasHtml) return canvasHtml;
const indexHtml = await serveStatic("index.html");
if (indexHtml) return indexHtml;
}
}
return response;
},
// ── WebSocket handlers (unchanged) ──
websocket: {
open(ws: ServerWebSocket<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;
}
// ── Legacy canvas protocol (no docId) ──
if (msg.type === "sync" && Array.isArray(msg.data)) {
if (ws.data.readOnly) {
ws.send(JSON.stringify({ type: "error", message: "Authentication required to edit this space" }));
return;
}
const syncMessage = new Uint8Array(msg.data);
const result = receiveSyncMessage(communitySlug, peerId, syncMessage);
if (result.response) {
ws.send(JSON.stringify({ type: "sync", data: Array.from(result.response) }));
}
for (const [targetPeerId, targetMessage] of result.broadcastToPeers) {
const targetClient = getClient(communitySlug, targetPeerId);
if (targetClient && targetClient.data.mode === "automerge" && targetClient.readyState === WebSocket.OPEN) {
targetClient.send(JSON.stringify({ type: "sync", data: Array.from(targetMessage) }));
}
}
if (result.broadcastToPeers.size > 0) {
broadcastJsonSnapshot(communitySlug, peerId);
}
} else if (msg.type === "ping") {
ws.send(JSON.stringify({ type: "pong", timestamp: msg.timestamp }));
} else if (msg.type === "presence") {
const clients = communityClients.get(communitySlug);
if (clients) {
const presenceMsg = JSON.stringify({ type: "presence", peerId, ...msg });
for (const [clientPeerId, client] of clients) {
if (clientPeerId !== peerId && client.readyState === WebSocket.OPEN) {
client.send(presenceMsg);
}
}
}
} else if (msg.type === "announce") {
// Client announcing its identity for the people-online panel
const { peerId: clientPeerId, username, color } = msg;
if (!clientPeerId || !username) return;
let slugAnnouncements = peerAnnouncements.get(communitySlug);
if (!slugAnnouncements) {
slugAnnouncements = new Map();
peerAnnouncements.set(communitySlug, slugAnnouncements);
}
slugAnnouncements.set(peerId, { clientPeerId, username, color });
// Send full peer list back to the announcing client
const peerList = Array.from(slugAnnouncements.values());
ws.send(JSON.stringify({ type: "peer-list", peers: peerList }));
// Broadcast peer-joined to all other clients
const joinedMsg = JSON.stringify({ type: "peer-joined", peerId: clientPeerId, username, color });
const clients2 = communityClients.get(communitySlug);
if (clients2) {
for (const [cPeerId, client] of clients2) {
if (cPeerId !== peerId && client.readyState === WebSocket.OPEN) {
client.send(joinedMsg);
}
}
}
} else if (msg.type === "ping-user") {
// Relay a "come here" ping to a specific peer
const { targetPeerId: targetClientPeerId, viewport } = msg;
const slugAnnouncements = peerAnnouncements.get(communitySlug);
const senderInfo = slugAnnouncements?.get(peerId);
if (!slugAnnouncements || !senderInfo) return;
// Find the server peerId that matches the target client peerId
const clients3 = communityClients.get(communitySlug);
if (clients3) {
for (const [serverPid, _client] of clients3) {
const ann = slugAnnouncements.get(serverPid);
if (ann && ann.clientPeerId === targetClientPeerId && _client.readyState === WebSocket.OPEN) {
_client.send(JSON.stringify({
type: "ping-user",
fromPeerId: senderInfo.clientPeerId,
fromUsername: senderInfo.username,
viewport,
}));
// Persist ping as a notification
const targetDid = _client.data.claims?.sub;
if (targetDid && ws.data.claims?.sub) {
notify({
userDid: targetDid,
category: 'social',
eventType: 'ping_user',
title: `${senderInfo.username} pinged you in "${communitySlug}"`,
spaceSlug: communitySlug,
actorDid: ws.data.claims.sub,
actorUsername: senderInfo.username,
actionUrl: `/${communitySlug}/rspace`,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24h
}).catch(() => {});
}
break;
}
}
}
} else if (msg.type === "update" && msg.id && msg.data) {
if (ws.data.readOnly) {
ws.send(JSON.stringify({ type: "error", message: "Authentication required to edit" }));
return;
}
// Nest-level: check addShapes for new shapes
if (ws.data.nestPermissions) {
const existingDoc = getDocumentData(communitySlug);
const isNewShape = existingDoc && !existingDoc.shapes?.[msg.id];
if (isNewShape && !ws.data.nestPermissions.addShapes) {
ws.send(JSON.stringify({ type: "error", message: "Nest permissions do not allow adding shapes" }));
return;
}
}
updateShape(communitySlug, msg.id, msg.data);
broadcastJsonSnapshot(communitySlug, peerId);
broadcastAutomergeSync(communitySlug, peerId);
} else if (msg.type === "delete" && msg.id) {
if (ws.data.readOnly) {
ws.send(JSON.stringify({ type: "error", message: "Authentication required to delete" }));
return;
}
if (ws.data.nestPermissions && !ws.data.nestPermissions.deleteShapes) {
ws.send(JSON.stringify({ type: "error", message: "Nest permissions do not allow deleting shapes" }));
return;
}
forgetShape(communitySlug, msg.id, ws.data.claims?.sub);
broadcastJsonSnapshot(communitySlug, peerId);
broadcastAutomergeSync(communitySlug, peerId);
} else if (msg.type === "forget" && msg.id) {
if (ws.data.readOnly) {
ws.send(JSON.stringify({ type: "error", message: "Authentication required to forget" }));
return;
}
if (ws.data.nestPermissions && !ws.data.nestPermissions.deleteShapes) {
ws.send(JSON.stringify({ type: "error", message: "Nest permissions do not allow forgetting shapes" }));
return;
}
forgetShape(communitySlug, msg.id, ws.data.claims?.sub);
broadcastJsonSnapshot(communitySlug, peerId);
broadcastAutomergeSync(communitySlug, peerId);
} else if (msg.type === "remember" && msg.id) {
if (ws.data.readOnly) {
ws.send(JSON.stringify({ type: "error", message: "Authentication required to remember" }));
return;
}
rememberShape(communitySlug, msg.id);
broadcastJsonSnapshot(communitySlug, peerId);
broadcastAutomergeSync(communitySlug, peerId);
}
} catch (e) {
console.error("[WS] Failed to parse message:", e);
}
},
close(ws: ServerWebSocket<WSData>) {
const { communitySlug, peerId, claims } = ws.data;
// Unregister from notification delivery
if (claims?.sub) unregisterUserConnection(claims.sub, ws);
// Broadcast peer-left before cleanup
const slugAnnouncements = peerAnnouncements.get(communitySlug);
if (slugAnnouncements) {
const ann = slugAnnouncements.get(peerId);
if (ann) {
const leftMsg = JSON.stringify({ type: "peer-left", peerId: ann.clientPeerId });
const clients = communityClients.get(communitySlug);
if (clients) {
for (const [cPeerId, client] of clients) {
if (cPeerId !== peerId && client.readyState === WebSocket.OPEN) {
client.send(leftMsg);
}
}
}
slugAnnouncements.delete(peerId);
if (slugAnnouncements.size === 0) peerAnnouncements.delete(communitySlug);
}
}
const clients = communityClients.get(communitySlug);
if (clients) {
clients.delete(peerId);
if (clients.size === 0) communityClients.delete(communitySlug);
}
removePeerSyncState(communitySlug, peerId);
syncServer.removePeer(peerId);
console.log(`[WS] Client ${peerId} disconnected from ${communitySlug}`);
},
},
});
// ── Startup ──
// Call onInit for each module that defines it (schema registration, DB init, etc.)
(async () => {
for (const mod of getAllModules()) {
if (mod.onInit) {
try {
await mod.onInit({ syncServer });
console.log(`[Init] ${mod.name} initialized`);
} catch (e) {
console.error(`[Init] ${mod.name} failed:`, e);
}
}
}
// Pass syncServer to OAuth handlers
setNotionOAuthSyncServer(syncServer);
setGoogleOAuthSyncServer(syncServer);
})();
// Ensure generated files directory exists
import { mkdirSync } from "node:fs";
try { mkdirSync(resolve(process.env.FILES_DIR || "./data/files", "generated"), { recursive: true }); } catch {}
ensureDemoCommunity().then(() => console.log("[Demo] Demo community ready")).catch((e) => console.error("[Demo] Failed:", e));
loadAllDocs(syncServer)
.then(() => {
ensureTemplateSeeding();
// Seed all modules' demo data so /demo routes always have content
for (const mod of getAllModules()) {
if (mod.seedTemplate) {
try { mod.seedTemplate("demo"); } catch { /* already seeded */ }
}
}
console.log("[Demo] All module demo data seeded");
})
.catch((e) => console.error("[DocStore] Startup load failed:", e));
// Restore relay mode for encrypted spaces
(async () => {
try {
const slugs = await listCommunities();
let relayCount = 0;
for (const slug of slugs) {
await loadCommunity(slug);
const data = getDocumentData(slug);
if (data?.meta?.encrypted) {
syncServer.setRelayOnly(slug, true);
relayCount++;
}
}
if (relayCount > 0) {
console.log(`[Encryption] ${relayCount} space(s) set to relay mode (encrypted)`);
}
} catch (e) {
console.error("[Encryption] Failed to restore relay modes:", e);
}
})();
console.log(`rSpace unified server running on http://localhost:${PORT}`);
console.log(`Modules: ${getAllModules().map((m) => `${m.icon} ${m.name}`).join(", ")}`);