rspace-online/server/spaces.ts

137 lines
3.3 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 all spaces (public + user's own) ──
spaces.get("/", async (c) => {
const slugs = await listCommunities();
const spacesList = [];
for (const slug of slugs) {
await loadCommunity(slug);
const data = getDocumentData(slug);
if (data?.meta) {
const vis = data.meta.visibility || "public_read";
// Only include public/public_read spaces in the public listing
if (vis === "public" || vis === "public_read") {
spacesList.push({
slug: data.meta.slug,
name: data.meta.name,
visibility: vis,
createdAt: data.meta.createdAt,
});
}
}
}
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 };