From eed7b2f151c7f9c8893f7245b2f3b423dfe849f8 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 20 Feb 2026 21:54:15 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20unified=20module=20system=20=E2=80=94?= =?UTF-8?q?=20Phase=200=20shell=20+=20Phase=201=20canvas=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the rSpace module architecture that enables all r-suite apps to run as modules within a single-origin platform at rspace.online, while each module can still deploy standalone at its own domain. Phase 0 — Shell + Module System: - RSpaceModule interface (shared/module.ts) with routes, metadata, hooks - Shell HTML renderer (server/shell.ts) for wrapping module content - Three header web components: rstack-app-switcher, rstack-space-switcher, rstack-identity (refactored from rspace-header.ts into Shadow DOM) - Space registry API (server/spaces.ts) — /api/spaces CRUD - Hono-based server (server/index.ts) replacing raw Bun.serve fetch handler while preserving all WebSocket, API, and subdomain backward compat - Shared PostgreSQL with per-module schema isolation (rbooks, rcart, etc.) - Vite multi-entry build: shell.js + shell.css built alongside existing entries - Module info API: GET /api/modules returns registered module metadata Phase 1 — Canvas Module: - modules/canvas/mod.ts exports canvasModule as first RSpaceModule - Canvas routes mounted at /:space/canvas with shell wrapper - Fallback serves existing canvas.html for backward compatibility - /:space redirects to /:space/canvas URL structure: rspace.online/{space}/{module} (e.g. /demo/canvas) All existing subdomain routing (*.rspace.online) preserved. Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 2 + db/init.sql | 16 + docker-compose.yml | 36 +- modules/canvas/mod.ts | 58 ++ server/index.ts | 871 ++++++++------------- server/shell.ts | 135 ++++ server/spaces.ts | 136 ++++ shared/components/rstack-app-switcher.ts | 154 ++++ shared/components/rstack-identity.ts | 513 ++++++++++++ shared/components/rstack-space-switcher.ts | 193 +++++ shared/module.ts | 60 ++ tsconfig.json | 6 +- vite.config.ts | 29 + website/public/shell.css | 92 +++ website/shell.ts | 17 + 15 files changed, 1766 insertions(+), 552 deletions(-) create mode 100644 db/init.sql create mode 100644 modules/canvas/mod.ts create mode 100644 server/shell.ts create mode 100644 server/spaces.ts create mode 100644 shared/components/rstack-app-switcher.ts create mode 100644 shared/components/rstack-identity.ts create mode 100644 shared/components/rstack-space-switcher.ts create mode 100644 shared/module.ts create mode 100644 website/public/shell.css create mode 100644 website/shell.ts 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 <main> */ + body: string; + /** Additional <script type="module"> tags for module-specific JS */ + scripts?: string; + /** Additional <link>/<style> tags for module-specific CSS */ + styles?: string; + /** List of available modules (for app switcher) */ + modules: ModuleInfo[]; + /** Theme for the header: 'dark' or 'light' */ + theme?: "dark" | "light"; + /** Extra <head> content (meta tags, preloads, etc.) */ + head?: string; +} + +export function renderShell(opts: ShellOptions): string { + const { + title, + moduleId, + spaceSlug, + spaceName, + body, + scripts = "", + styles = "", + modules, + theme = "light", + head = "", + } = opts; + + const moduleListJSON = JSON.stringify(modules); + + return `<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌌</text></svg>"> + <title>${escapeHtml(title)} + + ${styles} + ${head} + + +
+
+ + +
+
+ +
+
+
+ ${body} +
+ + ${scripts} + +`; +} + +/** Minimal shell for standalone module deployments (no app/space switcher) */ +export function renderStandaloneShell(opts: { + title: string; + body: string; + scripts?: string; + styles?: string; + theme?: "dark" | "light"; + head?: string; +}): string { + const { title, body, scripts = "", styles = "", theme = "light", head = "" } = opts; + + return ` + + + + + ${escapeHtml(title)} + + ${styles} + ${head} + + +
+ +
+ +
+
+
+ ${body} +
+ + ${scripts} + +`; +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function escapeAttr(s: string): string { + return s.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); +} diff --git a/server/spaces.ts b/server/spaces.ts new file mode 100644 index 0000000..00b294b --- /dev/null +++ b/server/spaces.ts @@ -0,0 +1,136 @@ +/** + * Space registry — CRUD for rSpace spaces. + * + * Spaces are stored as Automerge CRDT documents (extending the existing + * community-store pattern). This module provides Hono routes for listing, + * creating, and managing spaces. + */ + +import { Hono } from "hono"; +import { + communityExists, + createCommunity, + loadCommunity, + getDocumentData, + listCommunities, +} from "./community-store"; +import type { SpaceVisibility } from "./community-store"; +import { + verifyEncryptIDToken, + extractToken, +} from "@encryptid/sdk/server"; +import type { EncryptIDClaims } from "@encryptid/sdk/server"; +import { getAllModules } from "../shared/module"; + +const spaces = new Hono(); + +// ── List all spaces (public + user's own) ── + +spaces.get("/", async (c) => { + const slugs = await listCommunities(); + + const spacesList = []; + for (const slug of slugs) { + await loadCommunity(slug); + const data = getDocumentData(slug); + if (data?.meta) { + const vis = data.meta.visibility || "public_read"; + // Only include public/public_read spaces in the public listing + if (vis === "public" || vis === "public_read") { + spacesList.push({ + slug: data.meta.slug, + name: data.meta.name, + visibility: vis, + createdAt: data.meta.createdAt, + }); + } + } + } + + return c.json({ spaces: spacesList }); +}); + +// ── Create a new space (requires auth) ── + +spaces.post("/", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) { + return c.json({ error: "Authentication required" }, 401); + } + + let claims: EncryptIDClaims; + try { + claims = await verifyEncryptIDToken(token); + } catch { + return c.json({ error: "Invalid or expired token" }, 401); + } + + const 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. Must be one of: ${validVisibilities.join(", ")}` }, 400); + } + + if (await communityExists(slug)) { + return c.json({ error: "Space already exists" }, 409); + } + + await createCommunity(name, slug, claims.sub, visibility); + + // Notify all modules about the new space + for (const mod of getAllModules()) { + if (mod.onSpaceCreate) { + try { + await mod.onSpaceCreate(slug); + } catch (e) { + console.error(`[Spaces] Module ${mod.id} onSpaceCreate failed:`, e); + } + } + } + + return c.json({ + slug, + name, + visibility, + ownerDID: claims.sub, + url: `/${slug}/canvas`, + }, 201); +}); + +// ── Get space info ── + +spaces.get("/:slug", async (c) => { + const slug = c.req.param("slug"); + await loadCommunity(slug); + const data = getDocumentData(slug); + + if (!data) { + return c.json({ error: "Space not found" }, 404); + } + + return c.json({ + slug: data.meta.slug, + name: data.meta.name, + visibility: data.meta.visibility, + createdAt: data.meta.createdAt, + ownerDID: data.meta.ownerDID, + memberCount: Object.keys(data.members || {}).length, + }); +}); + +export { spaces }; diff --git a/shared/components/rstack-app-switcher.ts b/shared/components/rstack-app-switcher.ts new file mode 100644 index 0000000..7718bcd --- /dev/null +++ b/shared/components/rstack-app-switcher.ts @@ -0,0 +1,154 @@ +/** + * — Dropdown to switch between rSpace modules. + * + * Attributes: + * current — the active module ID (highlighted) + * + * Methods: + * setModules(list) — provide the list of available modules + */ + +export interface AppSwitcherModule { + id: string; + name: string; + icon: string; + description: string; +} + +export class RStackAppSwitcher extends HTMLElement { + #shadow: ShadowRoot; + #modules: AppSwitcherModule[] = []; + + constructor() { + super(); + this.#shadow = this.attachShadow({ mode: "open" }); + } + + static get observedAttributes() { + return ["current"]; + } + + get current(): string { + return this.getAttribute("current") || ""; + } + + connectedCallback() { + this.#render(); + } + + attributeChangedCallback() { + this.#render(); + } + + setModules(modules: AppSwitcherModule[]) { + this.#modules = modules; + this.#render(); + } + + #render() { + const current = this.current; + const currentMod = this.#modules.find((m) => m.id === current); + const label = currentMod ? `${currentMod.icon} ${currentMod.name}` : "🌌 rSpace"; + + this.#shadow.innerHTML = ` + +
+ + +
+ `; + + const trigger = this.#shadow.getElementById("trigger")!; + const menu = this.#shadow.getElementById("menu")!; + + trigger.addEventListener("click", (e) => { + e.stopPropagation(); + menu.classList.toggle("open"); + }); + + document.addEventListener("click", () => menu.classList.remove("open")); + } + + #getSpaceSlug(): string { + // Read from the space switcher or URL + const spaceSwitcher = document.querySelector("rstack-space-switcher"); + if (spaceSwitcher) return spaceSwitcher.getAttribute("current") || "personal"; + // Fallback: parse from URL (/:space/:module) + const parts = window.location.pathname.split("/").filter(Boolean); + return parts[0] || "personal"; + } + + static define(tag = "rstack-app-switcher") { + if (!customElements.get(tag)) customElements.define(tag, RStackAppSwitcher); + } +} + +const STYLES = ` +:host { display: contents; } + +.switcher { position: relative; } + +.trigger { + display: flex; align-items: center; gap: 6px; + padding: 6px 14px; border-radius: 8px; border: none; + font-size: 0.9rem; font-weight: 600; cursor: pointer; + transition: background 0.15s; + background: rgba(255,255,255,0.08); color: inherit; +} +:host-context([data-theme="light"]) .trigger { + background: rgba(0,0,0,0.05); color: #0f172a; +} +:host-context([data-theme="dark"]) .trigger { + background: rgba(255,255,255,0.08); color: #e2e8f0; +} +.trigger:hover { background: rgba(255,255,255,0.12); } +:host-context([data-theme="light"]) .trigger:hover { background: rgba(0,0,0,0.08); } + +.caret { font-size: 0.7em; opacity: 0.6; } + +.menu { + position: absolute; top: 100%; left: 0; margin-top: 6px; + min-width: 260px; border-radius: 12px; overflow: hidden; + box-shadow: 0 8px 30px rgba(0,0,0,0.25); display: none; z-index: 200; +} +.menu.open { display: block; } +:host-context([data-theme="light"]) .menu { + background: white; border: 1px solid rgba(0,0,0,0.1); +} +:host-context([data-theme="dark"]) .menu { + background: #1e293b; border: 1px solid rgba(255,255,255,0.1); +} + +.item { + display: flex; align-items: center; gap: 12px; + padding: 10px 14px; text-decoration: none; + transition: background 0.12s; cursor: pointer; +} +:host-context([data-theme="light"]) .item { color: #374151; } +:host-context([data-theme="light"]) .item:hover { background: #f1f5f9; } +:host-context([data-theme="light"]) .item.active { background: #e0f2fe; } +:host-context([data-theme="dark"]) .item { color: #e2e8f0; } +:host-context([data-theme="dark"]) .item:hover { background: rgba(255,255,255,0.05); } +:host-context([data-theme="dark"]) .item.active { background: rgba(6,182,212,0.1); } + +.item-icon { font-size: 1.3rem; width: 28px; text-align: center; flex-shrink: 0; } +.item-text { display: flex; flex-direction: column; min-width: 0; } +.item-name { font-size: 0.875rem; font-weight: 600; } +.item-desc { font-size: 0.75rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +`; diff --git a/shared/components/rstack-identity.ts b/shared/components/rstack-identity.ts new file mode 100644 index 0000000..5a6b343 --- /dev/null +++ b/shared/components/rstack-identity.ts @@ -0,0 +1,513 @@ +/** + * — Custom element for EncryptID sign-in/sign-out. + * + * Renders either a "Sign In" button or the user avatar + dropdown. + * Contains the full WebAuthn auth modal (sign-in + register). + * Refactored from lib/rspace-header.ts into a standalone web component. + */ + +const SESSION_KEY = "encryptid_session"; +const ENCRYPTID_URL = "https://auth.rspace.online"; + +interface SessionState { + accessToken: string; + claims: { + sub: string; + exp: number; + username?: string; + did?: string; + eid: { + authLevel: number; + capabilities: { encrypt: boolean; sign: boolean; wallet: boolean }; + }; + }; +} + +// ── Session helpers (exported for use by other code) ── + +export function getSession(): SessionState | null { + try { + const stored = localStorage.getItem(SESSION_KEY); + if (!stored) return null; + const session = JSON.parse(stored) as SessionState; + if (Math.floor(Date.now() / 1000) >= session.claims.exp) { + localStorage.removeItem(SESSION_KEY); + localStorage.removeItem("rspace-username"); + return null; + } + return session; + } catch { + return null; + } +} + +export function clearSession(): void { + localStorage.removeItem(SESSION_KEY); + localStorage.removeItem("rspace-username"); +} + +export function isAuthenticated(): boolean { + return getSession() !== null; +} + +export function getAccessToken(): string | null { + return getSession()?.accessToken ?? null; +} + +export function getUserDID(): string | null { + return getSession()?.claims.did ?? getSession()?.claims.sub ?? null; +} + +export function getUsername(): string | null { + return getSession()?.claims.username ?? null; +} + +// ── Helpers ── + +function base64urlToBuffer(b64url: string): ArrayBuffer { + const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/"); + const pad = "=".repeat((4 - (b64.length % 4)) % 4); + const bin = atob(b64 + pad); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes.buffer; +} + +function bufferToBase64url(buf: ArrayBuffer): string { + const bytes = new Uint8Array(buf); + let bin = ""; + for (let i = 0; i < bytes.byteLength; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); +} + +function parseJWT(token: string): Record { + const parts = token.split("."); + if (parts.length < 2) return {}; + try { + const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const pad = "=".repeat((4 - (b64.length % 4)) % 4); + return JSON.parse(atob(b64 + pad)); + } catch { + return {}; + } +} + +function storeSession(token: string, username: string, did: string): void { + const payload = parseJWT(token) as Record; + const session: SessionState = { + accessToken: token, + claims: { + sub: payload.sub || "", + exp: payload.exp || 0, + username, + did, + eid: payload.eid || { authLevel: 3, capabilities: { encrypt: true, sign: true, wallet: false } }, + }, + }; + localStorage.setItem(SESSION_KEY, JSON.stringify(session)); + if (username) localStorage.setItem("rspace-username", username); +} + +// ── The custom element ── + +export class RStackIdentity extends HTMLElement { + #shadow: ShadowRoot; + + constructor() { + super(); + this.#shadow = this.attachShadow({ mode: "open" }); + } + + connectedCallback() { + this.#render(); + } + + #render() { + const session = getSession(); + const theme = this.closest("[data-theme]")?.getAttribute("data-theme") || "light"; + + if (session) { + const username = session.claims.username || ""; + const did = session.claims.did || session.claims.sub; + const displayName = username || (did.length > 24 ? did.slice(0, 16) + "..." + did.slice(-6) : did); + const initial = username ? username[0].toUpperCase() : did.slice(8, 10).toUpperCase(); + + this.#shadow.innerHTML = ` + +
+
${initial}
+ ${displayName} + +
+ `; + + const toggle = this.#shadow.getElementById("user-toggle")!; + const dropdown = this.#shadow.getElementById("dropdown")!; + + toggle.addEventListener("click", (e) => { + e.stopPropagation(); + dropdown.classList.toggle("open"); + }); + + document.addEventListener("click", () => dropdown.classList.remove("open")); + + this.#shadow.querySelectorAll("[data-action]").forEach((el) => { + el.addEventListener("click", (e) => { + e.stopPropagation(); + const action = (el as HTMLElement).dataset.action; + dropdown.classList.remove("open"); + if (action === "signout") { + clearSession(); + this.#render(); + this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true })); + } else if (action === "profile") { + window.open(ENCRYPTID_URL, "_blank"); + } else if (action === "recovery") { + window.open(`${ENCRYPTID_URL}/recover`, "_blank"); + } + }); + }); + } else { + this.#shadow.innerHTML = ` + + + `; + + this.#shadow.getElementById("signin-btn")!.addEventListener("click", () => { + this.showAuthModal(); + }); + } + } + + /** Public method: show the auth modal programmatically */ + showAuthModal(callbacks?: { onSuccess?: () => void; onCancel?: () => void }): void { + if (document.querySelector(".rstack-auth-overlay")) return; + + const overlay = document.createElement("div"); + overlay.className = "rstack-auth-overlay"; + let mode: "signin" | "register" = "signin"; + + const render = () => { + overlay.innerHTML = mode === "signin" ? signinHTML() : registerHTML(); + attachListeners(); + }; + + const signinHTML = () => ` + +
+

Sign in with EncryptID

+

Use your passkey to sign in. No passwords needed.

+
+ + +
+
+
Don't have an account? Create one
+
+ `; + + const registerHTML = () => ` + +
+

Create your EncryptID

+

Set up a secure, passwordless identity.

+ +
+ + +
+
+
Already have an account? Sign in
+
+ `; + + const close = () => { + overlay.remove(); + }; + + const handleSignIn = async () => { + const errEl = overlay.querySelector("#auth-error") as HTMLElement; + const btn = overlay.querySelector('[data-action="signin"]') as HTMLButtonElement; + errEl.textContent = ""; + btn.disabled = true; + btn.innerHTML = ' Authenticating...'; + + try { + const startRes = await fetch(`${ENCRYPTID_URL}/api/auth/start`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + if (!startRes.ok) throw new Error("Failed to start authentication"); + const { options: serverOptions } = await startRes.json(); + + const credential = (await navigator.credentials.get({ + publicKey: { + challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)), + rpId: serverOptions.rpId || "rspace.online", + userVerification: "required", + timeout: 60000, + }, + })) as PublicKeyCredential; + if (!credential) throw new Error("Authentication failed"); + + const completeRes = await fetch(`${ENCRYPTID_URL}/api/auth/complete`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + challenge: serverOptions.challenge, + credential: { credentialId: bufferToBase64url(credential.rawId) }, + }), + }); + const data = await completeRes.json(); + if (!completeRes.ok || !data.success) throw new Error(data.error || "Authentication failed"); + + storeSession(data.token, data.username || "", data.did || ""); + close(); + this.#render(); + this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true })); + callbacks?.onSuccess?.(); + } catch (err: any) { + btn.disabled = false; + btn.innerHTML = "🔑 Sign In with Passkey"; + errEl.textContent = err.name === "NotAllowedError" ? "Authentication was cancelled." : err.message || "Authentication failed."; + } + }; + + const handleRegister = async () => { + const usernameInput = overlay.querySelector("#auth-username") as HTMLInputElement; + const errEl = overlay.querySelector("#auth-error") as HTMLElement; + const btn = overlay.querySelector('[data-action="register"]') as HTMLButtonElement; + const username = usernameInput.value.trim(); + + if (!username) { + errEl.textContent = "Please enter a username."; + usernameInput.focus(); + return; + } + + errEl.textContent = ""; + btn.disabled = true; + btn.innerHTML = ' Creating passkey...'; + + try { + const startRes = await fetch(`${ENCRYPTID_URL}/api/register/start`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, displayName: username }), + }); + if (!startRes.ok) throw new Error("Failed to start registration"); + const { options: serverOptions, userId } = await startRes.json(); + + const credential = (await navigator.credentials.create({ + publicKey: { + challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)), + rp: { id: serverOptions.rp?.id || "rspace.online", name: serverOptions.rp?.name || "EncryptID" }, + user: { id: new Uint8Array(base64urlToBuffer(serverOptions.user.id)), name: username, displayName: username }, + pubKeyCredParams: serverOptions.pubKeyCredParams || [ + { alg: -7, type: "public-key" as const }, + { alg: -257, type: "public-key" as const }, + ], + authenticatorSelection: { residentKey: "required", requireResidentKey: true, userVerification: "required" }, + attestation: "none", + timeout: 60000, + }, + })) as PublicKeyCredential; + if (!credential) throw new Error("Failed to create credential"); + + const response = credential.response as AuthenticatorAttestationResponse; + const publicKey = response.getPublicKey?.(); + + const completeRes = await fetch(`${ENCRYPTID_URL}/api/register/complete`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + challenge: serverOptions.challenge, + credential: { + credentialId: bufferToBase64url(credential.rawId), + publicKey: publicKey ? bufferToBase64url(publicKey) : "", + transports: response.getTransports?.() || [], + }, + userId, + username, + }), + }); + const data = await completeRes.json(); + if (!completeRes.ok || !data.success) throw new Error(data.error || "Registration failed"); + + storeSession(data.token, username, data.did || ""); + close(); + this.#render(); + this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true })); + callbacks?.onSuccess?.(); + } catch (err: any) { + btn.disabled = false; + btn.innerHTML = "🔐 Create Passkey"; + errEl.textContent = err.name === "NotAllowedError" ? "Passkey creation was cancelled." : err.message || "Registration failed."; + } + }; + + const attachListeners = () => { + overlay.querySelector('[data-action="cancel"]')?.addEventListener("click", () => { + close(); + callbacks?.onCancel?.(); + }); + overlay.querySelector('[data-action="signin"]')?.addEventListener("click", handleSignIn); + overlay.querySelector('[data-action="register"]')?.addEventListener("click", handleRegister); + overlay.querySelector('[data-action="switch-register"]')?.addEventListener("click", () => { + mode = "register"; + render(); + setTimeout(() => (overlay.querySelector("#auth-username") as HTMLInputElement)?.focus(), 50); + }); + overlay.querySelector('[data-action="switch-signin"]')?.addEventListener("click", () => { + mode = "signin"; + render(); + }); + overlay.querySelector("#auth-username")?.addEventListener("keydown", (e) => { + if ((e as KeyboardEvent).key === "Enter") handleRegister(); + }); + overlay.addEventListener("click", (e) => { + if (e.target === overlay) { + close(); + callbacks?.onCancel?.(); + } + }); + }; + + document.body.appendChild(overlay); + render(); + } + + static define(tag = "rstack-identity") { + if (!customElements.get(tag)) customElements.define(tag, RStackIdentity); + } +} + +// ── Require auth helper (for use by module code) ── + +export function requireAuth(onAuthenticated: () => void): boolean { + if (isAuthenticated()) return true; + const el = document.querySelector("rstack-identity") as RStackIdentity | null; + if (el) { + el.showAuthModal({ onSuccess: onAuthenticated }); + } + return false; +} + +// ── Styles ── + +const STYLES = ` +:host { display: contents; } + +.signin-btn { + display: flex; align-items: center; gap: 8px; + padding: 8px 20px; border-radius: 8px; border: none; + font-size: 0.875rem; font-weight: 600; cursor: pointer; + transition: all 0.2s; text-decoration: none; +} +.signin-btn.light { + background: linear-gradient(135deg, #06b6d4, #0891b2); color: white; +} +.signin-btn.dark { + background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white; +} +.signin-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(6,182,212,0.3); } + +.user { + display: flex; align-items: center; gap: 10px; + position: relative; cursor: pointer; +} +.avatar { + width: 34px; height: 34px; border-radius: 50%; + background: linear-gradient(135deg, #06b6d4, #7c3aed); + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: 0.8rem; color: white; +} +.name { + font-size: 0.8rem; max-width: 140px; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.user.light .name { color: #64748b; } +.user.dark .name { color: #94a3b8; } + +.dropdown { + position: absolute; top: 100%; right: 0; margin-top: 8px; + min-width: 200px; border-radius: 10px; overflow: hidden; + box-shadow: 0 8px 30px rgba(0,0,0,0.2); display: none; z-index: 100; +} +.dropdown.open { display: block; } +.user.light .dropdown { background: white; border: 1px solid rgba(0,0,0,0.1); } +.user.dark .dropdown { background: #1e293b; border: 1px solid rgba(255,255,255,0.1); } + +.dropdown-item { + display: flex; align-items: center; gap: 10px; + padding: 12px 16px; font-size: 0.875rem; cursor: pointer; + transition: background 0.15s; border: none; background: none; + width: 100%; text-align: left; +} +.user.light .dropdown-item { color: #374151; } +.user.light .dropdown-item:hover { background: #f1f5f9; } +.user.dark .dropdown-item { color: #e2e8f0; } +.user.dark .dropdown-item:hover { background: rgba(255,255,255,0.05); } +.dropdown-item--danger { color: #ef4444 !important; } + +.dropdown-divider { height: 1px; margin: 4px 0; } +.user.light .dropdown-divider { background: rgba(0,0,0,0.08); } +.user.dark .dropdown-divider { background: rgba(255,255,255,0.08); } +`; + +const MODAL_STYLES = ` +.rstack-auth-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.6); + backdrop-filter: blur(4px); display: flex; align-items: center; + justify-content: center; z-index: 10000; animation: fadeIn 0.2s; +} +.auth-modal { + background: #1e293b; border: 1px solid rgba(255,255,255,0.1); + border-radius: 16px; padding: 2rem; max-width: 420px; width: 90%; + text-align: center; color: white; box-shadow: 0 20px 60px rgba(0,0,0,0.4); + animation: slideUp 0.3s; +} +.auth-modal h2 { + font-size: 1.5rem; margin-bottom: 0.5rem; + background: linear-gradient(135deg, #06b6d4, #7c3aed); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; +} +.auth-modal p { color: #94a3b8; font-size: 0.95rem; line-height: 1.6; margin-bottom: 1.5rem; } +.input { + width: 100%; padding: 12px 16px; border-radius: 8px; + border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05); + color: white; font-size: 1rem; margin-bottom: 1rem; outline: none; + transition: border-color 0.2s; box-sizing: border-box; +} +.input:focus { border-color: #06b6d4; } +.input::placeholder { color: #64748b; } +.actions { display: flex; gap: 12px; margin-top: 0.5rem; } +.btn { + flex: 1; padding: 12px 20px; border-radius: 8px; border: none; + font-size: 0.95rem; font-weight: 600; cursor: pointer; transition: all 0.2s; +} +.btn--primary { background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white; } +.btn--primary:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(6,182,212,0.3); } +.btn--primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } +.btn--secondary { background: rgba(255,255,255,0.08); color: #94a3b8; border: 1px solid rgba(255,255,255,0.1); } +.btn--secondary:hover { background: rgba(255,255,255,0.12); color: white; } +.error { color: #ef4444; font-size: 0.85rem; margin-top: 0.5rem; min-height: 1.2em; } +.toggle { margin-top: 1rem; font-size: 0.85rem; color: #64748b; } +.toggle a { color: #06b6d4; cursor: pointer; text-decoration: none; } +.toggle a:hover { text-decoration: underline; } +.spinner { + display: inline-block; width: 18px; height: 18px; + border: 2px solid transparent; border-top-color: currentColor; + border-radius: 50%; animation: spin 0.7s linear infinite; + vertical-align: middle; margin-right: 6px; +} +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } +@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } +@keyframes spin { to { transform: rotate(360deg); } } +`; diff --git a/shared/components/rstack-space-switcher.ts b/shared/components/rstack-space-switcher.ts new file mode 100644 index 0000000..eb09b40 --- /dev/null +++ b/shared/components/rstack-space-switcher.ts @@ -0,0 +1,193 @@ +/** + * — Dropdown to switch between user's spaces. + * + * Attributes: + * current — the active space slug + * name — the display name of the active space + * + * Fetches the user's spaces from /api/spaces on click. + */ + +import { isAuthenticated } from "./rstack-identity"; + +interface SpaceInfo { + slug: string; + name: string; + icon?: string; +} + +export class RStackSpaceSwitcher extends HTMLElement { + #shadow: ShadowRoot; + #spaces: SpaceInfo[] = []; + #loaded = false; + + constructor() { + super(); + this.#shadow = this.attachShadow({ mode: "open" }); + } + + static get observedAttributes() { + return ["current", "name"]; + } + + get current(): string { + return this.getAttribute("current") || ""; + } + + get spaceName(): string { + return this.getAttribute("name") || this.current; + } + + connectedCallback() { + this.#render(); + } + + attributeChangedCallback() { + this.#render(); + } + + async #loadSpaces() { + if (this.#loaded) return; + try { + const res = await fetch("/api/spaces"); + if (res.ok) { + const data = await res.json(); + this.#spaces = data.spaces || []; + } + } catch { + // Offline or API not available — just show current + } + this.#loaded = true; + } + + #render() { + const current = this.current; + const name = this.spaceName; + + this.#shadow.innerHTML = ` + +
+ + +
+ `; + + const trigger = this.#shadow.getElementById("trigger")!; + const menu = this.#shadow.getElementById("menu")!; + + trigger.addEventListener("click", async (e) => { + e.stopPropagation(); + const isOpen = menu.classList.toggle("open"); + if (isOpen && !this.#loaded) { + await this.#loadSpaces(); + this.#renderMenu(menu, current); + } + }); + + document.addEventListener("click", () => menu.classList.remove("open")); + } + + #renderMenu(menu: HTMLElement, current: string) { + if (this.#spaces.length === 0) { + menu.innerHTML = ` + + + Create new space + `; + return; + } + + const moduleId = this.#getCurrentModule(); + + menu.innerHTML = ` + ${this.#spaces + .map( + (s) => ` + + ${s.icon || "🌐"} + ${s.name} + + ` + ) + .join("")} +
+ + Create new space + `; + } + + #getCurrentModule(): string { + const parts = window.location.pathname.split("/").filter(Boolean); + return parts[1] || "canvas"; + } + + static define(tag = "rstack-space-switcher") { + if (!customElements.get(tag)) customElements.define(tag, RStackSpaceSwitcher); + } +} + +const STYLES = ` +:host { display: contents; } + +.switcher { position: relative; } + +.trigger { + display: flex; align-items: center; gap: 4px; + padding: 6px 12px; border-radius: 8px; border: none; + font-size: 0.875rem; font-weight: 500; cursor: pointer; + transition: background 0.15s; background: transparent; color: inherit; +} +:host-context([data-theme="light"]) .trigger { color: #374151; } +:host-context([data-theme="dark"]) .trigger { color: #94a3b8; } +.trigger:hover { background: rgba(0,0,0,0.05); } +:host-context([data-theme="dark"]) .trigger:hover { background: rgba(255,255,255,0.05); } + +.slash { opacity: 0.4; font-weight: 300; margin-right: 2px; } +.space-name { max-width: 160px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.caret { font-size: 0.7em; opacity: 0.5; } + +.menu { + position: absolute; top: 100%; left: 0; margin-top: 6px; + min-width: 220px; max-height: 320px; overflow-y: auto; + border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.25); + display: none; z-index: 200; +} +.menu.open { display: block; } +:host-context([data-theme="light"]) .menu { background: white; border: 1px solid rgba(0,0,0,0.1); } +:host-context([data-theme="dark"]) .menu { background: #1e293b; border: 1px solid rgba(255,255,255,0.1); } + +.item { + display: flex; align-items: center; gap: 10px; + padding: 10px 14px; text-decoration: none; cursor: pointer; + transition: background 0.12s; +} +:host-context([data-theme="light"]) .item { color: #374151; } +:host-context([data-theme="light"]) .item:hover { background: #f1f5f9; } +:host-context([data-theme="light"]) .item.active { background: #e0f2fe; } +:host-context([data-theme="dark"]) .item { color: #e2e8f0; } +:host-context([data-theme="dark"]) .item:hover { background: rgba(255,255,255,0.05); } +:host-context([data-theme="dark"]) .item.active { background: rgba(6,182,212,0.1); } + +.item-icon { font-size: 1.1rem; flex-shrink: 0; } +.item-name { font-size: 0.875rem; font-weight: 500; } + +.item--create { + font-size: 0.85rem; font-weight: 600; color: #06b6d4 !important; +} +.item--create:hover { background: rgba(6,182,212,0.08) !important; } + +.divider { height: 1px; margin: 4px 0; } +:host-context([data-theme="light"]) .divider { background: rgba(0,0,0,0.08); } +:host-context([data-theme="dark"]) .divider { background: rgba(255,255,255,0.08); } + +.menu-loading, .menu-empty { + padding: 16px; text-align: center; font-size: 0.85rem; color: #94a3b8; +} +`; diff --git a/shared/module.ts b/shared/module.ts new file mode 100644 index 0000000..022acb4 --- /dev/null +++ b/shared/module.ts @@ -0,0 +1,60 @@ +import { Hono } from "hono"; + +/** + * The contract every rSpace module must implement. + * + * A module is a self-contained feature area (books, pubs, cart, canvas, etc.) + * that exposes Hono routes and metadata. The shell mounts these routes under + * `/:space/{moduleId}` in unified mode. In standalone mode, the module's own + * `standalone.ts` mounts them at the root with a minimal shell. + */ +export interface RSpaceModule { + /** Short identifier used in URLs: 'books', 'pubs', 'cart', 'canvas', etc. */ + id: string; + /** Human-readable name: 'rBooks', 'rPubs', 'rCart', etc. */ + name: string; + /** Emoji or SVG string for the app switcher */ + icon: string; + /** One-line description */ + description: string; + /** Mountable Hono sub-app. Routes are relative to the mount point. */ + routes: Hono; + /** Optional: standalone domain for this module (e.g. 'rbooks.online') */ + standaloneDomain?: string; + /** Called when a new space is created (e.g. to initialize module-specific data) */ + onSpaceCreate?: (spaceSlug: string) => Promise; + /** Called when a space is deleted (e.g. to clean up module-specific data) */ + onSpaceDelete?: (spaceSlug: string) => Promise; +} + +/** Registry of all loaded modules */ +const modules = new Map(); + +export function registerModule(mod: RSpaceModule): void { + modules.set(mod.id, mod); +} + +export function getModule(id: string): RSpaceModule | undefined { + return modules.get(id); +} + +export function getAllModules(): RSpaceModule[] { + return Array.from(modules.values()); +} + +/** Metadata exposed to the client for the app switcher */ +export interface ModuleInfo { + id: string; + name: string; + icon: string; + description: string; +} + +export function getModuleInfoList(): ModuleInfo[] { + return getAllModules().map((m) => ({ + id: m.id, + name: m.name, + icon: m.icon, + description: m.description, + })); +} diff --git a/tsconfig.json b/tsconfig.json index 1c15fc9..5f05587 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,9 +18,11 @@ "paths": { "@lib": ["lib"], "@lib/*": ["lib/*"], - "@encryptid/*": ["src/encryptid/*"] + "@encryptid/*": ["src/encryptid/*"], + "@shared": ["shared"], + "@shared/*": ["shared/*"] } }, "include": ["**/*.ts", "vite.config.ts"], - "exclude": ["node_modules/**/*", "dist/**/*", "src/encryptid/**/*", "website/sw.ts"] + "exclude": ["node_modules/**/*", "dist/**/*", "src/encryptid/**/*"] } diff --git a/vite.config.ts b/vite.config.ts index fc11b08..9853669 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -32,6 +32,32 @@ export default defineConfig({ }, }, }); + + // Build shell.ts as a standalone JS bundle + await build({ + configFile: false, + root: resolve(__dirname, "website"), + resolve: { + alias: { + "@lib": resolve(__dirname, "./lib"), + "@shared": resolve(__dirname, "./shared"), + }, + }, + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist"), + lib: { + entry: resolve(__dirname, "website/shell.ts"), + formats: ["es"], + fileName: () => "shell.js", + }, + rollupOptions: { + output: { + entryFileNames: "shell.js", + }, + }, + }, + }); }, }, }, @@ -40,6 +66,7 @@ export default defineConfig({ alias: { "@lib": resolve(__dirname, "./lib"), "@encryptid": resolve(__dirname, "./src/encryptid"), + "@shared": resolve(__dirname, "./shared"), }, }, build: { @@ -55,6 +82,8 @@ export default defineConfig({ }, outDir: "../dist", emptyOutDir: true, + // Copy shell.css to dist + cssCodeSplit: false, }, server: { port: 5173, diff --git a/website/public/shell.css b/website/public/shell.css new file mode 100644 index 0000000..7772dbb --- /dev/null +++ b/website/public/shell.css @@ -0,0 +1,92 @@ +/* ── rStack Shell Layout ── */ + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + min-height: 100vh; +} + +/* ── Header bar ── */ + +.rstack-header { + position: fixed; + top: 0; left: 0; right: 0; + height: 56px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + z-index: 9999; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +.rstack-header[data-theme="light"] { + background: rgba(255, 255, 255, 0.9); + border-bottom: 1px solid rgba(0, 0, 0, 0.08); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); + color: #0f172a; +} + +.rstack-header[data-theme="dark"] { + background: rgba(15, 23, 42, 0.85); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + color: #e2e8f0; +} + +.rstack-header__left { + display: flex; + align-items: center; + gap: 4px; +} + +.rstack-header__right { + display: flex; + align-items: center; + gap: 12px; +} + +.rstack-header__brand { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + font-size: 1.25rem; + font-weight: 700; + color: inherit; +} + +.rstack-header__brand-gradient { + background: linear-gradient(135deg, #14b8a6, #22d3ee); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +/* ── Main content area ── */ + +#app { + padding-top: 56px; /* Below fixed header */ + min-height: 100vh; +} + +/* When canvas module is active, make it fill the viewport */ +#app.canvas-layout { + padding-top: 56px; + height: 100vh; + overflow: hidden; +} + +/* ── Standalone mode (no app/space switcher) ── */ + +.rstack-header--standalone .rstack-header__left { + gap: 0; +} + +/* ── Mobile adjustments ── */ + +@media (max-width: 640px) { + .rstack-header { + padding: 0 12px; + } +} diff --git a/website/shell.ts b/website/shell.ts new file mode 100644 index 0000000..a7a6ac7 --- /dev/null +++ b/website/shell.ts @@ -0,0 +1,17 @@ +/** + * Shell entry point — loaded on every page. + * + * Registers the three header web components: + * + * + * + */ + +import { RStackIdentity } from "../shared/components/rstack-identity"; +import { RStackAppSwitcher } from "../shared/components/rstack-app-switcher"; +import { RStackSpaceSwitcher } from "../shared/components/rstack-space-switcher"; + +// Register all header components +RStackIdentity.define(); +RStackAppSwitcher.define(); +RStackSpaceSwitcher.define();