/** * 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 { createTransport, type Transporter } from "nodemailer"; import { communityExists, createCommunity, deleteCommunity, loadCommunity, getDocumentData, listCommunities, addNestedSpace, updateNestedSpace, removeNestedSpace, getNestPolicy, updateNestPolicy, updateSpaceMeta, capPermissions, findNestedIn, setEncryption, setMember, removeMember, DEFAULT_COMMUNITY_NEST_POLICY, addShapes, addConnection, updateConnection, removeConnection, getConnectionPolicy, updateConnectionPolicy, } from "./community-store"; import type { SpaceVisibility, NestPermissions, NestPolicy, SpaceRef, PendingNestRequest, NestRequestStatus, } from "./community-store"; import type { FlowKind } from "../lib/layer-types"; import type { ConnectionPolicy, ConnectionPermissions, ConnectionDirection, ConnectionState, SpaceConnection, PendingConnectionRequest, ConnectionRequestStatus, } from "../lib/connection-types"; import { DEFAULT_PERSONAL_CONNECTION_POLICY, DEFAULT_COMMUNITY_CONNECTION_POLICY, invertDirection, computeMembranePermeability, } from "../lib/connection-types"; import { verifyEncryptIDToken, extractToken, } from "@encryptid/sdk/server"; import type { EncryptIDClaims } from "@encryptid/sdk/server"; import { getAllModules, getModule } from "../shared/module"; import type { SpaceLifecycleContext } from "../shared/module"; import { syncServer } from "./sync-instance"; import { seedTemplateShapes } from "./seed-template"; // ── Role types and helpers ── export type SpaceRoleString = 'viewer' | 'member' | 'moderator' | 'admin'; const ROLE_LEVELS: Record = { viewer: 0, member: 1, moderator: 2, admin: 3, }; export function roleAtLeast(actual: SpaceRoleString, required: SpaceRoleString): boolean { return ROLE_LEVELS[actual] >= ROLE_LEVELS[required]; } export async function resolveCallerRole( slug: string, claims: EncryptIDClaims | null, ): Promise<{ role: SpaceRoleString; isOwner: boolean } | null> { await loadCommunity(slug); const data = getDocumentData(slug); if (!data) return null; if (!claims) return { role: 'viewer', isOwner: false }; const isOwner = data.meta.ownerDID === claims.sub; if (isOwner) return { role: 'admin', isOwner: true }; const member = data.members?.[claims.sub]; if (member) return { role: member.role, isOwner: false }; // Non-member defaults return { role: 'viewer', isOwner: false }; } // ── Unified space creation ── export interface CreateSpaceOpts { name: string; slug: string; ownerDID: string; visibility?: SpaceVisibility; enabledModules?: string[]; source?: 'api' | 'auto-provision' | 'subdomain' | 'internal'; } export type CreateSpaceResult = | { ok: true; slug: string; name: string; visibility: SpaceVisibility; ownerDID: string } | { ok: false; error: string; status: 400 | 409 }; /** * Unified space creation: validate → create → notify modules. * All creation endpoints should call this instead of duplicating logic. */ export async function createSpace(opts: CreateSpaceOpts): Promise { const { name, slug, ownerDID, visibility = 'private', enabledModules, source = 'api' } = opts; if (!name || !slug) return { ok: false, error: "Name and slug are required", status: 400 }; if (!/^[a-z0-9-]+$/.test(slug)) return { ok: false, error: "Slug must contain only lowercase letters, numbers, and hyphens", status: 400 }; if (await communityExists(slug)) return { ok: false, error: "Space already exists", status: 409 }; // Personal spaces (auto-provisioned) get conservative connection policy; // community spaces get the broader default. const isPersonal = source === 'auto-provision'; const connectionPolicy = isPersonal ? DEFAULT_PERSONAL_CONNECTION_POLICY : DEFAULT_COMMUNITY_CONNECTION_POLICY; await createCommunity(name, slug, ownerDID, visibility, { connectionPolicy }); // If enabledModules specified, update the community doc if (enabledModules) { updateSpaceMeta(slug, { enabledModules }); } // Build lifecycle context and notify all modules const ctx: SpaceLifecycleContext = { spaceSlug: slug, ownerDID, enabledModules: enabledModules || getAllModules().map(m => m.id), syncServer, }; for (const mod of getAllModules()) { if (mod.onSpaceCreate) { try { await mod.onSpaceCreate(ctx); } catch (e) { console.error(`[createSpace:${source}] Module ${mod.id} onSpaceCreate failed:`, e); } } } // Seed generic template content (non-fatal) try { seedTemplateShapes(slug); } catch (e) { console.error(`[createSpace:${source}] Template seeding failed for ${slug}:`, e); } console.log(`[createSpace:${source}] Created space: ${slug}`); return { ok: true, slug, name, visibility, ownerDID }; } /** Build lifecycle context for an existing space. */ function buildLifecycleContext(slug: string, doc: ReturnType): SpaceLifecycleContext { return { spaceSlug: slug, ownerDID: doc?.meta?.ownerDID ?? null, enabledModules: doc?.meta?.enabledModules || getAllModules().map(m => m.id), syncServer, }; } // ── In-memory pending nest requests (move to DB later) ── const nestRequests = new Map(); let nestRequestCounter = 0; // ── In-memory pending connection requests (move to DB later) ── const connectionRequests = new Map(); let connectionRequestCounter = 0; // ── In-memory access requests (move to DB later) ── interface AccessRequest { id: string; spaceSlug: string; requesterDID: string; requesterUsername: string; message?: string; status: "pending" | "approved" | "denied"; createdAt: number; resolvedAt?: number; resolvedBy?: string; } const accessRequests = new Map(); let accessRequestCounter = 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"; const isOwner = !!(claims && data.meta.ownerDID === claims.sub); const memberEntry = claims ? data.members?.[claims.sub] : undefined; const isMember = !!memberEntry; // Determine accessibility const isPublicSpace = vis === "public"; const isPermissioned = vis === "permissioned"; const accessible = isPublicSpace || isOwner || isMember || (isPermissioned && !!claims); // For unauthenticated: only show public spaces if (!claims && !isPublicSpace) continue; // Determine relationship const relationship = isOwner ? "owner" as const : isMember ? "member" as const : slug === "demo" ? "demo" as const : "other" as const; // Check for pending access request const pendingRequest = claims ? Array.from(accessRequests.values()).some( (r) => r.spaceSlug === slug && r.requesterDID === claims.sub && r.status === "pending" ) : false; spacesList.push({ slug: data.meta.slug, name: data.meta.name, visibility: vis, createdAt: data.meta.createdAt, role: isOwner ? "owner" : memberEntry?.role || undefined, accessible, relationship, pendingRequest: pendingRequest || undefined, }); } } // Sort: user's own spaces first, then demo, then others alphabetically spacesList.sort((a, b) => { if (a.role && !b.role) return -1; if (!a.role && b.role) return 1; if (a.slug === "demo") return -1; if (b.slug === "demo") return 1; return a.name.localeCompare(b.name); }); 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; enabledModules?: string[]; }>(); const { name, slug, visibility = "private", enabledModules } = body; const validVisibilities: SpaceVisibility[] = ["public", "permissioned", "private"]; if (visibility && !validVisibilities.includes(visibility)) { return c.json({ error: `Invalid visibility. Must be one of: ${validVisibilities.join(", ")}` }, 400); } const result = await createSpace({ name: name || "", slug: slug || "", ownerDID: claims.sub, visibility, enabledModules, source: 'api', }); if (!result.ok) return c.json({ error: result.error }, result.status); return c.json({ slug: result.slug, name: result.name, visibility: result.visibility, ownerDID: result.ownerDID, url: `/${result.slug}/canvas`, }, 201); }); // ── Module configuration per space ── spaces.get("/:slug/modules", async (c) => { const slug = c.req.param("slug"); await loadCommunity(slug); const doc = getDocumentData(slug); if (!doc?.meta) return c.json({ error: "Space not found" }, 404); const allModules = getAllModules(); const enabled = doc.meta.enabledModules; // null = all const overrides = doc.meta.moduleScopeOverrides || {}; const modules = allModules.map(mod => ({ id: mod.id, name: mod.name, icon: mod.icon, enabled: enabled ? enabled.includes(mod.id) : true, scoping: { defaultScope: mod.scoping.defaultScope, userConfigurable: mod.scoping.userConfigurable, currentScope: overrides[mod.id] || mod.scoping.defaultScope, }, })); return c.json({ modules, enabledModules: enabled }); }); spaces.patch("/:slug/modules", async (c) => { const slug = c.req.param("slug"); // Auth: only space owner or admin can configure modules 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 doc = getDocumentData(slug); if (!doc?.meta) return c.json({ error: "Space not found" }, 404); if (doc.meta.ownerDID && doc.meta.ownerDID !== claims.sub) { return c.json({ error: "Only the space owner can configure modules" }, 403); } const body = await c.req.json<{ enabledModules?: string[] | null; scopeOverrides?: Record; }>(); const updates: Partial = {}; // Validate and set enabledModules if (body.enabledModules !== undefined) { if (body.enabledModules === null) { updates.enabledModules = undefined; // reset to "all enabled" } else { const validIds = new Set(getAllModules().map(m => m.id)); const invalid = body.enabledModules.filter(id => !validIds.has(id)); if (invalid.length) return c.json({ error: `Unknown modules: ${invalid.join(", ")}` }, 400); // Always include rspace (core module) const enabled = new Set(body.enabledModules); enabled.add("rspace"); updates.enabledModules = Array.from(enabled); } } // Validate and set scope overrides if (body.scopeOverrides) { const newOverrides: Record = { ...(doc.meta.moduleScopeOverrides || {}) }; for (const [modId, scope] of Object.entries(body.scopeOverrides)) { const mod = getModule(modId); if (!mod) return c.json({ error: `Unknown module: ${modId}` }, 400); if (!mod.scoping.userConfigurable) { return c.json({ error: `Module ${modId} does not support scope configuration` }, 400); } if (scope !== 'space' && scope !== 'global') { return c.json({ error: `Invalid scope '${scope}' for ${modId}` }, 400); } newOverrides[modId] = scope; } updates.moduleScopeOverrides = newOverrides; } if (Object.keys(updates).length > 0) { updateSpaceMeta(slug, updates); } return c.json({ ok: true }); }); // ── Static routes must be defined BEFORE /:slug to avoid matching as a slug ── const ADMIN_DIDS = (process.env.ADMIN_DIDS || "").split(",").filter(Boolean); function isAdmin(did: string | undefined): boolean { return !!did && ADMIN_DIDS.includes(did); } // ── Admin: list ALL spaces with detailed stats ── spaces.get("/admin", 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 token" }, 401); } if (!isAdmin(claims.sub)) return c.json({ error: "Admin access required" }, 403); 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", 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 }); }); // ── Admin: force-delete a space (bypasses owner check) ── spaces.delete("/admin/:slug", 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); } if (!isAdmin(claims.sub)) return c.json({ error: "Admin access required" }, 403); await loadCommunity(slug); const data = getDocumentData(slug); if (!data) return c.json({ error: "Space not found" }, 404); // Notify modules for (const mod of getAllModules()) { if (mod.onSpaceDelete) { try { await mod.onSpaceDelete(buildLifecycleContext(slug, data)); } catch (e) { console.error(`[Spaces] Module ${mod.id} onSpaceDelete failed:`, e); } } } // Clean up in-memory request maps for (const [id, req] of accessRequests) { if (req.spaceSlug === slug) accessRequests.delete(id); } for (const [id, req] of nestRequests) { if (req.sourceSlug === slug || req.targetSlug === slug) nestRequests.delete(id); } for (const [id, req] of connectionRequests) { if (req.fromSlug === slug || req.toSlug === slug) connectionRequests.delete(id); } // Clean up EncryptID space_members try { await fetch(`https://auth.rspace.online/api/admin/spaces/${slug}/members`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` }, }); } catch (e) { console.error(`[Admin] Failed to clean EncryptID space_members for ${slug}:`, e); } await deleteCommunity(slug); return c.json({ ok: true, message: `Space "${slug}" deleted by admin` }); }); // ── Get pending access requests for spaces the user owns ── spaces.get("/notifications", 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 token" }, 401); } const slugs = await listCommunities(); const ownedSlugs = new Set(); for (const slug of slugs) { await loadCommunity(slug); const data = getDocumentData(slug); if (data?.meta?.ownerDID === claims.sub) { ownedSlugs.add(slug); } } const requests = Array.from(accessRequests.values()).filter( (r) => ownedSlugs.has(r.spaceSlug) && r.status === "pending" ); return c.json({ requests }); }); // ── 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, }); }); // ── Delete a space (owner only) ── spaces.delete("/:slug", 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); } // Protect core spaces if (slug === "demo" || slug === "commonshub") { return c.json({ error: "Cannot delete this space" }, 403); } await loadCommunity(slug); const data = getDocumentData(slug); if (!data) return c.json({ error: "Space not found" }, 404); // Must be owner if (data.meta.ownerDID !== claims.sub) { return c.json({ error: "Only the space owner can delete a space" }, 403); } // Notify all modules about deletion for (const mod of getAllModules()) { if (mod.onSpaceDelete) { try { await mod.onSpaceDelete(buildLifecycleContext(slug, data)); } catch (e) { console.error(`[Spaces] Module ${mod.id} onSpaceDelete failed:`, e); } } } // Clean up in-memory request maps for (const [id, req] of accessRequests) { if (req.spaceSlug === slug) accessRequests.delete(id); } for (const [id, req] of nestRequests) { if (req.sourceSlug === slug || req.targetSlug === slug) nestRequests.delete(id); } for (const [id, req] of connectionRequests) { if (req.fromSlug === slug || req.toSlug === slug) connectionRequests.delete(id); } await deleteCommunity(slug); return c.json({ ok: true, message: `Space "${slug}" deleted` }); }); // ── Update space metadata (owner/admin) ── spaces.patch("/:slug", 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); } const body = await c.req.json<{ name?: string; visibility?: SpaceVisibility; description?: string }>(); if (body.visibility) { const valid: SpaceVisibility[] = ["public", "permissioned", "private"]; if (!valid.includes(body.visibility)) { return c.json({ error: `Invalid visibility. Must be one of: ${valid.join(", ")}` }, 400); } } updateSpaceMeta(slug, body); const updated = getDocumentData(slug); return c.json({ ok: true, name: updated?.meta.name, visibility: updated?.meta.visibility, description: updated?.meta.description, }); }); // ── List members of a space (owner/member) ── spaces.get("/:slug/members", 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 isOwner = data.meta.ownerDID === claims.sub; const memberEntry = data.members?.[claims.sub]; if (!isOwner && !memberEntry) { return c.json({ error: "Access denied" }, 403); } const members = Object.values(data.members || {}).map((m) => ({ ...m, isOwner: m.did === data.meta.ownerDID, })); return c.json({ members }); }); // ── Change a member's role (owner/admin) ── spaces.patch("/:slug/members/:did", async (c) => { const slug = c.req.param("slug"); const did = decodeURIComponent(c.req.param("did")); 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 isOwner = data.meta.ownerDID === claims.sub; const callerMember = data.members?.[claims.sub]; if (!isOwner && callerMember?.role !== "admin") { return c.json({ error: "Admin access required" }, 403); } // Cannot change the owner's role if (did === data.meta.ownerDID) { return c.json({ error: "Cannot change the owner's role" }, 403); } const body = await c.req.json<{ role: "viewer" | "member" | "moderator" | "admin" }>(); const validRoles = ["viewer", "member", "moderator", "admin"]; if (!validRoles.includes(body.role)) { return c.json({ error: `Invalid role. Must be one of: ${validRoles.join(", ")}` }, 400); } setMember(slug, did, body.role); return c.json({ ok: true, did, role: body.role }); }); // ── Remove a member (owner/admin) ── spaces.delete("/:slug/members/:did", async (c) => { const slug = c.req.param("slug"); const did = decodeURIComponent(c.req.param("did")); 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 isOwner = data.meta.ownerDID === claims.sub; const callerMember = data.members?.[claims.sub]; if (!isOwner && callerMember?.role !== "admin") { return c.json({ error: "Admin access required" }, 403); } // Cannot remove the owner if (did === data.meta.ownerDID) { return c.json({ error: "Cannot remove the space owner" }, 403); } removeMember(slug, did); return c.json({ ok: true }); }); // ── Get access requests for a specific space (owner/admin) ── spaces.get("/:slug/access-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 isOwner = data.meta.ownerDID === claims.sub; const member = data.members?.[claims.sub]; if (!isOwner && member?.role !== "admin") { return c.json({ error: "Admin access required" }, 403); } const requests = Array.from(accessRequests.values()) .filter((r) => r.spaceSlug === slug); return c.json({ requests }); }); // ══════════════════════════════════════════════════════════════════════════════ // 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); }); // ══════════════════════════════════════════════════════════════════════════════ // CONNECTION API — bilateral typed relationships between spaces // ══════════════════════════════════════════════════════════════════════════════ // ── Get connection policy for a space ── spaces.get("/:slug/connection-policy", async (c) => { const slug = c.req.param("slug"); await loadCommunity(slug); const policy = getConnectionPolicy(slug); if (!policy) return c.json({ error: "Space not found" }, 404); return c.json({ connectionPolicy: policy }); }); // ── Update connection policy (admin only) ── spaces.patch("/:slug/connection-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); 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>(); updateConnectionPolicy(slug, body); return c.json({ ok: true, connectionPolicy: getConnectionPolicy(slug) }); }); // ── List connections for a space (auth required) ── spaces.get("/:slug/connections", 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 at least a member to see connections const member = data.members?.[claims.sub]; const isOwner = data.meta.ownerDID === claims.sub; if (!isOwner && !member) { return c.json({ error: "Must be a member to view connections" }, 403); } const connections = Object.values(data.connections || {}); return c.json({ connections }); }); // ── Propose a connection (auth required, admin/moderator in FROM space) ── spaces.post("/:slug/connections", async (c) => { const fromSlug = 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<{ toSlug: string; direction?: ConnectionDirection; flowKinds: FlowKind[]; permissions?: Partial; strength?: number; label?: string; economicFlows?: SpaceConnection['economicFlows']; delegations?: SpaceConnection['delegations']; informationFlow?: SpaceConnection['informationFlow']; meta?: Record; message?: string; }>(); const { toSlug } = body; if (!toSlug) return c.json({ error: "toSlug is required" }, 400); if (!body.flowKinds?.length) return c.json({ error: "flowKinds must be a non-empty array" }, 400); if (fromSlug === toSlug) return c.json({ error: "Cannot connect a space to itself" }, 400); // Load both spaces await loadCommunity(fromSlug); await loadCommunity(toSlug); const fromData = getDocumentData(fromSlug); const toData = getDocumentData(toSlug); if (!fromData) return c.json({ error: "Source space not found" }, 404); if (!toData) return c.json({ error: "Target space not found" }, 404); // Proposer must be admin or moderator in FROM space const fromMember = fromData.members?.[claims.sub]; const isFromOwner = fromData.meta.ownerDID === claims.sub; if (!isFromOwner && fromMember?.role !== 'admin' && fromMember?.role !== 'moderator') { return c.json({ error: "Admin or moderator role required in the source space" }, 403); } // Check FROM space's outbound consent policy const fromPolicy = fromData.meta.connectionPolicy || DEFAULT_COMMUNITY_CONNECTION_POLICY; if (fromPolicy.outboundConsent === 'closed' && !fromPolicy.allowlist?.includes(toSlug)) { return c.json({ error: "Source space does not allow outbound connections" }, 403); } // Check TO space's inbound consent policy const toPolicy = toData.meta.connectionPolicy || DEFAULT_COMMUNITY_CONNECTION_POLICY; if (toPolicy.blocklist?.includes(fromSlug)) { return c.json({ error: "Target space has blocked connections from this space" }, 403); } if (toPolicy.inboundConsent === 'closed' && !toPolicy.allowlist?.includes(fromSlug)) { return c.json({ error: "Target space does not accept inbound connections" }, 403); } // Check flow kinds are accepted by target const rejectedKinds = body.flowKinds.filter(k => !toPolicy.acceptedFlowKinds.includes(k)); if (rejectedKinds.length > 0) { return c.json({ error: `Target space does not accept flow kinds: ${rejectedKinds.join(', ')}` }, 403); } const direction: ConnectionDirection = body.direction || "bidirectional"; const permissions: ConnectionPermissions = { read: body.permissions?.read ?? toPolicy.defaultPermissions.read, write: body.permissions?.write ?? toPolicy.defaultPermissions.write, configure: body.permissions?.configure ?? toPolicy.defaultPermissions.configure, pause: body.permissions?.pause ?? toPolicy.defaultPermissions.pause, revoke: body.permissions?.revoke ?? toPolicy.defaultPermissions.revoke, expiry: body.permissions?.expiry, }; const strength = Math.max(0, Math.min(1, body.strength ?? 0.5)); const isOnAllowlist = toPolicy.allowlist?.includes(fromSlug); // If inbound consent is 'approval' and not on allowlist, create a pending request if (toPolicy.inboundConsent === 'approval' && !isOnAllowlist) { const reqId = `conn-req-${++connectionRequestCounter}`; const request: PendingConnectionRequest = { id: reqId, fromSlug, toSlug, proposedBy: claims.sub, direction, flowKinds: body.flowKinds, permissions, strength, label: body.label, economicFlows: body.economicFlows, delegations: body.delegations, informationFlow: body.informationFlow, meta: body.meta, message: body.message, status: "pending", createdAt: Date.now(), }; connectionRequests.set(reqId, request); return c.json({ status: 'pending', requestId: reqId, message: 'Connection request created. Awaiting target space admin approval.', }, 202); } // If 'members' consent, check proposer is also member in target if (toPolicy.inboundConsent === 'members' && !isOnAllowlist) { const toMember = toData.members?.[claims.sub]; const isToOwner = toData.meta.ownerDID === claims.sub; if (!isToOwner && !toMember) { return c.json({ error: "Must be a member of the target space for direct connection" }, 403); } } // Consent is 'open', 'members' (passed), or allowlisted — create immediately const connId = `conn-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const now = Date.now(); const conn: SpaceConnection = { id: connId, localSlug: fromSlug, remoteSlug: toSlug, remoteDID: toData.meta.ownerDID || undefined, direction, flowKinds: body.flowKinds, state: "active", economicFlows: body.economicFlows, delegations: body.delegations, informationFlow: body.informationFlow, permissions, strength, label: body.label, meta: body.meta, proposedBy: claims.sub, proposedAt: now, acceptedBy: claims.sub, acceptedAt: now, }; // Bilateral write: store in BOTH spaces' docs addConnection(fromSlug, conn); addConnection(toSlug, { ...conn, localSlug: toSlug, remoteSlug: fromSlug, remoteDID: fromData.meta.ownerDID || undefined, direction: invertDirection(direction), }); return c.json({ ok: true, connection: conn }, 201); }); // ── Get a specific connection ── spaces.get("/:slug/connections/:connId", async (c) => { const slug = c.req.param("slug"); const connId = c.req.param("connId"); 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) { return c.json({ error: "Must be a member to view connections" }, 403); } const conn = data.connections?.[connId]; if (!conn) return c.json({ error: "Connection not found" }, 404); return c.json({ connection: conn }); }); // ── Update a connection (state, permissions, strength) ── spaces.patch("/:slug/connections/:connId", async (c) => { const slug = c.req.param("slug"); const connId = c.req.param("connId"); 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 conn = data.connections?.[connId]; if (!conn) return c.json({ error: "Connection not found" }, 404); // Must be admin/moderator in this space const member = data.members?.[claims.sub]; const isOwner = data.meta.ownerDID === claims.sub; if (!isOwner && member?.role !== 'admin' && member?.role !== 'moderator') { return c.json({ error: "Admin or moderator role required" }, 403); } // Revoked is terminal if (conn.state === 'revoked') { return c.json({ error: "Revoked connections cannot be modified — propose a new connection" }, 400); } const body = await c.req.json<{ state?: ConnectionState; permissions?: Partial; strength?: number; label?: string; stateReason?: string; }>(); // Validate state transition: cannot go back from revoked (already checked), and can't set to 'proposed' if (body.state === 'proposed') { return c.json({ error: "Cannot set state back to 'proposed'" }, 400); } const updates: Partial = { lastModifiedBy: claims.sub, lastModifiedAt: Date.now(), }; if (body.state) updates.state = body.state; if (body.permissions) { updates.permissions = { ...conn.permissions, ...body.permissions }; } if (body.strength !== undefined) updates.strength = Math.max(0, Math.min(1, body.strength)); if (body.label !== undefined) updates.label = body.label; if (body.stateReason !== undefined) updates.stateReason = body.stateReason; // Update in this space's doc updateConnection(slug, connId, updates); // Bilateral update: also update remote space's copy const remoteSlug = conn.remoteSlug; await loadCommunity(remoteSlug); const remoteData = getDocumentData(remoteSlug); if (remoteData?.connections?.[connId]) { updateConnection(remoteSlug, connId, updates); } return c.json({ ok: true }); }); // ── Revoke (delete) a connection — either side's admin can revoke ── spaces.delete("/:slug/connections/:connId", async (c) => { const slug = c.req.param("slug"); const connId = c.req.param("connId"); 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 conn = data.connections?.[connId]; if (!conn) return c.json({ error: "Connection not found" }, 404); // Must be admin in THIS space (sovereignty guarantee: either side can revoke) 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 to revoke connections" }, 403); } // Already revoked? No-op if (conn.state === 'revoked') { return c.json({ ok: true, message: "Connection already revoked" }); } const revokeFields: Partial = { state: 'revoked', lastModifiedBy: claims.sub, lastModifiedAt: Date.now(), stateReason: 'Revoked by admin', }; // Bilateral revoke: update both docs to 'revoked' state updateConnection(slug, connId, revokeFields); const remoteSlug = conn.remoteSlug; await loadCommunity(remoteSlug); const remoteData = getDocumentData(remoteSlug); if (remoteData?.connections?.[connId]) { updateConnection(remoteSlug, connId, revokeFields); } return c.json({ ok: true, state: 'revoked' }); }); // ── List pending connection requests for a space (admin only) ── spaces.get("/:slug/connection-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 TARGET (someone wants to connect to us) const requests = Array.from(connectionRequests.values()) .filter(r => r.toSlug === slug); return c.json({ requests }); }); // ── Accept or reject a connection request ── spaces.patch("/:slug/connection-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 = connectionRequests.get(reqId); if (!request || request.toSlug !== slug) { return c.json({ error: "Connection request not found" }, 404); } if (request.status !== 'pending') { return c.json({ error: `Request already ${request.status}` }, 400); } const body = await c.req.json<{ action: 'accept' | 'reject'; modifiedPermissions?: ConnectionPermissions; }>(); if (body.action === 'reject') { request.status = 'rejected'; request.resolvedAt = Date.now(); request.resolvedBy = claims.sub; return c.json({ ok: true, status: 'rejected' }); } if (body.action === 'accept') { request.status = 'accepted'; request.resolvedAt = Date.now(); request.resolvedBy = claims.sub; const finalPerms = body.modifiedPermissions || request.permissions; const connId = `conn-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const now = Date.now(); // Load FROM space for DID await loadCommunity(request.fromSlug); const fromData = getDocumentData(request.fromSlug); const conn: SpaceConnection = { id: connId, localSlug: request.fromSlug, remoteSlug: request.toSlug, remoteDID: data.meta.ownerDID || undefined, direction: request.direction, flowKinds: request.flowKinds, state: "active", economicFlows: request.economicFlows, delegations: request.delegations, informationFlow: request.informationFlow, permissions: finalPerms, strength: request.strength, label: request.label, meta: request.meta, proposedBy: request.proposedBy, proposedAt: request.createdAt, acceptedBy: claims.sub, acceptedAt: now, }; // Bilateral write: store in BOTH spaces' docs addConnection(request.fromSlug, conn); addConnection(request.toSlug, { ...conn, localSlug: request.toSlug, remoteSlug: request.fromSlug, remoteDID: fromData?.meta.ownerDID || undefined, direction: invertDirection(request.direction), }); return c.json({ ok: true, status: 'accepted', connection: conn }); } return c.json({ error: "action must be 'accept' or 'reject'" }, 400); }); // ── Derived membrane permeability data ── spaces.get("/:slug/membrane", 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) { return c.json({ error: "Must be a member to view membrane data" }, 403); } const connections = Object.values(data.connections || {}); const permeability = computeMembranePermeability(connections, slug); return c.json({ membrane: permeability }); }); // ══════════════════════════════════════════════════════════════════════════════ // ENCRYPTION API // ══════════════════════════════════════════════════════════════════════════════ // ── Get encryption status ── spaces.get("/:slug/encryption", 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({ encrypted: !!data.meta.encrypted, encryptionKeyId: data.meta.encryptionKeyId || null, }); }); // ── Toggle encryption (admin only) ── spaces.patch("/:slug/encryption", 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<{ encrypted: boolean; encryptionKeyId?: string }>(); setEncryption(slug, body.encrypted, body.encryptionKeyId); // Switch SyncServer to relay mode for encrypted spaces (server can't read ciphertext) syncServer.setRelayOnly(slug, body.encrypted); return c.json({ ok: true, encrypted: body.encrypted, message: body.encrypted ? "Space encryption enabled. Documents synced in relay mode (server cannot read content)." : "Space encryption disabled. Documents synced in participant mode.", }); }); // ══════════════════════════════════════════════════════════════════════════════ // ACCESS REQUEST API (space membership request flow) // ══════════════════════════════════════════════════════════════════════════════ // ── Create an access request for a space ── spaces.post("/:slug/access-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); // Check user is not already owner or member if (data.meta.ownerDID === claims.sub) { return c.json({ error: "You are the owner of this space" }, 400); } const memberEntry = data.members?.[claims.sub]; if (memberEntry) { return c.json({ error: "You are already a member of this space" }, 400); } // Check for duplicate pending request const existing = Array.from(accessRequests.values()).find( (r) => r.spaceSlug === slug && r.requesterDID === claims.sub && r.status === "pending" ); if (existing) { return c.json({ error: "You already have a pending access request", requestId: existing.id }, 409); } const body = await c.req.json<{ message?: string }>().catch(() => ({} as { message?: string })); const reqId = `access-req-${++accessRequestCounter}`; const request: AccessRequest = { id: reqId, spaceSlug: slug, requesterDID: claims.sub, requesterUsername: claims.username || claims.sub.slice(0, 16), message: body.message, status: "pending", createdAt: Date.now(), }; accessRequests.set(reqId, request); return c.json({ id: reqId, status: "pending" }, 201); }); // ── Approve or deny an access request ── spaces.patch("/:slug/access-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); // Must be owner or admin 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 = accessRequests.get(reqId); if (!request || request.spaceSlug !== slug) { return c.json({ error: "Access 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"; role?: "viewer" | "member"; }>(); 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") { request.status = "approved"; request.resolvedAt = Date.now(); request.resolvedBy = claims.sub; // Add requester as member setMember(slug, request.requesterDID, body.role || "viewer", request.requesterUsername); return c.json({ ok: true, status: "approved" }); } return c.json({ error: "action must be 'approve' or 'deny'" }, 400); }); // ══════════════════════════════════════════════════════════════════════════════ // COPY SHAPES API // ══════════════════════════════════════════════════════════════════════════════ spaces.post("/:slug/copy-shapes", 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); } // Verify target space exists and user has write access await loadCommunity(targetSlug); const data = getDocumentData(targetSlug); if (!data) return c.json({ error: "Space not found" }, 404); const isOwner = data.meta.ownerDID === claims.sub; const member = data.members?.[claims.sub]; if (!isOwner && !member) { return c.json({ error: "You don't have access to this space" }, 403); } const body = await c.req.json<{ shapes: Record[] }>(); if (!Array.isArray(body.shapes) || body.shapes.length === 0) { return c.json({ error: "shapes array is required" }, 400); } const now = Date.now(); const rand4 = () => Math.random().toString(36).slice(2, 6); // Build old-ID → new-ID map const idMap = new Map(); for (let i = 0; i < body.shapes.length; i++) { const oldId = body.shapes[i].id as string; if (oldId) { idMap.set(oldId, `copy-${now}-${i}-${rand4()}`); } } // Compute bounding box of non-arrow shapes to center around (500, 300) let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const s of body.shapes) { if (s.type === "folk-arrow") continue; const sx = (s.x as number) ?? 0; const sy = (s.y as number) ?? 0; const sw = (s.width as number) ?? 0; const sh = (s.height as number) ?? 0; if (sx < minX) minX = sx; if (sy < minY) minY = sy; if (sx + sw > maxX) maxX = sx + sw; if (sy + sh > maxY) maxY = sy + sh; } const centerX = (minX + maxX) / 2; const centerY = (minY + maxY) / 2; const offsetX = isFinite(centerX) ? 500 - centerX : 0; const offsetY = isFinite(centerY) ? 300 - centerY : 0; // Remap shapes const remapped: Record[] = []; for (const s of body.shapes) { const clone = { ...s }; const oldId = clone.id as string; const newId = idMap.get(oldId) || `copy-${now}-${remapped.length}-${rand4()}`; clone.id = newId; // Offset position if (typeof clone.x === "number") clone.x = (clone.x as number) + offsetX; if (typeof clone.y === "number") clone.y = (clone.y as number) + offsetY; // Remap arrow endpoints (only if both are in the copied set) if (clone.type === "folk-arrow") { const srcMapped = idMap.get(clone.sourceId as string); const tgtMapped = idMap.get(clone.targetId as string); if (srcMapped && tgtMapped) { clone.sourceId = srcMapped; clone.targetId = tgtMapped; } } // Update folk-rapp spaceSlug to target if (clone.type === "folk-rapp") { clone.spaceSlug = targetSlug; } // Strip forgotten/deleted fields (clean copy) delete clone.forgotten; delete clone.forgottenBy; delete clone.deleted; remapped.push(clone); } addShapes(targetSlug, remapped); return c.json({ ok: true, count: remapped.length }, 201); }); // ── Invite by email ── let inviteTransport: Transporter | null = null; if (process.env.SMTP_PASS) { inviteTransport = createTransport({ host: process.env.SMTP_HOST || "mail.rmail.online", port: Number(process.env.SMTP_PORT) || 587, secure: Number(process.env.SMTP_PORT) === 465, auth: { user: process.env.SMTP_USER || "noreply@rspace.online", pass: process.env.SMTP_PASS, }, tls: { rejectUnauthorized: false }, }); } // ── Enhanced invite by email (with token + role) ── spaces.post("/:slug/invite", async (c) => { const { slug } = c.req.param(); 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 isOwner = data.meta.ownerDID === claims.sub; const callerMember = data.members?.[claims.sub]; if (!isOwner && callerMember?.role !== "admin") { return c.json({ error: "Admin access required" }, 403); } const body = await c.req.json<{ email: string; role?: string }>(); if (!body.email) return c.json({ error: "email is required" }, 400); const role = body.role || "member"; const validRoles = ["viewer", "member", "moderator", "admin"]; if (!validRoles.includes(role)) { return c.json({ error: `Invalid role. Must be one of: ${validRoles.join(", ")}` }, 400); } // Create invite token via EncryptID API const inviteId = crypto.randomUUID(); const inviteToken = crypto.randomUUID(); const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days // Store invite (call EncryptID API internally) const ENCRYPTID_URL = process.env.ENCRYPTID_INTERNAL_URL || "http://localhost:3000"; try { await fetch(`${ENCRYPTID_URL}/api/spaces/${slug}/invites`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}`, }, body: JSON.stringify({ email: body.email, role }), }); } catch (e) { console.error("Failed to create invite in EncryptID:", e); } // Send email const inviteUrl = `https://${slug}.rspace.online/?invite=${inviteToken}`; if (!inviteTransport) { console.warn("Invite email skipped (SMTP not configured) —", body.email, inviteUrl); return c.json({ ok: true, inviteUrl, note: "Email not configured — share the link manually" }); } try { await inviteTransport.sendMail({ from: process.env.SMTP_FROM || "rSpace ", to: body.email, subject: `You're invited to join "${slug}" on rSpace`, html: [ `

You've been invited to collaborate on ${slug} as a ${role}.

`, `

Join Space

`, `

This invite expires in 7 days. rSpace — collaborative knowledge work

`, ].join("\n"), }); return c.json({ ok: true, inviteUrl }); } catch (err: any) { console.error("Invite email failed:", err.message); return c.json({ error: "Failed to send email" }, 500); } }); // ── Add member by username (direct add, no invite needed) ── spaces.post("/:slug/members/add", async (c) => { const { slug } = c.req.param(); 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 isOwner = data.meta.ownerDID === claims.sub; const callerMember = data.members?.[claims.sub]; if (!isOwner && callerMember?.role !== "admin") { return c.json({ error: "Admin access required" }, 403); } const body = await c.req.json<{ username: string; role?: string }>(); if (!body.username) return c.json({ error: "username is required" }, 400); const role = body.role || "member"; const validRoles = ["viewer", "member", "moderator", "admin"]; if (!validRoles.includes(role)) { return c.json({ error: `Invalid role. Must be one of: ${validRoles.join(", ")}` }, 400); } // Look up user via EncryptID const ENCRYPTID_URL = process.env.ENCRYPTID_INTERNAL_URL || "http://localhost:3000"; const lookupRes = await fetch(`${ENCRYPTID_URL}/api/users/lookup?username=${encodeURIComponent(body.username)}`, { headers: { "Authorization": `Bearer ${token}` }, }); if (!lookupRes.ok) { return c.json({ error: "User not found" }, 404); } const user = await lookupRes.json() as { did: string; username: string; displayName: string }; if (!user.did) return c.json({ error: "User has no DID" }, 400); // Add to Automerge doc setMember(slug, user.did, role as any, user.displayName || user.username); // Also add to PostgreSQL via EncryptID try { await fetch(`${ENCRYPTID_URL}/api/spaces/${slug}/members`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}`, }, body: JSON.stringify({ userDID: user.did, role }), }); } catch (e) { console.error("Failed to sync member to EncryptID:", e); } return c.json({ ok: true, did: user.did, username: user.username, role }); }); // ── Accept invite via token ── spaces.post("/:slug/invite/accept", async (c) => { const { slug } = c.req.param(); const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required — sign in first" }, 401); let claims: EncryptIDClaims; try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const body = await c.req.json<{ inviteToken: string }>(); if (!body.inviteToken) return c.json({ error: "inviteToken is required" }, 400); // Accept via EncryptID API const ENCRYPTID_URL = process.env.ENCRYPTID_INTERNAL_URL || "http://localhost:3000"; const acceptRes = await fetch(`${ENCRYPTID_URL}/api/invites/${body.inviteToken}/accept`, { method: "POST", headers: { "Authorization": `Bearer ${token}` }, }); if (!acceptRes.ok) { const err = await acceptRes.json().catch(() => ({ error: "Failed to accept invite" })); return c.json(err as any, acceptRes.status as any); } const result = await acceptRes.json() as { ok: boolean; spaceSlug: string; role: string }; // Also add to Automerge doc await loadCommunity(slug); setMember(slug, claims.sub, result.role as any, (claims as any).username); return c.json({ ok: true, spaceSlug: result.spaceSlug, role: result.role }); }); // ── List invites for settings panel ── spaces.get("/:slug/invites", async (c) => { const { slug } = c.req.param(); 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 isOwner = data.meta.ownerDID === claims.sub; const callerMember = data.members?.[claims.sub]; if (!isOwner && callerMember?.role !== "admin") { return c.json({ error: "Admin access required" }, 403); } // Fetch from EncryptID const ENCRYPTID_URL = process.env.ENCRYPTID_INTERNAL_URL || "http://localhost:3000"; try { const res = await fetch(`${ENCRYPTID_URL}/api/spaces/${slug}/invites`, { headers: { "Authorization": `Bearer ${token}` }, }); const data = await res.json(); return c.json(data as any); } catch { return c.json({ invites: [], total: 0 }); } }); export { spaces };