/** * 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, addNestedSpace, updateNestedSpace, removeNestedSpace, getNestPolicy, updateNestPolicy, capPermissions, findNestedIn, DEFAULT_COMMUNITY_NEST_POLICY, } from "./community-store"; import type { SpaceVisibility, NestPermissions, NestPolicy, SpaceRef, PendingNestRequest, NestRequestStatus, } from "./community-store"; import { verifyEncryptIDToken, extractToken, } from "@encryptid/sdk/server"; import type { EncryptIDClaims } from "@encryptid/sdk/server"; import { getAllModules } from "../shared/module"; // ── In-memory pending nest requests (move to DB later) ── const nestRequests = new Map(); let nestRequestCounter = 0; 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 }); }); // ══════════════════════════════════════════════════════════════════════════════ // NESTING API // ══════════════════════════════════════════════════════════════════════════════ // ── Get nest policy for a space ── spaces.get("/:slug/nest-policy", async (c) => { const slug = c.req.param("slug"); await loadCommunity(slug); const policy = getNestPolicy(slug); if (!policy) return c.json({ error: "Space not found" }, 404); return c.json({ nestPolicy: policy }); }); // ── Update nest policy (admin only) ── spaces.patch("/:slug/nest-policy", async (c) => { const slug = c.req.param("slug"); 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 token" }, 401); } await loadCommunity(slug); const data = getDocumentData(slug); if (!data) return c.json({ error: "Space not found" }, 404); // Must be admin or owner const member = data.members?.[claims.sub]; const isOwner = data.meta.ownerDID === claims.sub; if (!isOwner && member?.role !== 'admin') { return c.json({ error: "Admin access required" }, 403); } const body = await c.req.json>(); updateNestPolicy(slug, body); return c.json({ ok: true, nestPolicy: getNestPolicy(slug) }); }); // ── List nested spaces in a space ── spaces.get("/:slug/nest", 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); const refs = Object.values(data.nestedSpaces || {}); return c.json({ nestedSpaces: refs }); }); // ── Nest a space (create SpaceRef) — respects source space's NestPolicy ── spaces.post("/:slug/nest", async (c) => { const targetSlug = c.req.param("slug"); 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 token" }, 401); } const body = await c.req.json<{ sourceSlug: string; permissions?: Partial; filter?: SpaceRef['filter']; x?: number; y?: number; width?: number; height?: number; rotation?: number; label?: string; collapsed?: boolean; message?: string; }>(); const { sourceSlug } = body; if (!sourceSlug) return c.json({ error: "sourceSlug is required" }, 400); // Load both spaces await loadCommunity(targetSlug); await loadCommunity(sourceSlug); const targetData = getDocumentData(targetSlug); const sourceData = getDocumentData(sourceSlug); if (!targetData) return c.json({ error: "Target space not found" }, 404); if (!sourceData) return c.json({ error: "Source space not found" }, 404); // Check: requester must be admin or moderator in the TARGET space const targetMember = targetData.members?.[claims.sub]; const isTargetOwner = targetData.meta.ownerDID === claims.sub; if (!isTargetOwner && targetMember?.role !== 'admin' && targetMember?.role !== 'moderator') { return c.json({ error: "Admin or moderator role required in the target space" }, 403); } // Get source space's nest policy const policy = sourceData.meta.nestPolicy || DEFAULT_COMMUNITY_NEST_POLICY; // Check blocklist if (policy.blocklist?.includes(targetSlug)) { return c.json({ error: "Source space has blocked nesting into this space" }, 403); } // Check consent level const isOnAllowlist = policy.allowlist?.includes(targetSlug); if (policy.consent === 'closed' && !isOnAllowlist) { return c.json({ error: "Source space does not allow nesting" }, 403); } if (policy.consent === 'members' && !isOnAllowlist) { const sourceMember = sourceData.members?.[claims.sub]; const isSourceOwner = sourceData.meta.ownerDID === claims.sub; if (!isSourceOwner && !sourceMember) { return c.json({ error: "Must be a member of the source space to nest it" }, 403); } } // Build requested permissions, capped by source policy's ceiling const requestedPerms: NestPermissions = { read: body.permissions?.read ?? true, write: body.permissions?.write ?? false, addShapes: body.permissions?.addShapes ?? false, deleteShapes: body.permissions?.deleteShapes ?? false, reshare: body.permissions?.reshare ?? false, expiry: body.permissions?.expiry, }; const cappedPerms = capPermissions(requestedPerms, policy.defaultPermissions); // If consent is 'approval' and not on allowlist, create a pending request if (policy.consent === 'approval' && !isOnAllowlist) { const reqId = `nest-req-${++nestRequestCounter}`; const request: PendingNestRequest = { id: reqId, sourceSlug, targetSlug, requestedBy: claims.sub, requestedPermissions: cappedPerms, message: body.message, status: 'pending', createdAt: Date.now(), }; nestRequests.set(reqId, request); return c.json({ status: 'pending', requestId: reqId, message: 'Nest request created. Awaiting source space admin approval.', cappedPermissions: cappedPerms, }, 202); } // Consent is 'open', 'members' (passed), or allowlisted — create immediately const refId = `ref-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const ref: SpaceRef = { id: refId, sourceSlug, sourceDID: claims.sub, filter: body.filter, x: body.x ?? 100, y: body.y ?? 100, width: body.width ?? 600, height: body.height ?? 400, rotation: body.rotation ?? 0, permissions: cappedPerms, collapsed: body.collapsed ?? false, label: body.label, createdAt: Date.now(), createdBy: claims.sub, }; addNestedSpace(targetSlug, ref); return c.json({ ok: true, ref }, 201); }); // ── Get a specific nested space ref ── spaces.get("/:slug/nest/:refId", async (c) => { const slug = c.req.param("slug"); const refId = c.req.param("refId"); await loadCommunity(slug); const data = getDocumentData(slug); if (!data) return c.json({ error: "Space not found" }, 404); const ref = data.nestedSpaces?.[refId]; if (!ref) return c.json({ error: "Nested space ref not found" }, 404); return c.json({ ref }); }); // ── Update a nested space ref (permissions, filter, position) ── spaces.patch("/:slug/nest/:refId", async (c) => { const slug = c.req.param("slug"); const refId = c.req.param("refId"); 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 token" }, 401); } await loadCommunity(slug); const data = getDocumentData(slug); if (!data) return c.json({ error: "Space not found" }, 404); const ref = data.nestedSpaces?.[refId]; if (!ref) return c.json({ error: "Nested space ref not found" }, 404); // Must be admin/moderator in the nesting space OR the creator of this ref const member = data.members?.[claims.sub]; const isOwner = data.meta.ownerDID === claims.sub; const isRefCreator = ref.createdBy === claims.sub; if (!isOwner && member?.role !== 'admin' && member?.role !== 'moderator' && !isRefCreator) { return c.json({ error: "Insufficient permissions" }, 403); } const body = await c.req.json>(); updateNestedSpace(slug, refId, body); return c.json({ ok: true }); }); // ── Remove a nested space ref (un-nest) ── spaces.delete("/:slug/nest/:refId", async (c) => { const slug = c.req.param("slug"); const refId = c.req.param("refId"); 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 token" }, 401); } await loadCommunity(slug); const data = getDocumentData(slug); if (!data) return c.json({ error: "Space not found" }, 404); const ref = data.nestedSpaces?.[refId]; if (!ref) return c.json({ error: "Nested space ref not found" }, 404); // Can be removed by: // 1. Admin/owner of the nesting (target) space // 2. Creator of this ref // 3. Admin/owner of the SOURCE space (sovereignty guarantee) const member = data.members?.[claims.sub]; const isOwner = data.meta.ownerDID === claims.sub; const isRefCreator = ref.createdBy === claims.sub; let isSourceAdmin = false; await loadCommunity(ref.sourceSlug); const sourceData = getDocumentData(ref.sourceSlug); if (sourceData) { const sourceMember = sourceData.members?.[claims.sub]; isSourceAdmin = sourceData.meta.ownerDID === claims.sub || sourceMember?.role === 'admin'; } if (!isOwner && member?.role !== 'admin' && !isRefCreator && !isSourceAdmin) { return c.json({ error: "Insufficient permissions" }, 403); } removeNestedSpace(slug, refId); return c.json({ ok: true }); }); // ── Reverse lookup: where is this space nested? ── spaces.get("/:slug/nested-in", async (c) => { const slug = c.req.param("slug"); 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 token" }, 401); } // Must be admin/owner of the space to see where it's nested await loadCommunity(slug); const data = getDocumentData(slug); if (!data) return c.json({ error: "Space not found" }, 404); const member = data.members?.[claims.sub]; const isOwner = data.meta.ownerDID === claims.sub; if (!isOwner && member?.role !== 'admin') { return c.json({ error: "Admin access required" }, 403); } const nestedIn = await findNestedIn(slug); return c.json({ nestedIn }); }); // ══════════════════════════════════════════════════════════════════════════════ // NEST REQUEST API (approval flow) // ══════════════════════════════════════════════════════════════════════════════ // ── List pending nest requests for a space (admin only) ── spaces.get("/:slug/nest-requests", async (c) => { const slug = c.req.param("slug"); 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 token" }, 401); } await loadCommunity(slug); const data = getDocumentData(slug); if (!data) return c.json({ error: "Space not found" }, 404); const member = data.members?.[claims.sub]; const isOwner = data.meta.ownerDID === claims.sub; if (!isOwner && member?.role !== 'admin') { return c.json({ error: "Admin access required" }, 403); } // Find requests where this space is the SOURCE (someone wants to nest us) const requests = Array.from(nestRequests.values()) .filter(r => r.sourceSlug === slug); return c.json({ requests }); }); // ── Approve or deny a nest request ── spaces.patch("/:slug/nest-requests/:reqId", async (c) => { const slug = c.req.param("slug"); const reqId = c.req.param("reqId"); 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 token" }, 401); } await loadCommunity(slug); const data = getDocumentData(slug); if (!data) return c.json({ error: "Space not found" }, 404); const member = data.members?.[claims.sub]; const isOwner = data.meta.ownerDID === claims.sub; if (!isOwner && member?.role !== 'admin') { return c.json({ error: "Admin access required" }, 403); } const request = nestRequests.get(reqId); if (!request || request.sourceSlug !== slug) { return c.json({ error: "Nest request not found" }, 404); } if (request.status !== 'pending') { return c.json({ error: `Request already ${request.status}` }, 400); } const body = await c.req.json<{ action: 'approve' | 'deny'; modifiedPermissions?: NestPermissions; }>(); if (body.action === 'deny') { request.status = 'denied'; request.resolvedAt = Date.now(); request.resolvedBy = claims.sub; return c.json({ ok: true, status: 'denied' }); } if (body.action === 'approve') { const finalPerms = body.modifiedPermissions || request.requestedPermissions; request.status = 'approved'; request.resolvedAt = Date.now(); request.resolvedBy = claims.sub; request.modifiedPermissions = body.modifiedPermissions; // Create the actual SpaceRef in the TARGET space await loadCommunity(request.targetSlug); const refId = `ref-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const ref: SpaceRef = { id: refId, sourceSlug: request.sourceSlug, sourceDID: request.requestedBy, x: 100, y: 100, width: 600, height: 400, rotation: 0, permissions: finalPerms, collapsed: false, createdAt: Date.now(), createdBy: request.requestedBy, }; addNestedSpace(request.targetSlug, ref); return c.json({ ok: true, status: 'approved', ref }); } return c.json({ error: "action must be 'approve' or 'deny'" }, 400); }); export { spaces };