rspace-online/server/spaces.ts

153 lines
3.8 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 {
communityExists,
createCommunity,
loadCommunity,
getDocumentData,
listCommunities,
} from "./community-store";
import type { SpaceVisibility } from "./community-store";
import {
verifyEncryptIDToken,
extractToken,
} from "@encryptid/sdk/server";
import type { EncryptIDClaims } from "@encryptid/sdk/server";
import { getAllModules } from "../shared/module";
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,
});
});
export { spaces };