feat: unified space lifecycle & module scoping contract (Phase 0+1)

Extend RSpaceModule with scoping, lifecycle hooks (onInit, onSpaceCreate/Delete
with SpaceLifecycleContext, onSpaceEnable/Disable), and DocSchema support.
Add scoping to all 25 modules (8 space, 11 global-configurable, 6 global-fixed).
Consolidate 4 space creation endpoints into shared createSpace() function.
Add enabledModules enforcement middleware and module configuration API
(GET/PATCH /api/spaces/:slug/modules). Deprecation header on /api/communities.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-02 13:35:41 -08:00
parent 347c7193d0
commit b2ea5e04cf
29 changed files with 338 additions and 105 deletions

View File

@ -12,7 +12,7 @@ import { randomUUID } from "node:crypto";
import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import type { RSpaceModule, SpaceLifecycleContext } from "../../shared/module";
import { renderLanding } from "./landing";
import {
verifyEncryptIDToken,
@ -300,6 +300,7 @@ export const booksModule: RSpaceModule = {
name: "rBooks",
icon: "📚",
description: "Community PDF library with flipbook reader",
scoping: { defaultScope: 'global', userConfigurable: true },
routes,
standaloneDomain: "rbooks.online",
landingPage: renderLanding,
@ -324,7 +325,7 @@ export const booksModule: RSpaceModule = {
{ path: "collections", name: "Collections", icon: "📑", description: "Curated book collections" },
],
async onSpaceCreate(spaceSlug: string) {
async onSpaceCreate(ctx: SpaceLifecycleContext) {
// Books are global, not space-scoped (for now). No-op.
},
};

View File

@ -394,6 +394,7 @@ export const calModule: RSpaceModule = {
name: "rCal",
icon: "📅",
description: "Temporal coordination calendar with lunar, solar, and seasonal systems",
scoping: { defaultScope: 'global', userConfigurable: true },
routes,
standaloneDomain: "rcal.online",
landingPage: renderLanding,

View File

@ -459,6 +459,7 @@ export const cartModule: RSpaceModule = {
name: "rCart",
icon: "🛒",
description: "Cosmolocal print-on-demand shop",
scoping: { defaultScope: 'space', userConfigurable: false },
routes,
standaloneDomain: "rcart.online",
landingPage: renderLanding,

View File

@ -66,6 +66,7 @@ export const choicesModule: RSpaceModule = {
name: "rChoices",
icon: "☑",
description: "Polls, rankings, and multi-criteria scoring",
scoping: { defaultScope: 'space', userConfigurable: false },
routes,
standaloneDomain: "rchoices.online",
landingPage: renderLanding,

View File

@ -138,6 +138,7 @@ export const dataModule: RSpaceModule = {
name: "rData",
icon: "📊",
description: "Privacy-first analytics for the r* ecosystem",
scoping: { defaultScope: 'global', userConfigurable: false },
routes,
standaloneDomain: "rdata.online",
landingPage: renderLanding,

View File

@ -54,6 +54,7 @@ export const designModule: RSpaceModule = {
name: "rDesign",
icon: "🎯",
description: "Collaborative design workspace with whiteboard and docs",
scoping: { defaultScope: 'global', userConfigurable: false },
routes,
standaloneDomain: "rdesign.online",
externalApp: { url: AFFINE_URL, name: "Affine" },

View File

@ -54,6 +54,7 @@ export const docsModule: RSpaceModule = {
name: "rDocs",
icon: "📝",
description: "Collaborative documentation and knowledge base",
scoping: { defaultScope: 'global', userConfigurable: true },
routes,
standaloneDomain: "rdocs.online",
externalApp: { url: DOCMOST_URL, name: "Docmost" },

View File

@ -399,6 +399,7 @@ export const filesModule: RSpaceModule = {
name: "rFiles",
icon: "📁",
description: "File sharing, share links, and memory cards",
scoping: { defaultScope: 'space', userConfigurable: false },
routes,
landingPage: renderLanding,
standaloneDomain: "rfiles.online",

View File

@ -190,6 +190,7 @@ export const forumModule: RSpaceModule = {
name: "rForum",
icon: "💬",
description: "Deploy and manage Discourse forums",
scoping: { defaultScope: 'global', userConfigurable: true },
routes,
landingPage: renderLanding,
standaloneDomain: "rforum.online",

View File

@ -246,6 +246,7 @@ export const fundsModule: RSpaceModule = {
name: "rFunds",
icon: "🌊",
description: "Budget flows, river visualization, and treasury management",
scoping: { defaultScope: 'space', userConfigurable: false },
routes,
landingPage: renderLanding,
standaloneDomain: "rfunds.online",

View File

@ -599,6 +599,7 @@ export const inboxModule: RSpaceModule = {
name: "rInbox",
icon: "📨",
description: "Collaborative email with multisig approval",
scoping: { defaultScope: 'space', userConfigurable: false },
routes,
landingPage: renderLanding,
standaloneDomain: "rinbox.online",

View File

@ -167,6 +167,7 @@ export const mapsModule: RSpaceModule = {
name: "rMaps",
icon: "🗺",
description: "Real-time collaborative location sharing and indoor/outdoor maps",
scoping: { defaultScope: 'global', userConfigurable: false },
routes,
landingPage: renderLanding,
standaloneDomain: "rmaps.online",

View File

@ -249,6 +249,7 @@ export const networkModule: RSpaceModule = {
name: "rNetwork",
icon: "🌐",
description: "Community relationship graph visualization with CRM sync",
scoping: { defaultScope: 'global', userConfigurable: false },
routes,
landingPage: renderLanding,
standaloneDomain: "rnetwork.online",

View File

@ -379,6 +379,7 @@ export const notesModule: RSpaceModule = {
name: "rNotes",
icon: "📝",
description: "Notebooks with rich-text notes, voice transcription, and collaboration",
scoping: { defaultScope: 'global', userConfigurable: true },
routes,
landingPage: renderLanding,
standaloneDomain: "rnotes.online",

View File

@ -141,6 +141,7 @@ export const photosModule: RSpaceModule = {
name: "rPhotos",
icon: "📸",
description: "Community photo commons",
scoping: { defaultScope: 'global', userConfigurable: false },
routes,
landingPage: renderLanding,
standaloneDomain: "rphotos.online",

View File

@ -341,6 +341,7 @@ export const pubsModule: RSpaceModule = {
name: "rPubs",
icon: "📖",
description: "Drop in a document, get a pocket book",
scoping: { defaultScope: 'global', userConfigurable: true },
routes,
standaloneDomain: "rpubs.online",
landingPage: renderLanding,

View File

@ -450,6 +450,7 @@ export const socialsModule: RSpaceModule = {
name: "rSocials",
icon: "📢",
description: "Federated social feed aggregator for communities",
scoping: { defaultScope: 'global', userConfigurable: true },
routes,
standaloneDomain: "rsocials.online",
landingPage: renderLanding,

View File

@ -110,6 +110,7 @@ export const canvasModule: RSpaceModule = {
name: "rSpace",
icon: "🎨",
description: "Real-time collaborative canvas",
scoping: { defaultScope: 'space', userConfigurable: false },
routes,
feeds: [
{

View File

@ -12,7 +12,7 @@ import { randomUUID } from "node:crypto";
import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import type { RSpaceModule, SpaceLifecycleContext } from "../../shared/module";
import { renderLanding } from "./landing";
import {
verifyEncryptIDToken,
@ -539,6 +539,7 @@ export const splatModule: RSpaceModule = {
name: "rSplat",
icon: "🔮",
description: "3D Gaussian splat viewer",
scoping: { defaultScope: 'global', userConfigurable: true },
routes,
landingPage: renderLanding,
standaloneDomain: "rsplat.online",
@ -547,7 +548,7 @@ export const splatModule: RSpaceModule = {
{ path: "drawings", name: "Drawings", icon: "🔮", description: "3D Gaussian splat drawings" },
],
async onSpaceCreate(_spaceSlug: string) {
async onSpaceCreate(ctx: SpaceLifecycleContext) {
// Splats are scoped by space_slug column. No per-space setup needed.
},
};

View File

@ -247,6 +247,7 @@ export const swagModule: RSpaceModule = {
name: "rSwag",
icon: "🎨",
description: "Design print-ready swag: stickers, posters, tees",
scoping: { defaultScope: 'global', userConfigurable: true },
routes,
landingPage: renderLanding,
standaloneDomain: "rswag.online",

View File

@ -271,6 +271,7 @@ export const tripsModule: RSpaceModule = {
name: "rTrips",
icon: "✈️",
description: "Collaborative trip planner with itinerary, bookings, and expense splitting",
scoping: { defaultScope: 'global', userConfigurable: true },
routes,
landingPage: renderLanding,
standaloneDomain: "rtrips.online",

View File

@ -209,6 +209,7 @@ export const tubeModule: RSpaceModule = {
name: "rTube",
icon: "🎬",
description: "Community video hosting & live streaming",
scoping: { defaultScope: 'global', userConfigurable: true },
routes,
landingPage: renderLanding,
standaloneDomain: "rtube.online",

View File

@ -345,6 +345,7 @@ export const voteModule: RSpaceModule = {
name: "rVote",
icon: "🗳",
description: "Conviction voting engine for collaborative governance",
scoping: { defaultScope: 'space', userConfigurable: false },
routes,
standaloneDomain: "rvote.online",
landingPage: renderLanding,

View File

@ -291,6 +291,7 @@ export const walletModule: RSpaceModule = {
name: "rWallet",
icon: "💰",
description: "Multichain Safe wallet visualization and treasury management",
scoping: { defaultScope: 'global', userConfigurable: false },
routes,
standaloneDomain: "rwallet.online",
landingPage: renderLanding,

View File

@ -235,6 +235,7 @@ export const workModule: RSpaceModule = {
name: "rWork",
icon: "📋",
description: "Kanban workspace boards for collaborative task management",
scoping: { defaultScope: 'space', userConfigurable: false },
routes,
standaloneDomain: "rwork.online",
landingPage: renderLanding,

View File

@ -122,7 +122,8 @@ export interface CommunityMeta {
createdAt: string;
visibility: SpaceVisibility;
ownerDID: string | null;
enabledModules?: string[];
enabledModules?: string[]; // null = all enabled
moduleScopeOverrides?: Record<string, 'space' | 'global'>;
description?: string;
avatar?: string;
nestPolicy?: NestPolicy;
@ -399,7 +400,13 @@ export async function deleteCommunity(slug: string): Promise<void> {
*/
export function updateSpaceMeta(
slug: string,
fields: { name?: string; visibility?: SpaceVisibility; description?: string },
fields: {
name?: string;
visibility?: SpaceVisibility;
description?: string;
enabledModules?: string[];
moduleScopeOverrides?: Record<string, 'space' | 'global'>;
},
): boolean {
const doc = communities.get(slug);
if (!doc) return false;
@ -408,6 +415,8 @@ export function updateSpaceMeta(
if (fields.name !== undefined) d.meta.name = fields.name;
if (fields.visibility !== undefined) d.meta.visibility = fields.visibility;
if (fields.description !== undefined) d.meta.description = fields.description;
if (fields.enabledModules !== undefined) d.meta.enabledModules = fields.enabledModules;
if (fields.moduleScopeOverrides !== undefined) d.meta.moduleScopeOverrides = fields.moduleScopeOverrides;
});
communities.set(slug, newDoc);
saveCommunity(slug);

View File

@ -67,7 +67,7 @@ import { photosModule } from "../modules/rphotos/mod";
import { socialsModule } from "../modules/rsocials/mod";
import { docsModule } from "../modules/rdocs/mod";
import { designModule } from "../modules/rdesign/mod";
import { spaces } from "./spaces";
import { spaces, createSpace } from "./spaces";
import { renderShell, renderModuleLanding } from "./shell";
import { renderOutputListPage } from "./output-list";
import { renderMainLanding, renderSpaceDashboard } from "./landing";
@ -318,7 +318,7 @@ async function getSpaceConfig(slug: string): Promise<SpaceAuthConfig | null> {
let lastDemoReset = 0;
const DEMO_RESET_COOLDOWN = 5 * 60 * 1000;
// POST /api/communities — create community
// POST /api/communities — create community (deprecated, use POST /api/spaces)
app.post("/api/communities", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required to create a community" }, 401);
@ -333,23 +333,17 @@ app.post("/api/communities", async (c) => {
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` }, 400);
if (await communityExists(slug)) return c.json({ error: "Community already exists" }, 409);
if (visibility && !validVisibilities.includes(visibility)) return c.json({ error: "Invalid visibility" }, 400);
await createCommunity(name, slug, claims.sub, visibility);
const result = await createSpace({
name: name || "", slug: slug || "", ownerDID: claims.sub, visibility, source: 'api',
});
if (!result.ok) return c.json({ error: result.error }, result.status);
// Notify modules
for (const mod of getAllModules()) {
if (mod.onSpaceCreate) {
try { await mod.onSpaceCreate(slug); } catch (e) { console.error(`Module ${mod.id} onSpaceCreate:`, e); }
}
}
return c.json({ url: `https://${slug}.rspace.online`, slug, name, visibility, ownerDID: claims.sub }, 201);
c.header("Deprecation", "true");
c.header("Link", "</api/spaces>; rel=\"successor-version\"");
return c.json({ url: `https://${result.slug}.rspace.online`, slug: result.slug, name: result.name, visibility: result.visibility, ownerDID: result.ownerDID }, 201);
});
// POST /api/internal/provision — auth-free, called by rSpace Registry
@ -362,19 +356,14 @@ app.post("/api/internal/provision", async (c) => {
return c.json({ status: "exists", slug: space });
}
const visibility: SpaceVisibility = body.public ? "public" : "public_read";
await createCommunity(
space.charAt(0).toUpperCase() + space.slice(1),
space,
`did:system:${space}`,
visibility,
);
for (const mod of getAllModules()) {
if (mod.onSpaceCreate) {
try { await mod.onSpaceCreate(space); } catch (e) { console.error(`Module ${mod.id} onSpaceCreate:`, e); }
}
}
const result = await createSpace({
name: space.charAt(0).toUpperCase() + space.slice(1),
slug: space,
ownerDID: `did:system:${space}`,
visibility: body.public ? "public" : "public_read",
source: 'internal',
});
if (!result.ok) return c.json({ error: result.error }, result.status);
return c.json({ status: "created", slug: space }, 201);
});
@ -789,24 +778,15 @@ app.post("/api/spaces/auto-provision", async (c) => {
return c.json({ status: "exists", slug: username });
}
await createCommunity(
`${claims.username}'s Space`,
username,
claims.sub,
"members_only",
);
const result = await createSpace({
name: `${claims.username}'s Space`,
slug: username,
ownerDID: claims.sub,
visibility: "members_only",
source: 'auto-provision',
});
if (!result.ok) return c.json({ error: result.error }, result.status);
for (const mod of getAllModules()) {
if (mod.onSpaceCreate) {
try {
await mod.onSpaceCreate(username);
} catch (e) {
console.error(`[AutoProvision] Module ${mod.id} onSpaceCreate:`, e);
}
}
}
console.log(`[AutoProvision] Created personal space: ${username}`);
return c.json({ status: "created", slug: username }, 201);
});
@ -834,7 +814,18 @@ app.use("/:space/*", async (c, next) => {
});
// ── Mount module routes under /:space/:moduleId ──
// Enforce enabledModules: if a space has an explicit list, only those modules route.
// The 'rspace' (canvas) module is always allowed as the core module.
for (const mod of getAllModules()) {
app.use(`/:space/${mod.id}/*`, async (c, next) => {
if (mod.id === "rspace") return next();
const space = c.req.param("space");
if (!space || space === "api" || space.includes(".")) return next();
const doc = getDocumentData(space);
if (!doc?.meta?.enabledModules) return next(); // null = all enabled
if (doc.meta.enabledModules.includes(mod.id)) return next();
return c.json({ error: "Module not enabled for this space" }, 404);
});
app.route(`/:space/${mod.id}`, mod.routes);
// Auto-mount browsable output list pages
if (mod.outputPaths) {
@ -976,9 +967,15 @@ app.post("/admin-action", async (c) => {
const data = getDocumentData(slug);
if (!data) return c.json({ error: "Space not found" }, 404);
const deleteCtx = {
spaceSlug: slug,
ownerDID: data.meta?.ownerDID ?? null,
enabledModules: data.meta?.enabledModules || getAllModules().map(m => m.id),
syncServer,
};
for (const mod of getAllModules()) {
if (mod.onSpaceDelete) {
try { await mod.onSpaceDelete(slug); } catch (e) {
try { await mod.onSpaceDelete(deleteCtx); } catch (e) {
console.error(`[Admin] Module ${mod.id} onSpaceDelete failed:`, e);
}
}
@ -1301,20 +1298,13 @@ const server = Bun.serve<WSData>({
const claims = await verifyEncryptIDToken(token);
const username = claims.username?.toLowerCase();
if (username === subdomain && !(await communityExists(subdomain))) {
await createCommunity(
`${claims.username}'s Space`,
subdomain,
claims.sub,
"members_only",
);
for (const mod of getAllModules()) {
if (mod.onSpaceCreate) {
try { await mod.onSpaceCreate(subdomain); } catch (e) {
console.error(`[AutoProvision] Module ${mod.id} onSpaceCreate:`, e);
}
}
}
console.log(`[AutoProvision] Created personal space on visit: ${subdomain}`);
await createSpace({
name: `${claims.username}'s Space`,
slug: subdomain,
ownerDID: claims.sub,
visibility: "members_only",
source: 'subdomain',
});
}
} catch (e) {
console.error(`[AutoProvision] Token verification failed for ${subdomain}:`, e);
@ -1616,6 +1606,21 @@ const server = Bun.serve<WSData>({
});
// ── Startup ──
// Call onInit for each module that defines it (schema registration, DB init, etc.)
(async () => {
for (const mod of getAllModules()) {
if (mod.onInit) {
try {
await mod.onInit({ syncServer });
console.log(`[Init] ${mod.name} initialized`);
} catch (e) {
console.error(`[Init] ${mod.name} failed:`, e);
}
}
}
})();
ensureDemoCommunity().then(() => console.log("[Demo] Demo community ready")).catch((e) => console.error("[Demo] Failed:", e));
loadAllDocs(syncServer).catch((e) => console.error("[DocStore] Startup load failed:", e));

View File

@ -42,7 +42,74 @@ import {
extractToken,
} from "@encryptid/sdk/server";
import type { EncryptIDClaims } from "@encryptid/sdk/server";
import { getAllModules } from "../shared/module";
import { getAllModules, getModule } from "../shared/module";
import type { SpaceLifecycleContext } from "../shared/module";
import { syncServer } from "./sync-instance";
// ── 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 = 'public_read', 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 };
await createCommunity(name, slug, ownerDID, visibility);
// 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);
}
}
}
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>();
@ -144,9 +211,7 @@ spaces.get("/", async (c) => {
spaces.post("/", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) {
return c.json({ error: "Authentication required" }, 401);
}
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims: EncryptIDClaims;
try {
@ -159,47 +224,123 @@ spaces.post("/", async (c) => {
name?: string;
slug?: string;
visibility?: SpaceVisibility;
enabledModules?: string[];
}>();
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 { name, slug, visibility = "public_read", enabledModules } = body;
const validVisibilities: SpaceVisibility[] = ["public", "public_read", "authenticated", "members_only"];
if (!validVisibilities.includes(visibility)) {
if (visibility && !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);
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);
}
await createCommunity(name, slug, claims.sub, visibility);
const body = await c.req.json<{
enabledModules?: string[] | null;
scopeOverrides?: Record<string, 'space' | 'global'>;
}>();
// 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);
}
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);
}
}
return c.json({
slug,
name,
visibility,
ownerDID: claims.sub,
url: `/${slug}/canvas`,
}, 201);
// 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 ──
@ -292,7 +433,7 @@ spaces.delete("/admin/:slug", async (c) => {
// Notify modules
for (const mod of getAllModules()) {
if (mod.onSpaceDelete) {
try { await mod.onSpaceDelete(slug); } catch (e) {
try { await mod.onSpaceDelete(buildLifecycleContext(slug, data)); } catch (e) {
console.error(`[Spaces] Module ${mod.id} onSpaceDelete failed:`, e);
}
}
@ -395,7 +536,7 @@ spaces.delete("/:slug", async (c) => {
// Notify all modules about deletion
for (const mod of getAllModules()) {
if (mod.onSpaceDelete) {
try { await mod.onSpaceDelete(slug); } catch (e) {
try { await mod.onSpaceDelete(buildLifecycleContext(slug, data)); } catch (e) {
console.error(`[Spaces] Module ${mod.id} onSpaceDelete failed:`, e);
}
}

View File

@ -1,6 +1,38 @@
import { Hono } from "hono";
import type { FlowKind, FeedDefinition } from "../lib/layer-types";
export type { FeedDefinition } from "../lib/layer-types";
import type { SyncServer } from "../server/local-first/sync-server";
// ── Module Scoping ──
export type ModuleScope = 'space' | 'global';
export interface ModuleScoping {
/** Whether the module's data lives per-space or globally by default */
defaultScope: ModuleScope;
/** Whether space owners can override the default scope */
userConfigurable: boolean;
}
// ── Lifecycle Context ──
export interface SpaceLifecycleContext {
spaceSlug: string;
ownerDID: string | null;
enabledModules: string[];
syncServer: SyncServer;
}
// ── Doc Schema (for Automerge document types a module manages) ──
export interface DocSchema<T = unknown> {
/** Document ID pattern, e.g. '{space}:notes:notebooks:{notebookId}' */
pattern: string;
/** Human-readable description */
description: string;
/** Factory to create a fresh empty document */
init: () => T;
}
/** A browsable content type that a module produces. */
export interface OutputPath {
@ -33,16 +65,35 @@ export interface RSpaceModule {
description: string;
/** Mountable Hono sub-app. Routes are relative to the mount point. */
routes: Hono;
// ── Scoping & Schema ──
/** How this module's data is scoped (space vs global) */
scoping: ModuleScoping;
/** Automerge document schemas this module manages */
docSchemas?: DocSchema[];
// ── Lifecycle hooks ──
/** Called once at server startup (register schemas, init DB, etc.) */
onInit?: (ctx: { syncServer: SyncServer }) => Promise<void>;
/** Called when a new space is created */
onSpaceCreate?: (ctx: SpaceLifecycleContext) => Promise<void>;
/** Called when a space is deleted */
onSpaceDelete?: (ctx: SpaceLifecycleContext) => Promise<void>;
/** Called when this module is enabled for a space */
onSpaceEnable?: (ctx: SpaceLifecycleContext) => Promise<void>;
/** Called when this module is disabled for a space */
onSpaceDisable?: (ctx: SpaceLifecycleContext) => Promise<void>;
// ── Display & routing ──
/** Optional: standalone domain for this module (e.g. 'rbooks.online') */
standaloneDomain?: string;
/** Feeds this module exposes to other layers */
feeds?: FeedDefinition[];
/** Feed kinds this module can consume from other layers */
acceptsFeeds?: FlowKind[];
/** Called when a new space is created (e.g. to initialize module-specific data) */
onSpaceCreate?: (spaceSlug: string) => Promise<void>;
/** Called when a space is deleted (e.g. to clean up module-specific data) */
onSpaceDelete?: (spaceSlug: string) => Promise<void>;
/** If true, module is hidden from app switcher (still has routes) */
hidden?: boolean;
/** Browsable content types this module produces */
@ -77,6 +128,7 @@ export interface ModuleInfo {
name: string;
icon: string;
description: string;
scoping: ModuleScoping;
standaloneDomain?: string;
feeds?: FeedDefinition[];
acceptsFeeds?: FlowKind[];
@ -97,6 +149,7 @@ export function getModuleInfoList(): ModuleInfo[] {
name: m.name,
icon: m.icon,
description: m.description,
scoping: m.scoping,
...(m.standaloneDomain ? { standaloneDomain: m.standaloneDomain } : {}),
...(m.feeds ? { feeds: m.feeds } : {}),
...(m.acceptsFeeds ? { acceptsFeeds: m.acceptsFeeds } : {}),