rspace-online/server/spaces.ts

687 lines
22 KiB
TypeScript

/**
* 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,
setEncryption,
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<string, PendingNestRequest>();
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,
});
}
}
}
// 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;
}>();
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<string, number> = {};
for (const shape of Object.values(shapes)) {
const t = (shape as Record<string, unknown>).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<Partial<NestPolicy>>();
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<NestPermissions>;
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<Partial<SpaceRef>>();
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);
});
// ══════════════════════════════════════════════════════════════════════════════
// 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);
return c.json({
ok: true,
encrypted: body.encrypted,
message: body.encrypted
? "Space encryption enabled. Document will be encrypted at rest."
: "Space encryption disabled. Document will be stored in plaintext.",
});
});
export { spaces };