/** * 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 { 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 { 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"; // ── 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(bnbModule); registerModule(vnbModule); registerModule(crowdsurfModule); registerModule(timeModule); registerModule(govModule); // Governance decision circuits registerModule(exchangeModule); // P2P crypto/fiat exchange 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(); // 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 app.use("/api/*", cors()); // ── .well-known/webauthn (WebAuthn Related Origins) ── // Browsers enforce a 5 eTLD+1 limit. Only list domains where passkey // ceremonies happen directly. Must match encryptid/server.ts priority list. app.get("/.well-known/webauthn", (c) => { return c.json( { origins: [ "https://ridentity.online", // 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 = { 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(); /** 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(); app.get("/api/link-preview", async (c) => { const url = c.req.query("url"); if (!url) return c.json({ error: "Missing url parameter" }, 400); try { new URL(url); } catch { return c.json({ error: "Invalid URL" }, 400); } // Check cache (1 hour TTL) const cached = linkPreviewCache.get(url); if (cached && Date.now() - cached.fetchedAt < 3600_000) { return c.json(cached, 200, { "Cache-Control": "public, max-age=3600" }); } try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); const res = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0 (compatible; rSpace/1.0; +https://rspace.online)", "Accept": "text/html" }, signal: controller.signal, redirect: "follow", }); clearTimeout(timeout); if (!res.ok) return c.json({ error: "Fetch failed" }, 502); const contentType = res.headers.get("content-type") || ""; if (!contentType.includes("text/html")) { const domain = new URL(url).hostname.replace(/^www\./, ""); const result = { title: url, description: "", image: null, domain, fetchedAt: Date.now() }; linkPreviewCache.set(url, result); return c.json(result); } const html = await res.text(); const getMetaContent = (nameOrProp: string): string => { const re = new RegExp(`]*(?:name|property)=["']${nameOrProp}["'][^>]*content=["']([^"']*?)["']`, "i"); const re2 = new RegExp(`]*content=["']([^"']*?)["'][^>]*(?:name|property)=["']${nameOrProp}["']`, "i"); return re.exec(html)?.[1] || re2.exec(html)?.[1] || ""; }; const ogTitle = getMetaContent("og:title") || getMetaContent("twitter:title"); const titleMatch = html.match(/]*>([^<]*)<\/title>/i); const title = ogTitle || titleMatch?.[1]?.trim() || url; const description = getMetaContent("og:description") || getMetaContent("twitter:description") || getMetaContent("description"); let image = getMetaContent("og:image") || getMetaContent("twitter:image") || null; if (image && !image.startsWith("http")) { try { image = new URL(image, url).href; } catch { image = null; } } const domain = new URL(url).hostname.replace(/^www\./, ""); const result = { title, description, image, domain, fetchedAt: Date.now() }; linkPreviewCache.set(url, result); // Cap cache size if (linkPreviewCache.size > 500) { const oldest = [...linkPreviewCache.entries()].sort((a, b) => a[1].fetchedAt - b[1].fetchedAt); for (let i = 0; i < 100; i++) linkPreviewCache.delete(oldest[i][0]); } return c.json(result, 200, { "Cache-Control": "public, max-age=3600" }); } catch (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(); app.get("/api/ecosystem/:appId/manifest", async (c) => { const appId = c.req.param("appId"); // Known ecosystem app origins const ECOSYSTEM_ORIGINS: Record = { rwallet: "https://rwallet.online", rvote: "https://rvote.online", rmaps: "https://rmaps.online", rsocials: "https://rsocials.online", }; const origin = ECOSYSTEM_ORIGINS[appId]; if (!origin) { return c.json({ error: "Unknown ecosystem app" }, 404); } // Check cache (1 hour TTL) const cached = ecosystemManifestCache.get(appId); if (cached && Date.now() - cached.fetchedAt < 3600_000) { return c.json(cached.data, 200, { "Cache-Control": "public, max-age=3600" }); } try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); const manifestUrl = `${origin}/.well-known/rspace-manifest.json`; const res = await fetch(manifestUrl, { headers: { "Accept": "application/json", "User-Agent": "rSpace-Ecosystem/1.0" }, signal: controller.signal, redirect: "error", // reject redirects — prevents allowlist bypass }); clearTimeout(timeout); if (!res.ok) return c.json({ error: "Failed to fetch manifest" }, 502); const data = await res.json(); // Resolve relative moduleUrl to absolute if (data.moduleUrl && !data.moduleUrl.startsWith("http")) { data.resolvedModuleUrl = `${origin}${data.moduleUrl.startsWith("/") ? "" : "/"}${data.moduleUrl}`; } else { data.resolvedModuleUrl = data.moduleUrl; } data.origin = origin; data.fetchedAt = Date.now(); ecosystemManifestCache.set(appId, { data, fetchedAt: Date.now() }); // Cap cache size if (ecosystemManifestCache.size > 50) { const oldest = [...ecosystemManifestCache.entries()].sort((a, b) => a[1].fetchedAt - b[1].fetchedAt); ecosystemManifestCache.delete(oldest[0][0]); } return c.json(data, 200, { "Cache-Control": "public, max-age=3600" }); } catch { return c.json({ error: "Failed to fetch manifest" }, 502); } }); // ── Ecosystem module proxy (CORS avoidance for JS modules) ── app.get("/api/ecosystem/:appId/module", async (c) => { const appId = c.req.param("appId"); // Fetch manifest first to get module URL const cached = ecosystemManifestCache.get(appId); if (!cached) { return c.json({ error: "Fetch manifest first via /api/ecosystem/:appId/manifest" }, 400); } const manifest = cached.data as Record; const moduleUrl = (manifest.resolvedModuleUrl || manifest.moduleUrl) as string; if (!moduleUrl) return c.json({ error: "No moduleUrl in manifest" }, 400); try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10_000); const res = await fetch(moduleUrl, { headers: { "Accept": "application/javascript", "User-Agent": "rSpace-Ecosystem/1.0" }, signal: controller.signal, redirect: "error", // reject redirects — prevents allowlist bypass }); clearTimeout(timeout); if (!res.ok) return c.json({ error: "Failed to fetch module" }, 502); const js = await res.text(); return new Response(js, { headers: { "Content-Type": "application/javascript", "Cache-Control": "public, max-age=86400", "Access-Control-Allow-Origin": "*", }, }); } catch { return c.json({ error: "Failed to fetch module" }, 502); } }); // ── Space registry API ── app.route("/api/spaces", spaces); // ── Backup API (encrypted blob storage) ── app.route("/api/backup", backupRouter); // ── 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.route("/api/mcp", createMcpRouter(syncServer)); // ── Magic Link Responses (top-level, bypasses space auth) ── app.route("/respond", magicLinkRoutes); // ── EncryptID proxy (forward /encryptid/* to encryptid container) ── const ENCRYPTID_INTERNAL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000"; 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 { 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", "; 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[] }>(); 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>(); 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() : `

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

`; 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([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, `

${preview}

View comment

`, ); }) .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((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(); // 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; } const videoGenJobs = new Map(); // 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 { const MODEL = job.type === "i2v" ? "fal-ai/kling-video/v1/standard/image-to-video" : "fal-ai/wan-t2v"; const body = job.type === "i2v" ? { image_url: job.sourceImage, prompt: job.prompt || "", duration: 5, aspect_ratio: "16:9" } : { prompt: job.prompt, num_frames: 81, resolution: "480p" }; // 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 { request_id } = await submitRes.json() as { request_id: string }; // Poll for completion (up to 10 min) const deadline = Date.now() + 600_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; 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 | 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 ", to: SPLAT_NOTIFY_EMAIL, subject: `Your 3D splat "${title}" is ready — rSplat`, html: `

Your 3D model is ready!

Your AI-generated 3D model "${title}" has finished processing.

View & Download

Or save it to your gallery at ${SITE_URL}/rsplat

Generated by rSplat on rSpace

`, }); 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 { const filename = serverPath.split("/").pop(); if (!filename) throw new Error("Invalid path"); const dir = resolve(process.env.FILES_DIR || "./data/files", "generated"); const file = Bun.file(resolve(dir, filename)); if (!(await file.exists())) throw new Error("File not found: " + serverPath); const buf = await file.arrayBuffer(); return Buffer.from(buf).toString("base64"); } /** Save a data:image/... URL to disk → return server-relative URL */ async function saveDataUrlToDisk(dataUrl: string, prefix: string): Promise { const match = dataUrl.match(/^data:image\/(\w+);base64,(.+)$/); if (!match) throw new Error("Invalid data URL"); const ext = match[1] === "jpeg" ? "jpg" : match[1]; const b64 = match[2]; const filename = `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.${ext}`; const dir = resolve(process.env.FILES_DIR || "./data/files", "generated"); await Bun.write(resolve(dir, filename), Buffer.from(b64, "base64")); 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 = { html: "Use plain HTML and CSS only. No external dependencies.", tailwind: "Use Tailwind CSS via CDN (). Use Tailwind utility classes for all styling.", react: "Use React via CDN (react, react-dom, babel-standalone). Include a single