4743 lines
174 KiB
TypeScript
4743 lines
174 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 sharp from "sharp";
|
||
import { Hono } from "hono";
|
||
import { cors } from "hono/cors";
|
||
import type { ServerWebSocket } from "bun";
|
||
import {
|
||
addShapes,
|
||
clearShapes,
|
||
communityExists,
|
||
createCommunity,
|
||
forgetShape,
|
||
rememberShape,
|
||
generateSyncMessageForPeer,
|
||
getDocumentData,
|
||
loadCommunity,
|
||
receiveSyncMessage,
|
||
removePeerSyncState,
|
||
updateShape,
|
||
updateShapeFields,
|
||
cascadePermissions,
|
||
listCommunities,
|
||
deleteCommunity,
|
||
updateSpaceMeta,
|
||
setMember,
|
||
} from "./community-store";
|
||
import type { NestPermissions, SpaceRefFilter } from "./community-store";
|
||
import { ensureDemoCommunity } from "./seed-demo";
|
||
import { seedTemplateShapes } from "./seed-template";
|
||
// Campaign demo moved to rsocials module — see modules/rsocials/campaign-data.ts
|
||
import type { SpaceVisibility } from "./community-store";
|
||
import {
|
||
evaluateSpaceAccess,
|
||
authenticateWSUpgrade,
|
||
} from "@encryptid/sdk/server";
|
||
import type { SpaceAuthConfig } from "@encryptid/sdk/server";
|
||
import { verifyToken, extractToken } from "./auth";
|
||
import type { EncryptIDClaims } from "./auth";
|
||
import { createMcpRouter } from "./mcp-server";
|
||
|
||
const spaceAuthOpts = () => ({
|
||
getSpaceConfig,
|
||
...(process.env.JWT_SECRET ? { secret: process.env.JWT_SECRET } : {}),
|
||
});
|
||
|
||
// ── Module system ──
|
||
import { registerModule, getAllModules, getModuleInfoList, getModule } from "../shared/module";
|
||
import { canvasModule } from "../modules/rspace/mod";
|
||
import { booksModule } from "../modules/rbooks/mod";
|
||
import { pubsModule } from "../modules/rpubs/mod";
|
||
import { cartModule } from "../modules/rcart/mod";
|
||
import { swagModule } from "../modules/rswag/mod";
|
||
import { choicesModule } from "../modules/rchoices/mod";
|
||
import { flowsModule } from "../modules/rflows/mod";
|
||
import { filesModule } from "../modules/rfiles/mod";
|
||
import { forumModule } from "../modules/rforum/mod";
|
||
import { walletModule } from "../modules/rwallet/mod";
|
||
import { voteModule } from "../modules/rvote/mod";
|
||
import { notesModule } from "../modules/rnotes/mod";
|
||
import { mapsModule } from "../modules/rmaps/mod";
|
||
import { tasksModule } from "../modules/rtasks/mod";
|
||
import { checklistCheckRoutes, checklistApiRoutes } from "../modules/rtasks/checklist-routes";
|
||
import { magicLinkRoutes } from "./magic-link/routes";
|
||
import { tripsModule } from "../modules/rtrips/mod";
|
||
import { calModule } from "../modules/rcal/mod";
|
||
import { networkModule } from "../modules/rnetwork/mod";
|
||
import { tubeModule } from "../modules/rtube/mod";
|
||
import { inboxModule } from "../modules/rinbox/mod";
|
||
import { dataModule } from "../modules/rdata/mod";
|
||
import { splatModule } from "../modules/rsplat/mod";
|
||
import { photosModule } from "../modules/rphotos/mod";
|
||
import { socialsModule } from "../modules/rsocials/mod";
|
||
import { meetsModule } from "../modules/rmeets/mod";
|
||
import { chatsModule } from "../modules/rchats/mod";
|
||
import { agentsModule } from "../modules/ragents/mod";
|
||
import { docsModule } from "../modules/rdocs/mod";
|
||
import { designModule } from "../modules/rdesign/mod";
|
||
import { scheduleModule } from "../modules/rschedule/mod";
|
||
import { bnbModule } from "../modules/rbnb/mod";
|
||
import { vnbModule } from "../modules/rvnb/mod";
|
||
import { crowdsurfModule } from "../modules/crowdsurf/mod";
|
||
import { timeModule } from "../modules/rtime/mod";
|
||
import { govModule } from "../modules/rgov/mod";
|
||
import { sheetsModule } from "../modules/rsheets/mod";
|
||
import { exchangeModule } from "../modules/rexchange/mod";
|
||
import { auctionsModule } from "../modules/rauctions/mod";
|
||
import { credModule } from "../modules/rcred/mod";
|
||
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
|
||
import type { SpaceRoleString } from "./spaces";
|
||
import { renderShell, renderSubPageInfo, renderModuleLanding, renderOnboarding, setFragmentMode } from "./shell";
|
||
import { renderOutputListPage } from "./output-list";
|
||
import { renderMainLanding, renderSpaceDashboard } from "./landing";
|
||
import { syncServer } from "./sync-instance";
|
||
import { loadAllDocs } from "./local-first/doc-persistence";
|
||
import { backupRouter } from "./local-first/backup-routes";
|
||
import { ipfsRouter } from "./ipfs-routes";
|
||
import { isIPFSEnabled, pinToIPFS } from "./ipfs";
|
||
import { oauthRouter, setOAuthStatusSyncServer } from "./oauth/index";
|
||
import { setNotionOAuthSyncServer } from "./oauth/notion";
|
||
import { setGoogleOAuthSyncServer } from "./oauth/google";
|
||
import { setClickUpOAuthSyncServer } from "./oauth/clickup";
|
||
import { notificationRouter } from "./notification-routes";
|
||
import { registerUserConnection, unregisterUserConnection, notify } from "./notification-service";
|
||
import { SystemClock } from "./clock-service";
|
||
import type { ClockPayload } from "./clock-service";
|
||
import { miRoutes } from "./mi-routes";
|
||
import { bugReportRouter } from "./bug-report-routes";
|
||
import { createSecurityMiddleware, mcpGuard } from "./security";
|
||
|
||
// ── Process-level error safety net (prevent crash on unhandled socket errors) ──
|
||
process.on('uncaughtException', (err) => {
|
||
console.error('[FATAL] Uncaught exception (swallowed):', err.message);
|
||
// Don't exit — keep the server running
|
||
});
|
||
process.on('unhandledRejection', (reason) => {
|
||
console.error('[FATAL] Unhandled rejection (swallowed):', reason);
|
||
});
|
||
|
||
// Register modules (order determines app-switcher menu position)
|
||
registerModule(canvasModule);
|
||
registerModule(pubsModule);
|
||
registerModule(cartModule);
|
||
registerModule(swagModule);
|
||
registerModule(choicesModule);
|
||
registerModule(flowsModule);
|
||
registerModule(filesModule);
|
||
registerModule(walletModule);
|
||
registerModule(voteModule);
|
||
registerModule(notesModule);
|
||
registerModule(mapsModule);
|
||
registerModule(tasksModule);
|
||
registerModule(calModule);
|
||
registerModule(networkModule);
|
||
registerModule(inboxModule);
|
||
registerModule(dataModule);
|
||
registerModule(splatModule);
|
||
registerModule(photosModule);
|
||
registerModule(socialsModule);
|
||
registerModule(scheduleModule);
|
||
registerModule(meetsModule);
|
||
registerModule(chatsModule);
|
||
registerModule(agentsModule);
|
||
registerModule(bnbModule);
|
||
registerModule(vnbModule);
|
||
registerModule(crowdsurfModule);
|
||
registerModule(timeModule);
|
||
registerModule(govModule); // Governance decision circuits
|
||
registerModule(exchangeModule); // P2P crypto/fiat exchange
|
||
registerModule(auctionsModule); // Community auctions with USDC
|
||
registerModule(credModule); // Contribution recognition via CredRank
|
||
registerModule(designModule); // Scribus DTP + AI design agent
|
||
// De-emphasized modules (bottom of menu)
|
||
registerModule(forumModule);
|
||
registerModule(tubeModule);
|
||
registerModule(tripsModule);
|
||
registerModule(booksModule);
|
||
registerModule(sheetsModule);
|
||
registerModule(docsModule); // Full TipTap editor (split from rNotes)
|
||
|
||
// ── Config ──
|
||
const PORT = Number(process.env.PORT) || 3000;
|
||
const INTERNAL_API_KEY = process.env.INTERNAL_API_KEY || "";
|
||
const DIST_DIR = resolve(import.meta.dir, "../dist");
|
||
|
||
|
||
// ── Hono app ──
|
||
type AppEnv = { Variables: { isSubdomain: boolean } };
|
||
const app = new Hono<AppEnv>();
|
||
|
||
// Detect subdomain routing and set context flag
|
||
app.use("*", async (c, next) => {
|
||
const host = c.req.header("host")?.split(":")[0] || "";
|
||
const parts = host.split(".");
|
||
const isSubdomain = parts.length >= 3 && parts.slice(-2).join(".") === "rspace.online"
|
||
&& !["www", "rspace", "create", "new", "start", "auth"].includes(parts[0]);
|
||
c.set("isSubdomain", isSubdomain);
|
||
await next();
|
||
});
|
||
|
||
// CORS for API routes — restrict to known origins
|
||
app.use("/api/*", cors({
|
||
origin: (origin) => {
|
||
if (!origin) return origin; // server-to-server (no Origin header) — allow
|
||
const allowed = [
|
||
"rspace.online", "ridentity.online", "rsocials.online",
|
||
"rwallet.online", "rvote.online", "rmaps.online",
|
||
];
|
||
try {
|
||
const host = new URL(origin).hostname;
|
||
if (allowed.some((d) => host === d || host.endsWith(`.${d}`))) return origin;
|
||
// Allow localhost for dev
|
||
if (host === "localhost" || host === "127.0.0.1") return origin;
|
||
} catch {}
|
||
return ""; // deny
|
||
},
|
||
credentials: true,
|
||
}));
|
||
|
||
// Security middleware — UA filtering + tiered rate limiting
|
||
app.use("/api/*", createSecurityMiddleware());
|
||
|
||
// ── .well-known/webauthn (WebAuthn Related Origins) ──
|
||
// Browsers enforce a 5 eTLD+1 limit. Only list domains where passkey
|
||
// ceremonies happen directly. Must match encryptid/server.ts priority list.
|
||
app.get("/.well-known/webauthn", (c) => {
|
||
return c.json(
|
||
{
|
||
origins: [
|
||
"https://ridentity.online", // EncryptID domain (eTLD+1 #1)
|
||
"https://auth.ridentity.online",
|
||
"https://rsocials.online", // Postiz ecosystem (eTLD+1 #2)
|
||
"https://demo.rsocials.online",
|
||
"https://socials.crypto-commons.org", // (eTLD+1 #3)
|
||
"https://socials.p2pfoundation.net", // (eTLD+1 #4)
|
||
],
|
||
},
|
||
200,
|
||
{
|
||
"Access-Control-Allow-Origin": "*",
|
||
"Cache-Control": "public, max-age=3600",
|
||
}
|
||
);
|
||
});
|
||
|
||
// ── Analytics tracker (proxied from Umami — replaces rdata.online/collect.js) ──
|
||
const UMAMI_URL = process.env.UMAMI_URL || "https://analytics.rspace.online";
|
||
app.get("/collect.js", async (c) => {
|
||
try {
|
||
const res = await fetch(`${UMAMI_URL}/script.js`, { signal: AbortSignal.timeout(5000) });
|
||
if (res.ok) {
|
||
const script = await res.text();
|
||
return new Response(script, {
|
||
headers: { "Content-Type": "application/javascript", "Cache-Control": "public, max-age=3600" },
|
||
});
|
||
}
|
||
} catch {}
|
||
return new Response("/* umami unavailable */", {
|
||
headers: { "Content-Type": "application/javascript" },
|
||
});
|
||
});
|
||
|
||
// ── Serve generated files from /data/files/generated/ and /api/files/generated/ ──
|
||
// The /api/ route avoids Cloudflare/Traefik redirecting /data/ paths
|
||
const GENERATED_MIME: Record<string, string> = {
|
||
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp",
|
||
glb: "model/gltf-binary", gltf: "model/gltf+json",
|
||
step: "application/step", stp: "application/step",
|
||
stl: "application/sla", fcstd: "application/octet-stream",
|
||
svg: "image/svg+xml", pdf: "application/pdf",
|
||
};
|
||
|
||
function serveGeneratedFile(c: any) {
|
||
// Support both flat files and subdirectory paths (e.g. freecad-xxx/model.step)
|
||
const filename = c.req.param("filename");
|
||
const subdir = c.req.param("subdir");
|
||
const relPath = subdir ? `${subdir}/${filename}` : filename;
|
||
if (!relPath || relPath.includes("..")) {
|
||
return c.json({ error: "Invalid filename" }, 400);
|
||
}
|
||
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
|
||
const filePath = resolve(dir, relPath);
|
||
// Ensure resolved path stays within generated dir
|
||
if (!filePath.startsWith(dir)) return c.json({ error: "Invalid path" }, 400);
|
||
const file = Bun.file(filePath);
|
||
return file.exists().then((exists: boolean) => {
|
||
if (!exists) return c.notFound();
|
||
const ext = filePath.split(".").pop()?.toLowerCase() || "";
|
||
return new Response(file, { headers: { "Content-Type": GENERATED_MIME[ext] || "application/octet-stream", "Cache-Control": "public, max-age=86400" } });
|
||
});
|
||
}
|
||
app.get("/data/files/generated/:filename", serveGeneratedFile);
|
||
app.get("/data/files/generated/:subdir/:filename", serveGeneratedFile);
|
||
app.get("/api/files/generated/:filename", serveGeneratedFile);
|
||
app.get("/api/files/generated/:subdir/:filename", serveGeneratedFile);
|
||
|
||
// ── IPFS background pinning for generated files ──
|
||
|
||
/** In-memory cache of filePath → CID. Populated from .cid sidecar files on startup. */
|
||
const generatedCidCache = new Map<string, string>();
|
||
|
||
/** Load existing .cid sidecar files into cache on startup. */
|
||
async function loadGeneratedCids() {
|
||
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
|
||
try {
|
||
const { readdir, readFile } = await import("node:fs/promises");
|
||
const files = await readdir(dir);
|
||
for (const f of files) {
|
||
if (!f.endsWith(".cid")) continue;
|
||
try {
|
||
const cid = (await readFile(resolve(dir, f), "utf-8")).trim();
|
||
const originalFile = f.replace(/\.cid$/, "");
|
||
generatedCidCache.set(originalFile, cid);
|
||
} catch {}
|
||
}
|
||
if (generatedCidCache.size > 0) {
|
||
console.log(`[ipfs] Loaded ${generatedCidCache.size} CID sidecar files`);
|
||
}
|
||
} catch {}
|
||
}
|
||
loadGeneratedCids();
|
||
|
||
/**
|
||
* Pin a generated file to IPFS in the background.
|
||
* Writes a .cid sidecar file alongside the original.
|
||
* Safe to call fire-and-forget — failures are logged and swallowed.
|
||
*/
|
||
async function pinGeneratedFile(filePath: string, filename: string) {
|
||
if (!isIPFSEnabled()) return;
|
||
if (generatedCidCache.has(filename)) return;
|
||
try {
|
||
const file = Bun.file(filePath);
|
||
const buf = new Uint8Array(await file.arrayBuffer());
|
||
const cid = await pinToIPFS(buf, filename);
|
||
generatedCidCache.set(filename, cid);
|
||
const { writeFile } = await import("node:fs/promises");
|
||
await writeFile(`${filePath}.cid`, cid);
|
||
console.log(`[ipfs] Pinned ${filename} → ${cid}`);
|
||
} catch (err: any) {
|
||
console.warn(`[ipfs] Pin failed for ${filename} (non-fatal):`, err.message);
|
||
}
|
||
}
|
||
|
||
// ── Link preview / unfurl API ──
|
||
const linkPreviewCache = new Map<string, { title: string; description: string; image: string | null; domain: string; fetchedAt: number }>();
|
||
|
||
app.get("/api/link-preview", async (c) => {
|
||
const url = c.req.query("url");
|
||
if (!url) return c.json({ error: "Missing url parameter" }, 400);
|
||
|
||
try { new URL(url); } catch { return c.json({ error: "Invalid URL" }, 400); }
|
||
|
||
// Check cache (1 hour TTL)
|
||
const cached = linkPreviewCache.get(url);
|
||
if (cached && Date.now() - cached.fetchedAt < 3600_000) {
|
||
return c.json(cached, 200, { "Cache-Control": "public, max-age=3600" });
|
||
}
|
||
|
||
try {
|
||
const controller = new AbortController();
|
||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||
const res = await fetch(url, {
|
||
headers: { "User-Agent": "Mozilla/5.0 (compatible; rSpace/1.0; +https://rspace.online)", "Accept": "text/html" },
|
||
signal: controller.signal,
|
||
redirect: "follow",
|
||
});
|
||
clearTimeout(timeout);
|
||
|
||
if (!res.ok) return c.json({ error: "Fetch failed" }, 502);
|
||
const contentType = res.headers.get("content-type") || "";
|
||
if (!contentType.includes("text/html")) {
|
||
const domain = new URL(url).hostname.replace(/^www\./, "");
|
||
const result = { title: url, description: "", image: null, domain, fetchedAt: Date.now() };
|
||
linkPreviewCache.set(url, result);
|
||
return c.json(result);
|
||
}
|
||
|
||
const html = await res.text();
|
||
const getMetaContent = (nameOrProp: string): string => {
|
||
const re = new RegExp(`<meta[^>]*(?:name|property)=["']${nameOrProp}["'][^>]*content=["']([^"']*?)["']`, "i");
|
||
const re2 = new RegExp(`<meta[^>]*content=["']([^"']*?)["'][^>]*(?:name|property)=["']${nameOrProp}["']`, "i");
|
||
return re.exec(html)?.[1] || re2.exec(html)?.[1] || "";
|
||
};
|
||
|
||
const ogTitle = getMetaContent("og:title") || getMetaContent("twitter:title");
|
||
const titleMatch = html.match(/<title[^>]*>([^<]*)<\/title>/i);
|
||
const title = ogTitle || titleMatch?.[1]?.trim() || url;
|
||
const description = getMetaContent("og:description") || getMetaContent("twitter:description") || getMetaContent("description");
|
||
let image = getMetaContent("og:image") || getMetaContent("twitter:image") || null;
|
||
if (image && !image.startsWith("http")) {
|
||
try { image = new URL(image, url).href; } catch { image = null; }
|
||
}
|
||
const domain = new URL(url).hostname.replace(/^www\./, "");
|
||
|
||
const result = { title, description, image, domain, fetchedAt: Date.now() };
|
||
linkPreviewCache.set(url, result);
|
||
|
||
// Cap cache size
|
||
if (linkPreviewCache.size > 500) {
|
||
const oldest = [...linkPreviewCache.entries()].sort((a, b) => a[1].fetchedAt - b[1].fetchedAt);
|
||
for (let i = 0; i < 100; i++) linkPreviewCache.delete(oldest[i][0]);
|
||
}
|
||
|
||
return c.json(result, 200, { "Cache-Control": "public, max-age=3600" });
|
||
} catch (e) {
|
||
console.error("[link-preview] fetch error:", url, e instanceof Error ? e.message : e);
|
||
return c.json({ error: "Failed to fetch URL" }, 502);
|
||
}
|
||
});
|
||
|
||
// ── Ecosystem manifest (self-declaration) ──
|
||
// Serves rSpace's own manifest so other ecosystem apps can discover it
|
||
app.get("/.well-known/rspace-manifest.json", (c) => {
|
||
const modules = getModuleInfoList();
|
||
const shapes = modules.map((m) => ({
|
||
tagName: `folk-rapp`,
|
||
name: m.name,
|
||
description: m.description || "",
|
||
defaults: { width: 500, height: 400 },
|
||
portDescriptors: [],
|
||
eventDescriptors: [],
|
||
config: { moduleId: m.id },
|
||
}));
|
||
|
||
return c.json(
|
||
{
|
||
appId: "rspace",
|
||
name: "rSpace",
|
||
version: "1.0.0",
|
||
icon: "🌐",
|
||
description: "Local-first workspace for the r-ecosystem",
|
||
homepage: "https://rspace.online",
|
||
moduleUrl: "/assets/folk-rapp.js",
|
||
color: "#6366f1",
|
||
embeddingModes: ["trusted", "sandboxed"],
|
||
shapes,
|
||
minProtocolVersion: 1,
|
||
},
|
||
200,
|
||
{
|
||
"Access-Control-Allow-Origin": "*",
|
||
"Cache-Control": "public, max-age=3600",
|
||
}
|
||
);
|
||
});
|
||
|
||
// ── Ecosystem manifest proxy (CORS avoidance) ──
|
||
// Fetches external app manifests server-side so canvas clients avoid CORS issues
|
||
const ecosystemManifestCache = new Map<string, { data: unknown; fetchedAt: number }>();
|
||
|
||
app.get("/api/ecosystem/:appId/manifest", async (c) => {
|
||
const appId = c.req.param("appId");
|
||
|
||
// Known ecosystem app origins
|
||
const ECOSYSTEM_ORIGINS: Record<string, string> = {
|
||
rwallet: "https://rwallet.online",
|
||
rvote: "https://rvote.online",
|
||
rmaps: "https://rmaps.online",
|
||
rsocials: "https://rsocials.online",
|
||
};
|
||
|
||
const origin = ECOSYSTEM_ORIGINS[appId];
|
||
if (!origin) {
|
||
return c.json({ error: "Unknown ecosystem app" }, 404);
|
||
}
|
||
|
||
// Check cache (1 hour TTL)
|
||
const cached = ecosystemManifestCache.get(appId);
|
||
if (cached && Date.now() - cached.fetchedAt < 3600_000) {
|
||
return c.json(cached.data, 200, { "Cache-Control": "public, max-age=3600" });
|
||
}
|
||
|
||
try {
|
||
const controller = new AbortController();
|
||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||
const manifestUrl = `${origin}/.well-known/rspace-manifest.json`;
|
||
const res = await fetch(manifestUrl, {
|
||
headers: { "Accept": "application/json", "User-Agent": "rSpace-Ecosystem/1.0" },
|
||
signal: controller.signal,
|
||
redirect: "error", // reject redirects — prevents allowlist bypass
|
||
});
|
||
clearTimeout(timeout);
|
||
|
||
if (!res.ok) return c.json({ error: "Failed to fetch manifest" }, 502);
|
||
const data = await res.json();
|
||
|
||
// Resolve relative moduleUrl to absolute
|
||
if (data.moduleUrl && !data.moduleUrl.startsWith("http")) {
|
||
data.resolvedModuleUrl = `${origin}${data.moduleUrl.startsWith("/") ? "" : "/"}${data.moduleUrl}`;
|
||
} else {
|
||
data.resolvedModuleUrl = data.moduleUrl;
|
||
}
|
||
data.origin = origin;
|
||
data.fetchedAt = Date.now();
|
||
|
||
ecosystemManifestCache.set(appId, { data, fetchedAt: Date.now() });
|
||
|
||
// Cap cache size
|
||
if (ecosystemManifestCache.size > 50) {
|
||
const oldest = [...ecosystemManifestCache.entries()].sort((a, b) => a[1].fetchedAt - b[1].fetchedAt);
|
||
ecosystemManifestCache.delete(oldest[0][0]);
|
||
}
|
||
|
||
return c.json(data, 200, { "Cache-Control": "public, max-age=3600" });
|
||
} catch {
|
||
return c.json({ error: "Failed to fetch manifest" }, 502);
|
||
}
|
||
});
|
||
|
||
// ── Ecosystem module proxy (CORS avoidance for JS modules) ──
|
||
app.get("/api/ecosystem/:appId/module", async (c) => {
|
||
const appId = c.req.param("appId");
|
||
|
||
// Fetch manifest first to get module URL
|
||
const cached = ecosystemManifestCache.get(appId);
|
||
if (!cached) {
|
||
return c.json({ error: "Fetch manifest first via /api/ecosystem/:appId/manifest" }, 400);
|
||
}
|
||
const manifest = cached.data as Record<string, unknown>;
|
||
const moduleUrl = (manifest.resolvedModuleUrl || manifest.moduleUrl) as string;
|
||
if (!moduleUrl) return c.json({ error: "No moduleUrl in manifest" }, 400);
|
||
|
||
try {
|
||
const controller = new AbortController();
|
||
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||
const res = await fetch(moduleUrl, {
|
||
headers: { "Accept": "application/javascript", "User-Agent": "rSpace-Ecosystem/1.0" },
|
||
signal: controller.signal,
|
||
redirect: "error", // reject redirects — prevents allowlist bypass
|
||
});
|
||
clearTimeout(timeout);
|
||
|
||
if (!res.ok) return c.json({ error: "Failed to fetch module" }, 502);
|
||
const js = await res.text();
|
||
return new Response(js, {
|
||
headers: {
|
||
"Content-Type": "application/javascript",
|
||
"Cache-Control": "public, max-age=86400",
|
||
"Access-Control-Allow-Origin": "*",
|
||
},
|
||
});
|
||
} catch {
|
||
return c.json({ error: "Failed to fetch module" }, 502);
|
||
}
|
||
});
|
||
|
||
// ── Space registry API ──
|
||
app.route("/api/spaces", spaces);
|
||
|
||
// ── Backup API (encrypted blob storage) ──
|
||
app.route("/api/backup", backupRouter);
|
||
|
||
// ── IPFS API (pinning + gateway proxy) ──
|
||
app.route("/api/ipfs", ipfsRouter);
|
||
|
||
// ── OAuth API (Notion, Google integrations) ──
|
||
app.route("/api/oauth", oauthRouter);
|
||
|
||
// ── Notifications API ──
|
||
app.route("/api/notifications", notificationRouter);
|
||
|
||
// ── MI — AI assistant endpoints ──
|
||
app.route("/api/mi", miRoutes);
|
||
|
||
// ── Email Checklist (top-level, bypasses space auth) ──
|
||
app.route("/rtasks/check", checklistCheckRoutes);
|
||
app.route("/api/rtasks", checklistApiRoutes);
|
||
|
||
// ── Dashboard summary API ──
|
||
import { dashboardRoutes } from "./dashboard-routes";
|
||
app.route("/api", dashboardRoutes);
|
||
|
||
// ── Bug Report API ──
|
||
app.route("/api/bug-report", bugReportRouter);
|
||
|
||
// ── MCP Server (Model Context Protocol) ──
|
||
app.use("/api/mcp/*", mcpGuard);
|
||
app.route("/api/mcp", createMcpRouter(syncServer));
|
||
|
||
// ── Magic Link Responses (top-level, bypasses space auth) ──
|
||
app.route("/respond", magicLinkRoutes);
|
||
|
||
// ── Same-origin auth proxy (avoids cross-origin issues on Safari) ──
|
||
const ENCRYPTID_INTERNAL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000";
|
||
|
||
const proxyToEncryptid = async (c: any) => {
|
||
const targetUrl = `${ENCRYPTID_INTERNAL}${c.req.path}${new URL(c.req.url).search}`;
|
||
const headers = new Headers(c.req.raw.headers);
|
||
headers.delete("host");
|
||
try {
|
||
const res = await fetch(targetUrl, {
|
||
method: c.req.method,
|
||
headers,
|
||
body: c.req.method !== "GET" && c.req.method !== "HEAD" ? c.req.raw.body : undefined,
|
||
// @ts-ignore duplex needed for streaming request bodies
|
||
duplex: "half",
|
||
});
|
||
return new Response(res.body, { status: res.status, headers: res.headers });
|
||
} catch (e: any) {
|
||
return c.json({ error: "EncryptID service unavailable" }, 502);
|
||
}
|
||
};
|
||
|
||
app.all("/api/auth/*", proxyToEncryptid);
|
||
app.all("/api/register/*", proxyToEncryptid);
|
||
app.all("/api/account/*", proxyToEncryptid);
|
||
app.all("/api/session/*", proxyToEncryptid);
|
||
app.all("/api/guardians/*", proxyToEncryptid);
|
||
app.all("/api/guardians", proxyToEncryptid);
|
||
app.all("/api/user/*", proxyToEncryptid);
|
||
app.all("/api/device-link/*", proxyToEncryptid);
|
||
app.all("/api/recovery/*", proxyToEncryptid);
|
||
|
||
// ── EncryptID proxy (forward /encryptid/* to encryptid container) ──
|
||
app.all("/encryptid/*", async (c) => {
|
||
const targetUrl = `${ENCRYPTID_INTERNAL}${c.req.path}${new URL(c.req.url).search}`;
|
||
const headers = new Headers(c.req.raw.headers);
|
||
headers.delete("host");
|
||
try {
|
||
const res = await fetch(targetUrl, {
|
||
method: c.req.method,
|
||
headers,
|
||
body: c.req.method !== "GET" && c.req.method !== "HEAD" ? c.req.raw.body : undefined,
|
||
// @ts-ignore duplex needed for streaming request bodies
|
||
duplex: "half",
|
||
});
|
||
return new Response(res.body, { status: res.status, headers: res.headers });
|
||
} catch (e: any) {
|
||
return c.json({ error: "EncryptID service unavailable" }, 502);
|
||
}
|
||
});
|
||
|
||
// ── User API proxy (forward /api/user/* and /api/users/* to EncryptID) ──
|
||
app.all("/api/user/*", async (c) => {
|
||
const targetUrl = `${ENCRYPTID_INTERNAL}${c.req.path}${new URL(c.req.url).search}`;
|
||
const headers = new Headers(c.req.raw.headers);
|
||
headers.delete("host");
|
||
try {
|
||
const res = await fetch(targetUrl, {
|
||
method: c.req.method,
|
||
headers,
|
||
body: c.req.method !== "GET" && c.req.method !== "HEAD" ? c.req.raw.body : undefined,
|
||
// @ts-ignore duplex needed for streaming request bodies
|
||
duplex: "half",
|
||
});
|
||
return new Response(res.body, { status: res.status, headers: res.headers });
|
||
} catch (e: any) {
|
||
return c.json({ error: "EncryptID service unavailable" }, 502);
|
||
}
|
||
});
|
||
app.all("/api/users/*", async (c) => {
|
||
const targetUrl = `${ENCRYPTID_INTERNAL}${c.req.path}${new URL(c.req.url).search}`;
|
||
const headers = new Headers(c.req.raw.headers);
|
||
headers.delete("host");
|
||
try {
|
||
const res = await fetch(targetUrl, {
|
||
method: c.req.method,
|
||
headers,
|
||
body: c.req.method !== "GET" && c.req.method !== "HEAD" ? c.req.raw.body : undefined,
|
||
// @ts-ignore duplex needed for streaming request bodies
|
||
duplex: "half",
|
||
});
|
||
return new Response(res.body, { status: res.status, headers: res.headers });
|
||
} catch (e: any) {
|
||
return c.json({ error: "EncryptID service unavailable" }, 502);
|
||
}
|
||
});
|
||
|
||
// ── Existing /api/communities/* routes (backward compatible) ──
|
||
|
||
/** Resolve a community slug to SpaceAuthConfig for the SDK guard */
|
||
async function getSpaceConfig(slug: string): Promise<SpaceAuthConfig | null> {
|
||
let doc = getDocumentData(slug);
|
||
if (!doc) {
|
||
await loadCommunity(slug);
|
||
doc = getDocumentData(slug);
|
||
}
|
||
if (!doc) return null;
|
||
let vis = (doc.meta.visibility || "private") as SpaceVisibility;
|
||
if (slug === "demo") vis = "public";
|
||
return {
|
||
spaceSlug: slug,
|
||
visibility: vis,
|
||
ownerDID: doc.meta.ownerDID || undefined,
|
||
app: "rspace",
|
||
};
|
||
}
|
||
|
||
// Demo reset rate limiter
|
||
let lastDemoReset = 0;
|
||
const DEMO_RESET_COOLDOWN = 5 * 60 * 1000;
|
||
|
||
// POST /api/communities — create community (deprecated, use POST /api/spaces)
|
||
app.post("/api/communities", async (c) => {
|
||
const token = extractToken(c.req.raw.headers);
|
||
if (!token) return c.json({ error: "Authentication required to create a community" }, 401);
|
||
|
||
let claims: EncryptIDClaims;
|
||
try {
|
||
claims = await verifyToken(token);
|
||
} catch {
|
||
return c.json({ error: "Invalid or expired authentication token" }, 401);
|
||
}
|
||
|
||
const body = await c.req.json<{ name?: string; slug?: string; visibility?: SpaceVisibility }>();
|
||
const { name, slug, visibility = "private" } = body;
|
||
|
||
const validVisibilities: SpaceVisibility[] = ["public", "permissioned", "private"];
|
||
if (visibility && !validVisibilities.includes(visibility)) return c.json({ error: "Invalid visibility" }, 400);
|
||
|
||
const result = await createSpace({
|
||
name: name || "", slug: slug || "", ownerDID: claims.sub, visibility, source: 'api',
|
||
});
|
||
if (!result.ok) return c.json({ error: result.error }, result.status);
|
||
|
||
c.header("Deprecation", "true");
|
||
c.header("Link", "</api/spaces>; rel=\"successor-version\"");
|
||
return c.json({ url: `https://${result.slug}.rspace.online`, slug: result.slug, name: result.name, visibility: result.visibility, ownerDID: result.ownerDID }, 201);
|
||
});
|
||
|
||
// POST /api/internal/provision — auth-free, called by rSpace Registry
|
||
app.post("/api/internal/provision", async (c) => {
|
||
const body = await c.req.json<{ space?: string; description?: string; public?: boolean }>();
|
||
const space = body.space?.trim();
|
||
if (!space) return c.json({ error: "Missing space name" }, 400);
|
||
|
||
if (await communityExists(space)) {
|
||
return c.json({ status: "exists", slug: space });
|
||
}
|
||
|
||
const result = await createSpace({
|
||
name: space.charAt(0).toUpperCase() + space.slice(1),
|
||
slug: space,
|
||
ownerDID: `did:system:${space}`,
|
||
visibility: "public",
|
||
source: 'internal',
|
||
});
|
||
if (!result.ok) return c.json({ error: result.error }, result.status);
|
||
|
||
return c.json({ status: "created", slug: space }, 201);
|
||
});
|
||
|
||
// POST /api/internal/sync-space-member — called by EncryptID after identity invite claim
|
||
// Syncs a member from EncryptID's space_members (PostgreSQL) to the Automerge doc
|
||
app.post("/api/internal/sync-space-member", async (c) => {
|
||
const body = await c.req.json<{ spaceSlug: string; userDid: string; role: string; username?: string }>();
|
||
if (!body.spaceSlug || !body.userDid || !body.role) {
|
||
return c.json({ error: "spaceSlug, userDid, and role are required" }, 400);
|
||
}
|
||
try {
|
||
await loadCommunity(body.spaceSlug);
|
||
setMember(body.spaceSlug, body.userDid, body.role as any, body.username);
|
||
return c.json({ ok: true });
|
||
} catch (err: any) {
|
||
console.error("sync-space-member error:", err.message);
|
||
return c.json({ error: "Failed to sync member" }, 500);
|
||
}
|
||
});
|
||
|
||
// POST /api/internal/mint-crdt — called by onramp-service after fiat payment confirmed
|
||
app.post("/api/internal/mint-crdt", async (c) => {
|
||
const internalKey = c.req.header("X-Internal-Key");
|
||
if (!INTERNAL_API_KEY || internalKey !== INTERNAL_API_KEY) {
|
||
return c.json({ error: "Unauthorized" }, 401);
|
||
}
|
||
|
||
const body = await c.req.json<{
|
||
did?: string;
|
||
label?: string;
|
||
amountDecimal?: string;
|
||
txHash?: string;
|
||
network?: string;
|
||
}>();
|
||
|
||
const { did, label, amountDecimal, txHash, network } = body;
|
||
if (!did || !amountDecimal || !txHash || !network) {
|
||
return c.json({ error: "did, amountDecimal, txHash, and network are required" }, 400);
|
||
}
|
||
|
||
const { mintFromOnChain } = await import("./token-service");
|
||
const success = mintFromOnChain(did, label || "Unknown", amountDecimal, txHash, network);
|
||
|
||
if (!success) {
|
||
// mintFromOnChain returns false for duplicates or invalid amounts — both are idempotent
|
||
return c.json({ ok: false, reason: "already minted or invalid amount" });
|
||
}
|
||
|
||
return c.json({ ok: true, minted: amountDecimal, did, txHash });
|
||
});
|
||
|
||
// POST /api/internal/escrow-burn — called by offramp-service to escrow cUSDC before payout
|
||
app.post("/api/internal/escrow-burn", async (c) => {
|
||
const internalKey = c.req.header("X-Internal-Key");
|
||
if (!INTERNAL_API_KEY || internalKey !== INTERNAL_API_KEY) {
|
||
return c.json({ error: "Unauthorized" }, 401);
|
||
}
|
||
|
||
const body = await c.req.json<{
|
||
did?: string;
|
||
label?: string;
|
||
amount?: number;
|
||
offRampId?: string;
|
||
}>();
|
||
|
||
if (!body.did || !body.amount || !body.offRampId) {
|
||
return c.json({ error: "did, amount, and offRampId are required" }, 400);
|
||
}
|
||
|
||
const { burnTokensEscrow, getTokenDoc, getBalance } = await import("./token-service");
|
||
const doc = getTokenDoc("cusdc");
|
||
if (!doc) return c.json({ error: "cUSDC token not found" }, 500);
|
||
|
||
const balance = getBalance(doc, body.did);
|
||
if (balance < body.amount) {
|
||
return c.json({ error: `Insufficient balance: ${balance} < ${body.amount}` }, 400);
|
||
}
|
||
|
||
const ok = burnTokensEscrow(
|
||
"cusdc", body.did, body.label || "", body.amount, body.offRampId,
|
||
`Off-ramp withdrawal: ${body.offRampId}`,
|
||
);
|
||
if (!ok) return c.json({ error: "Escrow burn failed" }, 500);
|
||
|
||
return c.json({ ok: true, escrowed: body.amount, offRampId: body.offRampId });
|
||
});
|
||
|
||
// POST /api/internal/confirm-offramp — called by offramp-service after payout confirmed/failed
|
||
app.post("/api/internal/confirm-offramp", async (c) => {
|
||
const internalKey = c.req.header("X-Internal-Key");
|
||
if (!INTERNAL_API_KEY || internalKey !== INTERNAL_API_KEY) {
|
||
return c.json({ error: "Unauthorized" }, 401);
|
||
}
|
||
|
||
const body = await c.req.json<{
|
||
offRampId?: string;
|
||
status?: "confirmed" | "reversed";
|
||
}>();
|
||
|
||
if (!body.offRampId || !body.status) {
|
||
return c.json({ error: "offRampId and status ('confirmed' | 'reversed') required" }, 400);
|
||
}
|
||
|
||
const { confirmBurn, reverseBurn } = await import("./token-service");
|
||
if (body.status === "confirmed") {
|
||
const ok = confirmBurn("cusdc", body.offRampId);
|
||
return c.json({ ok, action: "confirmed" });
|
||
} else {
|
||
const ok = reverseBurn("cusdc", body.offRampId);
|
||
return c.json({ ok, action: "reversed" });
|
||
}
|
||
});
|
||
|
||
// POST /api/communities/demo/reset
|
||
app.post("/api/communities/demo/reset", async (c) => {
|
||
const now = Date.now();
|
||
if (now - lastDemoReset < DEMO_RESET_COOLDOWN) {
|
||
const remaining = Math.ceil((DEMO_RESET_COOLDOWN - (now - lastDemoReset)) / 1000);
|
||
return c.json({ error: `Demo reset on cooldown. Try again in ${remaining}s` }, 429);
|
||
}
|
||
lastDemoReset = now;
|
||
await loadCommunity("demo");
|
||
clearShapes("demo");
|
||
await ensureDemoCommunity();
|
||
broadcastAutomergeSync("demo");
|
||
broadcastJsonSnapshot("demo");
|
||
return c.json({ ok: true, message: "Demo community reset to seed data" });
|
||
});
|
||
|
||
// Campaign demo reset removed — campaign is now at /:space/rsocials/campaign
|
||
|
||
// GET /api/communities/:slug/shapes
|
||
app.get("/api/communities/:slug/shapes", async (c) => {
|
||
const slug = c.req.param("slug");
|
||
const token = extractToken(c.req.raw.headers);
|
||
const access = await evaluateSpaceAccess(slug, token, "GET", spaceAuthOpts());
|
||
|
||
if (!access.allowed) return c.json({ error: access.reason }, access.claims ? 403 : 401);
|
||
|
||
await loadCommunity(slug);
|
||
const data = getDocumentData(slug);
|
||
if (!data) return c.json({ error: "Community not found" }, 404);
|
||
|
||
return c.json({ shapes: data.shapes || {} });
|
||
});
|
||
|
||
// POST /api/communities/:slug/shapes
|
||
app.post("/api/communities/:slug/shapes", async (c) => {
|
||
const slug = c.req.param("slug");
|
||
const internalKey = c.req.header("X-Internal-Key");
|
||
const isInternalCall = INTERNAL_API_KEY && internalKey === INTERNAL_API_KEY;
|
||
|
||
if (!isInternalCall) {
|
||
const token = extractToken(c.req.raw.headers);
|
||
const access = await evaluateSpaceAccess(slug, token, "POST", spaceAuthOpts());
|
||
if (!access.allowed) return c.json({ error: access.reason }, access.claims ? 403 : 401);
|
||
if (access.readOnly) return c.json({ error: "Write access required to add shapes" }, 403);
|
||
}
|
||
|
||
await loadCommunity(slug);
|
||
const data = getDocumentData(slug);
|
||
if (!data) return c.json({ error: "Community not found" }, 404);
|
||
|
||
const body = await c.req.json<{ shapes?: Record<string, unknown>[] }>();
|
||
if (!body.shapes || !Array.isArray(body.shapes) || body.shapes.length === 0) {
|
||
return c.json({ error: "shapes array is required and must not be empty" }, 400);
|
||
}
|
||
|
||
const ids = addShapes(slug, body.shapes);
|
||
broadcastAutomergeSync(slug);
|
||
broadcastJsonSnapshot(slug);
|
||
|
||
return c.json({ ok: true, ids }, 201);
|
||
});
|
||
|
||
// PATCH /api/communities/:slug/shapes/:shapeId
|
||
app.patch("/api/communities/:slug/shapes/:shapeId", async (c) => {
|
||
const slug = c.req.param("slug");
|
||
const shapeId = c.req.param("shapeId");
|
||
const internalKey = c.req.header("X-Internal-Key");
|
||
const isInternalCall = INTERNAL_API_KEY && internalKey === INTERNAL_API_KEY;
|
||
|
||
if (!isInternalCall) {
|
||
const token = extractToken(c.req.raw.headers);
|
||
const access = await evaluateSpaceAccess(slug, token, "PATCH", spaceAuthOpts());
|
||
if (!access.allowed) return c.json({ error: access.reason }, access.claims ? 403 : 401);
|
||
}
|
||
|
||
await loadCommunity(slug);
|
||
const body = await c.req.json<Record<string, unknown>>();
|
||
const updated = updateShapeFields(slug, shapeId, body);
|
||
if (!updated) return c.json({ error: "Shape not found" }, 404);
|
||
|
||
broadcastAutomergeSync(slug);
|
||
broadcastJsonSnapshot(slug);
|
||
|
||
return c.json({ ok: true });
|
||
});
|
||
|
||
// GET /api/space-access/:slug — lightweight membership check for client-side gate
|
||
app.get("/api/space-access/:slug", async (c) => {
|
||
const slug = c.req.param("slug");
|
||
const token = extractToken(c.req.raw.headers);
|
||
if (!token) return c.json({ access: false, reason: "not-authenticated" });
|
||
let claims: EncryptIDClaims | null = null;
|
||
try { claims = await verifyToken(token); } catch {}
|
||
if (!claims) return c.json({ access: false, reason: "not-authenticated" });
|
||
|
||
const config = await getSpaceConfig(slug);
|
||
const vis = config?.visibility || "private";
|
||
const resolved = await resolveCallerRole(slug, claims);
|
||
|
||
if (vis === "private" && resolved && !resolved.isOwner && resolved.role === "viewer") {
|
||
return c.json({ access: false, reason: "not-member", visibility: vis, role: resolved.role });
|
||
}
|
||
|
||
return c.json({
|
||
access: true,
|
||
visibility: vis,
|
||
role: resolved?.role || "viewer",
|
||
isOwner: resolved?.isOwner || false,
|
||
});
|
||
});
|
||
|
||
// GET /api/communities/:slug — community info
|
||
app.get("/api/communities/:slug", async (c) => {
|
||
const slug = c.req.param("slug");
|
||
const token = extractToken(c.req.raw.headers);
|
||
const access = await evaluateSpaceAccess(slug, token, "GET", spaceAuthOpts());
|
||
|
||
if (!access.allowed) return c.json({ error: access.reason }, access.claims ? 403 : 401);
|
||
|
||
let data = getDocumentData(slug);
|
||
if (!data) {
|
||
await loadCommunity(slug);
|
||
data = getDocumentData(slug);
|
||
}
|
||
if (!data) return c.json({ error: "Community not found" }, 404);
|
||
|
||
return c.json({ meta: data.meta, readOnly: access.readOnly });
|
||
});
|
||
|
||
// ── Module info API (for app switcher) ──
|
||
app.get("/api/modules", (c) => {
|
||
return c.json({ modules: getModuleInfoList() });
|
||
});
|
||
|
||
// ── Module landing HTML API (for info popover) ──
|
||
app.get("/api/modules/:moduleId/landing", (c) => {
|
||
const moduleId = c.req.param("moduleId");
|
||
const mod = getModule(moduleId);
|
||
if (!mod) return c.json({ error: "Module not found" }, 404);
|
||
const html = mod.landingPage ? mod.landingPage() : `<p>${mod.description || "No description available."}</p>`;
|
||
return c.json({ html, icon: mod.icon || "", name: mod.name || moduleId });
|
||
});
|
||
|
||
// ── Comment Pin API ──
|
||
import { listAllUsersWithTrust, listSpaceMembers } from "../src/encryptid/db";
|
||
|
||
// Space members for @mention autocomplete
|
||
app.get("/:space/api/space-members", async (c) => {
|
||
const space = c.req.param("space");
|
||
try {
|
||
const users = await listAllUsersWithTrust(space);
|
||
return c.json({
|
||
members: users.map((u) => ({
|
||
did: u.did,
|
||
username: u.username,
|
||
displayName: u.displayName,
|
||
})),
|
||
});
|
||
} catch {
|
||
return c.json({ members: [] });
|
||
}
|
||
});
|
||
|
||
// Space-wide comment notification (all members)
|
||
app.post("/:space/api/comment-pins/notify", async (c) => {
|
||
const space = c.req.param("space");
|
||
try {
|
||
const body = await c.req.json();
|
||
const { pinId, authorDid, authorName, text, pinIndex, isReply, mentionedDids, moduleId } = body;
|
||
if (!pinId || !authorDid) {
|
||
return c.json({ error: "Missing fields" }, 400);
|
||
}
|
||
|
||
const effectiveModule = moduleId || "rspace";
|
||
const isModuleComment = effectiveModule !== "rspace";
|
||
const members = await listSpaceMembers(space);
|
||
const excludeDids = new Set<string>([authorDid, ...(mentionedDids || [])]);
|
||
const title = isReply
|
||
? `${authorName || "Someone"} replied to a comment`
|
||
: `New comment from ${authorName || "Someone"}`;
|
||
const preview = text ? text.slice(0, 120) + (text.length > 120 ? "..." : "") : "";
|
||
|
||
// In-app + push notifications for all members (excluding author and @mentioned)
|
||
await Promise.all(
|
||
members
|
||
.filter((m) => !excludeDids.has(m.userDID))
|
||
.map((m) =>
|
||
notify({
|
||
userDid: m.userDID,
|
||
category: "module",
|
||
eventType: isModuleComment ? "module_comment" : "canvas_comment",
|
||
title,
|
||
body: preview || `Comment pin #${pinIndex || "?"} in ${space}`,
|
||
spaceSlug: space,
|
||
moduleId: effectiveModule,
|
||
actionUrl: `/${effectiveModule}#pin-${pinId}`,
|
||
actorDid: authorDid,
|
||
actorUsername: authorName,
|
||
}),
|
||
),
|
||
);
|
||
|
||
// Email notification to all space members (fire-and-forget)
|
||
import("../modules/rinbox/agent-notify")
|
||
.then(({ sendSpaceNotification }) => {
|
||
sendSpaceNotification(
|
||
space,
|
||
title,
|
||
`<p>${preview}</p><p><a href="https://${space}.rspace.online/${effectiveModule}#pin-${pinId}">View comment</a></p>`,
|
||
);
|
||
})
|
||
.catch(() => {});
|
||
|
||
return c.json({ ok: true });
|
||
} catch (err) {
|
||
console.error("[comment-pins] notify-all error:", err);
|
||
return c.json({ error: "Failed to send notifications" }, 500);
|
||
}
|
||
});
|
||
|
||
// Mention notification
|
||
app.post("/:space/api/comment-pins/notify-mention", async (c) => {
|
||
const space = c.req.param("space");
|
||
try {
|
||
const body = await c.req.json();
|
||
const { pinId, authorDid, authorName, mentionedDids, pinIndex, moduleId } = body;
|
||
if (!pinId || !authorDid || !mentionedDids?.length) {
|
||
return c.json({ error: "Missing fields" }, 400);
|
||
}
|
||
const effectiveModule = moduleId || "rspace";
|
||
const isModuleComment = effectiveModule !== "rspace";
|
||
for (const did of mentionedDids) {
|
||
await notify({
|
||
userDid: did,
|
||
category: "module",
|
||
eventType: isModuleComment ? "module_mention" : "canvas_mention",
|
||
title: `${authorName} mentioned you in a comment`,
|
||
body: `Comment pin #${pinIndex || "?"} in ${space}`,
|
||
spaceSlug: space,
|
||
moduleId: effectiveModule,
|
||
actionUrl: `/${effectiveModule}#pin-${pinId}`,
|
||
actorDid: authorDid,
|
||
actorUsername: authorName,
|
||
});
|
||
}
|
||
return c.json({ ok: true });
|
||
} catch (err) {
|
||
console.error("[comment-pins] notify error:", err);
|
||
return c.json({ error: "Failed to send notification" }, 500);
|
||
}
|
||
});
|
||
|
||
// ── x402 test endpoint (payment-gated, supports on-chain + CRDT) ──
|
||
import { setupX402FromEnv } from "../shared/x402/hono-middleware";
|
||
import { setTokenVerifier } from "../shared/x402/crdt-scheme";
|
||
import { getBalance, getTokenDoc, transferTokens, mintFromOnChain } from "./token-service";
|
||
|
||
// Wire EncryptID JWT verifier into CRDT scheme
|
||
setTokenVerifier(async (token: string) => {
|
||
const claims = await verifyToken(token);
|
||
return { sub: claims.sub, did: claims.did as string | undefined, username: claims.username };
|
||
});
|
||
|
||
const x402Test = setupX402FromEnv({
|
||
description: "x402 test endpoint",
|
||
resource: "/api/x402-test",
|
||
crdtPayment: {
|
||
tokenId: "cusdc",
|
||
amount: 10_000, // 0.01 cUSDC (6 decimals)
|
||
payToDid: "did:key:treasury-main-rspace-dao-2026",
|
||
payToLabel: "DAO Treasury",
|
||
getBalance,
|
||
getTokenDoc,
|
||
transferTokens,
|
||
},
|
||
onPaymentSettled: async ({ paymentHeader, context }) => {
|
||
// Direction 1: mint cUSDC after on-chain USDC payment
|
||
try {
|
||
const token = extractToken(context.req.raw.headers);
|
||
if (!token) {
|
||
console.warn("[x402 bridge] No JWT — skipping cUSDC mint (on-chain payment still valid)");
|
||
return;
|
||
}
|
||
const claims = await verifyToken(token);
|
||
const did = (claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`;
|
||
const label = claims.username || did;
|
||
const amount = process.env.X402_UPLOAD_PRICE || "0.01";
|
||
const network = process.env.X402_NETWORK || "eip155:84532";
|
||
// Use payment header hash as pseudo-txHash for idempotency
|
||
const txHash = `x402-${Buffer.from(paymentHeader).toString("base64url").slice(0, 40)}`;
|
||
mintFromOnChain(did, label, amount, txHash, network);
|
||
} catch (e) {
|
||
console.warn("[x402 bridge] cUSDC mint failed (non-fatal):", e);
|
||
}
|
||
},
|
||
});
|
||
|
||
app.post("/api/x402-test", async (c) => {
|
||
if (x402Test) {
|
||
const result = await new Promise<Response | null>((resolve) => {
|
||
x402Test(c, async () => { resolve(null); }).then((res) => {
|
||
if (res instanceof Response) resolve(res);
|
||
});
|
||
});
|
||
if (result) return result;
|
||
}
|
||
const scheme = c.get("x402Scheme") || "none";
|
||
return c.json({ ok: true, scheme, message: "Payment received!", timestamp: new Date().toISOString() });
|
||
});
|
||
|
||
// ── Creative tools API endpoints ──
|
||
|
||
const FAL_KEY = process.env.FAL_KEY || "";
|
||
const GEMINI_API_KEY = process.env.GEMINI_API_KEY || "";
|
||
const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434";
|
||
const SPLAT_NOTIFY_EMAIL = process.env.SPLAT_NOTIFY_EMAIL || "";
|
||
const SITE_URL = process.env.SITE_URL || "https://rspace.online";
|
||
|
||
// ── 3D Gen job queue ──
|
||
|
||
import { createTransport } from "nodemailer";
|
||
|
||
interface Gen3DJob {
|
||
id: string;
|
||
status: "pending" | "processing" | "complete" | "failed";
|
||
imageUrl: string;
|
||
resultUrl?: string;
|
||
resultFormat?: string;
|
||
error?: string;
|
||
createdAt: number;
|
||
completedAt?: number;
|
||
emailSent?: boolean;
|
||
title?: string;
|
||
}
|
||
|
||
const gen3dJobs = new Map<string, Gen3DJob>();
|
||
|
||
// Clean up old jobs every 30 minutes (keep for 24h)
|
||
setInterval(() => {
|
||
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
||
for (const [id, job] of gen3dJobs) {
|
||
if (job.createdAt < cutoff) gen3dJobs.delete(id);
|
||
}
|
||
}, 30 * 60 * 1000);
|
||
|
||
// ── Video generation job queue (async to avoid Cloudflare timeouts) ──
|
||
|
||
interface VideoGenJob {
|
||
id: string;
|
||
status: "pending" | "processing" | "complete" | "failed";
|
||
type: "t2v" | "i2v";
|
||
prompt: string;
|
||
sourceImage?: string;
|
||
resultUrl?: string;
|
||
error?: string;
|
||
createdAt: number;
|
||
completedAt?: number;
|
||
queuePosition?: number;
|
||
falStatus?: string;
|
||
model?: string;
|
||
duration?: string;
|
||
resolution?: string;
|
||
aspectRatio?: string;
|
||
generateAudio?: boolean;
|
||
}
|
||
|
||
const videoGenJobs = new Map<string, VideoGenJob>();
|
||
|
||
// Clean up old video jobs every 30 minutes (keep for 6h)
|
||
setInterval(() => {
|
||
const cutoff = Date.now() - 6 * 60 * 60 * 1000;
|
||
for (const [id, job] of videoGenJobs) {
|
||
if (job.createdAt < cutoff) videoGenJobs.delete(id);
|
||
}
|
||
}, 30 * 60 * 1000);
|
||
|
||
async function processVideoGenJob(job: VideoGenJob) {
|
||
job.status = "processing";
|
||
const falHeaders = { Authorization: `Key ${FAL_KEY}`, "Content-Type": "application/json" };
|
||
|
||
try {
|
||
let MODEL: string;
|
||
let body: Record<string, any>;
|
||
const m = job.model || (job.type === "i2v" ? "kling" : "wan");
|
||
|
||
switch (m) {
|
||
case "seedance":
|
||
case "seedance-fast": {
|
||
const fast = m === "seedance-fast" ? "/fast" : "";
|
||
const mode = job.type === "i2v" ? "image-to-video" : "text-to-video";
|
||
MODEL = `bytedance/seedance-2.0${fast}/${mode}`;
|
||
body = {
|
||
prompt: job.prompt,
|
||
duration: job.duration || "5",
|
||
resolution: job.resolution || "480p",
|
||
aspect_ratio: job.aspectRatio || "16:9",
|
||
generate_audio: job.generateAudio ?? false,
|
||
};
|
||
if (job.type === "i2v" && job.sourceImage) {
|
||
body.image_url = job.sourceImage;
|
||
}
|
||
break;
|
||
}
|
||
case "kling":
|
||
MODEL = "fal-ai/kling-video/v1/standard/image-to-video";
|
||
body = { image_url: job.sourceImage, prompt: job.prompt || "", duration: 5, aspect_ratio: "16:9" };
|
||
break;
|
||
case "wan":
|
||
default:
|
||
MODEL = "fal-ai/wan-t2v";
|
||
body = { prompt: job.prompt, num_frames: 81, resolution: "480p" };
|
||
break;
|
||
}
|
||
|
||
// Submit to fal.ai queue
|
||
const submitRes = await fetch(`https://queue.fal.run/${MODEL}`, {
|
||
method: "POST",
|
||
headers: falHeaders,
|
||
body: JSON.stringify(body),
|
||
});
|
||
|
||
if (!submitRes.ok) {
|
||
const errText = await submitRes.text();
|
||
console.error(`[video-gen] fal.ai submit error (${job.type}):`, submitRes.status, errText);
|
||
job.status = "failed";
|
||
job.error = "Video generation failed to start";
|
||
job.completedAt = Date.now();
|
||
return;
|
||
}
|
||
|
||
const submitData = await submitRes.json() as { request_id: string; status_url?: string; response_url?: string };
|
||
const request_id = submitData.request_id;
|
||
const statusPollUrl = submitData.status_url || `https://queue.fal.run/${MODEL}/requests/${request_id}/status`;
|
||
let responseUrl = submitData.response_url || "";
|
||
console.log(`[video-gen] Job ${job.id} submitted (model=${MODEL}, reqId=${request_id})`);
|
||
|
||
// Poll for completion (up to 10 min)
|
||
const deadline = Date.now() + 600_000;
|
||
let completed = false;
|
||
|
||
while (Date.now() < deadline) {
|
||
await new Promise((r) => setTimeout(r, 3000));
|
||
const statusRes = await fetch(statusPollUrl, { headers: falHeaders });
|
||
if (!statusRes.ok) {
|
||
console.log(`[video-gen] Poll ${job.id}: status HTTP ${statusRes.status}`);
|
||
continue;
|
||
}
|
||
const statusData = await statusRes.json() as { status: string; response_url?: string; queue_position?: number };
|
||
console.log(`[video-gen] Poll ${job.id}: status=${statusData.status}`);
|
||
job.falStatus = statusData.status;
|
||
job.queuePosition = statusData.queue_position;
|
||
if (statusData.response_url) responseUrl = statusData.response_url;
|
||
if (statusData.status === "COMPLETED") { completed = true; break; }
|
||
if (statusData.status === "FAILED") {
|
||
job.status = "failed";
|
||
job.error = "Video generation failed on fal.ai";
|
||
job.completedAt = Date.now();
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (!completed) {
|
||
job.status = "failed";
|
||
job.error = "Video generation timed out";
|
||
job.completedAt = Date.now();
|
||
return;
|
||
}
|
||
|
||
// Fetch result
|
||
const resultUrl = responseUrl || `https://queue.fal.run/${MODEL}/requests/${request_id}`;
|
||
const resultRes = await fetch(resultUrl, { headers: falHeaders });
|
||
if (!resultRes.ok) {
|
||
job.status = "failed";
|
||
job.error = "Failed to retrieve video";
|
||
job.completedAt = Date.now();
|
||
return;
|
||
}
|
||
|
||
const data = await resultRes.json();
|
||
const videoUrl = data.video?.url || data.output?.url;
|
||
if (!videoUrl) {
|
||
console.error(`[video-gen] No video URL in response:`, JSON.stringify(data).slice(0, 500));
|
||
job.status = "failed";
|
||
job.error = "No video returned";
|
||
job.completedAt = Date.now();
|
||
return;
|
||
}
|
||
|
||
job.status = "complete";
|
||
job.resultUrl = videoUrl;
|
||
job.completedAt = Date.now();
|
||
console.log(`[video-gen] Job ${job.id} complete: ${videoUrl}`);
|
||
} catch (e: any) {
|
||
console.error("[video-gen] error:", e.message);
|
||
job.status = "failed";
|
||
job.error = "Video generation failed";
|
||
job.completedAt = Date.now();
|
||
}
|
||
}
|
||
|
||
let splatMailTransport: ReturnType<typeof createTransport> | null = null;
|
||
if (process.env.SMTP_PASS) {
|
||
splatMailTransport = createTransport({
|
||
host: process.env.SMTP_HOST || "mail.rmail.online",
|
||
port: Number(process.env.SMTP_PORT) || 587,
|
||
secure: Number(process.env.SMTP_PORT) === 465,
|
||
tls: { rejectUnauthorized: false },
|
||
auth: {
|
||
user: process.env.SMTP_USER || "noreply@rmail.online",
|
||
pass: process.env.SMTP_PASS,
|
||
},
|
||
});
|
||
}
|
||
|
||
async function sendSplatEmail(job: Gen3DJob) {
|
||
if (!splatMailTransport || !SPLAT_NOTIFY_EMAIL) return;
|
||
const downloadUrl = `${SITE_URL}${job.resultUrl}`;
|
||
const title = job.title || "3D Model";
|
||
try {
|
||
await splatMailTransport.sendMail({
|
||
from: process.env.SMTP_FROM || "rSplat <noreply@rmail.online>",
|
||
to: SPLAT_NOTIFY_EMAIL,
|
||
subject: `Your 3D splat "${title}" is ready — rSplat`,
|
||
html: `
|
||
<div style="font-family:system-ui,sans-serif;max-width:600px;margin:0 auto;padding:24px;">
|
||
<h2 style="color:#818cf8;">Your 3D model is ready!</h2>
|
||
<p>Your AI-generated 3D model <strong>"${title}"</strong> has finished processing.</p>
|
||
<p style="margin:24px 0;">
|
||
<a href="${downloadUrl}" style="display:inline-block;padding:12px 24px;background:#818cf8;color:#fff;text-decoration:none;border-radius:8px;font-weight:600;">
|
||
View & Download
|
||
</a>
|
||
</p>
|
||
<p>Or save it to your gallery at <a href="${SITE_URL}/rsplat">${SITE_URL}/rsplat</a></p>
|
||
<p style="color:#64748b;font-size:13px;margin-top:32px;">Generated by rSplat on rSpace</p>
|
||
</div>
|
||
`,
|
||
});
|
||
job.emailSent = true;
|
||
console.log(`[3d-gen] Email sent to ${SPLAT_NOTIFY_EMAIL} for job ${job.id}`);
|
||
} catch (e: any) {
|
||
console.error(`[3d-gen] Failed to send email:`, e.message);
|
||
}
|
||
}
|
||
|
||
async function process3DGenJob(job: Gen3DJob) {
|
||
job.status = "processing";
|
||
const falHeaders = { Authorization: `Key ${FAL_KEY}`, "Content-Type": "application/json" };
|
||
const MODEL = "fal-ai/hunyuan3d-v21";
|
||
|
||
try {
|
||
// 1. Resize staged image with sharp (max 1024px) for reliable fal.ai processing
|
||
let imageInput = job.imageUrl;
|
||
const stagedMatch = job.imageUrl.match(/\/(?:api\/files|data\/files)\/generated\/([^?#]+)/);
|
||
if (stagedMatch) {
|
||
try {
|
||
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
|
||
const srcPath = resolve(dir, stagedMatch[1]);
|
||
const file = Bun.file(srcPath);
|
||
if (await file.exists()) {
|
||
const resizedBuf = await sharp(srcPath)
|
||
.resize(1024, 1024, { fit: "inside", withoutEnlargement: true })
|
||
.jpeg({ quality: 90 })
|
||
.toBuffer();
|
||
const resizedName = `stage-resized-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.jpg`;
|
||
await Bun.write(resolve(dir, resizedName), resizedBuf);
|
||
// Use public HTTPS URL for the resized image
|
||
imageInput = `${PUBLIC_ORIGIN}/api/files/generated/${resizedName}`;
|
||
console.log(`[3d-gen] Resized image: ${Math.round(resizedBuf.length / 1024)}KB → ${imageInput}`);
|
||
}
|
||
} catch (e: any) {
|
||
console.warn(`[3d-gen] Resize failed, using original URL:`, e.message);
|
||
}
|
||
}
|
||
|
||
// 2. Submit to fal.ai queue
|
||
const submitRes = await fetch(`https://queue.fal.run/${MODEL}`, {
|
||
method: "POST",
|
||
headers: falHeaders,
|
||
body: JSON.stringify({ input_image_url: imageInput, textured_mesh: true, octree_resolution: 256 }),
|
||
});
|
||
if (!submitRes.ok) {
|
||
const errText = await submitRes.text();
|
||
console.error("[3d-gen] fal.ai submit error:", submitRes.status, errText);
|
||
let detail = "3D generation failed to start";
|
||
try {
|
||
const parsed = JSON.parse(errText);
|
||
if (parsed.detail) {
|
||
detail = typeof parsed.detail === "string" ? parsed.detail
|
||
: Array.isArray(parsed.detail) ? parsed.detail[0]?.msg || detail
|
||
: detail;
|
||
}
|
||
} catch {}
|
||
job.status = "failed";
|
||
job.error = detail;
|
||
job.completedAt = Date.now();
|
||
return;
|
||
}
|
||
const { request_id } = await submitRes.json() as { request_id: string };
|
||
|
||
// 3. Poll for completion (up to 8 min — Hunyuan3D with textures can take 3-5 min)
|
||
const deadline = Date.now() + 480_000;
|
||
let responseUrl = "";
|
||
let completed = false;
|
||
while (Date.now() < deadline) {
|
||
await new Promise((r) => setTimeout(r, 3000));
|
||
const statusRes = await fetch(
|
||
`https://queue.fal.run/${MODEL}/requests/${request_id}/status`,
|
||
{ headers: falHeaders },
|
||
);
|
||
if (!statusRes.ok) continue;
|
||
const statusData = await statusRes.json() as { status: string; response_url?: string };
|
||
console.log(`[3d-gen] Poll ${job.id}: status=${statusData.status}`);
|
||
if (statusData.response_url) responseUrl = statusData.response_url;
|
||
if (statusData.status === "COMPLETED") { completed = true; break; }
|
||
if (statusData.status === "FAILED") {
|
||
job.status = "failed";
|
||
job.error = "3D generation failed on fal.ai";
|
||
job.completedAt = Date.now();
|
||
return;
|
||
}
|
||
}
|
||
if (!completed) {
|
||
job.status = "failed";
|
||
job.error = "3D generation timed out";
|
||
job.completedAt = Date.now();
|
||
return;
|
||
}
|
||
|
||
// 4. Fetch result using response_url from status (with fallback)
|
||
const resultUrl = responseUrl || `https://queue.fal.run/${MODEL}/requests/${request_id}`;
|
||
console.log(`[3d-gen] Fetching result from: ${resultUrl}`);
|
||
let resultRes = await fetch(resultUrl, { headers: falHeaders });
|
||
if (!resultRes.ok) {
|
||
console.warn(`[3d-gen] Result fetch failed (status=${resultRes.status}), retrying in 3s...`);
|
||
const errBody = await resultRes.text().catch(() => "");
|
||
console.warn(`[3d-gen] Result error body:`, errBody);
|
||
await new Promise((r) => setTimeout(r, 3000));
|
||
resultRes = await fetch(resultUrl, { headers: falHeaders });
|
||
}
|
||
if (!resultRes.ok) {
|
||
const errBody = await resultRes.text().catch(() => "");
|
||
console.error(`[3d-gen] Result fetch failed after retry (status=${resultRes.status}):`, errBody);
|
||
let detail = "Failed to retrieve 3D model";
|
||
try {
|
||
const parsed = JSON.parse(errBody);
|
||
if (parsed.detail) {
|
||
detail = Array.isArray(parsed.detail) ? parsed.detail[0]?.msg || detail
|
||
: typeof parsed.detail === "string" ? parsed.detail : detail;
|
||
}
|
||
} catch {}
|
||
job.status = "failed";
|
||
job.error = detail;
|
||
job.completedAt = Date.now();
|
||
return;
|
||
}
|
||
|
||
const data = await resultRes.json();
|
||
console.log(`[3d-gen] Result keys for ${job.id}:`, Object.keys(data));
|
||
const modelUrl = data.model_glb?.url || data.model_glb_pbr?.url || data.mesh?.url;
|
||
if (!modelUrl) {
|
||
console.error(`[3d-gen] No model URL found in response:`, JSON.stringify(data).slice(0, 500));
|
||
job.status = "failed";
|
||
job.error = "No 3D model returned";
|
||
job.completedAt = Date.now();
|
||
return;
|
||
}
|
||
|
||
// 5. Download and save
|
||
const modelRes = await fetch(modelUrl);
|
||
if (!modelRes.ok) {
|
||
job.status = "failed";
|
||
job.error = "Failed to download model";
|
||
job.completedAt = Date.now();
|
||
return;
|
||
}
|
||
|
||
const modelBuf = await modelRes.arrayBuffer();
|
||
const filename = `splat-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.glb`;
|
||
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
|
||
await Bun.write(resolve(dir, filename), modelBuf);
|
||
pinGeneratedFile(resolve(dir, filename), filename);
|
||
|
||
job.status = "complete";
|
||
job.resultUrl = `/data/files/generated/${filename}`;
|
||
job.resultFormat = "glb";
|
||
job.completedAt = Date.now();
|
||
console.log(`[3d-gen] Job ${job.id} complete: ${job.resultUrl}`);
|
||
|
||
// Send email notification
|
||
sendSplatEmail(job).catch(() => {});
|
||
} catch (e: any) {
|
||
console.error("[3d-gen] error:", e.message);
|
||
job.status = "failed";
|
||
job.error = "3D generation failed";
|
||
job.completedAt = Date.now();
|
||
}
|
||
}
|
||
|
||
// ── Image helpers ──
|
||
|
||
/** Read a /data/files/generated/... path from disk → base64 */
|
||
async function readFileAsBase64(serverPath: string): Promise<string> {
|
||
const filename = serverPath.split("/").pop();
|
||
if (!filename) throw new Error("Invalid path");
|
||
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
|
||
const file = Bun.file(resolve(dir, filename));
|
||
if (!(await file.exists())) throw new Error("File not found: " + serverPath);
|
||
const buf = await file.arrayBuffer();
|
||
return Buffer.from(buf).toString("base64");
|
||
}
|
||
|
||
/** Save a data:image/... URL to disk → return server-relative URL */
|
||
async function saveDataUrlToDisk(dataUrl: string, prefix: string): Promise<string> {
|
||
const match = dataUrl.match(/^data:image\/(\w+);base64,(.+)$/);
|
||
if (!match) throw new Error("Invalid data URL");
|
||
const ext = match[1] === "jpeg" ? "jpg" : match[1];
|
||
const b64 = match[2];
|
||
const filename = `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.${ext}`;
|
||
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
|
||
await Bun.write(resolve(dir, filename), Buffer.from(b64, "base64"));
|
||
pinGeneratedFile(resolve(dir, filename), filename);
|
||
return `/data/files/generated/${filename}`;
|
||
}
|
||
|
||
// Image generation via fal.ai Flux Pro (delegates to shared helper)
|
||
app.post("/api/image-gen", async (c) => {
|
||
const { prompt, style } = await c.req.json();
|
||
if (!prompt) return c.json({ error: "prompt required" }, 400);
|
||
|
||
const { generateImageViaFal } = await import("./mi-media");
|
||
const result = await generateImageViaFal(prompt, style);
|
||
if (!result.ok) return c.json({ error: result.error }, 502);
|
||
return c.json({ url: result.url, image_url: result.url });
|
||
});
|
||
|
||
// Upload image (data URL → disk)
|
||
app.post("/api/image-upload", async (c) => {
|
||
const { image } = await c.req.json();
|
||
if (!image) return c.json({ error: "image required" }, 400);
|
||
try {
|
||
const url = await saveDataUrlToDisk(image, "upload");
|
||
return c.json({ url });
|
||
} catch (e: any) {
|
||
console.error("[image-upload]", e.message);
|
||
return c.json({ error: e.message }, 400);
|
||
}
|
||
});
|
||
|
||
// Analyze style from reference images via Gemini vision
|
||
app.post("/api/image-gen/analyze-style", async (c) => {
|
||
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
|
||
|
||
const { images, context } = await c.req.json();
|
||
if (!images?.length) return c.json({ error: "images required" }, 400);
|
||
|
||
const { GoogleGenerativeAI } = await import("@google/generative-ai");
|
||
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
|
||
const model = genAI.getGenerativeModel({
|
||
model: "gemini-2.5-flash",
|
||
generationConfig: { responseMimeType: "application/json" },
|
||
});
|
||
|
||
// Build multimodal parts
|
||
const parts: any[] = [];
|
||
for (const img of images) {
|
||
let b64: string, mimeType: string;
|
||
if (img.startsWith("data:")) {
|
||
const match = img.match(/^data:(image\/\w+);base64,(.+)$/);
|
||
if (!match) continue;
|
||
mimeType = match[1];
|
||
b64 = match[2];
|
||
} else {
|
||
// Server path
|
||
b64 = await readFileAsBase64(img);
|
||
mimeType = img.endsWith(".png") ? "image/png" : "image/jpeg";
|
||
}
|
||
parts.push({ inlineData: { data: b64, mimeType } });
|
||
}
|
||
|
||
parts.push({
|
||
text: `Analyze the visual style of these reference images. ${context ? `Context: ${context}` : ""}
|
||
|
||
Return JSON with these fields:
|
||
{
|
||
"style_description": "A detailed paragraph describing the overall visual style",
|
||
"color_palette": ["#hex1", "#hex2", ...up to 6 dominant colors],
|
||
"style_keywords": ["keyword1", "keyword2", ...up to 8 keywords],
|
||
"brand_prompt_prefix": "A concise prompt prefix (under 50 words) that can be prepended to any image generation prompt to reproduce this style"
|
||
}
|
||
|
||
Focus on: color palette, textures, composition patterns, mood, typography style, illustration approach.`,
|
||
});
|
||
|
||
try {
|
||
const result = await model.generateContent({ contents: [{ role: "user", parts }] });
|
||
const text = result.response.text();
|
||
const parsed = JSON.parse(text);
|
||
return c.json(parsed);
|
||
} catch (e: any) {
|
||
console.error("[analyze-style] error:", e.message);
|
||
return c.json({ error: "Style analysis failed" }, 502);
|
||
}
|
||
});
|
||
|
||
// MakeReal: sketch-to-HTML via Gemini vision
|
||
app.post("/api/makereal", async (c) => {
|
||
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
|
||
|
||
const { sketch_image, prompt, framework = "html" } = await c.req.json();
|
||
if (!sketch_image) return c.json({ error: "sketch_image required" }, 400);
|
||
|
||
const { GoogleGenerativeAI } = await import("@google/generative-ai");
|
||
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
|
||
const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" });
|
||
|
||
// Extract base64 from data URL
|
||
const match = sketch_image.match(/^data:(image\/\w+);base64,(.+)$/);
|
||
if (!match) return c.json({ error: "Invalid image data URL" }, 400);
|
||
|
||
const frameworkInstructions: Record<string, string> = {
|
||
html: "Use plain HTML and CSS only. No external dependencies.",
|
||
tailwind: "Use Tailwind CSS via CDN (<script src=\"https://cdn.tailwindcss.com\"></script>). Use Tailwind utility classes for all styling.",
|
||
react: "Use React via CDN (react, react-dom, babel-standalone). Include a single <script type=\"text/babel\"> block with the React component.",
|
||
};
|
||
|
||
const systemPrompt = `You are an expert frontend developer. Convert the wireframe sketch into a complete, functional HTML page.
|
||
Rules:
|
||
- Return ONLY the HTML code, no markdown fences, no explanation
|
||
- ${frameworkInstructions[framework] || frameworkInstructions.html}
|
||
- Make it visually polished with proper spacing, colors, and typography
|
||
- Include all styles inline or in a <style> block — the page must be fully self-contained
|
||
- Make interactive elements functional (buttons, inputs, toggles should work)
|
||
- Use modern CSS (flexbox/grid) for layout
|
||
- Match the layout and structure shown in the wireframe sketch as closely as possible`;
|
||
|
||
const userPrompt = prompt || "Convert this wireframe into a working UI";
|
||
|
||
try {
|
||
const result = await model.generateContent({
|
||
contents: [{
|
||
role: "user",
|
||
parts: [
|
||
{ inlineData: { data: match[2], mimeType: match[1] } },
|
||
{ text: `${systemPrompt}\n\nUser request: ${userPrompt}` },
|
||
],
|
||
}],
|
||
});
|
||
|
||
let htmlContent = result.response.text();
|
||
// Strip markdown fences if present
|
||
htmlContent = htmlContent.replace(/^```html?\s*\n?/i, "").replace(/\n?```\s*$/i, "").trim();
|
||
|
||
// Save to generated files
|
||
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
|
||
const filename = `makereal-${Date.now()}.html`;
|
||
await Bun.write(resolve(dir, filename), htmlContent);
|
||
|
||
return c.json({ html: htmlContent, url: `/data/files/generated/${filename}` });
|
||
} catch (e: any) {
|
||
console.error("[makereal] error:", e.message);
|
||
return c.json({ error: "HTML generation failed" }, 502);
|
||
}
|
||
});
|
||
|
||
// img2img generation via fal.ai or Gemini
|
||
app.post("/api/image-gen/img2img", async (c) => {
|
||
const { prompt, source_image, reference_images, provider = "fal", strength = 0.85, style, model: modelOverride } = await c.req.json();
|
||
if (!prompt) return c.json({ error: "prompt required" }, 400);
|
||
if (!source_image) return c.json({ error: "source_image required" }, 400);
|
||
|
||
const fullPrompt = (style ? style + " " : "") + prompt;
|
||
|
||
if (provider === "fal") {
|
||
if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
|
||
|
||
try {
|
||
// Save source to disk if data URL
|
||
let sourceUrl: string;
|
||
if (source_image.startsWith("data:")) {
|
||
const path = await saveDataUrlToDisk(source_image, "img2img-src");
|
||
sourceUrl = `https://rspace.online${path}`;
|
||
} else if (source_image.startsWith("/")) {
|
||
sourceUrl = `https://rspace.online${source_image}`;
|
||
} else {
|
||
sourceUrl = source_image;
|
||
}
|
||
|
||
let falEndpoint: string;
|
||
let falBody: any;
|
||
|
||
if (reference_images?.length) {
|
||
// Multi-reference edit mode
|
||
const imageUrls: string[] = [sourceUrl];
|
||
for (const ref of reference_images) {
|
||
if (ref.startsWith("data:")) {
|
||
const path = await saveDataUrlToDisk(ref, "img2img-ref");
|
||
imageUrls.push(`https://rspace.online${path}`);
|
||
} else if (ref.startsWith("/")) {
|
||
imageUrls.push(`https://rspace.online${ref}`);
|
||
} else {
|
||
imageUrls.push(ref);
|
||
}
|
||
}
|
||
falEndpoint = "https://fal.run/fal-ai/flux-2/edit";
|
||
falBody = { image_urls: imageUrls, prompt: fullPrompt };
|
||
} else {
|
||
// Single source img2img
|
||
falEndpoint = "https://fal.run/fal-ai/flux/dev/image-to-image";
|
||
falBody = { image_url: sourceUrl, prompt: fullPrompt, strength, num_inference_steps: 28 };
|
||
}
|
||
|
||
const res = await fetch(falEndpoint, {
|
||
method: "POST",
|
||
headers: { Authorization: `Key ${FAL_KEY}`, "Content-Type": "application/json" },
|
||
body: JSON.stringify(falBody),
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const err = await res.text();
|
||
console.error("[img2img] fal.ai error:", err);
|
||
return c.json({ error: "img2img generation failed" }, 502);
|
||
}
|
||
|
||
const data = await res.json();
|
||
const resultUrl = data.images?.[0]?.url || data.output?.url;
|
||
if (!resultUrl) return c.json({ error: "No image returned" }, 502);
|
||
|
||
// Download and save locally
|
||
const imgRes = await fetch(resultUrl);
|
||
if (!imgRes.ok) return c.json({ url: resultUrl, image_url: resultUrl });
|
||
const imgBuf = await imgRes.arrayBuffer();
|
||
const filename = `img2img-${Date.now()}.png`;
|
||
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
|
||
await Bun.write(resolve(dir, filename), Buffer.from(imgBuf));
|
||
pinGeneratedFile(resolve(dir, filename), filename);
|
||
const localUrl = `/data/files/generated/${filename}`;
|
||
return c.json({ url: localUrl, image_url: localUrl });
|
||
} catch (e: any) {
|
||
console.error("[img2img] fal error:", e.message);
|
||
return c.json({ error: "img2img generation failed" }, 502);
|
||
}
|
||
}
|
||
|
||
if (provider === "gemini") {
|
||
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
|
||
|
||
const { GoogleGenAI } = await import("@google/genai");
|
||
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
|
||
|
||
const geminiModelMap: Record<string, string> = {
|
||
"gemini-flash": "gemini-2.5-flash-image",
|
||
"gemini-pro": "gemini-3-pro-image-preview",
|
||
"gemini-flash-2": "gemini-3.1-flash-image-preview",
|
||
};
|
||
const modelName = geminiModelMap[modelOverride || "gemini-flash"] || "gemini-2.5-flash-image";
|
||
|
||
try {
|
||
// Build multimodal parts
|
||
const parts: any[] = [{ text: fullPrompt }];
|
||
|
||
// Add source image
|
||
const addImage = async (img: string) => {
|
||
let b64: string, mimeType: string;
|
||
if (img.startsWith("data:")) {
|
||
const match = img.match(/^data:(image\/\w+);base64,(.+)$/);
|
||
if (!match) return;
|
||
mimeType = match[1];
|
||
b64 = match[2];
|
||
} else {
|
||
b64 = await readFileAsBase64(img);
|
||
mimeType = img.endsWith(".png") ? "image/png" : "image/jpeg";
|
||
}
|
||
parts.push({ inlineData: { data: b64, mimeType } });
|
||
};
|
||
|
||
await addImage(source_image);
|
||
if (reference_images?.length) {
|
||
for (const ref of reference_images) await addImage(ref);
|
||
}
|
||
|
||
const result = await ai.models.generateContent({
|
||
model: modelName,
|
||
contents: [{ role: "user", parts }],
|
||
config: { responseModalities: ["Text", "Image"] },
|
||
});
|
||
|
||
const resParts = result.candidates?.[0]?.content?.parts || [];
|
||
for (const part of resParts) {
|
||
if ((part as any).inlineData) {
|
||
const { data: b64, mimeType } = (part as any).inlineData;
|
||
const ext = mimeType?.includes("png") ? "png" : "jpg";
|
||
const filename = `img2img-gemini-${Date.now()}.${ext}`;
|
||
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
|
||
await Bun.write(resolve(dir, filename), Buffer.from(b64, "base64"));
|
||
pinGeneratedFile(resolve(dir, filename), filename);
|
||
const url = `/data/files/generated/${filename}`;
|
||
return c.json({ url, image_url: url });
|
||
}
|
||
}
|
||
|
||
return c.json({ error: "No image in Gemini response" }, 502);
|
||
} catch (e: any) {
|
||
console.error("[img2img] Gemini error:", e.message);
|
||
return c.json({ error: "Gemini img2img failed" }, 502);
|
||
}
|
||
}
|
||
|
||
return c.json({ error: `Unknown provider: ${provider}` }, 400);
|
||
});
|
||
|
||
// Text-to-video via fal.ai WAN 2.1 (async job queue to avoid Cloudflare timeouts)
|
||
app.post("/api/video-gen/t2v", async (c) => {
|
||
if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
|
||
|
||
const { prompt, model, duration, resolution, aspect_ratio, generate_audio } = await c.req.json();
|
||
if (!prompt) return c.json({ error: "prompt required" }, 400);
|
||
|
||
const jobId = crypto.randomUUID();
|
||
const job: VideoGenJob = {
|
||
id: jobId, status: "pending", type: "t2v",
|
||
prompt, createdAt: Date.now(),
|
||
model: model || "wan",
|
||
duration, resolution, aspectRatio: aspect_ratio, generateAudio: generate_audio,
|
||
};
|
||
videoGenJobs.set(jobId, job);
|
||
processVideoGenJob(job);
|
||
return c.json({ job_id: jobId, status: "pending" });
|
||
});
|
||
|
||
// Image-to-video via fal.ai Kling (async job queue)
|
||
app.post("/api/video-gen/i2v", async (c) => {
|
||
if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
|
||
|
||
const { image, prompt, model, duration, resolution, aspect_ratio, generate_audio } = await c.req.json();
|
||
if (!image) return c.json({ error: "image required" }, 400);
|
||
|
||
// Stage the source image if it's a data URL
|
||
let imageUrl = image;
|
||
if (image.startsWith("data:")) {
|
||
const url = await saveDataUrlToDisk(image, "vid-src");
|
||
imageUrl = publicUrl(c, url);
|
||
} else if (image.startsWith("/")) {
|
||
imageUrl = publicUrl(c, image);
|
||
}
|
||
|
||
const jobId = crypto.randomUUID();
|
||
const job: VideoGenJob = {
|
||
id: jobId, status: "pending", type: "i2v",
|
||
prompt: prompt || "", sourceImage: imageUrl, createdAt: Date.now(),
|
||
model: model || "kling",
|
||
duration, resolution, aspectRatio: aspect_ratio, generateAudio: generate_audio,
|
||
};
|
||
videoGenJobs.set(jobId, job);
|
||
processVideoGenJob(job);
|
||
return c.json({ job_id: jobId, status: "pending" });
|
||
});
|
||
|
||
// Poll video generation job status
|
||
app.get("/api/video-gen/:jobId", async (c) => {
|
||
const jobId = c.req.param("jobId");
|
||
const job = videoGenJobs.get(jobId);
|
||
if (!job) return c.json({ error: "Job not found" }, 404);
|
||
|
||
const response: Record<string, any> = {
|
||
job_id: job.id, status: job.status, created_at: job.createdAt,
|
||
fal_status: job.falStatus, queue_position: job.queuePosition,
|
||
};
|
||
if (job.status === "complete") {
|
||
response.url = job.resultUrl;
|
||
response.video_url = job.resultUrl;
|
||
response.completed_at = job.completedAt;
|
||
} else if (job.status === "failed") {
|
||
response.error = job.error;
|
||
response.completed_at = job.completedAt;
|
||
}
|
||
return c.json(response);
|
||
});
|
||
|
||
// Stage image for 3D generation (binary upload → HTTPS URL for fal.ai)
|
||
const PUBLIC_ORIGIN = process.env.PUBLIC_ORIGIN || "https://rspace.online";
|
||
|
||
/** Build public URL using the request's Host header so subdomains (jeff.rspace.online) work */
|
||
function publicUrl(c: any, path: string): string {
|
||
const host = c.req.header("host") || new URL(PUBLIC_ORIGIN).host;
|
||
// Always use https — site is behind Cloudflare/Traefik, x-forwarded-proto may be "http" internally
|
||
return `https://${host}${path}`;
|
||
}
|
||
|
||
app.post("/api/image-stage", async (c) => {
|
||
const ct = c.req.header("content-type") || "";
|
||
console.log("[image-stage] Content-Type:", ct);
|
||
|
||
let formData: FormData;
|
||
try {
|
||
formData = await c.req.formData();
|
||
} catch (e: any) {
|
||
console.error("[image-stage] formData parse failed:", e.message, "| Content-Type:", ct);
|
||
// Fallback: try reading raw body as binary if it's not multipart
|
||
if (ct.startsWith("application/octet-stream") || !ct.includes("multipart")) {
|
||
const buf = Buffer.from(await c.req.arrayBuffer());
|
||
if (!buf.length) return c.json({ error: "Empty body" }, 400);
|
||
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
|
||
const filename = `stage-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.jpg`;
|
||
await Bun.write(resolve(dir, filename), buf);
|
||
const url = publicUrl(c, `/api/files/generated/${filename}`);
|
||
console.log("[image-stage] Staged (binary fallback):", url);
|
||
return c.json({ url });
|
||
}
|
||
return c.json({ error: "Invalid upload format. Expected multipart/form-data." }, 400);
|
||
}
|
||
|
||
const file = formData.get("file") as File | null;
|
||
if (!file) return c.json({ error: "file required" }, 400);
|
||
|
||
// HEIC rejection
|
||
const ext = file.name.split(".").pop()?.toLowerCase() || "";
|
||
if (ext === "heic" || ext === "heif" || file.type === "image/heic" || file.type === "image/heif") {
|
||
return c.json({ error: "HEIC files are not supported. Please convert to JPEG or PNG first." }, 400);
|
||
}
|
||
|
||
// Validate type
|
||
const validTypes = ["image/jpeg", "image/png", "image/webp"];
|
||
const validExts = ["jpg", "jpeg", "png", "webp"];
|
||
if (!validTypes.includes(file.type) && !validExts.includes(ext)) {
|
||
return c.json({ error: "Only JPEG, PNG, and WebP images are supported" }, 400);
|
||
}
|
||
|
||
// 15MB limit
|
||
if (file.size > 15 * 1024 * 1024) {
|
||
return c.json({ error: "Image too large. Maximum 15MB." }, 400);
|
||
}
|
||
|
||
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
|
||
const outExt = ext === "png" ? "png" : ext === "webp" ? "webp" : "jpg";
|
||
const filename = `stage-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.${outExt}`;
|
||
const buf = Buffer.from(await file.arrayBuffer());
|
||
await Bun.write(resolve(dir, filename), buf);
|
||
|
||
const url = publicUrl(c, `/api/files/generated/${filename}`);
|
||
console.log("[image-stage] Staged:", url, `(${buf.length} bytes)`);
|
||
return c.json({ url });
|
||
});
|
||
|
||
// Image-to-3D via fal.ai Hunyuan3D v2.1 (async job queue)
|
||
app.post("/api/3d-gen", async (c) => {
|
||
if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
|
||
|
||
const { image_url, title, email } = await c.req.json();
|
||
if (!image_url) return c.json({ error: "image_url required" }, 400);
|
||
|
||
const jobId = crypto.randomUUID();
|
||
const job: Gen3DJob = {
|
||
id: jobId,
|
||
status: "pending",
|
||
imageUrl: image_url,
|
||
createdAt: Date.now(),
|
||
title: title || "Untitled",
|
||
};
|
||
gen3dJobs.set(jobId, job);
|
||
|
||
// Process in background — no await, returns immediately
|
||
process3DGenJob(job);
|
||
|
||
return c.json({ job_id: jobId, status: "pending" });
|
||
});
|
||
|
||
// Poll job status
|
||
app.get("/api/3d-gen/:jobId", async (c) => {
|
||
const jobId = c.req.param("jobId");
|
||
const job = gen3dJobs.get(jobId);
|
||
if (!job) return c.json({ error: "Job not found" }, 404);
|
||
|
||
const response: Record<string, any> = {
|
||
job_id: job.id,
|
||
status: job.status,
|
||
created_at: job.createdAt,
|
||
};
|
||
|
||
if (job.status === "complete") {
|
||
response.url = job.resultUrl;
|
||
response.format = job.resultFormat;
|
||
response.completed_at = job.completedAt;
|
||
response.email_sent = job.emailSent || false;
|
||
response.source_image = job.imageUrl;
|
||
} else if (job.status === "failed") {
|
||
response.error = job.error;
|
||
response.completed_at = job.completedAt;
|
||
}
|
||
|
||
return c.json(response);
|
||
});
|
||
|
||
// Blender 3D generation via LLM + headless worker sidecar
|
||
const BLENDER_WORKER_URL = process.env.BLENDER_WORKER_URL || "http://blender-worker:8810";
|
||
|
||
app.get("/api/blender-gen/health", async (c) => {
|
||
const issues: string[] = [];
|
||
const warnings: string[] = [];
|
||
if (!GEMINI_API_KEY) issues.push("GEMINI_API_KEY not configured");
|
||
|
||
const running = await isSidecarRunning("blender-worker");
|
||
if (!running) warnings.push("blender-worker stopped (will start on demand)");
|
||
else {
|
||
try {
|
||
const res = await fetch(`${BLENDER_WORKER_URL}/health`, { signal: AbortSignal.timeout(3000) });
|
||
if (!res.ok) warnings.push("blender-worker unhealthy");
|
||
} catch {
|
||
warnings.push("blender-worker unreachable");
|
||
}
|
||
}
|
||
|
||
return c.json({ available: issues.length === 0, issues, warnings });
|
||
});
|
||
|
||
app.post("/api/blender-gen", async (c) => {
|
||
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
|
||
|
||
const { prompt } = await c.req.json();
|
||
if (!prompt) return c.json({ error: "prompt required" }, 400);
|
||
|
||
// Step 1: Generate Blender Python script via Gemini Flash
|
||
let script = "";
|
||
|
||
try {
|
||
const { GoogleGenerativeAI } = await import("@google/generative-ai");
|
||
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
|
||
const model = genAI.getGenerativeModel({ model: "gemini-2.0-flash" });
|
||
const result = await model.generateContent(`Generate a Blender Python script that creates: ${prompt}
|
||
|
||
The script should:
|
||
- Import bpy
|
||
- Clear the default scene (delete all default objects)
|
||
- Create the described objects with materials and colors
|
||
- Set up basic lighting (sun + area light) and camera positioned to frame the scene
|
||
- Use Cycles render engine with CPU device: bpy.context.scene.render.engine = "CYCLES" and bpy.context.scene.cycles.device = "CPU" and bpy.context.scene.cycles.samples = 64 and bpy.context.scene.cycles.use_denoising = False
|
||
- Render to /tmp/render.png at 1024x1024 with bpy.context.scene.render.image_settings.file_format = "PNG"
|
||
|
||
Output ONLY the Python code, no explanations or comments outside the code.`);
|
||
|
||
const text = result.response.text();
|
||
const codeMatch = text.match(/```(?:python)?\n([\s\S]*?)```/);
|
||
script = codeMatch ? codeMatch[1].trim() : text.trim();
|
||
} catch (e) {
|
||
console.error("[blender-gen] Gemini error:", e);
|
||
}
|
||
|
||
if (!script) {
|
||
return c.json({ error: "Failed to generate Blender script" }, 502);
|
||
}
|
||
|
||
// Step 2: Start sidecar on demand, execute on blender-worker
|
||
try {
|
||
await ensureSidecar("blender-worker");
|
||
const workerRes = await fetch(`${BLENDER_WORKER_URL}/render`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ script }),
|
||
signal: AbortSignal.timeout(95_000), // 95s — worker has 90s internal timeout
|
||
});
|
||
|
||
const data = await workerRes.json() as {
|
||
success?: boolean;
|
||
render_url?: string;
|
||
error?: string;
|
||
stdout?: string;
|
||
stderr?: string;
|
||
};
|
||
|
||
markSidecarUsed("blender-worker");
|
||
|
||
if (data.success && data.render_url) {
|
||
return c.json({ script, render_url: data.render_url });
|
||
}
|
||
|
||
// Worker ran but render failed — return script + error details
|
||
return c.json({
|
||
script,
|
||
render_url: null,
|
||
error_detail: data.error || "Render failed",
|
||
});
|
||
} catch (e) {
|
||
console.error("[blender-gen] worker error:", e);
|
||
// Worker unreachable — return script only
|
||
return c.json({ script, render_url: null, error_detail: "blender-worker unavailable" });
|
||
}
|
||
});
|
||
|
||
// ── Blender Multi-User server status ──
|
||
const MULTIUSER_HOST = process.env.MULTIUSER_HOST || "159.195.32.209";
|
||
const MULTIUSER_PORT = 5555;
|
||
|
||
app.get("/api/blender-multiuser/status", async (c) => {
|
||
let available = false;
|
||
try {
|
||
const net = await import("node:net");
|
||
available = await new Promise<boolean>((resolve) => {
|
||
const sock = net.createConnection({ host: "blender-multiuser", port: MULTIUSER_PORT }, () => {
|
||
sock.destroy();
|
||
resolve(true);
|
||
});
|
||
sock.setTimeout(2000);
|
||
sock.on("timeout", () => { sock.destroy(); resolve(false); });
|
||
sock.on("error", () => resolve(false));
|
||
});
|
||
} catch {}
|
||
|
||
return c.json({
|
||
available,
|
||
host: MULTIUSER_HOST,
|
||
port: MULTIUSER_PORT,
|
||
instructions: "Install Multi-User addon from extensions.blender.org, connect to host:port in Blender",
|
||
});
|
||
});
|
||
|
||
// ── Design Agent (Scribus DTP via Gemini tool loop) ──
|
||
const SCRIBUS_BRIDGE_URL = process.env.SCRIBUS_BRIDGE_URL || "http://scribus-novnc:8765";
|
||
const SCRIBUS_BRIDGE_SECRET = process.env.SCRIBUS_BRIDGE_SECRET || "";
|
||
|
||
const SCRIBUS_TOOLS = [
|
||
{
|
||
name: "new_document",
|
||
description: "Create a new Scribus document with given page size and margins",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
width: { type: "number", description: "Page width in points (default 595 = A4)" },
|
||
height: { type: "number", description: "Page height in points (default 842 = A4)" },
|
||
margins: { type: "number", description: "Margin in points (default 40)" },
|
||
pages: { type: "number", description: "Number of pages (default 1)" },
|
||
unit: { type: "string", description: "Unit: pt, mm, in (default pt)" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "add_text_frame",
|
||
description: "Add a text frame to the document",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
x: { type: "number", description: "X position in points" },
|
||
y: { type: "number", description: "Y position in points" },
|
||
width: { type: "number", description: "Frame width in points" },
|
||
height: { type: "number", description: "Frame height in points" },
|
||
text: { type: "string", description: "Text content" },
|
||
fontSize: { type: "number", description: "Font size in points (default 12)" },
|
||
fontName: { type: "string", description: "Font name (default: Arial Regular)" },
|
||
name: { type: "string", description: "Unique frame name" },
|
||
},
|
||
required: ["x", "y", "width", "height", "text", "name"],
|
||
},
|
||
},
|
||
{
|
||
name: "add_image_frame",
|
||
description: "Add an image frame to the document",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
x: { type: "number", description: "X position" },
|
||
y: { type: "number", description: "Y position" },
|
||
width: { type: "number", description: "Frame width" },
|
||
height: { type: "number", description: "Frame height" },
|
||
imagePath: { type: "string", description: "Path to image file inside container" },
|
||
name: { type: "string", description: "Unique frame name" },
|
||
},
|
||
required: ["x", "y", "width", "height", "name"],
|
||
},
|
||
},
|
||
{
|
||
name: "add_shape",
|
||
description: "Add a rectangle or ellipse shape to the document",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
x: { type: "number", description: "X position" },
|
||
y: { type: "number", description: "Y position" },
|
||
width: { type: "number", description: "Shape width" },
|
||
height: { type: "number", description: "Shape height" },
|
||
shapeType: { type: "string", description: "Shape type: rectangle or ellipse" },
|
||
fillColor: { type: "string", description: "Fill color as hex (e.g. #ff0000)" },
|
||
name: { type: "string", description: "Unique frame name" },
|
||
},
|
||
required: ["x", "y", "width", "height", "name"],
|
||
},
|
||
},
|
||
{
|
||
name: "set_background_color",
|
||
description: "Set the page background color",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
color: { type: "string", description: "Hex color (e.g. #ffffff)" },
|
||
},
|
||
required: ["color"],
|
||
},
|
||
},
|
||
{
|
||
name: "move_frame",
|
||
description: "Move a frame to a new position",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
name: { type: "string", description: "Frame name" },
|
||
x: { type: "number", description: "New X position" },
|
||
y: { type: "number", description: "New Y position" },
|
||
relative: { type: "boolean", description: "If true, move relative to current position" },
|
||
},
|
||
required: ["name", "x", "y"],
|
||
},
|
||
},
|
||
{
|
||
name: "delete_frame",
|
||
description: "Delete a frame by name",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
name: { type: "string", description: "Frame name to delete" },
|
||
},
|
||
required: ["name"],
|
||
},
|
||
},
|
||
{
|
||
name: "save_as_sla",
|
||
description: "Save the document as a Scribus .sla file",
|
||
parameters: {
|
||
type: "object",
|
||
properties: {
|
||
space: { type: "string", description: "Space/project name for organizing files" },
|
||
filename: { type: "string", description: "Filename without extension" },
|
||
},
|
||
required: ["space", "filename"],
|
||
},
|
||
},
|
||
{
|
||
name: "get_doc_state",
|
||
description: "Get current document state including all frames and their properties",
|
||
parameters: { type: "object", properties: {} },
|
||
},
|
||
];
|
||
|
||
const SCRIBUS_SYSTEM_PROMPT = `You are a Scribus desktop publishing design assistant. You create professional print-ready layouts by calling Scribus tools step by step.
|
||
|
||
Available tools control a live Scribus document. Follow this workflow:
|
||
1. new_document — Create the page (default A4: 595×842 pt, or specify custom size)
|
||
2. set_background_color — Set page background if needed
|
||
3. add_text_frame — Add titles, headings, body text
|
||
4. add_shape — Add decorative shapes, dividers, color blocks
|
||
5. add_image_frame — Add image placeholders
|
||
6. get_doc_state — Verify the layout
|
||
7. save_as_sla — Save the final document
|
||
|
||
Design principles:
|
||
- Use clear visual hierarchy: large bold titles, medium subheads, smaller body text
|
||
- Leave generous margins (40+ pt) and whitespace
|
||
- Use shapes as backgrounds or accent elements behind text
|
||
- For flyers: big headline at top, key info in middle, call-to-action at bottom
|
||
- For posters: dramatic title, supporting image area, details below
|
||
- For cards: centered content, decorative border shapes
|
||
- Name every frame descriptively (e.g. "title", "subtitle", "hero-bg", "body-text")
|
||
- Standard A4 is 595×842 pt, US Letter is 612×792 pt
|
||
- Font sizes: titles 36-60pt, subtitles 18-24pt, body 10-14pt`;
|
||
|
||
async function callScribusBridge(action: string, args: Record<string, any> = {}): Promise<any> {
|
||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||
if (SCRIBUS_BRIDGE_SECRET) headers["X-Bridge-Secret"] = SCRIBUS_BRIDGE_SECRET;
|
||
|
||
const res = await fetch(`${SCRIBUS_BRIDGE_URL}/api/scribus/command`, {
|
||
method: "POST",
|
||
headers,
|
||
body: JSON.stringify({ action, args }),
|
||
signal: AbortSignal.timeout(30_000),
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
app.post("/api/design-agent", async (c) => {
|
||
const { brief, space } = await c.req.json();
|
||
if (!brief) return c.json({ error: "brief is required" }, 400);
|
||
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
|
||
|
||
const encoder = new TextEncoder();
|
||
const stream = new ReadableStream({
|
||
async start(controller) {
|
||
const send = (event: any) => {
|
||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||
};
|
||
|
||
try {
|
||
// 1. Start Scribus sidecar
|
||
send({ action: "starting_scribus", status: "Starting Scribus container..." });
|
||
await ensureSidecar("scribus-novnc");
|
||
|
||
// 2. Health check
|
||
try {
|
||
const healthRes = await fetch(`${SCRIBUS_BRIDGE_URL}/health`, { signal: AbortSignal.timeout(10_000) });
|
||
const health = await healthRes.json() as any;
|
||
if (!health.ok) {
|
||
send({ action: "error", error: "Scribus bridge unhealthy" });
|
||
controller.close();
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
send({ action: "error", error: `Scribus bridge unreachable: ${e instanceof Error ? e.message : e}` });
|
||
controller.close();
|
||
return;
|
||
}
|
||
send({ action: "scribus_ready", status: "Scribus ready" });
|
||
|
||
// 3. Initialize Gemini Flash with Scribus tools
|
||
const { GoogleGenerativeAI } = await import("@google/generative-ai");
|
||
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
|
||
const model = genAI.getGenerativeModel({
|
||
model: "gemini-2.5-flash",
|
||
systemInstruction: SCRIBUS_SYSTEM_PROMPT,
|
||
generationConfig: { temperature: 0.3 },
|
||
tools: [{ functionDeclarations: SCRIBUS_TOOLS as any }],
|
||
});
|
||
|
||
// 4. Agent loop
|
||
const maxTurns = 15;
|
||
const deadline = Date.now() + 90_000;
|
||
let contents: any[] = [
|
||
{ role: "user", parts: [{ text: `Design brief: ${brief}\n\nSpace/project: ${space || "demo"}\nCreate this design now using the available Scribus tools.` }] },
|
||
];
|
||
|
||
for (let turn = 0; turn < maxTurns; turn++) {
|
||
if (Date.now() > deadline) {
|
||
send({ action: "thinking", status: "Deadline reached, finishing up..." });
|
||
break;
|
||
}
|
||
|
||
send({ action: "thinking", status: `Planning step ${turn + 1}...` });
|
||
|
||
const result = await model.generateContent({ contents });
|
||
const candidate = result.response.candidates?.[0];
|
||
if (!candidate) break;
|
||
|
||
const parts = candidate.content?.parts || [];
|
||
const fnCalls = parts.filter((p: any) => p.functionCall);
|
||
|
||
if (fnCalls.length === 0) {
|
||
// Model is done — extract final text
|
||
const finalText = parts.filter((p: any) => p.text).map((p: any) => p.text).join("\n");
|
||
if (finalText) send({ action: "thinking", status: finalText });
|
||
break;
|
||
}
|
||
|
||
// Execute each function call
|
||
const fnResponseParts: any[] = [];
|
||
for (const part of fnCalls) {
|
||
const fc = part.functionCall!;
|
||
const toolName = fc.name;
|
||
const toolArgs = fc.args || {};
|
||
|
||
send({ action: "executing", tool: toolName, status: `Executing ${toolName}` });
|
||
console.log(`[design-agent] Turn ${turn + 1}: ${toolName}(${JSON.stringify(toolArgs).slice(0, 200)})`);
|
||
|
||
let toolResult: any;
|
||
try {
|
||
toolResult = await callScribusBridge(toolName, toolArgs);
|
||
} catch (err) {
|
||
toolResult = { error: err instanceof Error ? err.message : String(err) };
|
||
}
|
||
|
||
send({ action: "tool_result", tool: toolName, result: toolResult });
|
||
|
||
fnResponseParts.push({
|
||
functionResponse: {
|
||
name: toolName,
|
||
response: { result: JSON.stringify(toolResult) },
|
||
},
|
||
});
|
||
}
|
||
|
||
contents.push({ role: "model", parts });
|
||
contents.push({ role: "user", parts: fnResponseParts });
|
||
}
|
||
|
||
// 5. Get final document state
|
||
let docState: any = null;
|
||
try {
|
||
const stateRes = await fetch(`${SCRIBUS_BRIDGE_URL}/api/scribus/state`, {
|
||
headers: SCRIBUS_BRIDGE_SECRET ? { "X-Bridge-Secret": SCRIBUS_BRIDGE_SECRET } : {},
|
||
signal: AbortSignal.timeout(10_000),
|
||
});
|
||
docState = await stateRes.json();
|
||
} catch (e) {
|
||
console.warn("[design-agent] Failed to get final state:", e);
|
||
}
|
||
|
||
send({ action: "done", status: "Design complete", state: docState });
|
||
markSidecarUsed("scribus-novnc");
|
||
} catch (e) {
|
||
console.error("[design-agent] error:", e);
|
||
send({ action: "error", error: e instanceof Error ? e.message : String(e) });
|
||
}
|
||
|
||
controller.close();
|
||
},
|
||
});
|
||
|
||
return new Response(stream, {
|
||
headers: {
|
||
"Content-Type": "text/event-stream",
|
||
"Cache-Control": "no-cache",
|
||
Connection: "keep-alive",
|
||
},
|
||
});
|
||
});
|
||
|
||
// ── ASCII Art Generation (proxies to ascii-art service on rspace-internal) ──
|
||
const ASCII_ART_URL = process.env.ASCII_ART_URL || "http://ascii-art:8000";
|
||
|
||
app.post("/api/ascii-gen", async (c) => {
|
||
const body = await c.req.json();
|
||
const { prompt, width, height, palette, output_format } = body;
|
||
if (!prompt) return c.json({ error: "prompt required" }, 400);
|
||
|
||
try {
|
||
const res = await fetch(`${ASCII_ART_URL}/api/pattern`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
prompt,
|
||
width: width || 80,
|
||
height: height || 40,
|
||
palette: palette || "classic",
|
||
output_format: output_format || "html",
|
||
}),
|
||
signal: AbortSignal.timeout(30_000),
|
||
});
|
||
if (!res.ok) {
|
||
const err = await res.text();
|
||
return c.json({ error: `ASCII art service error: ${err}` }, res.status as any);
|
||
}
|
||
// Service returns raw HTML — wrap in JSON for the client
|
||
const htmlContent = await res.text();
|
||
// Strip HTML tags to get plain text version
|
||
const textContent = htmlContent.replace(/<[^>]*>/g, "").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
||
return c.json({ html: htmlContent, text: textContent });
|
||
} catch (e: any) {
|
||
console.error("[ascii-gen] error:", e);
|
||
return c.json({ error: `ASCII art service unavailable: ${e.message}` }, 502);
|
||
}
|
||
});
|
||
|
||
app.post("/api/ascii-gen/render", async (c) => {
|
||
try {
|
||
const res = await fetch(`${ASCII_ART_URL}/api/render`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": c.req.header("Content-Type") || "application/json" },
|
||
body: await c.req.arrayBuffer(),
|
||
signal: AbortSignal.timeout(30_000),
|
||
});
|
||
if (!res.ok) {
|
||
const err = await res.text();
|
||
return c.json({ error: `ASCII render error: ${err}` }, res.status as any);
|
||
}
|
||
const htmlContent = await res.text();
|
||
const textContent = htmlContent.replace(/<[^>]*>/g, "").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
||
return c.json({ html: htmlContent, text: textContent });
|
||
} catch (e: any) {
|
||
console.error("[ascii-gen] render error:", e);
|
||
return c.json({ error: `ASCII art service unavailable: ${e.message}` }, 502);
|
||
}
|
||
});
|
||
|
||
// KiCAD PCB design — MCP StreamableHTTP bridge (sidecar container)
|
||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||
import { runCadAgentLoop, assembleKicadResult, assembleFreecadResult, KICAD_SYSTEM_PROMPT, FREECAD_SYSTEM_PROMPT } from "./cad-orchestrator";
|
||
import { ensureSidecar, markSidecarUsed, isSidecarRunning, startIdleWatcher } from "./sidecar-manager";
|
||
|
||
const KICAD_MCP_URL = process.env.KICAD_MCP_URL || "http://kicad-mcp:8809/mcp";
|
||
let kicadClient: Client | null = null;
|
||
|
||
async function getKicadClient(): Promise<Client> {
|
||
await ensureSidecar("kicad-mcp");
|
||
if (kicadClient) return kicadClient;
|
||
|
||
const transport = new StreamableHTTPClientTransport(new URL(KICAD_MCP_URL));
|
||
const client = new Client({ name: "rspace-kicad-bridge", version: "1.0.0" });
|
||
|
||
transport.onclose = () => { kicadClient = null; };
|
||
transport.onerror = () => { kicadClient = null; };
|
||
|
||
await client.connect(transport);
|
||
kicadClient = client;
|
||
return client;
|
||
}
|
||
|
||
app.get("/api/kicad/health", async (c) => {
|
||
try {
|
||
const running = await isSidecarRunning("kicad-mcp");
|
||
if (!running) return c.json({ available: false, status: "stopped (starts on demand)" });
|
||
const client = await getKicadClient();
|
||
const tools = await client.listTools();
|
||
return c.json({ available: true, tools: tools.tools.length });
|
||
} catch (e) {
|
||
return c.json({ available: false, error: e instanceof Error ? e.message : "Connection failed" });
|
||
}
|
||
});
|
||
|
||
// KiCAD AI-orchestrated design generation
|
||
app.post("/api/kicad/generate", async (c) => {
|
||
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
|
||
|
||
const { prompt, components } = await c.req.json();
|
||
if (!prompt) return c.json({ error: "prompt required" }, 400);
|
||
|
||
const enrichedPrompt = components?.length
|
||
? `${prompt}\n\nKey components to use: ${components.join(", ")}`
|
||
: prompt;
|
||
|
||
try {
|
||
const client = await getKicadClient();
|
||
const orch = await runCadAgentLoop(client, KICAD_SYSTEM_PROMPT, enrichedPrompt, GEMINI_API_KEY);
|
||
const result = assembleKicadResult(orch);
|
||
markSidecarUsed("kicad-mcp");
|
||
|
||
return c.json({
|
||
schematic_svg: result.schematicSvg,
|
||
board_svg: result.boardSvg,
|
||
gerber_url: result.gerberUrl,
|
||
bom_url: result.bomUrl,
|
||
pdf_url: result.pdfUrl,
|
||
drc: result.drcResults,
|
||
summary: result.summary,
|
||
});
|
||
} catch (e) {
|
||
console.error("[kicad/generate] error:", e);
|
||
kicadClient = null;
|
||
return c.json({ error: e instanceof Error ? e.message : "KiCAD generation failed" }, 502);
|
||
}
|
||
});
|
||
|
||
app.post("/api/kicad/:action", async (c) => {
|
||
const action = c.req.param("action");
|
||
const body = await c.req.json();
|
||
|
||
const validActions = [
|
||
"create_project", "add_schematic_component", "add_schematic_connection",
|
||
"export_svg", "run_drc", "export_gerber", "export_bom", "export_pdf",
|
||
"search_symbols", "search_footprints", "place_component",
|
||
];
|
||
|
||
if (!validActions.includes(action)) {
|
||
return c.json({ error: `Unknown action: ${action}` }, 400);
|
||
}
|
||
|
||
try {
|
||
const client = await getKicadClient();
|
||
const result = await client.callTool({ name: action, arguments: body });
|
||
|
||
// Extract text content and parse as JSON if possible
|
||
const content = result.content as Array<{ type: string; text?: string }>;
|
||
const textContent = content
|
||
?.filter((c) => c.type === "text")
|
||
.map((c) => c.text)
|
||
.join("\n");
|
||
|
||
try {
|
||
return c.json(JSON.parse(textContent || "{}"));
|
||
} catch {
|
||
return c.json({ result: textContent });
|
||
}
|
||
} catch (e) {
|
||
console.error(`[kicad/${action}] MCP error:`, e);
|
||
kicadClient = null;
|
||
return c.json({ error: "KiCAD MCP server not available" }, 503);
|
||
}
|
||
});
|
||
|
||
// FreeCAD parametric CAD — MCP StreamableHTTP bridge (sidecar container)
|
||
const FREECAD_MCP_URL = process.env.FREECAD_MCP_URL || "http://freecad-mcp:8808/mcp";
|
||
let freecadClient: Client | null = null;
|
||
|
||
async function getFreecadClient(): Promise<Client> {
|
||
await ensureSidecar("freecad-mcp");
|
||
if (freecadClient) return freecadClient;
|
||
|
||
const transport = new StreamableHTTPClientTransport(new URL(FREECAD_MCP_URL));
|
||
const client = new Client({ name: "rspace-freecad-bridge", version: "1.0.0" });
|
||
|
||
transport.onclose = () => { freecadClient = null; };
|
||
transport.onerror = () => { freecadClient = null; };
|
||
|
||
await client.connect(transport);
|
||
freecadClient = client;
|
||
return client;
|
||
}
|
||
|
||
app.get("/api/freecad/health", async (c) => {
|
||
try {
|
||
const running = await isSidecarRunning("freecad-mcp");
|
||
if (!running) return c.json({ available: false, status: "stopped (starts on demand)" });
|
||
const client = await getFreecadClient();
|
||
const tools = await client.listTools();
|
||
return c.json({ available: true, tools: tools.tools.length });
|
||
} catch (e) {
|
||
return c.json({ available: false, error: e instanceof Error ? e.message : "Connection failed" });
|
||
}
|
||
});
|
||
|
||
// FreeCAD AI-orchestrated CAD generation
|
||
app.post("/api/freecad/generate", async (c) => {
|
||
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
|
||
|
||
const { prompt } = await c.req.json();
|
||
if (!prompt) return c.json({ error: "prompt required" }, 400);
|
||
|
||
try {
|
||
const client = await getFreecadClient();
|
||
const orch = await runCadAgentLoop(client, FREECAD_SYSTEM_PROMPT, prompt, GEMINI_API_KEY);
|
||
const result = assembleFreecadResult(orch);
|
||
markSidecarUsed("freecad-mcp");
|
||
|
||
return c.json({
|
||
preview_url: result.previewUrl,
|
||
step_url: result.stepUrl,
|
||
stl_url: result.stlUrl,
|
||
summary: result.summary,
|
||
});
|
||
} catch (e) {
|
||
console.error("[freecad/generate] error:", e);
|
||
freecadClient = null;
|
||
return c.json({ error: e instanceof Error ? e.message : "FreeCAD generation failed" }, 502);
|
||
}
|
||
});
|
||
|
||
app.post("/api/freecad/:action", async (c) => {
|
||
const action = c.req.param("action");
|
||
const body = await c.req.json();
|
||
|
||
const validActions = [
|
||
"generate", "create_box", "create_cylinder", "create_sphere",
|
||
"boolean_operation", "save_document", "list_objects", "execute_script",
|
||
"export_step", "export_stl", "update_parameters",
|
||
];
|
||
|
||
if (!validActions.includes(action)) {
|
||
return c.json({ error: `Unknown action: ${action}` }, 400);
|
||
}
|
||
|
||
try {
|
||
const client = await getFreecadClient();
|
||
const result = await client.callTool({ name: action, arguments: body });
|
||
|
||
const fcContent = result.content as Array<{ type: string; text?: string }>;
|
||
const textContent = fcContent
|
||
?.filter((c) => c.type === "text")
|
||
.map((c) => c.text)
|
||
.join("\n");
|
||
|
||
try {
|
||
return c.json(JSON.parse(textContent || "{}"));
|
||
} catch {
|
||
return c.json({ result: textContent });
|
||
}
|
||
} catch (e) {
|
||
console.error(`[freecad/${action}] MCP error:`, e);
|
||
freecadClient = null;
|
||
return c.json({ error: "FreeCAD MCP server not available" }, 503);
|
||
}
|
||
});
|
||
|
||
// ── Multi-provider prompt endpoint ──
|
||
const GEMINI_MODELS: Record<string, string> = {
|
||
"gemini-flash": "gemini-2.5-flash",
|
||
"gemini-pro": "gemini-2.5-pro",
|
||
};
|
||
const OLLAMA_MODELS: Record<string, string> = {
|
||
"llama3.2": "llama3.2:3b",
|
||
"llama3.1": "llama3.1:8b",
|
||
"qwen2.5-coder": "qwen2.5-coder:7b",
|
||
"mistral-small": "mistral-small:24b",
|
||
};
|
||
|
||
const CANVAS_TOOLS_SYSTEM_PROMPT = `You are a helpful AI assistant in rSpace, a collaborative canvas workspace.
|
||
When the user asks to create, show, display, or visualize something, use the available tools to spawn shapes on the canvas.
|
||
After creating shapes, give a brief summary of what you placed. Only create shapes directly relevant to the request.
|
||
For text-only questions (explanations, coding help, math), respond with text — don't create shapes unless asked.`;
|
||
|
||
app.post("/api/prompt", async (c) => {
|
||
const { messages, model = "gemini-flash", useTools = false, systemPrompt, enabledModules } = await c.req.json();
|
||
if (!messages?.length) return c.json({ error: "messages required" }, 400);
|
||
|
||
// Determine provider
|
||
if (GEMINI_MODELS[model]) {
|
||
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
|
||
|
||
const { GoogleGenerativeAI } = await import("@google/generative-ai");
|
||
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
|
||
|
||
// Build model config with optional tools
|
||
const modelConfig: any = { model: GEMINI_MODELS[model] };
|
||
if (useTools) {
|
||
const { getToolsForModules } = await import("../lib/canvas-tools");
|
||
const tools = getToolsForModules(enabledModules ?? null);
|
||
modelConfig.tools = [{ functionDeclarations: tools.map(t => t.declaration) }];
|
||
modelConfig.systemInstruction = systemPrompt || CANVAS_TOOLS_SYSTEM_PROMPT;
|
||
}
|
||
const geminiModel = genAI.getGenerativeModel(modelConfig);
|
||
|
||
// Convert chat messages to Gemini contents format (with optional images)
|
||
const contents = messages.map((m: { role: string; content: string; images?: string[] }) => {
|
||
const parts: any[] = [{ text: m.content }];
|
||
if (m.images?.length) {
|
||
for (const img of m.images) {
|
||
const match = img.match(/^data:(image\/\w+);base64,(.+)$/);
|
||
if (match) {
|
||
parts.push({ inlineData: { data: match[2], mimeType: match[1] } });
|
||
}
|
||
}
|
||
}
|
||
return { role: m.role === "assistant" ? "model" : "user", parts };
|
||
});
|
||
|
||
try {
|
||
if (!useTools) {
|
||
const result = await geminiModel.generateContent({ contents });
|
||
const text = result.response.text();
|
||
return c.json({ content: text });
|
||
}
|
||
|
||
// Multi-turn tool loop (max 5 rounds)
|
||
const toolCalls: { name: string; args: Record<string, any>; label: string }[] = [];
|
||
const { findTool } = await import("../lib/canvas-tools");
|
||
let loopContents = [...contents];
|
||
|
||
for (let turn = 0; turn < 5; turn++) {
|
||
const result = await geminiModel.generateContent({ contents: loopContents });
|
||
const response = result.response;
|
||
const candidate = response.candidates?.[0];
|
||
if (!candidate) break;
|
||
|
||
const parts = candidate.content?.parts || [];
|
||
const fnCalls = parts.filter((p: any) => p.functionCall);
|
||
|
||
if (fnCalls.length === 0) {
|
||
// No more tool calls — extract final text
|
||
const text = response.text();
|
||
return c.json({ content: text, toolCalls });
|
||
}
|
||
|
||
// Record tool calls and build function responses
|
||
const fnResponseParts: any[] = [];
|
||
for (const part of fnCalls) {
|
||
const fc = part.functionCall!;
|
||
const tool = findTool(fc.name);
|
||
const label = tool?.actionLabel(fc.args) || fc.name;
|
||
toolCalls.push({ name: fc.name, args: fc.args, label });
|
||
fnResponseParts.push({
|
||
functionResponse: {
|
||
name: fc.name,
|
||
response: { success: true, message: `${label} — shape will be created on the canvas.` },
|
||
},
|
||
});
|
||
}
|
||
|
||
// Append model turn + function responses for next iteration
|
||
loopContents.push({ role: "model", parts });
|
||
loopContents.push({ role: "user", parts: fnResponseParts });
|
||
}
|
||
|
||
// Exhausted loop — return what we have
|
||
return c.json({ content: "I've set up the requested items on your canvas.", toolCalls });
|
||
} catch (e: any) {
|
||
console.error("[prompt] Gemini error:", e.message);
|
||
return c.json({ error: "Gemini request failed" }, 502);
|
||
}
|
||
}
|
||
|
||
if (OLLAMA_MODELS[model]) {
|
||
try {
|
||
await ensureSidecar("ollama");
|
||
const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
model: OLLAMA_MODELS[model],
|
||
messages: messages.map((m: { role: string; content: string }) => ({
|
||
role: m.role,
|
||
content: m.content,
|
||
})),
|
||
stream: false,
|
||
}),
|
||
});
|
||
|
||
if (!ollamaRes.ok) {
|
||
const err = await ollamaRes.text();
|
||
console.error("[prompt] Ollama error:", err);
|
||
return c.json({ error: "Ollama request failed" }, 502);
|
||
}
|
||
|
||
const data = await ollamaRes.json();
|
||
markSidecarUsed("ollama");
|
||
return c.json({ content: data.message?.content || "" });
|
||
} catch (e: any) {
|
||
console.error("[prompt] Ollama unreachable:", e.message);
|
||
return c.json({ error: "Ollama unreachable" }, 503);
|
||
}
|
||
}
|
||
|
||
return c.json({ error: `Unknown model: ${model}` }, 400);
|
||
});
|
||
|
||
// ── Trip AI Prompt (executes trip tools server-side) ──
|
||
app.post("/api/trips/ai-prompt", async (c) => {
|
||
const { messages, model = "gemini-flash", systemPrompt } = await c.req.json();
|
||
if (!messages?.length) return c.json({ error: "messages required" }, 400);
|
||
|
||
if (!GEMINI_MODELS[model]) return c.json({ error: `Unsupported model: ${model}` }, 400);
|
||
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
|
||
|
||
const { GoogleGenerativeAI } = await import("@google/generative-ai");
|
||
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
|
||
|
||
// Combine canvas tools (create_destination, etc.) + trip tools (search_flights, etc.)
|
||
const { CANVAS_TOOL_DECLARATIONS } = await import("../lib/canvas-tools");
|
||
const { TRIP_TOOL_DECLARATIONS, findTripTool } = await import("../lib/trip-ai-tools");
|
||
const { findTool } = await import("../lib/canvas-tools");
|
||
const allDeclarations = [...CANVAS_TOOL_DECLARATIONS, ...TRIP_TOOL_DECLARATIONS];
|
||
|
||
const geminiModel = genAI.getGenerativeModel({
|
||
model: GEMINI_MODELS[model],
|
||
tools: [{ functionDeclarations: allDeclarations as any }],
|
||
systemInstruction: systemPrompt || "You are a travel planning assistant.",
|
||
});
|
||
|
||
const contents = messages.map((m: { role: string; content: string }) => ({
|
||
role: m.role === "assistant" ? "model" : "user",
|
||
parts: [{ text: m.content }],
|
||
}));
|
||
|
||
const OSRM_URL = process.env.OSRM_URL || "http://osrm-backend:5000";
|
||
|
||
try {
|
||
const toolCalls: { name: string; args: Record<string, any>; label: string }[] = [];
|
||
const toolResults: Record<string, any> = {};
|
||
let loopContents = [...contents];
|
||
|
||
for (let turn = 0; turn < 5; turn++) {
|
||
const result = await geminiModel.generateContent({ contents: loopContents });
|
||
const response = result.response;
|
||
const candidate = response.candidates?.[0];
|
||
if (!candidate) break;
|
||
|
||
const parts = candidate.content?.parts || [];
|
||
const fnCalls = parts.filter((p: any) => p.functionCall);
|
||
|
||
if (fnCalls.length === 0) {
|
||
const text = response.text();
|
||
return c.json({ content: text, toolCalls, toolResults });
|
||
}
|
||
|
||
const fnResponseParts: any[] = [];
|
||
for (const part of fnCalls) {
|
||
const fc = part.functionCall!;
|
||
const tripTool = findTripTool(fc.name);
|
||
if (tripTool) {
|
||
// Execute server-side and return real data
|
||
const data = await tripTool.execute(fc.args, { osrmUrl: OSRM_URL });
|
||
const label = tripTool.actionLabel(fc.args);
|
||
toolCalls.push({ name: fc.name, args: fc.args, label });
|
||
toolResults[`${fc.name}_${toolCalls.length}`] = data;
|
||
fnResponseParts.push({
|
||
functionResponse: { name: fc.name, response: data },
|
||
});
|
||
} else {
|
||
// Canvas tool — acknowledge only
|
||
const canvasTool = findTool(fc.name);
|
||
const label = canvasTool?.actionLabel(fc.args) || fc.name;
|
||
toolCalls.push({ name: fc.name, args: fc.args, label });
|
||
fnResponseParts.push({
|
||
functionResponse: {
|
||
name: fc.name,
|
||
response: { success: true, message: `${label} — item will be created.` },
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
loopContents.push({ role: "model", parts });
|
||
loopContents.push({ role: "user", parts: fnResponseParts });
|
||
}
|
||
|
||
return c.json({ content: "I've set up the requested items.", toolCalls, toolResults });
|
||
} catch (e: any) {
|
||
console.error("[trips/ai-prompt] Gemini error:", e.message);
|
||
return c.json({ error: "Gemini request failed" }, 502);
|
||
}
|
||
});
|
||
|
||
// ── Gemini image generation ──
|
||
app.post("/api/gemini/image", async (c) => {
|
||
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
|
||
|
||
const { prompt, style, aspect_ratio, reference_images } = await c.req.json();
|
||
if (!prompt) return c.json({ error: "prompt required" }, 400);
|
||
|
||
const styleHints: Record<string, string> = {
|
||
photorealistic: "photorealistic, high detail, natural lighting, ",
|
||
illustration: "digital illustration, clean lines, vibrant colors, ",
|
||
painting: "oil painting style, brushstrokes visible, painterly, ",
|
||
sketch: "pencil sketch, hand-drawn, line art, ",
|
||
"punk-zine": "punk zine aesthetic, xerox texture, high contrast, DIY, rough edges, ",
|
||
collage: "cut-and-paste collage, mixed media, layered paper textures, ",
|
||
vintage: "vintage aesthetic, retro colors, aged paper texture, ",
|
||
minimalist: "minimalist design, simple shapes, limited color palette, ",
|
||
};
|
||
const enhancedPrompt = (styleHints[style] || "") + prompt;
|
||
|
||
const { GoogleGenAI } = await import("@google/genai");
|
||
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
|
||
|
||
// Build multimodal contents if reference images provided
|
||
let contentsPayload: any = enhancedPrompt;
|
||
if (reference_images?.length) {
|
||
const parts: any[] = [{ text: enhancedPrompt + "\n\nUse these reference images as style guidance:" }];
|
||
for (const ref of reference_images) {
|
||
let b64: string, mimeType: string;
|
||
if (ref.startsWith("data:")) {
|
||
const match = ref.match(/^data:(image\/\w+);base64,(.+)$/);
|
||
if (!match) continue;
|
||
mimeType = match[1];
|
||
b64 = match[2];
|
||
} else {
|
||
b64 = await readFileAsBase64(ref);
|
||
mimeType = ref.endsWith(".png") ? "image/png" : "image/jpeg";
|
||
}
|
||
parts.push({ inlineData: { data: b64, mimeType } });
|
||
}
|
||
contentsPayload = [{ role: "user", parts }];
|
||
}
|
||
|
||
const models = ["gemini-2.5-flash-image", "imagen-3.0-generate-002"];
|
||
for (const modelName of models) {
|
||
try {
|
||
if (modelName.startsWith("gemini")) {
|
||
const result = await ai.models.generateContent({
|
||
model: modelName,
|
||
contents: contentsPayload,
|
||
config: { responseModalities: ["Text", "Image"] },
|
||
});
|
||
|
||
const parts = result.candidates?.[0]?.content?.parts || [];
|
||
for (const part of parts) {
|
||
if ((part as any).inlineData) {
|
||
const { data: b64, mimeType } = (part as any).inlineData;
|
||
const ext = mimeType?.includes("png") ? "png" : "jpg";
|
||
const filename = `gemini-${Date.now()}.${ext}`;
|
||
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
|
||
await Bun.write(resolve(dir, filename), Buffer.from(b64, "base64"));
|
||
pinGeneratedFile(resolve(dir, filename), filename);
|
||
const url = `/data/files/generated/${filename}`;
|
||
return c.json({ url, image_url: url });
|
||
}
|
||
}
|
||
} else {
|
||
// Imagen API
|
||
const result = await ai.models.generateImages({
|
||
model: modelName,
|
||
prompt: enhancedPrompt,
|
||
config: {
|
||
numberOfImages: 1,
|
||
aspectRatio: aspect_ratio || "3:4",
|
||
},
|
||
});
|
||
const img = (result as any).generatedImages?.[0];
|
||
if (img?.image?.imageBytes) {
|
||
const filename = `imagen-${Date.now()}.png`;
|
||
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
|
||
await Bun.write(resolve(dir, filename), Buffer.from(img.image.imageBytes, "base64"));
|
||
pinGeneratedFile(resolve(dir, filename), filename);
|
||
const url = `/data/files/generated/${filename}`;
|
||
return c.json({ url, image_url: url });
|
||
}
|
||
}
|
||
} catch (e: any) {
|
||
console.error(`[gemini/image] ${modelName} error:`, e.message);
|
||
continue;
|
||
}
|
||
}
|
||
|
||
return c.json({ error: "All Gemini image models failed" }, 502);
|
||
});
|
||
|
||
// ── Zine generation endpoints ──
|
||
|
||
const ZINE_STYLES: Record<string, string> = {
|
||
"punk-zine": "Xerox texture, high contrast B&W, DIY collage, hand-drawn typography",
|
||
mycelial: "Organic networks, spore patterns, earth tones, fungal textures, interconnected webs",
|
||
minimal: "Clean lines, white space, modern sans-serif, subtle gradients",
|
||
collage: "Layered imagery, mixed media textures, vintage photographs",
|
||
retro: "1970s aesthetic, earth tones, groovy typography, halftone patterns",
|
||
academic: "Diagram-heavy, annotated illustrations, infographic elements",
|
||
};
|
||
|
||
const ZINE_TONES: Record<string, string> = {
|
||
rebellious: "Defiant, anti-establishment, punk energy",
|
||
regenerative: "Hopeful, nature-inspired, healing, systems-thinking",
|
||
playful: "Fun, whimsical, approachable",
|
||
informative: "Educational, clear, accessible",
|
||
poetic: "Lyrical, metaphorical, evocative",
|
||
};
|
||
|
||
app.post("/api/zine/outline", async (c) => {
|
||
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
|
||
|
||
const { topic, style = "punk-zine", tone = "informative", pageCount = 8 } = await c.req.json();
|
||
if (!topic) return c.json({ error: "topic required" }, 400);
|
||
|
||
const { GoogleGenerativeAI } = await import("@google/generative-ai");
|
||
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
|
||
const model = genAI.getGenerativeModel({ model: "gemini-2.5-pro" });
|
||
|
||
const systemPrompt = `You are a zine designer creating a ${pageCount}-page MycroZine.
|
||
Style: ${ZINE_STYLES[style] || style}
|
||
Tone: ${ZINE_TONES[tone] || tone}
|
||
|
||
Create a structured outline for a zine about: "${topic}"
|
||
|
||
Each page has sections that will be generated independently. Return ONLY valid JSON (no markdown fences):
|
||
{
|
||
"pages": [
|
||
{
|
||
"pageNumber": 1,
|
||
"type": "cover",
|
||
"title": "...",
|
||
"sections": [
|
||
{ "id": "headline", "type": "text", "content": "Main headline text" },
|
||
{ "id": "subhead", "type": "text", "content": "Subtitle or tagline" },
|
||
{ "id": "hero", "type": "image", "imagePrompt": "Detailed image description for cover art" }
|
||
],
|
||
"hashtags": ["#tag1"]
|
||
},
|
||
{
|
||
"pageNumber": 2,
|
||
"type": "content",
|
||
"title": "...",
|
||
"sections": [
|
||
{ "id": "heading", "type": "text", "content": "Section heading" },
|
||
{ "id": "body", "type": "text", "content": "Main body text (2-3 paragraphs)" },
|
||
{ "id": "pullquote", "type": "text", "content": "A punchy pull quote" },
|
||
{ "id": "visual", "type": "image", "imagePrompt": "Detailed description for page illustration" }
|
||
],
|
||
"hashtags": ["#tag"]
|
||
}
|
||
]
|
||
}
|
||
|
||
Rules:
|
||
- Page 1 is always "cover" type with headline, subhead, hero image
|
||
- Pages 2-${pageCount - 1} are "content" with heading, body, pullquote, visual
|
||
- Page ${pageCount} is "cta" (call-to-action) with heading, body, action, visual
|
||
- Each image prompt should be highly specific, matching the ${style} style
|
||
- Text content should match the ${tone} tone
|
||
- Make body text substantive (2-3 short paragraphs each)`;
|
||
|
||
try {
|
||
const result = await model.generateContent(systemPrompt);
|
||
const text = result.response.text();
|
||
// Parse JSON (strip markdown fences if present)
|
||
const jsonStr = text.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim();
|
||
const outline = JSON.parse(jsonStr);
|
||
return c.json(outline);
|
||
} catch (e: any) {
|
||
console.error("[zine/outline] error:", e.message);
|
||
return c.json({ error: "Failed to generate outline" }, 502);
|
||
}
|
||
});
|
||
|
||
app.post("/api/zine/page", async (c) => {
|
||
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
|
||
|
||
const { outline, style = "punk-zine", tone = "informative" } = await c.req.json();
|
||
if (!outline?.pageNumber) return c.json({ error: "outline required with pageNumber" }, 400);
|
||
|
||
// Find all image sections and generate them
|
||
const imageSections = (outline.sections || []).filter((s: any) => s.type === "image");
|
||
const generatedImages: Record<string, string> = {};
|
||
|
||
const { GoogleGenAI } = await import("@google/genai");
|
||
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
|
||
|
||
for (const section of imageSections) {
|
||
const styleDesc = ZINE_STYLES[style] || style;
|
||
const toneDesc = ZINE_TONES[tone] || tone;
|
||
const enhancedPrompt = `${styleDesc} style, ${toneDesc} mood. ${section.imagePrompt}. For a zine page about "${outline.title}".`;
|
||
|
||
try {
|
||
const result = await ai.models.generateContent({
|
||
model: "gemini-2.5-flash-image",
|
||
contents: enhancedPrompt,
|
||
config: { responseModalities: ["Text", "Image"] },
|
||
});
|
||
|
||
const parts = result.candidates?.[0]?.content?.parts || [];
|
||
for (const part of parts) {
|
||
if ((part as any).inlineData) {
|
||
const { data: b64, mimeType } = (part as any).inlineData;
|
||
const ext = mimeType?.includes("png") ? "png" : "jpg";
|
||
const filename = `zine-p${outline.pageNumber}-${section.id}-${Date.now()}.${ext}`;
|
||
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
|
||
await Bun.write(resolve(dir, filename), Buffer.from(b64, "base64"));
|
||
pinGeneratedFile(resolve(dir, filename), filename);
|
||
generatedImages[section.id] = `/data/files/generated/${filename}`;
|
||
break;
|
||
}
|
||
}
|
||
} catch (e: any) {
|
||
console.error(`[zine/page] Image gen failed for ${section.id}:`, e.message);
|
||
}
|
||
}
|
||
|
||
return c.json({
|
||
pageNumber: outline.pageNumber,
|
||
images: generatedImages,
|
||
outline,
|
||
});
|
||
});
|
||
|
||
app.post("/api/zine/regenerate-section", async (c) => {
|
||
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
|
||
|
||
const { section, pageTitle, style = "punk-zine", tone = "informative", feedback = "" } = await c.req.json();
|
||
if (!section?.id) return c.json({ error: "section with id required" }, 400);
|
||
|
||
if (section.type === "image") {
|
||
// Regenerate image with feedback
|
||
const { GoogleGenAI } = await import("@google/genai");
|
||
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
|
||
|
||
const styleDesc = ZINE_STYLES[style] || style;
|
||
const toneDesc = ZINE_TONES[tone] || tone;
|
||
const enhancedPrompt = `${styleDesc} style, ${toneDesc} mood. ${section.imagePrompt}. For a zine page about "${pageTitle}".${feedback ? ` User feedback: ${feedback}` : ""}`;
|
||
|
||
try {
|
||
const result = await ai.models.generateContent({
|
||
model: "gemini-2.5-flash-image",
|
||
contents: enhancedPrompt,
|
||
config: { responseModalities: ["Text", "Image"] },
|
||
});
|
||
|
||
const parts = result.candidates?.[0]?.content?.parts || [];
|
||
for (const part of parts) {
|
||
if ((part as any).inlineData) {
|
||
const { data: b64, mimeType } = (part as any).inlineData;
|
||
const ext = mimeType?.includes("png") ? "png" : "jpg";
|
||
const filename = `zine-regen-${section.id}-${Date.now()}.${ext}`;
|
||
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
|
||
await Bun.write(resolve(dir, filename), Buffer.from(b64, "base64"));
|
||
pinGeneratedFile(resolve(dir, filename), filename);
|
||
return c.json({ sectionId: section.id, type: "image", url: `/data/files/generated/${filename}` });
|
||
}
|
||
}
|
||
return c.json({ error: "No image generated" }, 502);
|
||
} catch (e: any) {
|
||
console.error("[zine/regenerate-section] image error:", e.message);
|
||
return c.json({ error: "Image regeneration failed" }, 502);
|
||
}
|
||
}
|
||
|
||
if (section.type === "text") {
|
||
// Regenerate text via Gemini
|
||
const { GoogleGenerativeAI } = await import("@google/generative-ai");
|
||
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
|
||
const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" });
|
||
|
||
const toneDesc = ZINE_TONES[tone] || tone;
|
||
const prompt = `Rewrite this zine text section. Section type: "${section.id}". Page title: "${pageTitle}". Tone: ${toneDesc}.
|
||
Current text: "${section.content}"
|
||
${feedback ? `User feedback: ${feedback}` : "Make it punchier and more engaging."}
|
||
|
||
Return ONLY the new text, no quotes or explanation.`;
|
||
|
||
try {
|
||
const result = await model.generateContent(prompt);
|
||
return c.json({ sectionId: section.id, type: "text", content: result.response.text().trim() });
|
||
} catch (e: any) {
|
||
console.error("[zine/regenerate-section] text error:", e.message);
|
||
return c.json({ error: "Text regeneration failed" }, 502);
|
||
}
|
||
}
|
||
|
||
return c.json({ error: `Unknown section type: ${section.type}` }, 400);
|
||
});
|
||
|
||
// ── Auto-provision personal space ──
|
||
app.post("/api/spaces/auto-provision", async (c) => {
|
||
const token = extractToken(c.req.raw.headers);
|
||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||
|
||
let claims: EncryptIDClaims;
|
||
try {
|
||
claims = await verifyToken(token);
|
||
} catch {
|
||
return c.json({ error: "Invalid or expired token" }, 401);
|
||
}
|
||
|
||
const username = claims.username?.toLowerCase();
|
||
if (!username || !/^[a-z0-9][a-z0-9-]*$/.test(username)) {
|
||
return c.json({ error: "Username not suitable for space slug" }, 400);
|
||
}
|
||
|
||
if (await communityExists(username)) {
|
||
return c.json({ status: "exists", slug: username });
|
||
}
|
||
|
||
const result = await createSpace({
|
||
name: `${claims.username}'s Space`,
|
||
slug: username,
|
||
ownerDID: claims.sub,
|
||
visibility: "private",
|
||
source: 'auto-provision',
|
||
});
|
||
if (!result.ok) return c.json({ error: result.error }, result.status);
|
||
|
||
return c.json({ status: "created", slug: username }, 201);
|
||
});
|
||
|
||
// ── Inject space visibility into HTML responses ──
|
||
// Replaces the default data-space-visibility="public" rendered by renderShell
|
||
// with the actual visibility from the space config, so the client-side access gate
|
||
// can block content for private spaces when no session exists.
|
||
app.use("/:space/*", async (c, next) => {
|
||
await next();
|
||
const ct = c.res.headers.get("content-type");
|
||
if (!ct?.includes("text/html")) return;
|
||
const space = c.req.param("space");
|
||
if (!space || space === "api" || space.includes(".")) return;
|
||
const config = await getSpaceConfig(space);
|
||
const vis = config?.visibility || "private";
|
||
if (vis === "private") return; // Shell already defaults to private
|
||
const html = await c.res.text();
|
||
c.res = new Response(
|
||
html.replace(
|
||
'data-space-visibility="private"',
|
||
`data-space-visibility="${vis}"`,
|
||
),
|
||
{ status: c.res.status, headers: c.res.headers },
|
||
);
|
||
});
|
||
|
||
// ── Template seeding route: /:space/:moduleId/template ──
|
||
// Triggers template seeding for empty spaces, then redirects to the module page.
|
||
app.get("/:space/:moduleId/template", async (c) => {
|
||
const space = c.req.param("space");
|
||
const moduleId = c.req.param("moduleId");
|
||
if (!space || space === "api" || space.includes(".")) return c.notFound();
|
||
|
||
try {
|
||
await loadCommunity(space);
|
||
const seeded = seedTemplateShapes(space);
|
||
if (seeded) {
|
||
broadcastAutomergeSync(space);
|
||
broadcastJsonSnapshot(space);
|
||
}
|
||
// Seed module-specific demo data (calendar events, notes, tasks, etc.)
|
||
const mod = getModule(moduleId);
|
||
if (mod?.seedTemplate) mod.seedTemplate(space);
|
||
} catch (e) {
|
||
console.error(`[Template] On-demand seed failed for "${space}":`, e);
|
||
}
|
||
|
||
const redirectPath = c.get("isSubdomain") ? `/${moduleId}` : `/${space}/${moduleId}`;
|
||
return c.redirect(redirectPath, 302);
|
||
});
|
||
|
||
// ── Empty-state detection for onboarding ──
|
||
|
||
import type { RSpaceModule } from "../shared/module";
|
||
import { resolveDataSpace } from "../shared/scope-resolver";
|
||
|
||
function moduleHasData(_space: string, _mod: RSpaceModule): boolean {
|
||
// Always show the module's own UI — let each module handle its empty state.
|
||
// Previously demo returned true while live spaces checked for CRDT docs,
|
||
// causing live spaces to show a generic onboarding page instead of the module.
|
||
return true;
|
||
}
|
||
|
||
// ── Mount module routes under /:space/:moduleId ──
|
||
// Enforce enabledModules: if a space has an explicit list, only those modules route.
|
||
// The 'rspace' (canvas) module is always allowed as the core module.
|
||
for (const mod of getAllModules()) {
|
||
app.use(`/:space/${mod.id}/*`, async (c, next) => {
|
||
const space = c.req.param("space");
|
||
if (!space || space === "api" || space.includes(".")) return next();
|
||
|
||
// Check enabled modules (skip for core rspace module)
|
||
await loadCommunity(space);
|
||
const doc = getDocumentData(space);
|
||
if (mod.id !== "rspace") {
|
||
if (doc?.meta?.enabledModules && !doc.meta.enabledModules.includes(mod.id)) {
|
||
// Redirect browser navigations to space root; return JSON error for API calls
|
||
const accept = c.req.header("Accept") || "";
|
||
if (accept.includes("text/html")) {
|
||
return c.redirect(`https://${space}.rspace.online/`);
|
||
}
|
||
return c.json({ error: "Module not enabled for this space" }, 404);
|
||
}
|
||
}
|
||
|
||
// Resolve effective data space (global vs space-scoped)
|
||
const overrides = doc?.meta?.moduleScopeOverrides ?? null;
|
||
const effectiveSpace = resolveDataSpace(mod.id, space, overrides);
|
||
c.set("effectiveSpace", effectiveSpace);
|
||
|
||
// ── Access control for private/permissioned spaces (API requests only) ──
|
||
// HTML page navigations are gated client-side (tokens live in localStorage,
|
||
// not cookies, so the server can't check auth on browser navigations).
|
||
// The WebSocket gate prevents data sync for non-members regardless.
|
||
const vis = doc?.meta?.visibility || "private";
|
||
const accept = c.req.header("Accept") || "";
|
||
const isHtmlRequest = accept.includes("text/html");
|
||
|
||
// Exempt public-facing endpoints that are designed for unauthenticated users
|
||
const pathname = new URL(c.req.url).pathname;
|
||
const isPublicEndpoint = pathname.endsWith("/api/flows/user-onramp")
|
||
|| pathname.endsWith("/api/onramp/config")
|
||
|| pathname.endsWith("/api/transak/config")
|
||
|| pathname.endsWith("/api/transak/webhook")
|
||
|| pathname.endsWith("/api/coinbase/webhook")
|
||
|| pathname.endsWith("/api/ramp/webhook")
|
||
|| pathname.includes("/rcart/api/payments")
|
||
|| pathname.includes("/rcart/pay/")
|
||
|| pathname.includes("/rwallet/api/")
|
||
|| pathname.includes("/rdesign/api/")
|
||
|| pathname.includes("/rtasks/api/")
|
||
|| (c.req.method === "GET" && pathname.includes("/rvote/api/"));
|
||
|
||
if (!isHtmlRequest && !isPublicEndpoint && (vis === "private" || vis === "permissioned")) {
|
||
const token = extractToken(c.req.raw.headers);
|
||
if (!token) {
|
||
return c.json({ error: "Authentication required" }, 401);
|
||
}
|
||
let claims: EncryptIDClaims | null = null;
|
||
try { claims = await verifyToken(token); } catch {}
|
||
if (!claims) {
|
||
return c.json({ error: "Authentication required" }, 401);
|
||
}
|
||
if (vis === "private") {
|
||
const resolved = await resolveCallerRole(space, claims);
|
||
if (resolved) {
|
||
c.set("spaceRole", resolved.role);
|
||
c.set("isOwner", resolved.isOwner);
|
||
}
|
||
if (!resolved || (!resolved.isOwner && resolved.role === "viewer")) {
|
||
return c.json({ error: "You don't have access to this space" }, 403);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Resolve caller's role for write-method blocking
|
||
const method = c.req.method;
|
||
if (!mod.publicWrite && (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE")) {
|
||
// Skip if already resolved above for private spaces
|
||
if (!c.get("spaceRole")) {
|
||
const token = extractToken(c.req.raw.headers);
|
||
let claims: EncryptIDClaims | null = null;
|
||
if (token) {
|
||
try { claims = await verifyToken(token); } catch {}
|
||
}
|
||
const resolved = await resolveCallerRole(space, claims);
|
||
if (resolved) {
|
||
c.set("spaceRole", resolved.role);
|
||
c.set("isOwner", resolved.isOwner);
|
||
if (resolved.role === "viewer") {
|
||
return c.json({ error: "Write access required" }, 403);
|
||
}
|
||
}
|
||
} else {
|
||
const role = c.get("spaceRole") as SpaceRoleString | undefined;
|
||
if (role === "viewer") {
|
||
return c.json({ error: "Write access required" }, 403);
|
||
}
|
||
}
|
||
}
|
||
|
||
return next();
|
||
});
|
||
// Onboarding + enabledModules check for root path (root page only)
|
||
if (mod.id !== "rspace") {
|
||
app.use(`/:space/${mod.id}`, async (c, next) => {
|
||
const space = c.req.param("space");
|
||
if (!space || space === "api" || space.includes(".")) return next();
|
||
|
||
// Block disabled modules (same check as sub-path middleware)
|
||
await loadCommunity(space);
|
||
const spaceDoc = getDocumentData(space);
|
||
if (spaceDoc?.meta?.enabledModules && !spaceDoc.meta.enabledModules.includes(mod.id)) {
|
||
const accept = c.req.header("Accept") || "";
|
||
if (accept.includes("text/html")) {
|
||
const redir = c.get("isSubdomain") ? "/rspace" : `/${space}/rspace`;
|
||
return c.redirect(redir);
|
||
}
|
||
return c.json({ error: "Module not enabled for this space" }, 404);
|
||
}
|
||
|
||
if (space === "demo") return next();
|
||
if (c.req.method !== "GET") return next();
|
||
const path = c.req.path;
|
||
const root = `/${space}/${mod.id}`;
|
||
if (path !== root && path !== root + '/') return next();
|
||
if (!moduleHasData(space, mod)) {
|
||
return c.html(renderOnboarding({
|
||
moduleId: mod.id,
|
||
moduleName: mod.name,
|
||
moduleIcon: mod.icon,
|
||
moduleDescription: mod.description,
|
||
spaceSlug: space,
|
||
modules: getModuleInfoList(),
|
||
landingHTML: mod.landingPage?.(),
|
||
onboardingActions: mod.onboardingActions,
|
||
}));
|
||
}
|
||
return next();
|
||
});
|
||
}
|
||
// Fragment mode: signal renderShell to return lightweight JSON instead of full shell HTML.
|
||
// Used by TabCache for fast tab switching — skips the entire 2000-line shell template.
|
||
app.use(`/:space/${mod.id}`, async (c, next) => {
|
||
if (c.req.method !== "GET" || !c.req.query("fragment")) return next();
|
||
// Set the global flag so renderShell returns JSON fragment
|
||
setFragmentMode(true);
|
||
await next();
|
||
// renderShell already returned JSON — fix the content-type header
|
||
if (c.res.headers.get("content-type")?.includes("text/html")) {
|
||
const body = await c.res.text();
|
||
c.res = new Response(body, {
|
||
status: c.res.status,
|
||
headers: { "content-type": "application/json" },
|
||
});
|
||
}
|
||
});
|
||
app.route(`/:space/${mod.id}`, mod.routes);
|
||
// Auto-mount browsable output list pages
|
||
if (mod.outputPaths) {
|
||
for (const op of mod.outputPaths) {
|
||
app.get(`/:space/${mod.id}/${op.path}`, (c) => {
|
||
return c.html(renderOutputListPage(
|
||
c.req.param("space"), mod, op, getModuleInfoList()
|
||
));
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Page routes ──
|
||
|
||
// Landing page: rspace.online/ → server-rendered main landing
|
||
app.get("/", (c) => {
|
||
c.header("Cache-Control", "no-cache, no-store, must-revalidate");
|
||
return c.html(renderMainLanding(getModuleInfoList()));
|
||
});
|
||
|
||
// About/info page (full landing content)
|
||
app.get("/about", async (c) => {
|
||
const file = Bun.file(resolve(DIST_DIR, "index.html"));
|
||
if (await file.exists()) {
|
||
return new Response(file, { headers: { "Content-Type": "text/html" } });
|
||
}
|
||
return c.text("rSpace", 200);
|
||
});
|
||
|
||
// Create new space page
|
||
app.get("/create-space", async (c) => {
|
||
const file = Bun.file(resolve(DIST_DIR, "create-space.html"));
|
||
if (await file.exists()) {
|
||
return new Response(file, { headers: { "Content-Type": "text/html" } });
|
||
}
|
||
return c.text("Create space", 200);
|
||
});
|
||
|
||
// Legacy redirect
|
||
app.get("/new", (c) => c.redirect("/create-space", 301));
|
||
|
||
// ── Admin dashboard & API ──
|
||
|
||
const ADMIN_DIDS = (process.env.ADMIN_DIDS || "").split(",").filter(Boolean);
|
||
|
||
function isAdminDID(did: string | undefined): boolean {
|
||
return !!did && ADMIN_DIDS.includes(did);
|
||
}
|
||
|
||
// Serve admin HTML page
|
||
app.get("/admin", async (c) => {
|
||
const file = Bun.file(resolve(DIST_DIR, "admin.html"));
|
||
if (await file.exists()) {
|
||
return new Response(file, { headers: { "Content-Type": "text/html" } });
|
||
}
|
||
return c.text("Admin", 200);
|
||
});
|
||
|
||
// Admin data endpoint (GET) — returns spaces list + modules
|
||
app.get("/admin-data", async (c) => {
|
||
const token = extractToken(c.req.raw.headers);
|
||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||
let claims: EncryptIDClaims;
|
||
try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||
if (!isAdminDID(claims.sub)) return c.json({ error: "Admin access required" }, 403);
|
||
|
||
const STORAGE_DIR = process.env.STORAGE_DIR || "./data/communities";
|
||
const slugs = await listCommunities();
|
||
|
||
const spacesList = [];
|
||
for (const slug of slugs) {
|
||
await loadCommunity(slug);
|
||
const data = getDocumentData(slug);
|
||
if (!data?.meta) continue;
|
||
|
||
const shapes = data.shapes || {};
|
||
const members = data.members || {};
|
||
const shapeCount = Object.keys(shapes).length;
|
||
const memberCount = Object.keys(members).length;
|
||
|
||
let fileSizeBytes = 0;
|
||
try {
|
||
const { stat } = await import("node:fs/promises");
|
||
const s = await stat(`${STORAGE_DIR}/${slug}.automerge`);
|
||
fileSizeBytes = s.size;
|
||
} catch {
|
||
try {
|
||
const { stat } = await import("node:fs/promises");
|
||
const s = await stat(`${STORAGE_DIR}/${slug}.json`);
|
||
fileSizeBytes = s.size;
|
||
} catch { /* not on disk yet */ }
|
||
}
|
||
|
||
const shapeTypes: Record<string, number> = {};
|
||
for (const shape of Object.values(shapes)) {
|
||
const t = (shape as Record<string, unknown>).type as string || "unknown";
|
||
shapeTypes[t] = (shapeTypes[t] || 0) + 1;
|
||
}
|
||
|
||
spacesList.push({
|
||
slug: data.meta.slug,
|
||
name: data.meta.name,
|
||
visibility: data.meta.visibility || "private",
|
||
createdAt: data.meta.createdAt,
|
||
ownerDID: data.meta.ownerDID,
|
||
shapeCount,
|
||
memberCount,
|
||
fileSizeBytes,
|
||
shapeTypes,
|
||
});
|
||
}
|
||
|
||
spacesList.sort((a, b) => {
|
||
const da = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||
const db = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||
return db - da;
|
||
});
|
||
|
||
return c.json({
|
||
spaces: spacesList,
|
||
total: spacesList.length,
|
||
modules: getModuleInfoList(),
|
||
});
|
||
});
|
||
|
||
// Admin action endpoint (POST) — mutations (delete space, etc.)
|
||
app.post("/admin-action", async (c) => {
|
||
const token = extractToken(c.req.raw.headers);
|
||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||
let claims: EncryptIDClaims;
|
||
try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||
if (!isAdminDID(claims.sub)) return c.json({ error: "Admin access required" }, 403);
|
||
|
||
const body = await c.req.json();
|
||
const { action, slug } = body;
|
||
|
||
if (action === "delete-space" && slug) {
|
||
await loadCommunity(slug);
|
||
const data = getDocumentData(slug);
|
||
if (!data) return c.json({ error: "Space not found" }, 404);
|
||
|
||
const deleteCtx = {
|
||
spaceSlug: slug,
|
||
ownerDID: data.meta?.ownerDID ?? null,
|
||
enabledModules: data.meta?.enabledModules || getAllModules().map(m => m.id),
|
||
syncServer,
|
||
};
|
||
for (const mod of getAllModules()) {
|
||
if (mod.onSpaceDelete) {
|
||
try { await mod.onSpaceDelete(deleteCtx); } catch (e) {
|
||
console.error(`[Admin] Module ${mod.id} onSpaceDelete failed:`, e);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Clean up EncryptID space_members
|
||
try {
|
||
await fetch(`https://auth.rspace.online/api/admin/spaces/${slug}/members`, {
|
||
method: "DELETE",
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
});
|
||
} catch (e) {
|
||
console.error(`[Admin] Failed to clean EncryptID space_members for ${slug}:`, e);
|
||
}
|
||
|
||
await deleteCommunity(slug);
|
||
return c.json({ ok: true, message: `Space "${slug}" deleted by admin` });
|
||
}
|
||
|
||
return c.json({ error: "Unknown action" }, 400);
|
||
});
|
||
|
||
// Space root: /:space → space dashboard
|
||
// Access control for private spaces is handled client-side (tokens in localStorage)
|
||
// and enforced at the WebSocket layer (prevents data sync for non-members).
|
||
app.get("/:space", (c) => {
|
||
const space = c.req.param("space");
|
||
// Don't serve dashboard for static file paths
|
||
if (space.includes(".")) return c.notFound();
|
||
// On production bare domain, this route is unreachable (caught by subdomain redirect above).
|
||
// On localhost/dev: redirect authenticated users to canvas, show dashboard for guests.
|
||
const token = extractToken(c.req.raw.headers);
|
||
if (token) {
|
||
return c.redirect(`/${space}/rspace`, 302);
|
||
}
|
||
// Demo space: show dashboard; all others: redirect to main landing
|
||
if (space === "demo") {
|
||
return c.html(renderSpaceDashboard(space, getModuleInfoList()));
|
||
}
|
||
return c.redirect("/", 302);
|
||
});
|
||
|
||
// ── WebSocket types ──
|
||
interface WSData {
|
||
communitySlug: string;
|
||
peerId: string;
|
||
claims: EncryptIDClaims | null;
|
||
readOnly: boolean;
|
||
spaceRole: 'viewer' | 'member' | 'moderator' | 'admin' | null;
|
||
mode: "automerge" | "json";
|
||
// Nest context: set when a folk-canvas shape connects to a nested space
|
||
nestFrom?: string; // slug of the parent space that contains the nest
|
||
nestPermissions?: NestPermissions; // effective permissions for this nested view
|
||
nestFilter?: SpaceRefFilter; // shape filter applied to this nested view
|
||
// Anonymous connection tracking
|
||
anonIp?: string; // set for anonymous connections, used for cleanup on close
|
||
}
|
||
|
||
// Track connected clients per community
|
||
const communityClients = new Map<string, Map<string, ServerWebSocket<WSData>>>();
|
||
|
||
// Track anonymous WebSocket connections per IP (max 3 per IP)
|
||
const wsAnonConnectionsByIP = new Map<string, number>();
|
||
const MAX_ANON_WS_PER_IP = 3;
|
||
|
||
// Track announced peer info per community: slug → serverPeerId → { clientPeerId, username, color }
|
||
const peerAnnouncements = new Map<string, Map<string, { clientPeerId: string; username: string; color: string }>>();
|
||
|
||
function generatePeerId(): string {
|
||
return `peer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||
}
|
||
|
||
function getClient(slug: string, peerId: string): ServerWebSocket<WSData> | undefined {
|
||
return communityClients.get(slug)?.get(peerId);
|
||
}
|
||
|
||
function broadcastJsonSnapshot(slug: string, excludePeerId?: string): void {
|
||
const clients = communityClients.get(slug);
|
||
if (!clients) return;
|
||
const docData = getDocumentData(slug);
|
||
if (!docData) return;
|
||
const allShapes = docData.shapes || {};
|
||
|
||
// Pre-build the unfiltered message (most clients use this)
|
||
let unfilteredMsg: string | null = null;
|
||
|
||
for (const [clientPeerId, client] of clients) {
|
||
if (client.data.mode === "json" && clientPeerId !== excludePeerId && client.readyState === WebSocket.OPEN) {
|
||
// Apply nest filter for nested connections
|
||
if (client.data.nestFilter) {
|
||
const filtered: typeof allShapes = {};
|
||
for (const [id, shape] of Object.entries(allShapes)) {
|
||
if (client.data.nestFilter.shapeTypes && !client.data.nestFilter.shapeTypes.includes(shape.type)) continue;
|
||
if (client.data.nestFilter.shapeIds && !client.data.nestFilter.shapeIds.includes(id)) continue;
|
||
filtered[id] = shape;
|
||
}
|
||
client.send(JSON.stringify({ type: "snapshot", shapes: filtered }));
|
||
} else {
|
||
if (!unfilteredMsg) unfilteredMsg = JSON.stringify({ type: "snapshot", shapes: allShapes });
|
||
client.send(unfilteredMsg);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function broadcastAutomergeSync(slug: string, excludePeerId?: string): void {
|
||
const clients = communityClients.get(slug);
|
||
if (!clients) return;
|
||
for (const [clientPeerId, client] of clients) {
|
||
if (client.data.mode === "automerge" && clientPeerId !== excludePeerId && client.readyState === WebSocket.OPEN) {
|
||
const syncMsg = generateSyncMessageForPeer(slug, clientPeerId);
|
||
if (syncMsg) {
|
||
client.send(JSON.stringify({ type: "sync", data: Array.from(syncMsg) }));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── System Clock ──
|
||
|
||
/** Broadcast a clock event to all connected clients across all communities. */
|
||
function broadcastClockEvent(channel: string, payload: ClockPayload): void {
|
||
const msg = JSON.stringify({ type: "clock", channel, payload });
|
||
for (const [_slug, clients] of communityClients) {
|
||
for (const [_peerId, client] of clients) {
|
||
if (client.readyState === WebSocket.OPEN) {
|
||
try {
|
||
client.send(msg);
|
||
} catch {
|
||
// Ignore send errors on closing sockets
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const systemClock = new SystemClock(broadcastClockEvent);
|
||
systemClock.start();
|
||
|
||
// ── Subdomain parsing (backward compat) ──
|
||
const RESERVED_SUBDOMAINS = ["www", "rspace", "create", "new", "start", "auth"];
|
||
|
||
function getSubdomain(host: string | null): string | null {
|
||
if (!host) return null;
|
||
if (host.includes("localhost") || host.includes("127.0.0.1")) return null;
|
||
const parts = host.split(".");
|
||
if (parts.length >= 3 && parts.slice(-2).join(".") === "rspace.online") {
|
||
const sub = parts[0];
|
||
if (!RESERVED_SUBDOMAINS.includes(sub)) return sub;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ── Auto-provision helpers ──
|
||
|
||
/** Extract JWT from Authorization header, then eid_token cookie, then encryptid_token cookie */
|
||
function extractTokenFromRequest(req: Request): string | null {
|
||
const auth = req.headers.get("authorization");
|
||
if (auth?.startsWith("Bearer ")) return auth.slice(7);
|
||
const cookie = req.headers.get("cookie");
|
||
if (cookie) {
|
||
const eidMatch = cookie.match(/(?:^|;\s*)eid_token=([^;]*)/);
|
||
if (eidMatch) return decodeURIComponent(eidMatch[1]);
|
||
const encMatch = cookie.match(/(?:^|;\s*)encryptid_token=([^;]*)/);
|
||
if (encMatch) return decodeURIComponent(encMatch[1]);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Base64-decode JWT payload to extract username (cheap pre-check, no crypto) */
|
||
function decodeJWTUsername(token: string): string | null {
|
||
try {
|
||
const parts = token.split(".");
|
||
if (parts.length < 2) return null;
|
||
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||
const pad = "=".repeat((4 - (b64.length % 4)) % 4);
|
||
const payload = JSON.parse(atob(b64 + pad));
|
||
return payload.username?.toLowerCase() || null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Tracks slugs currently being provisioned to prevent concurrent double-creation */
|
||
const provisioningInProgress = new Set<string>();
|
||
|
||
// ── Static file serving ──
|
||
function getContentType(path: string): string {
|
||
if (path.endsWith(".html")) return "text/html";
|
||
if (path.endsWith(".js")) return "application/javascript";
|
||
if (path.endsWith(".css")) return "text/css";
|
||
if (path.endsWith(".json")) return "application/json";
|
||
if (path.endsWith(".svg")) return "image/svg+xml";
|
||
if (path.endsWith(".wasm")) return "application/wasm";
|
||
if (path.endsWith(".png")) return "image/png";
|
||
if (path.endsWith(".jpg") || path.endsWith(".jpeg")) return "image/jpeg";
|
||
if (path.endsWith(".gif")) return "image/gif";
|
||
if (path.endsWith(".ico")) return "image/x-icon";
|
||
return "application/octet-stream";
|
||
}
|
||
|
||
async function serveStatic(path: string, url?: URL): Promise<Response | null> {
|
||
const filePath = resolve(DIST_DIR, path);
|
||
const file = Bun.file(filePath);
|
||
if (await file.exists()) {
|
||
const headers: Record<string, string> = { "Content-Type": getContentType(path) };
|
||
if (url?.searchParams.has("v")) {
|
||
headers["Cache-Control"] = "public, max-age=31536000, immutable";
|
||
} else if (path.endsWith(".html") || path === "sw.js" || path === "manifest.json") {
|
||
// HTML, service worker, and manifest must revalidate every time
|
||
headers["Cache-Control"] = "no-cache";
|
||
} else if (path.startsWith("assets/")) {
|
||
// Vite content-hashed assets are safe to cache long-term
|
||
headers["Cache-Control"] = "public, max-age=31536000, immutable";
|
||
}
|
||
return new Response(file, { headers });
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ── Module ID aliases (plural/misspelling → canonical) ──
|
||
const MODULE_ALIASES: Record<string, string> = { rsheet: "rsheets", auctions: "rauctions" };
|
||
function resolveModuleAlias(id: string): string { return MODULE_ALIASES[id] ?? id; }
|
||
|
||
// ── Standalone domain → module lookup ──
|
||
const domainToModule = new Map<string, string>();
|
||
for (const mod of getAllModules()) {
|
||
if (mod.standaloneDomain) {
|
||
domainToModule.set(mod.standaloneDomain, mod.id);
|
||
}
|
||
}
|
||
|
||
// ── Bun.serve: WebSocket + fetch delegation ──
|
||
const server = Bun.serve<WSData>({
|
||
port: PORT,
|
||
|
||
async fetch(req, server) {
|
||
const url = new URL(req.url);
|
||
const host = req.headers.get("host");
|
||
const hostClean = host?.split(":")[0] || "";
|
||
const subdomain = getSubdomain(host);
|
||
// TLS is terminated by Cloudflare/Traefik — url.protocol is always http: internally
|
||
const proto = hostClean.includes("rspace.online") || hostClean.includes(".online") ? "https:" : url.protocol;
|
||
|
||
// ── Standalone domain → 301 redirect to rspace.online ──
|
||
// Check both bare domain and subdomain variants (e.g. rnotes.online, alice.rnotes.online)
|
||
let standaloneModuleId = domainToModule.get(hostClean);
|
||
let standaloneSub: string | null = null;
|
||
if (!standaloneModuleId) {
|
||
// Check if this is a subdomain of a standalone domain (e.g. alice.rnotes.online)
|
||
const hostParts = hostClean.split(".");
|
||
if (hostParts.length >= 3) {
|
||
const baseDomain = hostParts.slice(-2).join(".");
|
||
const candidate = domainToModule.get(baseDomain);
|
||
if (candidate) {
|
||
standaloneModuleId = candidate;
|
||
standaloneSub = hostParts.slice(0, -2).join(".");
|
||
}
|
||
}
|
||
}
|
||
if (standaloneModuleId) {
|
||
// Self-fetch detection: landing proxy uses this User-Agent;
|
||
// return 404 to break circular fetch so the generic fallback is used
|
||
if (req.headers.get("user-agent") === "rSpace-Proxy/1.0") {
|
||
return new Response("Not found", { status: 404 });
|
||
}
|
||
|
||
// Determine the space and remaining path
|
||
const pathParts = url.pathname.split("/").filter(Boolean);
|
||
let space = standaloneSub || null; // subdomain on standalone domain = space
|
||
let remainingPath = "";
|
||
|
||
if (pathParts.length > 0 && !pathParts[0].includes(".") &&
|
||
pathParts[0] !== "api" && pathParts[0] !== "ws") {
|
||
// First path segment is the space (if no subdomain already set it)
|
||
if (!space) {
|
||
space = pathParts[0];
|
||
remainingPath = pathParts.length > 1 ? "/" + pathParts.slice(1).join("/") : "";
|
||
} else {
|
||
remainingPath = url.pathname;
|
||
}
|
||
} else {
|
||
remainingPath = url.pathname === "/" ? "" : url.pathname;
|
||
}
|
||
|
||
// Build redirect URL
|
||
let redirectUrl: string;
|
||
if (space) {
|
||
// Space-qualified: alice.rnotes.online/path or rnotes.online/alice/path
|
||
// → alice.rspace.online/rnotes/path
|
||
redirectUrl = `https://${space}.rspace.online/${standaloneModuleId}${remainingPath}`;
|
||
} else {
|
||
// No space: rnotes.online/ or rnotes.online/api/...
|
||
// → rspace.online/rnotes or rspace.online/rnotes/api/...
|
||
redirectUrl = `https://rspace.online/${standaloneModuleId}${remainingPath}`;
|
||
}
|
||
if (url.search) redirectUrl += url.search;
|
||
return Response.redirect(redirectUrl, 302);
|
||
}
|
||
|
||
// ── WebSocket upgrade ──
|
||
if (url.pathname.startsWith("/ws/")) {
|
||
const communitySlug = url.pathname.split("/")[2];
|
||
if (communitySlug) {
|
||
const spaceConfig = await getSpaceConfig(communitySlug);
|
||
const wsAuthOpts = process.env.JWT_SECRET ? { secret: process.env.JWT_SECRET } : {};
|
||
const claims = await authenticateWSUpgrade(req, wsAuthOpts);
|
||
let readOnly = false;
|
||
let spaceRole: WSData['spaceRole'] = null;
|
||
|
||
// Load doc early so we can check membership for private spaces
|
||
await loadCommunity(communitySlug);
|
||
const spaceData = getDocumentData(communitySlug);
|
||
|
||
if (spaceConfig) {
|
||
const vis = spaceConfig.visibility;
|
||
if (vis === "permissioned" || vis === "private") {
|
||
if (!claims) return new Response("Authentication required", { status: 401 });
|
||
}
|
||
// Reject non-members of private spaces
|
||
if (vis === "private" && claims && spaceData) {
|
||
const callerDid = (claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`;
|
||
const isOwner = spaceData.meta?.ownerDID === claims.sub || spaceData.meta?.ownerDID === callerDid;
|
||
let isMember = spaceData.members?.[claims.sub] || spaceData.members?.[callerDid];
|
||
// Fallback: check EncryptID space_members (handles identity-invite claims not yet synced to Automerge)
|
||
if (!isOwner && !isMember) {
|
||
try {
|
||
const memberRes = await fetch(`${ENCRYPTID_INTERNAL}/api/spaces/${encodeURIComponent(communitySlug)}/members/${encodeURIComponent(callerDid)}`);
|
||
if (memberRes.ok) {
|
||
const memberData = await memberRes.json() as { role: string; userDID: string };
|
||
// Sync to Automerge so future connections don't need the fallback
|
||
setMember(communitySlug, callerDid, memberData.role as any, (claims as any).username);
|
||
isMember = { did: callerDid, role: memberData.role as "admin" | "viewer" | "member" | "moderator", joinedAt: Date.now() };
|
||
}
|
||
} catch {}
|
||
}
|
||
if (!isOwner && !isMember) {
|
||
return new Response("You don't have access to this space", { status: 403 });
|
||
}
|
||
}
|
||
if (vis === "public") {
|
||
readOnly = !claims;
|
||
}
|
||
}
|
||
|
||
// Resolve the caller's space role (re-read doc in case setMember was called during fallback sync)
|
||
const spaceDataFresh = getDocumentData(communitySlug);
|
||
if (spaceDataFresh || spaceData) {
|
||
const sd = spaceDataFresh || spaceData;
|
||
const callerDid = claims ? ((claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`) : undefined;
|
||
if (claims && sd!.meta.ownerDID === claims.sub) {
|
||
spaceRole = 'admin';
|
||
} else if (claims && (sd!.members?.[claims.sub]?.role || (callerDid && sd!.members?.[callerDid]?.role))) {
|
||
spaceRole = sd!.members?.[claims.sub]?.role || sd!.members?.[callerDid!]?.role;
|
||
} else {
|
||
// Non-member defaults by visibility
|
||
const vis = spaceConfig?.visibility;
|
||
if (vis === 'public' && claims) spaceRole = 'member';
|
||
else if (vis === 'permissioned' && claims) spaceRole = 'viewer';
|
||
else if (claims) spaceRole = 'viewer';
|
||
else spaceRole = 'viewer'; // anonymous
|
||
}
|
||
}
|
||
|
||
// Enforce read-only for viewers
|
||
if (spaceRole === 'viewer') {
|
||
readOnly = true;
|
||
}
|
||
|
||
const peerId = generatePeerId();
|
||
const mode = url.searchParams.get("mode") === "json" ? "json" : "automerge";
|
||
|
||
// Nest context: if connecting from a parent space via folk-canvas
|
||
const nestFrom = url.searchParams.get("nest-from") || undefined;
|
||
let nestPermissions: NestPermissions | undefined;
|
||
let nestFilter: SpaceRefFilter | undefined;
|
||
|
||
if (nestFrom) {
|
||
await loadCommunity(nestFrom);
|
||
const parentData = getDocumentData(nestFrom);
|
||
if (parentData?.nestedSpaces) {
|
||
// Find the SpaceRef in the parent that points to this space
|
||
const matchingRef = Object.values(parentData.nestedSpaces)
|
||
.find(ref => ref.sourceSlug === communitySlug);
|
||
if (matchingRef) {
|
||
nestPermissions = matchingRef.permissions;
|
||
nestFilter = matchingRef.filter;
|
||
// If nest doesn't allow writes, force readOnly
|
||
if (!matchingRef.permissions.write) {
|
||
readOnly = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Anonymous WebSocket connection cap (max 3 per IP)
|
||
let anonIp: string | undefined;
|
||
if (!claims) {
|
||
const wsIp = req.headers.get("cf-connecting-ip")
|
||
|| req.headers.get("x-forwarded-for")?.split(",")[0].trim()
|
||
|| "unknown";
|
||
const current = wsAnonConnectionsByIP.get(wsIp) || 0;
|
||
if (current >= MAX_ANON_WS_PER_IP) {
|
||
return new Response("Too many anonymous connections", { status: 429 });
|
||
}
|
||
wsAnonConnectionsByIP.set(wsIp, current + 1);
|
||
anonIp = wsIp;
|
||
}
|
||
|
||
const upgraded = server.upgrade(req, {
|
||
data: { communitySlug, peerId, claims, readOnly, spaceRole, mode, nestFrom, nestPermissions, nestFilter, anonIp } as WSData,
|
||
});
|
||
if (upgraded) return undefined;
|
||
|
||
// Upgrade failed — roll back anon counter
|
||
if (anonIp) {
|
||
const count = wsAnonConnectionsByIP.get(anonIp) || 1;
|
||
if (count <= 1) wsAnonConnectionsByIP.delete(anonIp);
|
||
else wsAnonConnectionsByIP.set(anonIp, count - 1);
|
||
}
|
||
}
|
||
return new Response("WebSocket upgrade failed", { status: 400 });
|
||
}
|
||
|
||
// ── Explicit page routes (before Hono, to avoid /:space catch-all) ──
|
||
if (url.pathname === "/admin") {
|
||
const adminHtml = await serveStatic("admin.html");
|
||
if (adminHtml) return adminHtml;
|
||
}
|
||
|
||
// ── favicon.ico → favicon.png fallback ──
|
||
if (url.pathname === "/favicon.ico") {
|
||
const staticResponse = await serveStatic("favicon.png");
|
||
if (staticResponse) return staticResponse;
|
||
}
|
||
|
||
// ── 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) {
|
||
// ── Guard: module IDs are not space slugs ──
|
||
// Redirect rcred.rspace.online → demo.rspace.online/rcred (not a space)
|
||
const allModuleIds = new Set(getAllModules().map(m => m.id));
|
||
if (allModuleIds.has(subdomain.toLowerCase())) {
|
||
const pathSuffix = url.pathname === "/" ? "" : url.pathname;
|
||
return Response.redirect(`${proto}//demo.rspace.online/${subdomain}${pathSuffix}${url.search}`, 302);
|
||
}
|
||
|
||
// ── Auto-provision personal space on first visit ──
|
||
// Fast path: communityExists is an in-memory Map check for cached spaces.
|
||
if (!(await communityExists(subdomain))) {
|
||
const token = extractTokenFromRequest(req);
|
||
if (token) {
|
||
const jwtUsername = decodeJWTUsername(token);
|
||
if (jwtUsername === subdomain && !provisioningInProgress.has(subdomain)) {
|
||
provisioningInProgress.add(subdomain);
|
||
try {
|
||
const claims = await verifyToken(token);
|
||
const username = claims.username?.toLowerCase();
|
||
if (username === subdomain && !(await communityExists(subdomain))) {
|
||
await createSpace({
|
||
name: `${claims.username}'s Space`,
|
||
slug: subdomain,
|
||
ownerDID: claims.sub,
|
||
visibility: "private",
|
||
source: 'subdomain',
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.error(`[AutoProvision] Token verification failed for ${subdomain}:`, e);
|
||
} finally {
|
||
provisioningInProgress.delete(subdomain);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const pathSegments = url.pathname.split("/").filter(Boolean);
|
||
|
||
// Root: redirect authenticated users straight to rSpace canvas
|
||
// (avoids rendering space dashboard just to JS-redirect, eliminating
|
||
// the flash of different header + 2-3 redirect chain)
|
||
if (pathSegments.length === 0) {
|
||
const token = extractTokenFromRequest(req);
|
||
if (token) {
|
||
return Response.redirect(`${proto}//${url.host}/rspace`, 302);
|
||
}
|
||
// Demo space: show dashboard; all others: redirect to main landing
|
||
if (subdomain === "demo") {
|
||
return new Response(renderSpaceDashboard(subdomain, getModuleInfoList()), {
|
||
headers: { "Content-Type": "text/html" },
|
||
});
|
||
}
|
||
return Response.redirect(`${proto}//rspace.online/`, 302);
|
||
}
|
||
|
||
// Global routes pass through without subdomain prefix
|
||
if (
|
||
url.pathname.startsWith("/api/") ||
|
||
url.pathname.startsWith("/data/") ||
|
||
url.pathname.startsWith("/encryptid/") ||
|
||
url.pathname.startsWith("/rtasks/check/") ||
|
||
url.pathname.startsWith("/respond/") ||
|
||
url.pathname.startsWith("/.well-known/") ||
|
||
url.pathname === "/about" ||
|
||
url.pathname === "/admin" ||
|
||
url.pathname === "/create-space" ||
|
||
url.pathname === "/new"
|
||
) {
|
||
return app.fetch(req);
|
||
}
|
||
|
||
// Static assets (paths with file extensions) pass through
|
||
if (pathSegments[0].includes(".")) {
|
||
return app.fetch(req);
|
||
}
|
||
|
||
// Template seeding: /{moduleId}/template → seed + redirect
|
||
if (pathSegments.length >= 2 && pathSegments[pathSegments.length - 1] === "template") {
|
||
const moduleId = pathSegments[0].toLowerCase();
|
||
try {
|
||
await loadCommunity(subdomain);
|
||
const seeded = seedTemplateShapes(subdomain);
|
||
if (seeded) {
|
||
broadcastAutomergeSync(subdomain);
|
||
broadcastJsonSnapshot(subdomain);
|
||
}
|
||
// Seed module-specific demo data
|
||
const mod = getModule(moduleId);
|
||
if (mod?.seedTemplate) mod.seedTemplate(subdomain);
|
||
} catch (e) {
|
||
console.error(`[Template] On-demand seed failed for "${subdomain}":`, e);
|
||
}
|
||
const withoutTemplate = pathSegments.slice(0, -1).join("/");
|
||
return Response.redirect(`${proto}//${url.host}/${withoutTemplate}`, 302);
|
||
}
|
||
|
||
// Demo route: /{moduleId}/demo → rewrite to /demo/{moduleId}
|
||
if (pathSegments.length >= 2 && pathSegments[pathSegments.length - 1] === "demo") {
|
||
const moduleId = pathSegments[0].toLowerCase();
|
||
const mod = getModule(moduleId);
|
||
if (mod) {
|
||
const rewrittenPath = `/demo/${moduleId}`;
|
||
const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`);
|
||
return app.fetch(new Request(rewrittenUrl, req));
|
||
}
|
||
}
|
||
|
||
// If first segment matches the subdomain (space slug), the URL has a
|
||
// redundant /{space}/... prefix. For HTML navigation, redirect to strip
|
||
// it so the address bar never shows the space slug. For API/fetch
|
||
// requests, silently rewrite to avoid breaking client-side fetches.
|
||
// e.g. demo.rspace.online/demo/rspace → 302 → demo.rspace.online/rspace
|
||
if (pathSegments[0].toLowerCase() === subdomain) {
|
||
const accept = req.headers.get("Accept") || "";
|
||
if (accept.includes("text/html") && !url.pathname.includes("/api/")) {
|
||
const cleanPath = "/" + pathSegments.slice(1).join("/") + url.search;
|
||
return Response.redirect(`https://${host}${cleanPath}`, 302);
|
||
}
|
||
// API/fetch — rewrite internally
|
||
const rewrittenUrl = new URL(url.pathname + url.search, `http://localhost:${PORT}`);
|
||
return app.fetch(new Request(rewrittenUrl, req));
|
||
}
|
||
|
||
// Block disabled modules before rewriting — redirect to space root
|
||
const firstModId = resolveModuleAlias(pathSegments[0].toLowerCase());
|
||
if (firstModId !== "rspace") {
|
||
await loadCommunity(subdomain);
|
||
const spaceDoc = getDocumentData(subdomain);
|
||
if (spaceDoc?.meta?.enabledModules && !spaceDoc.meta.enabledModules.includes(firstModId)) {
|
||
return Response.redirect(`https://${subdomain}.rspace.online/`, 302);
|
||
}
|
||
}
|
||
|
||
// Normalize module ID to lowercase + resolve aliases (rTrips → rtrips, rsheet → rsheets)
|
||
const normalizedPath = "/" + pathSegments.map((seg, i) =>
|
||
i === 0 ? resolveModuleAlias(seg.toLowerCase()) : seg
|
||
).join("/");
|
||
|
||
// Rewrite: /{moduleId}/... → /{space}/{moduleId}/...
|
||
// e.g. demo.rspace.online/vote/api/polls → /demo/vote/api/polls
|
||
const rewrittenPath = `/${subdomain}${normalizedPath}`;
|
||
const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`);
|
||
const rewrittenReq = new Request(rewrittenUrl, req);
|
||
const subdomainResponse = await app.fetch(rewrittenReq);
|
||
|
||
// If 404 on an HTML request for a known module sub-path, serve the
|
||
// module shell so client-side SPA routing can handle it
|
||
if (subdomainResponse.status === 404 && pathSegments.length >= 2) {
|
||
const accept = req.headers.get("Accept") || "";
|
||
if (accept.includes("text/html") && !normalizedPath.includes("/api/")) {
|
||
const moduleRoot = `/${subdomain}/${resolveModuleAlias(pathSegments[0].toLowerCase())}`;
|
||
const shellUrl = new URL(moduleRoot, `http://localhost:${PORT}`);
|
||
const shellResponse = await app.fetch(new Request(shellUrl, {
|
||
headers: req.headers,
|
||
method: "GET",
|
||
}));
|
||
if (shellResponse.status === 200) return shellResponse;
|
||
}
|
||
}
|
||
|
||
return subdomainResponse;
|
||
}
|
||
|
||
// ── Bare-domain routing: rspace.online/{...} ──
|
||
// Only match canonical bare domain, not stacked subdomains like rspace.rspace.online
|
||
if (!subdomain && (hostClean === "rspace.online" || hostClean === "www.rspace.online")) {
|
||
// Top-level routes that must bypass module rewriting
|
||
if (url.pathname.startsWith("/rtasks/check/") || url.pathname.startsWith("/respond/")) {
|
||
return app.fetch(req);
|
||
}
|
||
|
||
const pathSegments = url.pathname.split("/").filter(Boolean);
|
||
if (pathSegments.length >= 1) {
|
||
const firstSegment = resolveModuleAlias(pathSegments[0].toLowerCase());
|
||
const allModules = getAllModules();
|
||
const knownModuleIds = new Set(allModules.map((m) => m.id));
|
||
const mod = allModules.find((m) => m.id === firstSegment);
|
||
|
||
if (mod) {
|
||
// rspace.online/{moduleId} → go straight to demo interactive app
|
||
if (pathSegments.length === 1) {
|
||
const rewrittenPath = `/demo/${firstSegment}`;
|
||
const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`);
|
||
return app.fetch(new Request(rewrittenUrl, req));
|
||
}
|
||
// rspace.online/{moduleId}/sub-path
|
||
const secondSegment = pathSegments[1]?.toLowerCase();
|
||
|
||
// 1. API routes always pass through to demo
|
||
if (secondSegment === "api") {
|
||
const normalizedPath = "/" + [firstSegment, ...pathSegments.slice(1)].join("/");
|
||
const rewrittenPath = `/demo${normalizedPath}`;
|
||
const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`);
|
||
return app.fetch(new Request(rewrittenUrl, req));
|
||
}
|
||
|
||
// 2. If sub-path matches a subPageInfo → render info page
|
||
const subPageInfo = mod.subPageInfos?.find(sp => sp.path === secondSegment);
|
||
if (subPageInfo) {
|
||
const html = renderSubPageInfo({
|
||
subPage: subPageInfo,
|
||
module: mod,
|
||
modules: getModuleInfoList(),
|
||
});
|
||
return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
|
||
}
|
||
|
||
// 3. Fallback: rewrite to demo space (backward compat)
|
||
const normalizedPath = "/" + [firstSegment, ...pathSegments.slice(1)].join("/");
|
||
const rewrittenPath = `/demo${normalizedPath}`;
|
||
const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`);
|
||
const rewrittenReq = new Request(rewrittenUrl, req);
|
||
return app.fetch(rewrittenReq);
|
||
}
|
||
|
||
// rspace.online/{space} or rspace.online/{space}/{...} → redirect to subdomain
|
||
// (space is not a module ID — it's a space slug)
|
||
// Skip for known server paths (api, admin, etc.)
|
||
const serverPaths = new Set(["api", "admin", "admin-data", "admin-action", "modules", ".well-known", "about", "create-space", "new", "discover"]);
|
||
if (!knownModuleIds.has(firstSegment) && !serverPaths.has(firstSegment)) {
|
||
const space = firstSegment;
|
||
if (pathSegments.length >= 2) {
|
||
const secondSeg = pathSegments[1]?.toLowerCase();
|
||
const isApiCall = secondSeg === "api" || pathSegments.some((s, i) => i >= 1 && s === "api");
|
||
if (isApiCall) {
|
||
// API calls: rewrite internally (avoid redirect + mixed-content)
|
||
const rewrittenUrl = new URL(url.pathname + url.search, `http://localhost:${PORT}`);
|
||
return app.fetch(new Request(rewrittenUrl, req));
|
||
}
|
||
// Page navigation: redirect to canonical subdomain URL
|
||
const rest = "/" + pathSegments.slice(1).join("/");
|
||
return Response.redirect(
|
||
`${proto}//${space}.rspace.online${rest}${url.search}`, 301
|
||
);
|
||
}
|
||
// Single segment: rspace.online/{space} → {space}.rspace.online/
|
||
return Response.redirect(`${proto}//${space}.rspace.online/${url.search}`, 301);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Hono handles everything else ──
|
||
const response = await app.fetch(req);
|
||
|
||
// If Hono returns 404, serve the module shell for client-side routing
|
||
// (module sub-paths like /demo/rcal/settings are handled by the SPA router)
|
||
if (response.status === 404 && !url.pathname.startsWith("/api/")) {
|
||
const accept = req.headers.get("Accept") || "";
|
||
const parts = url.pathname.split("/").filter(Boolean);
|
||
|
||
if (accept.includes("text/html") && parts.length >= 1 && !parts[0].includes(".")) {
|
||
const knownModuleIds = getAllModules().map((m) => m.id);
|
||
|
||
// Subdomain requests: URL is /{moduleId}/... (space is the subdomain)
|
||
// Bare-domain requests: URL is /{space}/{moduleId}/...
|
||
const moduleId = subdomain
|
||
? resolveModuleAlias(parts[0].toLowerCase())
|
||
: parts.length >= 2 ? parts[1].toLowerCase() : null;
|
||
const space = subdomain || parts[0];
|
||
|
||
if (moduleId && knownModuleIds.includes(moduleId)) {
|
||
// Module sub-path: rewrite to the module root so the shell renders
|
||
// and the client-side SPA router handles the sub-path
|
||
const moduleRoot = `/${space}/${moduleId}`;
|
||
const rewrittenUrl = new URL(moduleRoot, `http://localhost:${PORT}`);
|
||
const shellResponse = await app.fetch(new Request(rewrittenUrl, {
|
||
headers: req.headers,
|
||
method: "GET",
|
||
}));
|
||
if (shellResponse.status === 200) return shellResponse;
|
||
}
|
||
|
||
// Non-module path — try canvas/index SPA fallback
|
||
const canvasHtml = await serveStatic("canvas.html");
|
||
if (canvasHtml) return canvasHtml;
|
||
const indexHtml = await serveStatic("index.html");
|
||
if (indexHtml) return indexHtml;
|
||
}
|
||
}
|
||
|
||
return response;
|
||
},
|
||
|
||
// ── WebSocket handlers (unchanged) ──
|
||
websocket: {
|
||
open(ws: ServerWebSocket<WSData>) {
|
||
const { communitySlug, peerId, mode, nestFrom, nestFilter, claims } = ws.data;
|
||
|
||
if (!communityClients.has(communitySlug)) {
|
||
communityClients.set(communitySlug, new Map());
|
||
}
|
||
communityClients.get(communitySlug)!.set(peerId, ws);
|
||
|
||
// Register with DocSyncManager for multi-doc sync
|
||
syncServer.addPeer(peerId, ws, claims ? { sub: claims.sub, username: claims.username } : undefined);
|
||
|
||
// Register for notification delivery
|
||
if (claims?.sub) registerUserConnection(claims.sub, ws);
|
||
|
||
const nestLabel = nestFrom ? ` (nested from ${nestFrom})` : "";
|
||
console.log(`[WS] Client ${peerId} connected to ${communitySlug} (mode: ${mode})${nestLabel}`);
|
||
|
||
loadCommunity(communitySlug).then((doc) => {
|
||
if (!doc) return;
|
||
// Guard: peer may have disconnected during async load
|
||
if (ws.readyState !== WebSocket.OPEN) return;
|
||
|
||
if (mode === "json") {
|
||
const docData = getDocumentData(communitySlug);
|
||
if (docData) {
|
||
let shapes = docData.shapes || {};
|
||
|
||
// Apply nest filter if this is a nested connection
|
||
if (nestFilter) {
|
||
const filtered: typeof shapes = {};
|
||
for (const [id, shape] of Object.entries(shapes)) {
|
||
if (nestFilter.shapeTypes && !nestFilter.shapeTypes.includes(shape.type)) continue;
|
||
if (nestFilter.shapeIds && !nestFilter.shapeIds.includes(id)) continue;
|
||
filtered[id] = shape;
|
||
}
|
||
shapes = filtered;
|
||
}
|
||
|
||
ws.send(JSON.stringify({ type: "snapshot", shapes }));
|
||
}
|
||
} else {
|
||
const syncMessage = generateSyncMessageForPeer(communitySlug, peerId);
|
||
if (syncMessage) {
|
||
ws.send(JSON.stringify({ type: "sync", data: Array.from(syncMessage) }));
|
||
}
|
||
}
|
||
});
|
||
},
|
||
|
||
message(ws: ServerWebSocket<WSData>, message: string | Buffer) {
|
||
const { communitySlug, peerId } = ws.data;
|
||
|
||
try {
|
||
const msg = JSON.parse(message.toString());
|
||
|
||
// ── DocSyncManager protocol (messages with docId) ──
|
||
if (msg.type === "subscribe" || msg.type === "unsubscribe" || msg.type === "awareness") {
|
||
if (msg.docIds || msg.docId) {
|
||
syncServer.handleMessage(peerId, message.toString());
|
||
return;
|
||
}
|
||
}
|
||
if (msg.type === "sync" && msg.docId) {
|
||
// New protocol: sync with docId → route to SyncServer
|
||
syncServer.handleMessage(peerId, message.toString());
|
||
return;
|
||
}
|
||
|
||
// ── Yjs collaboration relay (pure relay — server doesn't parse Yjs binary) ──
|
||
if (msg.type === "yjs-sync" || msg.type === "yjs-awareness") {
|
||
const clients = communityClients.get(communitySlug);
|
||
if (clients) {
|
||
const relay = JSON.stringify({ ...msg, peerId });
|
||
for (const [cid, client] of clients) {
|
||
if (cid !== peerId && client.readyState === WebSocket.OPEN) {
|
||
client.send(relay);
|
||
}
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// ── Legacy canvas protocol (no docId) ──
|
||
if (msg.type === "sync" && Array.isArray(msg.data)) {
|
||
if (ws.data.readOnly) {
|
||
ws.send(JSON.stringify({ type: "error", message: "Authentication required to edit this space" }));
|
||
return;
|
||
}
|
||
const syncMessage = new Uint8Array(msg.data);
|
||
const result = receiveSyncMessage(communitySlug, peerId, syncMessage);
|
||
|
||
if (result.response) {
|
||
ws.send(JSON.stringify({ type: "sync", data: Array.from(result.response) }));
|
||
}
|
||
|
||
for (const [targetPeerId, targetMessage] of result.broadcastToPeers) {
|
||
const targetClient = getClient(communitySlug, targetPeerId);
|
||
if (targetClient && targetClient.data.mode === "automerge" && targetClient.readyState === WebSocket.OPEN) {
|
||
targetClient.send(JSON.stringify({ type: "sync", data: Array.from(targetMessage) }));
|
||
}
|
||
}
|
||
|
||
if (result.broadcastToPeers.size > 0) {
|
||
broadcastJsonSnapshot(communitySlug, peerId);
|
||
}
|
||
} else if (msg.type === "ping") {
|
||
ws.send(JSON.stringify({ type: "pong", timestamp: msg.timestamp }));
|
||
} else if (msg.type === "presence" || msg.type === "presence-leave") {
|
||
const clients = communityClients.get(communitySlug);
|
||
if (clients) {
|
||
const presenceMsg = JSON.stringify({ ...msg, peerId });
|
||
for (const [clientPeerId, client] of clients) {
|
||
if (clientPeerId !== peerId && client.readyState === WebSocket.OPEN) {
|
||
client.send(presenceMsg);
|
||
}
|
||
}
|
||
}
|
||
} else if (msg.type === "announce") {
|
||
// Client announcing its identity for the people-online panel
|
||
const { peerId: clientPeerId, username, color } = msg;
|
||
if (!clientPeerId || !username) return;
|
||
|
||
let slugAnnouncements = peerAnnouncements.get(communitySlug);
|
||
if (!slugAnnouncements) {
|
||
slugAnnouncements = new Map();
|
||
peerAnnouncements.set(communitySlug, slugAnnouncements);
|
||
}
|
||
slugAnnouncements.set(peerId, { clientPeerId, username, color });
|
||
|
||
// Send full peer list back to the announcing client
|
||
const peerList = Array.from(slugAnnouncements.values());
|
||
ws.send(JSON.stringify({ type: "peer-list", peers: peerList }));
|
||
|
||
// Broadcast peer-joined to all other clients
|
||
const joinedMsg = JSON.stringify({ type: "peer-joined", peerId: clientPeerId, username, color });
|
||
const clients2 = communityClients.get(communitySlug);
|
||
if (clients2) {
|
||
for (const [cPeerId, client] of clients2) {
|
||
if (cPeerId !== peerId && client.readyState === WebSocket.OPEN) {
|
||
client.send(joinedMsg);
|
||
}
|
||
}
|
||
}
|
||
} else if (msg.type === "ping-user") {
|
||
// Relay a "come here" ping to a specific peer
|
||
const { targetPeerId: targetClientPeerId, viewport } = msg;
|
||
const slugAnnouncements = peerAnnouncements.get(communitySlug);
|
||
const senderInfo = slugAnnouncements?.get(peerId);
|
||
if (!slugAnnouncements || !senderInfo) return;
|
||
|
||
// Find the server peerId that matches the target client peerId
|
||
const clients3 = communityClients.get(communitySlug);
|
||
if (clients3) {
|
||
for (const [serverPid, _client] of clients3) {
|
||
const ann = slugAnnouncements.get(serverPid);
|
||
if (ann && ann.clientPeerId === targetClientPeerId && _client.readyState === WebSocket.OPEN) {
|
||
_client.send(JSON.stringify({
|
||
type: "ping-user",
|
||
fromPeerId: senderInfo.clientPeerId,
|
||
fromUsername: senderInfo.username,
|
||
viewport,
|
||
}));
|
||
|
||
// Persist ping as a notification
|
||
const targetDid = _client.data.claims?.sub;
|
||
if (targetDid && ws.data.claims?.sub) {
|
||
notify({
|
||
userDid: targetDid,
|
||
category: 'social',
|
||
eventType: 'ping_user',
|
||
title: `${senderInfo.username} pinged you in "${communitySlug}"`,
|
||
spaceSlug: communitySlug,
|
||
actorDid: ws.data.claims.sub,
|
||
actorUsername: senderInfo.username,
|
||
actionUrl: `/rspace`,
|
||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24h
|
||
}).catch(() => {});
|
||
}
|
||
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
} else if (msg.type === "update" && msg.id && msg.data) {
|
||
if (ws.data.readOnly) {
|
||
ws.send(JSON.stringify({ type: "error", message: "Authentication required to edit" }));
|
||
return;
|
||
}
|
||
// Nest-level: check addShapes for new shapes
|
||
if (ws.data.nestPermissions) {
|
||
const existingDoc = getDocumentData(communitySlug);
|
||
const isNewShape = existingDoc && !existingDoc.shapes?.[msg.id];
|
||
if (isNewShape && !ws.data.nestPermissions.addShapes) {
|
||
ws.send(JSON.stringify({ type: "error", message: "Nest permissions do not allow adding shapes" }));
|
||
return;
|
||
}
|
||
}
|
||
updateShape(communitySlug, msg.id, msg.data);
|
||
broadcastJsonSnapshot(communitySlug, peerId);
|
||
broadcastAutomergeSync(communitySlug, peerId);
|
||
} else if (msg.type === "delete" && msg.id) {
|
||
if (ws.data.readOnly) {
|
||
ws.send(JSON.stringify({ type: "error", message: "Authentication required to delete" }));
|
||
return;
|
||
}
|
||
if (ws.data.nestPermissions && !ws.data.nestPermissions.deleteShapes) {
|
||
ws.send(JSON.stringify({ type: "error", message: "Nest permissions do not allow deleting shapes" }));
|
||
return;
|
||
}
|
||
forgetShape(communitySlug, msg.id, ws.data.claims?.sub);
|
||
broadcastJsonSnapshot(communitySlug, peerId);
|
||
broadcastAutomergeSync(communitySlug, peerId);
|
||
} else if (msg.type === "forget" && msg.id) {
|
||
if (ws.data.readOnly) {
|
||
ws.send(JSON.stringify({ type: "error", message: "Authentication required to forget" }));
|
||
return;
|
||
}
|
||
if (ws.data.nestPermissions && !ws.data.nestPermissions.deleteShapes) {
|
||
ws.send(JSON.stringify({ type: "error", message: "Nest permissions do not allow forgetting shapes" }));
|
||
return;
|
||
}
|
||
forgetShape(communitySlug, msg.id, ws.data.claims?.sub);
|
||
broadcastJsonSnapshot(communitySlug, peerId);
|
||
broadcastAutomergeSync(communitySlug, peerId);
|
||
} else if (msg.type === "remember" && msg.id) {
|
||
if (ws.data.readOnly) {
|
||
ws.send(JSON.stringify({ type: "error", message: "Authentication required to remember" }));
|
||
return;
|
||
}
|
||
rememberShape(communitySlug, msg.id);
|
||
broadcastJsonSnapshot(communitySlug, peerId);
|
||
broadcastAutomergeSync(communitySlug, peerId);
|
||
}
|
||
} catch (e) {
|
||
console.error("[WS] Failed to parse message:", e);
|
||
}
|
||
},
|
||
|
||
close(ws: ServerWebSocket<WSData>) {
|
||
const { communitySlug, peerId, claims } = ws.data;
|
||
|
||
// Decrement anonymous WS connection counter
|
||
if (ws.data.anonIp) {
|
||
const count = wsAnonConnectionsByIP.get(ws.data.anonIp) || 1;
|
||
if (count <= 1) wsAnonConnectionsByIP.delete(ws.data.anonIp);
|
||
else wsAnonConnectionsByIP.set(ws.data.anonIp, count - 1);
|
||
}
|
||
|
||
// Unregister from notification delivery
|
||
if (claims?.sub) unregisterUserConnection(claims.sub, ws);
|
||
|
||
// Broadcast peer-left before cleanup
|
||
const slugAnnouncements = peerAnnouncements.get(communitySlug);
|
||
if (slugAnnouncements) {
|
||
const ann = slugAnnouncements.get(peerId);
|
||
if (ann) {
|
||
const leftMsg = JSON.stringify({ type: "peer-left", peerId: ann.clientPeerId });
|
||
const clients = communityClients.get(communitySlug);
|
||
if (clients) {
|
||
for (const [cPeerId, client] of clients) {
|
||
if (cPeerId !== peerId && client.readyState === WebSocket.OPEN) {
|
||
client.send(leftMsg);
|
||
}
|
||
}
|
||
}
|
||
slugAnnouncements.delete(peerId);
|
||
if (slugAnnouncements.size === 0) peerAnnouncements.delete(communitySlug);
|
||
}
|
||
}
|
||
|
||
const clients = communityClients.get(communitySlug);
|
||
if (clients) {
|
||
clients.delete(peerId);
|
||
if (clients.size === 0) communityClients.delete(communitySlug);
|
||
}
|
||
removePeerSyncState(communitySlug, peerId);
|
||
syncServer.removePeer(peerId);
|
||
console.log(`[WS] Client ${peerId} disconnected from ${communitySlug}`);
|
||
},
|
||
},
|
||
});
|
||
|
||
// ── Startup ──
|
||
|
||
// Ensure generated files directory exists
|
||
import { mkdirSync } from "node:fs";
|
||
try { mkdirSync(resolve(process.env.FILES_DIR || "./data/files", "generated"), { recursive: true }); } catch {}
|
||
|
||
import { initTokenService, seedCUSDC } from "./token-service";
|
||
|
||
// IMPORTANT: Load persisted docs FIRST, then run module onInit + seeding.
|
||
// Previously onInit ran before loadAllDocs, causing seed functions to see
|
||
// empty docs and re-create demo data that users had already deleted.
|
||
ensureDemoCommunity().then(() => console.log("[Demo] Demo community ready")).catch((e) => console.error("[Demo] Failed:", e));
|
||
loadAllDocs(syncServer)
|
||
.then(async () => {
|
||
// Now that persisted docs are loaded, init modules (which may seed)
|
||
for (const mod of getAllModules()) {
|
||
if (mod.onInit) {
|
||
try {
|
||
await mod.onInit({ syncServer });
|
||
console.log(`[Init] ${mod.name} initialized`);
|
||
} catch (e) {
|
||
console.error(`[Init] ${mod.name} failed:`, e);
|
||
}
|
||
}
|
||
}
|
||
// Pass syncServer to OAuth handlers
|
||
setNotionOAuthSyncServer(syncServer);
|
||
setGoogleOAuthSyncServer(syncServer);
|
||
setClickUpOAuthSyncServer(syncServer);
|
||
setOAuthStatusSyncServer(syncServer);
|
||
|
||
// Seed all modules' demo data so /demo routes always have content
|
||
for (const mod of getAllModules()) {
|
||
if (mod.seedTemplate) {
|
||
try { mod.seedTemplate("demo"); } catch { /* already seeded */ }
|
||
}
|
||
}
|
||
console.log("[Demo] All module demo data seeded");
|
||
|
||
// Initialize CRDT token service and seed cUSDC
|
||
initTokenService(syncServer);
|
||
try {
|
||
await seedCUSDC();
|
||
} catch (e) {
|
||
console.error("[TokenService] Seed failed:", e);
|
||
}
|
||
|
||
// Provision Mailcow forwarding aliases for all existing spaces
|
||
const ENCRYPTID_INTERNAL_FOR_ALIAS = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000";
|
||
try {
|
||
const slugs = await listCommunities();
|
||
let count = 0;
|
||
for (const slug of slugs) {
|
||
if (slug === "demo") continue;
|
||
try {
|
||
await fetch(`${ENCRYPTID_INTERNAL_FOR_ALIAS}/api/internal/spaces/${slug}/alias`, { method: "POST" });
|
||
count++;
|
||
} catch { /* encryptid not ready yet, will provision on next restart */ }
|
||
}
|
||
if (count > 0) console.log(`[SpaceAlias] Provisioned ${count} space aliases`);
|
||
} catch (e) {
|
||
console.error("[SpaceAlias] Startup provisioning failed:", e);
|
||
}
|
||
})
|
||
.catch((e) => console.error("[DocStore] Startup load failed:", e));
|
||
|
||
// Restore relay mode for encrypted spaces + one-shot visibility fixes
|
||
(async () => {
|
||
try {
|
||
const slugs = await listCommunities();
|
||
let relayCount = 0;
|
||
for (const slug of slugs) {
|
||
await loadCommunity(slug);
|
||
const data = getDocumentData(slug);
|
||
if (data?.meta?.encrypted) {
|
||
syncServer.setRelayOnly(slug, true);
|
||
relayCount++;
|
||
}
|
||
}
|
||
if (relayCount > 0) {
|
||
console.log(`[Encryption] ${relayCount} space(s) set to relay mode (encrypted)`);
|
||
}
|
||
|
||
// One-shot: fix spaces that should be permissioned
|
||
const fixPermissioned = ["clf", "bcrg"];
|
||
for (const slug of fixPermissioned) {
|
||
const data = getDocumentData(slug);
|
||
if (data && data.meta?.visibility !== "permissioned") {
|
||
updateSpaceMeta(slug, { visibility: "permissioned" });
|
||
console.log(`[VisFix] Set ${slug} to permissioned (was: ${data.meta?.visibility})`);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error("[Encryption] Failed to restore relay modes:", e);
|
||
}
|
||
})();
|
||
|
||
console.log(`rSpace unified server running on http://localhost:${PORT}`);
|
||
console.log(`Modules: ${getAllModules().map((m) => `${m.icon} ${m.name}`).join(", ")}`);
|
||
|
||
// Start sidecar lifecycle manager — stops idle containers after 5min
|
||
startIdleWatcher();
|