/** * 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 { stat } from "node:fs/promises"; 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 spaces (public + user's own/member spaces) ── spaces.get("/", async (c) => { const slugs = await listCommunities(); // Check if user is authenticated const token = extractToken(c.req.raw.headers); let claims: EncryptIDClaims | null = null; if (token) { try { claims = await verifyEncryptIDToken(token); } catch { // Invalid token — treat as unauthenticated } } const spacesList = []; for (const slug of slugs) { await loadCommunity(slug); const data = getDocumentData(slug); if (data?.meta) { const vis = data.meta.visibility || "public_read"; const isOwner = !!(claims && data.meta.ownerDID === claims.sub); const memberEntry = claims ? data.members?.[claims.sub] : undefined; const isMember = !!memberEntry; // Include if: public/public_read OR user is owner OR user is member if (vis === "public" || vis === "public_read" || isOwner || isMember) { spacesList.push({ slug: data.meta.slug, name: data.meta.name, visibility: vis, createdAt: data.meta.createdAt, role: isOwner ? "owner" : memberEntry?.role || undefined, }); } } } 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, }); }); // ── Admin: list ALL spaces with detailed stats ── spaces.get("/admin", async (c) => { const STORAGE_DIR = process.env.STORAGE_DIR || "./data/communities"; const slugs = await listCommunities(); const spacesList = []; for (const slug of slugs) { await loadCommunity(slug); const data = getDocumentData(slug); if (!data?.meta) continue; const shapes = data.shapes || {}; const members = data.members || {}; const shapeCount = Object.keys(shapes).length; const memberCount = Object.keys(members).length; // Get file size on disk let fileSizeBytes = 0; try { const s = await stat(`${STORAGE_DIR}/${slug}.automerge`); fileSizeBytes = s.size; } catch { try { const s = await stat(`${STORAGE_DIR}/${slug}.json`); fileSizeBytes = s.size; } catch { /* not on disk yet */ } } // Count shapes by type const shapeTypes: Record = {}; for (const shape of Object.values(shapes)) { const t = (shape as Record).type as string || "unknown"; shapeTypes[t] = (shapeTypes[t] || 0) + 1; } spacesList.push({ slug: data.meta.slug, name: data.meta.name, visibility: data.meta.visibility || "public_read", createdAt: data.meta.createdAt, ownerDID: data.meta.ownerDID, shapeCount, memberCount, fileSizeBytes, shapeTypes, }); } // Sort by creation date descending (newest first) spacesList.sort((a, b) => { const da = a.createdAt ? new Date(a.createdAt).getTime() : 0; const db = b.createdAt ? new Date(b.createdAt).getTime() : 0; return db - da; }); return c.json({ spaces: spacesList, total: spacesList.length }); }); export { spaces };