213 lines
5.5 KiB
TypeScript
213 lines
5.5 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,
|
|
} 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,
|
|
});
|
|
});
|
|
|
|
// ── 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 });
|
|
});
|
|
|
|
export { spaces };
|