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:
parent
347c7193d0
commit
b2ea5e04cf
|
|
@ -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.
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ export const canvasModule: RSpaceModule = {
|
|||
name: "rSpace",
|
||||
icon: "🎨",
|
||||
description: "Real-time collaborative canvas",
|
||||
scoping: { defaultScope: 'space', userConfigurable: false },
|
||||
routes,
|
||||
feeds: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
127
server/index.ts
127
server/index.ts
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
209
server/spaces.ts
209
server/spaces.ts
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
Loading…
Reference in New Issue