rspace-online/server/index.ts

1382 lines
48 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,
} from "./community-store";
import type { NestPermissions, SpaceRefFilter } from "./community-store";
import { ensureDemoCommunity } from "./seed-demo";
import { ensureCampaignDemo } from "./seed-campaign";
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 } from "../shared/module";
import { canvasModule } from "../modules/canvas/mod";
import { booksModule } from "../modules/books/mod";
import { pubsModule } from "../modules/pubs/mod";
import { cartModule } from "../modules/cart/mod";
import { swagModule } from "../modules/swag/mod";
import { choicesModule } from "../modules/choices/mod";
import { fundsModule } from "../modules/funds/mod";
import { filesModule } from "../modules/files/mod";
import { forumModule } from "../modules/forum/mod";
import { walletModule } from "../modules/wallet/mod";
import { voteModule } from "../modules/vote/mod";
import { notesModule } from "../modules/notes/mod";
import { mapsModule } from "../modules/maps/mod";
import { workModule } from "../modules/work/mod";
import { tripsModule } from "../modules/trips/mod";
import { calModule } from "../modules/cal/mod";
import { networkModule } from "../modules/network/mod";
import { tubeModule } from "../modules/tube/mod";
import { inboxModule } from "../modules/inbox/mod";
import { dataModule } from "../modules/data/mod";
import { splatModule } from "../modules/splat/mod";
import { photosModule } from "../modules/photos/mod";
import { socialsModule } from "../modules/rsocials/mod";
import { spaces } from "./spaces";
import { renderShell, renderModuleLanding } from "./shell";
import { fetchLandingPage } from "./landing-proxy";
import { syncServer } from "./sync-instance";
import { loadAllDocs } from "./local-first/doc-persistence";
// Register modules
registerModule(canvasModule);
registerModule(booksModule);
registerModule(pubsModule);
registerModule(cartModule);
registerModule(swagModule);
registerModule(choicesModule);
registerModule(fundsModule);
registerModule(filesModule);
registerModule(forumModule);
registerModule(walletModule);
registerModule(voteModule);
registerModule(notesModule);
registerModule(mapsModule);
registerModule(workModule);
registerModule(tripsModule);
registerModule(calModule);
registerModule(networkModule);
registerModule(tubeModule);
registerModule(inboxModule);
registerModule(dataModule);
registerModule(splatModule);
registerModule(photosModule);
registerModule(socialsModule);
// ── 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) ──
app.get("/.well-known/webauthn", (c) => {
return c.json(
{
origins: [
"https://rwallet.online",
"https://rvote.online",
"https://rmaps.online",
"https://rfiles.online",
"https://rnotes.online",
],
},
200,
{
"Access-Control-Allow-Origin": "*",
"Cache-Control": "public, max-age=3600",
}
);
});
// ── Space registry API ──
app.route("/api/spaces", spaces);
// ── mi — AI assistant endpoint ──
const MI_MODEL = process.env.MI_MODEL || "llama3.2";
const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434";
app.post("/api/mi/ask", async (c) => {
const { query, messages = [], space, module: currentModule, context = {} } = await c.req.json();
if (!query) return c.json({ error: "Query required" }, 400);
// Build rApp context for the system prompt
const moduleList = getModuleInfoList()
.map((m) => `- **${m.name}** (${m.id}): ${m.icon} ${m.description}`)
.join("\n");
// Build extended context section from client-provided context
let contextSection = `- Space: ${space || "none selected"}\n- Active rApp: ${currentModule || "none"}`;
if (context.pageTitle) contextSection += `\n- Page: ${context.pageTitle}`;
if (context.activeTab) contextSection += `\n- Active tab: ${context.activeTab}`;
if (context.openShapes?.length) {
const shapeSummary = context.openShapes
.slice(0, 15)
.map((s: any) => {
let desc = ` - ${s.type} (id: ${s.id})`;
if (s.title) desc += `: ${s.title}`;
if (s.snippet) desc += ` — "${s.snippet}"`;
if (s.x != null) desc += ` at (${s.x}, ${s.y})`;
return desc;
})
.join("\n");
contextSection += `\n- Open shapes on canvas:\n${shapeSummary}`;
}
if (context.selectedShapes?.length) {
const selSummary = context.selectedShapes
.map((s: any) => ` - ${s.type} (id: ${s.id})${s.title ? `: ${s.title}` : ""}${s.snippet ? ` — "${s.snippet}"` : ""}`)
.join("\n");
contextSection += `\n- The user currently has selected:\n${selSummary}`;
}
if (context.connections?.length) {
const connSummary = context.connections
.slice(0, 15)
.map((c: any) => ` - ${c.sourceId}${c.targetId}`)
.join("\n");
contextSection += `\n- Connected shapes:\n${connSummary}`;
}
if (context.viewport) {
contextSection += `\n- Viewport: zoom ${context.viewport.scale?.toFixed?.(2) || context.viewport.scale}, pan (${Math.round(context.viewport.x)}, ${Math.round(context.viewport.y)})`;
}
if (context.shapeGroups?.length) {
contextSection += `\n- ${context.shapeGroups.length} group(s) of connected shapes`;
}
if (context.shapeCountByType && Object.keys(context.shapeCountByType).length) {
const typeCounts = Object.entries(context.shapeCountByType)
.map(([t, n]) => `${t}: ${n}`)
.join(", ");
contextSection += `\n- Shape types: ${typeCounts}`;
}
const systemPrompt = `You are mi (mycelial intelligence), the intelligent assistant for rSpace — a self-hosted, community-run platform.
You help users navigate, understand, and get the most out of the platform's apps (rApps).
You understand the full context of what the user has open and can guide them through setup and usage.
## Available rApps
${moduleList}
## Current Context
${contextSection}
## Guidelines
- Be concise and helpful. Keep responses short (2-4 sentences) unless the user asks for detail.
- When suggesting actions, reference specific rApps by name and explain how they connect.
- You can suggest navigating to /:space/:moduleId paths.
- If the user has shapes open on their canvas, you can reference them by id and suggest connections.
- Help with setup: guide users through creating spaces, adding content, configuring rApps.
- If you don't know something specific about the user's data, say so honestly.
- Use a warm, knowledgeable tone. You're a mycelial guide, connecting knowledge across the platform.
## Actions
When the user asks you to create, modify, delete, connect, move, or arrange shapes on the canvas,
include action markers in your response. Each marker is on its own line:
[MI_ACTION:{"type":"create-shape","tagName":"folk-markdown","props":{"content":"# Hello"},"ref":"$1"}]
[MI_ACTION:{"type":"connect","sourceId":"$1","targetId":"shape-123"}]
[MI_ACTION:{"type":"update-shape","shapeId":"shape-123","fields":{"content":"Updated text"}}]
[MI_ACTION:{"type":"delete-shape","shapeId":"shape-123"}]
[MI_ACTION:{"type":"move-shape","shapeId":"shape-123","x":400,"y":200}]
[MI_ACTION:{"type":"navigate","path":"/myspace/canvas"}]
Use "$1", "$2", etc. as ref values when creating shapes, then reference them in subsequent connect actions.
Available shape types: folk-markdown, folk-wrapper, folk-image-gen, folk-video-gen, folk-prompt,
folk-embed, folk-calendar, folk-map, folk-chat, folk-slide, folk-obs-note, folk-workflow-block,
folk-social-post, folk-splat, folk-drawfast, folk-rapp, folk-feed.
## Transforms
When the user asks to align, distribute, or arrange selected shapes:
[MI_ACTION:{"type":"transform","transform":"align-left","shapeIds":["shape-1","shape-2"]}]
Available transforms: align-left, align-right, align-center-h, align-top, align-bottom, align-center-v,
distribute-h, distribute-v, arrange-row, arrange-column, arrange-grid, arrange-circle,
match-width, match-height, match-size.`;
// Build conversation for Ollama
const ollamaMessages = [
{ role: "system", content: systemPrompt },
...messages.slice(-8).map((m: any) => ({ role: m.role, content: m.content })),
{ role: "user", content: query },
];
try {
const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: MI_MODEL, messages: ollamaMessages, stream: true }),
});
if (!ollamaRes.ok) {
const errText = await ollamaRes.text().catch(() => "");
console.error("mi: Ollama error:", ollamaRes.status, errText);
return c.json({ error: "AI service unavailable" }, 502);
}
// Stream Ollama's NDJSON response directly to client
return new Response(ollamaRes.body, {
headers: {
"Content-Type": "application/x-ndjson",
"Cache-Control": "no-cache",
"Transfer-Encoding": "chunked",
},
});
} catch (e: any) {
console.error("mi: Failed to reach Ollama:", e.message);
// Fallback: return a static helpful response
const fallback = generateFallbackResponse(query, currentModule, space, getModuleInfoList());
return c.json({ response: fallback });
}
});
function generateFallbackResponse(
query: string,
currentModule: string,
space: string,
modules: ReturnType<typeof getModuleInfoList>,
): string {
const q = query.toLowerCase();
// Simple keyword matching for common questions
for (const m of modules) {
if (q.includes(m.id) || q.includes(m.name.toLowerCase())) {
return `**${m.name}** ${m.icon}${m.description}. You can access it at /${space || "personal"}/${m.id}.`;
}
}
if (q.includes("help") || q.includes("what can")) {
return `rSpace has ${modules.length} apps you can use. Some popular ones: **rSpace** (spatial canvas), **rNotes** (notes), **rChat** (messaging), **rFunds** (community funding), and **rVote** (governance). What would you like to explore?`;
}
if (q.includes("search") || q.includes("find")) {
return `You can browse your content through the app switcher (top-left dropdown), or navigate directly to any rApp. Try **rNotes** for text content, **rFiles** for documents, or **rPhotos** for images.`;
}
return `I'm currently running in offline mode (AI service not connected). I can still help with basic navigation — ask me about any specific rApp or feature! There are ${modules.length} apps available in rSpace.`;
}
// ── 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_read") 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
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 = "public_read" } = body;
if (!name || !slug) return c.json({ error: "Name and slug are required" }, 400);
if (!/^[a-z0-9-]+$/.test(slug)) return c.json({ error: "Slug must contain only lowercase letters, numbers, and hyphens" }, 400);
const validVisibilities: SpaceVisibility[] = ["public", "public_read", "authenticated", "members_only"];
if (!validVisibilities.includes(visibility)) return c.json({ error: `Invalid visibility` }, 400);
if (await communityExists(slug)) return c.json({ error: "Community already exists" }, 409);
await createCommunity(name, slug, claims.sub, visibility);
// Notify modules
for (const mod of getAllModules()) {
if (mod.onSpaceCreate) {
try { await mod.onSpaceCreate(slug); } catch (e) { console.error(`Module ${mod.id} onSpaceCreate:`, e); }
}
}
return c.json({ url: `https://${slug}.rspace.online`, slug, name, visibility, ownerDID: claims.sub }, 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 visibility: SpaceVisibility = body.public ? "public" : "public_read";
await createCommunity(
space.charAt(0).toUpperCase() + space.slice(1),
space,
`did:system:${space}`,
visibility,
);
for (const mod of getAllModules()) {
if (mod.onSpaceCreate) {
try { await mod.onSpaceCreate(space); } catch (e) { console.error(`Module ${mod.id} onSpaceCreate:`, e); }
}
}
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" });
});
// POST /api/communities/campaign-demo/reset
app.post("/api/communities/campaign-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: `Reset on cooldown. Try again in ${remaining}s` }, 429);
}
lastDemoReset = now;
await loadCommunity("campaign-demo");
clearShapes("campaign-demo");
await ensureCampaignDemo();
broadcastAutomergeSync("campaign-demo");
broadcastJsonSnapshot("campaign-demo");
return c.json({ ok: true, message: "Campaign demo reset to seed data" });
});
// 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() });
});
// ── Creative tools API endpoints ──
const FAL_KEY = process.env.FAL_KEY || "";
// 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://queue.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 });
});
// 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://queue.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://queue.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 });
});
// 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);
}
});
// ── 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 });
}
await createCommunity(
`${claims.username}'s Space`,
username,
claims.sub,
"authenticated",
);
for (const mod of getAllModules()) {
if (mod.onSpaceCreate) {
try {
await mod.onSpaceCreate(username);
} catch (e) {
console.error(`[AutoProvision] Module ${mod.id} onSpaceCreate:`, e);
}
}
}
console.log(`[AutoProvision] Created personal space: ${username}`);
return c.json({ status: "created", slug: username }, 201);
});
// ── Mount module routes under /:space/:moduleId ──
for (const mod of getAllModules()) {
app.route(`/:space/${mod.id}`, mod.routes);
}
// ── Page routes ──
// Landing page: rspace.online/ → serve marketing/info page
app.get("/", 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);
});
// 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
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);
});
// Space root: /:space → redirect to /:space/rspace
app.get("/:space", (c) => {
const space = c.req.param("space");
// Don't redirect for static file paths
if (space.includes(".")) return c.notFound();
return c.redirect(`/${space}/rspace`);
});
// ── WebSocket types ──
interface WSData {
communitySlug: string;
peerId: string;
claims: EncryptIDClaims | null;
readOnly: boolean;
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>>>();
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) }));
}
}
}
}
// ── 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;
}
// ── 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): Promise<Response | null> {
const filePath = resolve(DIST_DIR, path);
const file = Bun.file(filePath);
if (await file.exists()) {
return new Response(file, { headers: { "Content-Type": getContentType(path) } });
}
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 → internal rewrite to module routes ──
const standaloneModuleId = domainToModule.get(hostClean);
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 });
}
// Static assets pass through
if (url.pathname !== "/" && !url.pathname.startsWith("/api/") && !url.pathname.startsWith("/ws/")) {
const assetPath = url.pathname.slice(1);
if (assetPath.includes(".")) {
const staticResponse = await serveStatic(assetPath);
if (staticResponse) return staticResponse;
}
}
// Root path → redirect to rspace.online/{moduleId} landing page
if (url.pathname === "/") {
return Response.redirect(`https://rspace.online/${standaloneModuleId}`, 302);
}
// Sub-paths: rewrite internally → /{space}/{moduleId}/...
const pathParts = url.pathname.split("/").filter(Boolean);
let space = "demo";
let suffix = "";
if (
pathParts.length > 0 &&
!pathParts[0].includes(".") &&
pathParts[0] !== "api" &&
pathParts[0] !== "ws"
) {
space = pathParts[0];
suffix = pathParts.length > 1 ? "/" + pathParts.slice(1).join("/") : "";
} else if (url.pathname !== "/") {
suffix = url.pathname;
}
const rewrittenPath = `/${space}/${standaloneModuleId}${suffix}`;
const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`);
const rewrittenReq = new Request(rewrittenUrl, req);
return app.fetch(rewrittenReq);
}
// ── 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;
if (spaceConfig) {
const vis = spaceConfig.visibility;
if (vis === "authenticated" || vis === "members_only") {
if (!claims) return new Response("Authentication required", { status: 401 });
} else if (vis === "public_read") {
readOnly = !claims;
}
}
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, 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);
if (staticResponse) return staticResponse;
}
}
// ── Subdomain routing: {space}.rspace.online/{moduleId}/... ──
if (subdomain) {
const pathSegments = url.pathname.split("/").filter(Boolean);
// Root: redirect to default module (rspace)
if (pathSegments.length === 0) {
return Response.redirect(`${url.protocol}//${host}/rspace`, 302);
}
// Global routes pass through without subdomain prefix
if (
url.pathname.startsWith("/api/") ||
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);
}
// Rewrite: /{moduleId}/... → /{space}/{moduleId}/...
// e.g. demo.rspace.online/vote/api/polls → /demo/vote/api/polls
const rewrittenPath = `/${subdomain}${url.pathname}`;
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];
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 → rewrite to demo space internally
const rewrittenPath = `/demo${url.pathname}`;
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)
if (!knownModuleIds.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]);
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);
const nestLabel = nestFrom ? ` (nested from ${nestFrom})` : "";
console.log(`[WS] Client ${peerId} connected to ${communitySlug} (mode: ${mode})${nestLabel}`);
loadCommunity(communitySlug).then((doc) => {
if (!doc) 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 === "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 } = ws.data;
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 ──
ensureDemoCommunity().then(() => console.log("[Demo] Demo community ready")).catch((e) => console.error("[Demo] Failed:", e));
ensureCampaignDemo().then(() => console.log("[Campaign] Campaign demo ready")).catch((e) => console.error("[Campaign] Failed:", e));
loadAllDocs(syncServer).catch((e) => console.error("[DocStore] Startup load failed:", e));
console.log(`rSpace unified server running on http://localhost:${PORT}`);
console.log(`Modules: ${getAllModules().map((m) => `${m.icon} ${m.name}`).join(", ")}`);