diff --git a/Dockerfile b/Dockerfile
index 3b06a38..c221efa 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -24,6 +24,8 @@ WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/server ./server
COPY --from=build /app/lib ./lib
+COPY --from=build /app/shared ./shared
+COPY --from=build /app/modules ./modules
COPY --from=build /app/package.json .
COPY --from=build /encryptid-sdk /encryptid-sdk
diff --git a/db/init.sql b/db/init.sql
new file mode 100644
index 0000000..c338054
--- /dev/null
+++ b/db/init.sql
@@ -0,0 +1,16 @@
+-- rSpace shared PostgreSQL — per-module schema isolation
+-- Each module owns its schema. Modules that don't need a DB skip this.
+
+-- Module schemas (created on init, populated by module migrations)
+CREATE SCHEMA IF NOT EXISTS rbooks;
+CREATE SCHEMA IF NOT EXISTS rcart;
+CREATE SCHEMA IF NOT EXISTS providers;
+CREATE SCHEMA IF NOT EXISTS rfiles;
+CREATE SCHEMA IF NOT EXISTS rforum;
+
+-- Grant usage to the rspace user
+GRANT ALL ON SCHEMA rbooks TO rspace;
+GRANT ALL ON SCHEMA rcart TO rspace;
+GRANT ALL ON SCHEMA providers TO rspace;
+GRANT ALL ON SCHEMA rfiles TO rspace;
+GRANT ALL ON SCHEMA rforum TO rspace;
diff --git a/docker-compose.yml b/docker-compose.yml
index 94d4aee..53c3300 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -13,21 +13,51 @@ services:
- STORAGE_DIR=/data/communities
- PORT=3000
- INTERNAL_API_KEY=${INTERNAL_API_KEY}
+ - DATABASE_URL=postgres://rspace:${POSTGRES_PASSWORD:-rspace}@rspace-db:5432/rspace
+ depends_on:
+ rspace-db:
+ condition: service_healthy
labels:
- "traefik.enable=true"
- # Only handle subdomains (rspace-prod handles main domain)
- - "traefik.http.routers.rspace-canvas.rule=HostRegexp(`{subdomain:[a-z0-9-]+}.rspace.online`) && !Host(`rspace.online`) && !Host(`www.rspace.online`)"
+ # Main domain — serves landing + path-based routing
+ - "traefik.http.routers.rspace-main.rule=Host(`rspace.online`)"
+ - "traefik.http.routers.rspace-main.entrypoints=web"
+ - "traefik.http.routers.rspace-main.priority=110"
+ # Subdomains — backward compat for *.rspace.online canvas
+ - "traefik.http.routers.rspace-canvas.rule=HostRegexp(`{subdomain:[a-z0-9-]+}.rspace.online`) && !Host(`rspace.online`) && !Host(`www.rspace.online`) && !Host(`auth.rspace.online`)"
- "traefik.http.routers.rspace-canvas.entrypoints=web"
- "traefik.http.routers.rspace-canvas.priority=100"
# Service configuration
- - "traefik.http.services.rspace-canvas.loadbalancer.server.port=3000"
+ - "traefik.http.services.rspace-online.loadbalancer.server.port=3000"
- "traefik.docker.network=traefik-public"
networks:
- traefik-public
+ - rspace-internal
+
+ rspace-db:
+ image: postgres:16-alpine
+ container_name: rspace-db
+ restart: unless-stopped
+ volumes:
+ - rspace-pgdata:/var/lib/postgresql/data
+ - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
+ environment:
+ - POSTGRES_DB=rspace
+ - POSTGRES_USER=rspace
+ - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-rspace}
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U rspace"]
+ interval: 5s
+ timeout: 3s
+ retries: 5
+ networks:
+ - rspace-internal
volumes:
rspace-data:
+ rspace-pgdata:
networks:
traefik-public:
external: true
+ rspace-internal:
diff --git a/modules/canvas/mod.ts b/modules/canvas/mod.ts
new file mode 100644
index 0000000..b79019f
--- /dev/null
+++ b/modules/canvas/mod.ts
@@ -0,0 +1,58 @@
+/**
+ * Canvas module — the collaborative infinite canvas.
+ *
+ * This is the original rSpace canvas restructured as an rSpace module.
+ * Routes are relative to the mount point (/:space/canvas in unified mode,
+ * / in standalone mode).
+ */
+
+import { Hono } from "hono";
+import { resolve } from "node:path";
+import { renderShell } from "../../server/shell";
+import { getModuleInfoList } from "../../shared/module";
+import type { RSpaceModule } from "../../shared/module";
+
+const DIST_DIR = resolve(import.meta.dir, "../../dist");
+
+const routes = new Hono();
+
+// GET / — serve the canvas page wrapped in shell
+routes.get("/", async (c) => {
+ const spaceSlug = c.req.param("space") || c.req.query("space") || "demo";
+
+ // Read the canvas page template from dist
+ const canvasFile = Bun.file(resolve(DIST_DIR, "canvas-module.html"));
+ let canvasBody = "";
+ if (await canvasFile.exists()) {
+ canvasBody = await canvasFile.text();
+ } else {
+ // Fallback: serve full canvas.html directly if module template not built yet
+ const fallbackFile = Bun.file(resolve(DIST_DIR, "canvas.html"));
+ if (await fallbackFile.exists()) {
+ return new Response(fallbackFile, {
+ headers: { "Content-Type": "text/html" },
+ });
+ }
+ canvasBody = `
Canvas loading...
`;
+ }
+
+ const html = renderShell({
+ title: `${spaceSlug} — Canvas | rSpace`,
+ moduleId: "canvas",
+ spaceSlug,
+ body: canvasBody,
+ modules: getModuleInfoList(),
+ theme: "light",
+ scripts: ``,
+ });
+
+ return c.html(html);
+});
+
+export const canvasModule: RSpaceModule = {
+ id: "canvas",
+ name: "Canvas",
+ icon: "🎨",
+ description: "Collaborative infinite canvas",
+ routes,
+};
diff --git a/server/index.ts b/server/index.ts
index fddc933..d609345 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -1,11 +1,20 @@
+/**
+ * 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,
- deleteShape,
forgetShape,
rememberShape,
generateSyncMessageForPeer,
@@ -15,8 +24,6 @@ import {
removePeerSyncState,
updateShape,
updateShapeFields,
- setMember,
- removeMember,
} from "./community-store";
import { ensureDemoCommunity } from "./seed-demo";
import { ensureCampaignDemo } from "./seed-campaign";
@@ -29,7 +36,52 @@ import {
} from "@encryptid/sdk/server";
import type { EncryptIDClaims, SpaceAuthConfig } from "@encryptid/sdk/server";
-/** Resolve a community slug to its SpaceAuthConfig for the SDK guard */
+// ── Module system ──
+import { registerModule, getAllModules, getModuleInfoList } from "../shared/module";
+import { canvasModule } from "../modules/canvas/mod";
+import { spaces } from "./spaces";
+import { renderShell } from "./shell";
+
+// Register modules
+registerModule(canvasModule);
+
+// ── 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 {
let doc = getDocumentData(slug);
if (!doc) {
@@ -45,11 +97,201 @@ async function getSpaceConfig(slug: string): Promise {
};
}
-const PORT = Number(process.env.PORT) || 3000;
-const INTERNAL_API_KEY = process.env.INTERNAL_API_KEY || "";
-const DIST_DIR = resolve(import.meta.dir, "../dist");
+// Demo reset rate limiter
+let lastDemoReset = 0;
+const DEMO_RESET_COOLDOWN = 5 * 60 * 1000;
-// WebSocket data type
+// 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[] }>();
+ 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>();
+ 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;
@@ -58,20 +300,17 @@ interface WSData {
mode: "automerge" | "json";
}
-// Track connected clients per community (for broadcasting)
+// Track connected clients per community
const communityClients = new Map>>();
-// Generate unique peer ID
function generatePeerId(): string {
return `peer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
-// Helper to get client by peer ID
function getClient(slug: string, peerId: string): ServerWebSocket | undefined {
return communityClients.get(slug)?.get(peerId);
}
-// Broadcast a JSON snapshot of all shapes to json-mode clients in a community
function broadcastJsonSnapshot(slug: string, excludePeerId?: string): void {
const clients = communityClients.get(slug);
if (!clients) return;
@@ -85,7 +324,6 @@ function broadcastJsonSnapshot(slug: string, excludePeerId?: string): void {
}
}
-// Broadcast Automerge sync messages to automerge-mode clients in a community
function broadcastAutomergeSync(slug: string, excludePeerId?: string): void {
const clients = communityClients.get(slug);
if (!clients) return;
@@ -99,50 +337,21 @@ function broadcastAutomergeSync(slug: string, excludePeerId?: string): void {
}
}
-// Demo reset rate limiter
-let lastDemoReset = 0;
-const DEMO_RESET_COOLDOWN = 5 * 60 * 1000; // 5 minutes
+// ── Subdomain parsing (backward compat) ──
+const RESERVED_SUBDOMAINS = ["www", "rspace", "create", "new", "start", "auth"];
-// Special subdomains that should show the creation form instead of canvas
-const RESERVED_SUBDOMAINS = ["www", "rspace", "create", "new", "start"];
-
-// Parse subdomain from host header
function getSubdomain(host: string | null): string | null {
if (!host) return null;
-
- // Handle localhost for development
- if (host.includes("localhost") || host.includes("127.0.0.1")) {
- return null;
- }
-
- // Extract subdomain from *.rspace.online
+ 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 subdomain = parts[0];
- // Reserved subdomains show the creation form
- if (!RESERVED_SUBDOMAINS.includes(subdomain)) {
- return subdomain;
- }
+ const sub = parts[0];
+ if (!RESERVED_SUBDOMAINS.includes(sub)) return sub;
}
-
- return null;
-}
-
-// Serve static files
-async function serveStatic(path: string): Promise {
- const filePath = resolve(DIST_DIR, path);
- const file = Bun.file(filePath);
-
- if (await file.exists()) {
- const contentType = getContentType(path);
- return new Response(file, {
- headers: { "Content-Type": contentType },
- });
- }
-
return null;
}
+// ── Static file serving ──
function getContentType(path: string): string {
if (path.endsWith(".html")) return "text/html";
if (path.endsWith(".js")) return "application/javascript";
@@ -157,7 +366,16 @@ function getContentType(path: string): string {
return "application/octet-stream";
}
-// Main server
+async function serveStatic(path: string): Promise {
+ 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({
port: PORT,
@@ -166,11 +384,10 @@ const server = Bun.serve({
const host = req.headers.get("host");
const subdomain = getSubdomain(host);
- // Handle WebSocket upgrade (with auth for non-public communities)
+ // ── WebSocket upgrade ──
if (url.pathname.startsWith("/ws/")) {
const communitySlug = url.pathname.split("/")[2];
if (communitySlug) {
- // Check space visibility — authenticate if needed
const spaceConfig = await getSpaceConfig(communitySlug);
const claims = await authenticateWSUpgrade(req);
let readOnly = false;
@@ -178,9 +395,7 @@ const server = Bun.serve({
if (spaceConfig) {
const vis = spaceConfig.visibility;
if (vis === "authenticated" || vis === "members_only") {
- if (!claims) {
- return new Response("Authentication required to join this space", { status: 401 });
- }
+ if (!claims) return new Response("Authentication required", { status: 401 });
} else if (vis === "public_read") {
readOnly = !claims;
}
@@ -196,89 +411,67 @@ const server = Bun.serve({
return new Response("WebSocket upgrade failed", { status: 400 });
}
- // Serve .well-known/webauthn for WebAuthn Related Origins
- // RP ID is rspace.online — *.rspace.online subdomains are automatic,
- // this file lists non-rspace.online origins that should also be allowed.
- if (url.pathname === "/.well-known/webauthn") {
- return Response.json({
- origins: [
- "https://rwallet.online",
- "https://rvote.online",
- "https://rmaps.online",
- "https://rfiles.online",
- "https://rnotes.online",
- ],
- }, {
- headers: {
- "Access-Control-Allow-Origin": "*",
- "Cache-Control": "public, max-age=3600",
- },
- });
+ // ── 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;
+ }
}
- // API routes
- if (url.pathname.startsWith("/api/")) {
- return handleAPI(req, url);
- }
-
- // Static files (serve these first, before subdomain routing)
- let filePath = url.pathname;
-
- // Try to serve static assets first (js, css, wasm, etc.)
- if (filePath !== "/" && filePath !== "/canvas") {
- const assetPath = filePath.slice(1); // Remove leading slash
- const staticResponse = await serveStatic(assetPath);
- if (staticResponse) return staticResponse;
- }
-
- // Community canvas route (subdomain detected)
- // Supports path-based slugs: cca.rspace.online/campaign/demo → slug "campaign-demo"
+ // ── Subdomain backward compat: redirect to path-based routing ──
if (subdomain) {
const pathSegments = url.pathname.split("/").filter(Boolean);
- // Derive slug: path segments joined with "-", or subdomain if at root
- const slug = pathSegments.length > 0 ? pathSegments.join("-") : subdomain;
-
- const community = await loadCommunity(slug);
- if (!community) {
- // Path slug not found — fall back to subdomain slug if path was given
- if (pathSegments.length > 0) {
- const fallback = await loadCommunity(subdomain);
- if (!fallback) {
- return new Response("Community not found", { status: 404 });
- }
- } else {
- return new Response("Community not found", { status: 404 });
+ // 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 });
}
- // Serve canvas.html for community
- const canvasHtml = await serveStatic("canvas.html");
- if (canvasHtml) return canvasHtml;
+ // 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 });
}
- // Handle root paths
- if (filePath === "/") filePath = "/index.html";
- if (filePath === "/canvas") filePath = "/canvas.html";
+ // ── Hono handles everything else ──
+ const response = await app.fetch(req);
- // Remove leading slash
- filePath = filePath.slice(1);
+ // If Hono returns 404, try serving canvas.html as SPA fallback
+ if (response.status === 404 && !url.pathname.startsWith("/api/")) {
+ // Check if this looks like a /:space/:module path
+ const parts = url.pathname.split("/").filter(Boolean);
+ if (parts.length >= 1 && !parts[0].includes(".")) {
+ // Could be a space/module path — try canvas.html fallback
+ const canvasHtml = await serveStatic("canvas.html");
+ if (canvasHtml) return canvasHtml;
- const staticResponse = await serveStatic(filePath);
- if (staticResponse) return staticResponse;
+ const indexHtml = await serveStatic("index.html");
+ if (indexHtml) return indexHtml;
+ }
+ }
- // Fallback to index.html for SPA routing
- const indexResponse = await serveStatic("index.html");
- if (indexResponse) return indexResponse;
-
- return new Response("Not Found", { status: 404 });
+ return response;
},
+ // ── WebSocket handlers (unchanged) ──
websocket: {
open(ws: ServerWebSocket) {
const { communitySlug, peerId, mode } = ws.data;
- // Add to clients map
if (!communityClients.has(communitySlug)) {
communityClients.set(communitySlug, new Map());
}
@@ -286,29 +479,18 @@ const server = Bun.serve({
console.log(`[WS] Client ${peerId} connected to ${communitySlug} (mode: ${mode})`);
- // Load community and send initial data
loadCommunity(communitySlug).then((doc) => {
if (!doc) return;
if (mode === "json") {
- // JSON mode: send full shapes snapshot
const docData = getDocumentData(communitySlug);
if (docData) {
- ws.send(JSON.stringify({
- type: "snapshot",
- shapes: docData.shapes || {},
- }));
+ ws.send(JSON.stringify({ type: "snapshot", shapes: docData.shapes || {} }));
}
} else {
- // Automerge mode: send sync message
const syncMessage = generateSyncMessageForPeer(communitySlug, peerId);
if (syncMessage) {
- ws.send(
- JSON.stringify({
- type: "sync",
- data: Array.from(syncMessage),
- })
- );
+ ws.send(JSON.stringify({ type: "sync", data: Array.from(syncMessage) }));
}
}
});
@@ -321,81 +503,52 @@ const server = Bun.serve({
const msg = JSON.parse(message.toString());
if (msg.type === "sync" && Array.isArray(msg.data)) {
- // Block sync writes from read-only connections
if (ws.data.readOnly) {
- ws.send(JSON.stringify({
- type: "error",
- message: "Authentication required to edit this space",
- }));
+ ws.send(JSON.stringify({ type: "error", message: "Authentication required to edit this space" }));
return;
}
- // Handle Automerge sync message
const syncMessage = new Uint8Array(msg.data);
const result = receiveSyncMessage(communitySlug, peerId, syncMessage);
- // Send response to this peer
if (result.response) {
- ws.send(
- JSON.stringify({
- type: "sync",
- data: Array.from(result.response),
- })
- );
+ ws.send(JSON.stringify({ type: "sync", data: Array.from(result.response) }));
}
- // Broadcast to other Automerge peers
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),
- })
- );
+ targetClient.send(JSON.stringify({ type: "sync", data: Array.from(targetMessage) }));
}
}
- // Also broadcast JSON snapshot to json-mode clients
if (result.broadcastToPeers.size > 0) {
broadcastJsonSnapshot(communitySlug, peerId);
}
} else if (msg.type === "ping") {
- // Handle keep-alive ping
ws.send(JSON.stringify({ type: "pong", timestamp: msg.timestamp }));
} else if (msg.type === "presence") {
- // Broadcast presence to other clients
const clients = communityClients.get(communitySlug);
if (clients) {
- const presenceMsg = JSON.stringify({
- type: "presence",
- peerId,
- ...msg,
- });
+ const presenceMsg = JSON.stringify({ type: "presence", peerId, ...msg });
for (const [clientPeerId, client] of clients) {
if (clientPeerId !== peerId && client.readyState === WebSocket.OPEN) {
client.send(presenceMsg);
}
}
}
- }
- // Legacy/JSON-mode message handling
- else if (msg.type === "update" && msg.id && msg.data) {
+ } 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);
- // Broadcast JSON update to other json-mode clients
broadcastJsonSnapshot(communitySlug, peerId);
- // Broadcast Automerge sync to automerge-mode clients
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;
}
- // FUN model: "delete" now means "forget" (soft-delete)
forgetShape(communitySlug, msg.id, ws.data.claims?.sub);
broadcastJsonSnapshot(communitySlug, peerId);
broadcastAutomergeSync(communitySlug, peerId);
@@ -423,396 +576,20 @@ const server = Bun.serve({
close(ws: ServerWebSocket) {
const { communitySlug, peerId } = ws.data;
-
- // Remove from clients map
const clients = communityClients.get(communitySlug);
if (clients) {
clients.delete(peerId);
- if (clients.size === 0) {
- communityClients.delete(communitySlug);
- }
+ if (clients.size === 0) communityClients.delete(communitySlug);
}
-
- // Clean up peer sync state
removePeerSyncState(communitySlug, peerId);
-
console.log(`[WS] Client ${peerId} disconnected from ${communitySlug}`);
},
},
});
-// API handler
-async function handleAPI(req: Request, url: URL): Promise {
- const corsHeaders = {
- "Access-Control-Allow-Origin": "*",
- "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
- "Access-Control-Allow-Headers": "Content-Type, Authorization",
- };
+// ── 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));
- if (req.method === "OPTIONS") {
- return new Response(null, { headers: corsHeaders });
- }
-
- // POST /api/communities - Create new community (requires auth)
- if (url.pathname === "/api/communities" && req.method === "POST") {
- try {
- // Require EncryptID authentication to create a community
- const token = extractToken(req.headers);
- if (!token) {
- return Response.json(
- { error: "Authentication required to create a community" },
- { status: 401, headers: corsHeaders }
- );
- }
-
- let claims: EncryptIDClaims;
- try {
- claims = await verifyEncryptIDToken(token);
- } catch {
- return Response.json(
- { error: "Invalid or expired authentication token" },
- { status: 401, headers: corsHeaders }
- );
- }
-
- const body = (await req.json()) as {
- name?: string;
- slug?: string;
- visibility?: SpaceVisibility;
- };
- const { name, slug, visibility = "public_read" } = body;
-
- if (!name || !slug) {
- return Response.json(
- { error: "Name and slug are required" },
- { status: 400, headers: corsHeaders }
- );
- }
-
- // Validate slug format
- if (!/^[a-z0-9-]+$/.test(slug)) {
- return Response.json(
- { error: "Slug must contain only lowercase letters, numbers, and hyphens" },
- { status: 400, headers: corsHeaders }
- );
- }
-
- // Validate visibility
- const validVisibilities = ["public", "public_read", "authenticated", "members_only"];
- if (!validVisibilities.includes(visibility)) {
- return Response.json(
- { error: `Invalid visibility. Must be one of: ${validVisibilities.join(", ")}` },
- { status: 400, headers: corsHeaders }
- );
- }
-
- // Check if exists
- if (await communityExists(slug)) {
- return Response.json(
- { error: "Community already exists" },
- { status: 409, headers: corsHeaders }
- );
- }
-
- // Create community with owner and visibility
- await createCommunity(name, slug, claims.sub, visibility);
-
- // Return URL to new community
- return Response.json(
- {
- url: `https://${slug}.rspace.online`,
- slug,
- name,
- visibility,
- ownerDID: claims.sub,
- },
- { headers: corsHeaders }
- );
- } catch (e) {
- console.error("Failed to create community:", e);
- return Response.json(
- { error: "Failed to create community" },
- { status: 500, headers: corsHeaders }
- );
- }
- }
-
- // GET /api/communities/:slug/shapes - Get all shapes as JSON
- if (
- url.pathname.match(/^\/api\/communities\/[^/]+\/shapes$/) &&
- req.method === "GET"
- ) {
- const slug = url.pathname.split("/")[3];
-
- const token = extractToken(req.headers);
- const access = await evaluateSpaceAccess(slug, token, req.method, {
- getSpaceConfig,
- });
-
- if (!access.allowed) {
- return Response.json(
- { error: access.reason },
- { status: access.claims ? 403 : 401, headers: corsHeaders }
- );
- }
-
- await loadCommunity(slug);
- const data = getDocumentData(slug);
- if (!data) {
- return Response.json(
- { error: "Community not found" },
- { status: 404, headers: corsHeaders }
- );
- }
-
- return Response.json(
- { shapes: data.shapes || {} },
- { headers: corsHeaders }
- );
- }
-
- // POST /api/communities/demo/reset - Reset demo community to seed data
- if (url.pathname === "/api/communities/demo/reset" && req.method === "POST") {
- const now = Date.now();
- if (now - lastDemoReset < DEMO_RESET_COOLDOWN) {
- const remaining = Math.ceil((DEMO_RESET_COOLDOWN - (now - lastDemoReset)) / 1000);
- return Response.json(
- { error: `Demo reset on cooldown. Try again in ${remaining}s` },
- { status: 429, headers: corsHeaders }
- );
- }
-
- try {
- lastDemoReset = now;
- await loadCommunity("demo");
- clearShapes("demo");
- await ensureDemoCommunity();
-
- // Broadcast new state to all connected clients
- broadcastAutomergeSync("demo");
- broadcastJsonSnapshot("demo");
-
- return Response.json(
- { ok: true, message: "Demo community reset to seed data" },
- { headers: corsHeaders }
- );
- } catch (e) {
- console.error("Failed to reset demo:", e);
- return Response.json(
- { error: "Failed to reset demo community" },
- { status: 500, headers: corsHeaders }
- );
- }
- }
-
- // POST /api/communities/campaign-demo/reset - Reset campaign demo
- if (url.pathname === "/api/communities/campaign-demo/reset" && req.method === "POST") {
- const now = Date.now();
- if (now - lastDemoReset < DEMO_RESET_COOLDOWN) {
- const remaining = Math.ceil((DEMO_RESET_COOLDOWN - (now - lastDemoReset)) / 1000);
- return Response.json(
- { error: `Reset on cooldown. Try again in ${remaining}s` },
- { status: 429, headers: corsHeaders }
- );
- }
-
- try {
- lastDemoReset = now;
- await loadCommunity("campaign-demo");
- clearShapes("campaign-demo");
- await ensureCampaignDemo();
-
- broadcastAutomergeSync("campaign-demo");
- broadcastJsonSnapshot("campaign-demo");
-
- return Response.json(
- { ok: true, message: "Campaign demo reset to seed data" },
- { headers: corsHeaders }
- );
- } catch (e) {
- console.error("Failed to reset campaign demo:", e);
- return Response.json(
- { error: "Failed to reset campaign demo" },
- { status: 500, headers: corsHeaders }
- );
- }
- }
-
- // GET /api/communities/:slug - Get community info (respects visibility)
- if (url.pathname.startsWith("/api/communities/") && req.method === "GET") {
- const slug = url.pathname.split("/")[3];
-
- // Check space access using SDK guard
- const token = extractToken(req.headers);
- const access = await evaluateSpaceAccess(slug, token, req.method, {
- getSpaceConfig,
- });
-
- if (!access.allowed) {
- return Response.json(
- { error: access.reason },
- { status: access.claims ? 403 : 401, headers: corsHeaders }
- );
- }
-
- const data = getDocumentData(slug);
- if (!data) {
- await loadCommunity(slug);
- const loadedData = getDocumentData(slug);
- if (!loadedData) {
- return Response.json(
- { error: "Community not found" },
- { status: 404, headers: corsHeaders }
- );
- }
- return Response.json(
- { meta: loadedData.meta, readOnly: access.readOnly },
- { headers: corsHeaders }
- );
- }
-
- return Response.json(
- { meta: data.meta, readOnly: access.readOnly },
- { headers: corsHeaders }
- );
- }
-
- // POST /api/communities/:slug/shapes - Add shapes to a community canvas
- if (
- url.pathname.match(/^\/api\/communities\/[^/]+\/shapes$/) &&
- req.method === "POST"
- ) {
- const slug = url.pathname.split("/")[3];
-
- // Allow internal service-to-service calls with shared key
- const internalKey = req.headers.get("X-Internal-Key");
- const isInternalCall = INTERNAL_API_KEY && internalKey === INTERNAL_API_KEY;
-
- if (!isInternalCall) {
- // Check space access (write required) for external calls
- const token = extractToken(req.headers);
- const access = await evaluateSpaceAccess(slug, token, req.method, {
- getSpaceConfig,
- });
-
- if (!access.allowed) {
- return Response.json(
- { error: access.reason },
- { status: access.claims ? 403 : 401, headers: corsHeaders }
- );
- }
-
- if (access.readOnly) {
- return Response.json(
- { error: "Write access required to add shapes" },
- { status: 403, headers: corsHeaders }
- );
- }
- }
-
- try {
- // Ensure community is loaded
- await loadCommunity(slug);
- const data = getDocumentData(slug);
- if (!data) {
- return Response.json(
- { error: "Community not found" },
- { status: 404, headers: corsHeaders }
- );
- }
-
- const body = (await req.json()) as { shapes?: Record[] };
- if (!body.shapes || !Array.isArray(body.shapes) || body.shapes.length === 0) {
- return Response.json(
- { error: "shapes array is required and must not be empty" },
- { status: 400, headers: corsHeaders }
- );
- }
-
- // Add shapes to the Automerge document
- const ids = addShapes(slug, body.shapes);
-
- // Broadcast to all connected WebSocket clients
- broadcastAutomergeSync(slug);
- broadcastJsonSnapshot(slug);
-
- return Response.json(
- { ok: true, ids },
- { status: 201, headers: corsHeaders }
- );
- } catch (e) {
- console.error("Failed to add shapes:", e);
- return Response.json(
- { error: "Failed to add shapes" },
- { status: 500, headers: corsHeaders }
- );
- }
- }
-
- // PATCH /api/communities/:slug/shapes/:shapeId — Update shape fields (bidirectional sync)
- const shapeUpdateMatch = url.pathname.match(
- /^\/api\/communities\/([^/]+)\/shapes\/([^/]+)$/
- );
- if (shapeUpdateMatch && req.method === "PATCH") {
- const slug = shapeUpdateMatch[1];
- const shapeId = shapeUpdateMatch[2];
-
- // Allow internal service-to-service calls with shared key
- const internalKey = req.headers.get("X-Internal-Key");
- const isInternalCall = INTERNAL_API_KEY && internalKey === INTERNAL_API_KEY;
-
- if (!isInternalCall) {
- const token = extractToken(req.headers);
- const access = await evaluateSpaceAccess(slug, token, "PATCH", {
- getSpaceConfig,
- });
- if (!access.allowed) {
- return Response.json(
- { error: access.reason },
- { status: access.claims ? 403 : 401, headers: corsHeaders }
- );
- }
- }
-
- try {
- await loadCommunity(slug);
- const body = (await req.json()) as Record;
- const updated = updateShapeFields(slug, shapeId, body);
- if (!updated) {
- return Response.json(
- { error: "Shape not found" },
- { status: 404, headers: corsHeaders }
- );
- }
-
- // Broadcast to connected clients
- broadcastAutomergeSync(slug);
- broadcastJsonSnapshot(slug);
-
- return Response.json({ ok: true }, { headers: corsHeaders });
- } catch (e) {
- console.error("Failed to update shape fields:", e);
- return Response.json(
- { error: "Failed to update shape" },
- { status: 500, headers: corsHeaders }
- );
- }
- }
-
- return Response.json({ error: "Not found" }, { status: 404, headers: corsHeaders });
-}
-
-// Ensure demo communities exist on startup
-ensureDemoCommunity().then(() => {
- console.log("[Demo] Demo community ready");
-}).catch((e) => {
- console.error("[Demo] Failed to initialize demo community:", e);
-});
-
-ensureCampaignDemo().then(() => {
- console.log("[Campaign] Campaign demo community ready");
-}).catch((e) => {
- console.error("[Campaign] Failed to initialize campaign demo:", e);
-});
-
-console.log(`rSpace server running on http://localhost:${PORT}`);
+console.log(`rSpace unified server running on http://localhost:${PORT}`);
+console.log(`Modules: ${getAllModules().map((m) => `${m.icon} ${m.name}`).join(", ")}`);
diff --git a/server/shell.ts b/server/shell.ts
new file mode 100644
index 0000000..6a6023c
--- /dev/null
+++ b/server/shell.ts
@@ -0,0 +1,135 @@
+/**
+ * Shell HTML renderer.
+ *
+ * Wraps module content in the shared rSpace layout: header with app/space
+ * switchers + identity, with module content, shell script + styles.
+ *
+ * In standalone mode, modules call renderStandaloneShell() which omits the
+ * app/space switchers and only includes identity.
+ */
+
+import type { ModuleInfo } from "../shared/module";
+
+export interface ShellOptions {
+ /** Page */
+ title: string;
+ /** Current module ID (highlighted in app switcher) */
+ moduleId: string;
+ /** Current space slug */
+ spaceSlug: string;
+ /** Space display name */
+ spaceName?: string;
+ /** Module HTML content to inject into */
+ body: string;
+ /** Additional
+ ${scripts}
+