678 lines
21 KiB
TypeScript
678 lines
21 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,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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 };
|