rspace-online/server/index.ts

644 lines
22 KiB
TypeScript

/**
* rSpace Unified Server
*
* Hono-based HTTP router + Bun WebSocket handler.
* Mounts module routes under /:space/:moduleId.
* Preserves backward-compatible subdomain routing and /api/communities/* API.
*/
import { resolve } from "node:path";
import { Hono } from "hono";
import { cors } from "hono/cors";
import type { ServerWebSocket } from "bun";
import {
addShapes,
clearShapes,
communityExists,
createCommunity,
forgetShape,
rememberShape,
generateSyncMessageForPeer,
getDocumentData,
loadCommunity,
receiveSyncMessage,
removePeerSyncState,
updateShape,
updateShapeFields,
} from "./community-store";
import { ensureDemoCommunity } from "./seed-demo";
import { ensureCampaignDemo } from "./seed-campaign";
import type { SpaceVisibility } from "./community-store";
import {
verifyEncryptIDToken,
evaluateSpaceAccess,
extractToken,
authenticateWSUpgrade,
} from "@encryptid/sdk/server";
import type { EncryptIDClaims, SpaceAuthConfig } from "@encryptid/sdk/server";
// ── Module system ──
import { registerModule, getAllModules, getModuleInfoList } from "../shared/module";
import { canvasModule } from "../modules/canvas/mod";
import { booksModule } from "../modules/books/mod";
import { pubsModule } from "../modules/pubs/mod";
import { cartModule } from "../modules/cart/mod";
import { providersModule } from "../modules/providers/mod";
import { swagModule } from "../modules/swag/mod";
import { choicesModule } from "../modules/choices/mod";
import { fundsModule } from "../modules/funds/mod";
import { filesModule } from "../modules/files/mod";
import { forumModule } from "../modules/forum/mod";
import { walletModule } from "../modules/wallet/mod";
import { voteModule } from "../modules/vote/mod";
import { notesModule } from "../modules/notes/mod";
import { mapsModule } from "../modules/maps/mod";
import { workModule } from "../modules/work/mod";
import { tripsModule } from "../modules/trips/mod";
import { calModule } from "../modules/cal/mod";
import { networkModule } from "../modules/network/mod";
import { tubeModule } from "../modules/tube/mod";
import { inboxModule } from "../modules/inbox/mod";
import { dataModule } from "../modules/data/mod";
import { conicModule } from "../modules/conic/mod";
import { splatModule } from "../modules/splat/mod";
import { spaces } from "./spaces";
import { renderShell } from "./shell";
// Register modules
registerModule(canvasModule);
registerModule(booksModule);
registerModule(pubsModule);
registerModule(cartModule);
registerModule(providersModule);
registerModule(swagModule);
registerModule(choicesModule);
registerModule(fundsModule);
registerModule(filesModule);
registerModule(forumModule);
registerModule(walletModule);
registerModule(voteModule);
registerModule(notesModule);
registerModule(mapsModule);
registerModule(workModule);
registerModule(tripsModule);
registerModule(calModule);
registerModule(networkModule);
registerModule(tubeModule);
registerModule(inboxModule);
registerModule(dataModule);
registerModule(conicModule);
registerModule(splatModule);
// ── Config ──
const PORT = Number(process.env.PORT) || 3000;
const INTERNAL_API_KEY = process.env.INTERNAL_API_KEY || "";
const DIST_DIR = resolve(import.meta.dir, "../dist");
// ── Hono app ──
const app = new Hono();
// CORS for API routes
app.use("/api/*", cors());
// ── .well-known/webauthn (WebAuthn Related Origins) ──
app.get("/.well-known/webauthn", (c) => {
return c.json(
{
origins: [
"https://rwallet.online",
"https://rvote.online",
"https://rmaps.online",
"https://rfiles.online",
"https://rnotes.online",
],
},
200,
{
"Access-Control-Allow-Origin": "*",
"Cache-Control": "public, max-age=3600",
}
);
});
// ── Space registry API ──
app.route("/api/spaces", spaces);
// ── Existing /api/communities/* routes (backward compatible) ──
/** Resolve a community slug to SpaceAuthConfig for the SDK guard */
async function getSpaceConfig(slug: string): Promise<SpaceAuthConfig | null> {
let doc = getDocumentData(slug);
if (!doc) {
await loadCommunity(slug);
doc = getDocumentData(slug);
}
if (!doc) return null;
return {
spaceSlug: slug,
visibility: (doc.meta.visibility || "public_read") as SpaceVisibility,
ownerDID: doc.meta.ownerDID || undefined,
app: "rspace",
};
}
// Demo reset rate limiter
let lastDemoReset = 0;
const DEMO_RESET_COOLDOWN = 5 * 60 * 1000;
// POST /api/communities — create community
app.post("/api/communities", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required to create a community" }, 401);
let claims: EncryptIDClaims;
try {
claims = await verifyEncryptIDToken(token);
} catch {
return c.json({ error: "Invalid or expired authentication token" }, 401);
}
const body = await c.req.json<{ name?: string; slug?: string; visibility?: SpaceVisibility }>();
const { name, slug, visibility = "public_read" } = body;
if (!name || !slug) return c.json({ error: "Name and slug are required" }, 400);
if (!/^[a-z0-9-]+$/.test(slug)) return c.json({ error: "Slug must contain only lowercase letters, numbers, and hyphens" }, 400);
const validVisibilities: SpaceVisibility[] = ["public", "public_read", "authenticated", "members_only"];
if (!validVisibilities.includes(visibility)) return c.json({ error: `Invalid visibility` }, 400);
if (await communityExists(slug)) return c.json({ error: "Community already exists" }, 409);
await createCommunity(name, slug, claims.sub, visibility);
// Notify modules
for (const mod of getAllModules()) {
if (mod.onSpaceCreate) {
try { await mod.onSpaceCreate(slug); } catch (e) { console.error(`Module ${mod.id} onSpaceCreate:`, e); }
}
}
return c.json({ url: `https://${slug}.rspace.online`, slug, name, visibility, ownerDID: claims.sub }, 201);
});
// POST /api/communities/demo/reset
app.post("/api/communities/demo/reset", async (c) => {
const now = Date.now();
if (now - lastDemoReset < DEMO_RESET_COOLDOWN) {
const remaining = Math.ceil((DEMO_RESET_COOLDOWN - (now - lastDemoReset)) / 1000);
return c.json({ error: `Demo reset on cooldown. Try again in ${remaining}s` }, 429);
}
lastDemoReset = now;
await loadCommunity("demo");
clearShapes("demo");
await ensureDemoCommunity();
broadcastAutomergeSync("demo");
broadcastJsonSnapshot("demo");
return c.json({ ok: true, message: "Demo community reset to seed data" });
});
// POST /api/communities/campaign-demo/reset
app.post("/api/communities/campaign-demo/reset", async (c) => {
const now = Date.now();
if (now - lastDemoReset < DEMO_RESET_COOLDOWN) {
const remaining = Math.ceil((DEMO_RESET_COOLDOWN - (now - lastDemoReset)) / 1000);
return c.json({ error: `Reset on cooldown. Try again in ${remaining}s` }, 429);
}
lastDemoReset = now;
await loadCommunity("campaign-demo");
clearShapes("campaign-demo");
await ensureCampaignDemo();
broadcastAutomergeSync("campaign-demo");
broadcastJsonSnapshot("campaign-demo");
return c.json({ ok: true, message: "Campaign demo reset to seed data" });
});
// GET /api/communities/:slug/shapes
app.get("/api/communities/:slug/shapes", async (c) => {
const slug = c.req.param("slug");
const token = extractToken(c.req.raw.headers);
const access = await evaluateSpaceAccess(slug, token, "GET", { getSpaceConfig });
if (!access.allowed) return c.json({ error: access.reason }, access.claims ? 403 : 401);
await loadCommunity(slug);
const data = getDocumentData(slug);
if (!data) return c.json({ error: "Community not found" }, 404);
return c.json({ shapes: data.shapes || {} });
});
// POST /api/communities/:slug/shapes
app.post("/api/communities/:slug/shapes", async (c) => {
const slug = c.req.param("slug");
const internalKey = c.req.header("X-Internal-Key");
const isInternalCall = INTERNAL_API_KEY && internalKey === INTERNAL_API_KEY;
if (!isInternalCall) {
const token = extractToken(c.req.raw.headers);
const access = await evaluateSpaceAccess(slug, token, "POST", { getSpaceConfig });
if (!access.allowed) return c.json({ error: access.reason }, access.claims ? 403 : 401);
if (access.readOnly) return c.json({ error: "Write access required to add shapes" }, 403);
}
await loadCommunity(slug);
const data = getDocumentData(slug);
if (!data) return c.json({ error: "Community not found" }, 404);
const body = await c.req.json<{ shapes?: Record<string, unknown>[] }>();
if (!body.shapes || !Array.isArray(body.shapes) || body.shapes.length === 0) {
return c.json({ error: "shapes array is required and must not be empty" }, 400);
}
const ids = addShapes(slug, body.shapes);
broadcastAutomergeSync(slug);
broadcastJsonSnapshot(slug);
return c.json({ ok: true, ids }, 201);
});
// PATCH /api/communities/:slug/shapes/:shapeId
app.patch("/api/communities/:slug/shapes/:shapeId", async (c) => {
const slug = c.req.param("slug");
const shapeId = c.req.param("shapeId");
const internalKey = c.req.header("X-Internal-Key");
const isInternalCall = INTERNAL_API_KEY && internalKey === INTERNAL_API_KEY;
if (!isInternalCall) {
const token = extractToken(c.req.raw.headers);
const access = await evaluateSpaceAccess(slug, token, "PATCH", { getSpaceConfig });
if (!access.allowed) return c.json({ error: access.reason }, access.claims ? 403 : 401);
}
await loadCommunity(slug);
const body = await c.req.json<Record<string, unknown>>();
const updated = updateShapeFields(slug, shapeId, body);
if (!updated) return c.json({ error: "Shape not found" }, 404);
broadcastAutomergeSync(slug);
broadcastJsonSnapshot(slug);
return c.json({ ok: true });
});
// GET /api/communities/:slug — community info
app.get("/api/communities/:slug", async (c) => {
const slug = c.req.param("slug");
const token = extractToken(c.req.raw.headers);
const access = await evaluateSpaceAccess(slug, token, "GET", { getSpaceConfig });
if (!access.allowed) return c.json({ error: access.reason }, access.claims ? 403 : 401);
let data = getDocumentData(slug);
if (!data) {
await loadCommunity(slug);
data = getDocumentData(slug);
}
if (!data) return c.json({ error: "Community not found" }, 404);
return c.json({ meta: data.meta, readOnly: access.readOnly });
});
// ── Module info API (for app switcher) ──
app.get("/api/modules", (c) => {
return c.json({ modules: getModuleInfoList() });
});
// ── Mount module routes under /:space/:moduleId ──
for (const mod of getAllModules()) {
app.route(`/:space/${mod.id}`, mod.routes);
}
// ── Page routes ──
// Landing page: rspace.online/
app.get("/", async (c) => {
const file = Bun.file(resolve(DIST_DIR, "index.html"));
if (await file.exists()) {
return new Response(file, { headers: { "Content-Type": "text/html" } });
}
return c.text("rSpace", 200);
});
// Create new space page
app.get("/new", 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("Create space", 200);
});
// Space root: /:space → redirect to /:space/canvas
app.get("/:space", (c) => {
const space = c.req.param("space");
// Don't redirect for static file paths
if (space.includes(".")) return c.notFound();
return c.redirect(`/${space}/canvas`);
});
// ── WebSocket types ──
interface WSData {
communitySlug: string;
peerId: string;
claims: EncryptIDClaims | null;
readOnly: boolean;
mode: "automerge" | "json";
}
// Track connected clients per community
const communityClients = new Map<string, Map<string, ServerWebSocket<WSData>>>();
function generatePeerId(): string {
return `peer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
function getClient(slug: string, peerId: string): ServerWebSocket<WSData> | undefined {
return communityClients.get(slug)?.get(peerId);
}
function broadcastJsonSnapshot(slug: string, excludePeerId?: string): void {
const clients = communityClients.get(slug);
if (!clients) return;
const docData = getDocumentData(slug);
if (!docData) return;
const snapshotMsg = JSON.stringify({ type: "snapshot", shapes: docData.shapes || {} });
for (const [clientPeerId, client] of clients) {
if (client.data.mode === "json" && clientPeerId !== excludePeerId && client.readyState === WebSocket.OPEN) {
client.send(snapshotMsg);
}
}
}
function broadcastAutomergeSync(slug: string, excludePeerId?: string): void {
const clients = communityClients.get(slug);
if (!clients) return;
for (const [clientPeerId, client] of clients) {
if (client.data.mode === "automerge" && clientPeerId !== excludePeerId && client.readyState === WebSocket.OPEN) {
const syncMsg = generateSyncMessageForPeer(slug, clientPeerId);
if (syncMsg) {
client.send(JSON.stringify({ type: "sync", data: Array.from(syncMsg) }));
}
}
}
}
// ── Subdomain parsing (backward compat) ──
const RESERVED_SUBDOMAINS = ["www", "rspace", "create", "new", "start", "auth"];
function getSubdomain(host: string | null): string | null {
if (!host) return null;
if (host.includes("localhost") || host.includes("127.0.0.1")) return null;
const parts = host.split(".");
if (parts.length >= 3 && parts.slice(-2).join(".") === "rspace.online") {
const sub = parts[0];
if (!RESERVED_SUBDOMAINS.includes(sub)) return sub;
}
return null;
}
// ── Static file serving ──
function getContentType(path: string): string {
if (path.endsWith(".html")) return "text/html";
if (path.endsWith(".js")) return "application/javascript";
if (path.endsWith(".css")) return "text/css";
if (path.endsWith(".json")) return "application/json";
if (path.endsWith(".svg")) return "image/svg+xml";
if (path.endsWith(".wasm")) return "application/wasm";
if (path.endsWith(".png")) return "image/png";
if (path.endsWith(".jpg") || path.endsWith(".jpeg")) return "image/jpeg";
if (path.endsWith(".gif")) return "image/gif";
if (path.endsWith(".ico")) return "image/x-icon";
return "application/octet-stream";
}
async function serveStatic(path: string): Promise<Response | null> {
const filePath = resolve(DIST_DIR, path);
const file = Bun.file(filePath);
if (await file.exists()) {
return new Response(file, { headers: { "Content-Type": getContentType(path) } });
}
return null;
}
// ── 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 subdomain = getSubdomain(host);
// ── WebSocket upgrade ──
if (url.pathname.startsWith("/ws/")) {
const communitySlug = url.pathname.split("/")[2];
if (communitySlug) {
const spaceConfig = await getSpaceConfig(communitySlug);
const claims = await authenticateWSUpgrade(req);
let readOnly = false;
if (spaceConfig) {
const vis = spaceConfig.visibility;
if (vis === "authenticated" || vis === "members_only") {
if (!claims) return new Response("Authentication required", { status: 401 });
} else if (vis === "public_read") {
readOnly = !claims;
}
}
const peerId = generatePeerId();
const mode = url.searchParams.get("mode") === "json" ? "json" : "automerge";
const upgraded = server.upgrade(req, {
data: { communitySlug, peerId, claims, readOnly, mode } as WSData,
});
if (upgraded) return undefined;
}
return new Response("WebSocket upgrade failed", { status: 400 });
}
// ── Static assets (before Hono routing) ──
if (url.pathname !== "/" && !url.pathname.startsWith("/api/") && !url.pathname.startsWith("/ws/")) {
const assetPath = url.pathname.slice(1);
// Serve files with extensions directly
if (assetPath.includes(".")) {
const staticResponse = await serveStatic(assetPath);
if (staticResponse) return staticResponse;
}
}
// ── Subdomain backward compat: redirect to path-based routing ──
if (subdomain) {
const pathSegments = url.pathname.split("/").filter(Boolean);
// If visiting subdomain root, redirect to /:subdomain/canvas
if (pathSegments.length === 0) {
// First, ensure the community exists
const community = await loadCommunity(subdomain);
if (community) {
// Serve canvas.html directly for backward compat
const canvasHtml = await serveStatic("canvas.html");
if (canvasHtml) return canvasHtml;
}
return new Response("Community not found", { status: 404 });
}
// Subdomain with path: serve canvas.html
const slug = pathSegments.join("-");
const community = await loadCommunity(slug) || await loadCommunity(subdomain);
if (community) {
const canvasHtml = await serveStatic("canvas.html");
if (canvasHtml) return canvasHtml;
}
return new Response("Community not found", { status: 404 });
}
// ── Hono handles everything else ──
const response = await app.fetch(req);
// If Hono returns 404, try serving canvas.html as SPA fallback
// But only for paths that don't match a known module route
if (response.status === 404 && !url.pathname.startsWith("/api/")) {
const parts = url.pathname.split("/").filter(Boolean);
// Check if this is under a known module — if so, the module's 404 is authoritative
const knownModuleIds = getAllModules().map((m) => m.id);
const isModulePath = parts.length >= 2 && knownModuleIds.includes(parts[1]);
if (!isModulePath && parts.length >= 1 && !parts[0].includes(".")) {
// Not a module path — could be a canvas SPA route, try fallback
const canvasHtml = await serveStatic("canvas.html");
if (canvasHtml) return canvasHtml;
const indexHtml = await serveStatic("index.html");
if (indexHtml) return indexHtml;
}
}
return response;
},
// ── WebSocket handlers (unchanged) ──
websocket: {
open(ws: ServerWebSocket<WSData>) {
const { communitySlug, peerId, mode } = ws.data;
if (!communityClients.has(communitySlug)) {
communityClients.set(communitySlug, new Map());
}
communityClients.get(communitySlug)!.set(peerId, ws);
console.log(`[WS] Client ${peerId} connected to ${communitySlug} (mode: ${mode})`);
loadCommunity(communitySlug).then((doc) => {
if (!doc) return;
if (mode === "json") {
const docData = getDocumentData(communitySlug);
if (docData) {
ws.send(JSON.stringify({ type: "snapshot", shapes: docData.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());
if (msg.type === "sync" && Array.isArray(msg.data)) {
if (ws.data.readOnly) {
ws.send(JSON.stringify({ type: "error", message: "Authentication required to edit this space" }));
return;
}
const syncMessage = new Uint8Array(msg.data);
const result = receiveSyncMessage(communitySlug, peerId, syncMessage);
if (result.response) {
ws.send(JSON.stringify({ type: "sync", data: Array.from(result.response) }));
}
for (const [targetPeerId, targetMessage] of result.broadcastToPeers) {
const targetClient = getClient(communitySlug, targetPeerId);
if (targetClient && targetClient.data.mode === "automerge" && targetClient.readyState === WebSocket.OPEN) {
targetClient.send(JSON.stringify({ type: "sync", data: Array.from(targetMessage) }));
}
}
if (result.broadcastToPeers.size > 0) {
broadcastJsonSnapshot(communitySlug, peerId);
}
} else if (msg.type === "ping") {
ws.send(JSON.stringify({ type: "pong", timestamp: msg.timestamp }));
} else if (msg.type === "presence") {
const clients = communityClients.get(communitySlug);
if (clients) {
const presenceMsg = JSON.stringify({ type: "presence", peerId, ...msg });
for (const [clientPeerId, client] of clients) {
if (clientPeerId !== peerId && client.readyState === WebSocket.OPEN) {
client.send(presenceMsg);
}
}
}
} else if (msg.type === "update" && msg.id && msg.data) {
if (ws.data.readOnly) {
ws.send(JSON.stringify({ type: "error", message: "Authentication required to edit" }));
return;
}
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;
}
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;
}
forgetShape(communitySlug, msg.id, ws.data.claims?.sub);
broadcastJsonSnapshot(communitySlug, peerId);
broadcastAutomergeSync(communitySlug, peerId);
} else if (msg.type === "remember" && msg.id) {
if (ws.data.readOnly) {
ws.send(JSON.stringify({ type: "error", message: "Authentication required to remember" }));
return;
}
rememberShape(communitySlug, msg.id);
broadcastJsonSnapshot(communitySlug, peerId);
broadcastAutomergeSync(communitySlug, peerId);
}
} catch (e) {
console.error("[WS] Failed to parse message:", e);
}
},
close(ws: ServerWebSocket<WSData>) {
const { communitySlug, peerId } = ws.data;
const clients = communityClients.get(communitySlug);
if (clients) {
clients.delete(peerId);
if (clients.size === 0) communityClients.delete(communitySlug);
}
removePeerSyncState(communitySlug, peerId);
console.log(`[WS] Client ${peerId} disconnected from ${communitySlug}`);
},
},
});
// ── Startup ──
ensureDemoCommunity().then(() => console.log("[Demo] Demo community ready")).catch((e) => console.error("[Demo] Failed:", e));
ensureCampaignDemo().then(() => console.log("[Campaign] Campaign demo ready")).catch((e) => console.error("[Campaign] Failed:", e));
console.log(`rSpace unified server running on http://localhost:${PORT}`);
console.log(`Modules: ${getAllModules().map((m) => `${m.icon} ${m.name}`).join(", ")}`);