/** * 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 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, }); }); export { spaces };