2183 lines
73 KiB
TypeScript
2183 lines
73 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 { 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<SpaceRoleString, number> = {
|
|
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<CreateSpaceResult> {
|
|
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<typeof getDocumentData>): 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<string, PendingNestRequest>();
|
|
let nestRequestCounter = 0;
|
|
|
|
// ── In-memory pending connection requests (move to DB later) ──
|
|
const connectionRequests = new Map<string, PendingConnectionRequest>();
|
|
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<string, AccessRequest>();
|
|
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<string, 'space' | 'global'>;
|
|
}>();
|
|
|
|
const updates: Partial<typeof doc.meta> = {};
|
|
|
|
// 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<string, 'space' | 'global'> = { ...(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<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",
|
|
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<string>();
|
|
|
|
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<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);
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
// 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<Partial<ConnectionPolicy>>();
|
|
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<ConnectionPermissions>;
|
|
strength?: number;
|
|
label?: string;
|
|
economicFlows?: SpaceConnection['economicFlows'];
|
|
delegations?: SpaceConnection['delegations'];
|
|
informationFlow?: SpaceConnection['informationFlow'];
|
|
meta?: Record<string, unknown>;
|
|
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<ConnectionPermissions>;
|
|
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<SpaceConnection> = {
|
|
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<SpaceConnection> = {
|
|
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<string, unknown>[] }>();
|
|
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<string, string>();
|
|
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<string, unknown>[] = [];
|
|
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 <noreply@rspace.online>",
|
|
to: body.email,
|
|
subject: `You're invited to join "${slug}" on rSpace`,
|
|
html: [
|
|
`<p>You've been invited to collaborate on <strong>${slug}</strong> as a <strong>${role}</strong>.</p>`,
|
|
`<p><a href="${inviteUrl}" style="display:inline-block;padding:12px 24px;background:#14b8a6;color:white;border-radius:8px;text-decoration:none;font-weight:600;">Join Space</a></p>`,
|
|
`<p style="color:#64748b;font-size:12px;">This invite expires in 7 days. rSpace — collaborative knowledge work</p>`,
|
|
].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 };
|