diff --git a/modules/rbooks/mod.ts b/modules/rbooks/mod.ts index ca9e624..623b23d 100644 --- a/modules/rbooks/mod.ts +++ b/modules/rbooks/mod.ts @@ -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. }, }; diff --git a/modules/rcal/mod.ts b/modules/rcal/mod.ts index 326652d..93b6557 100644 --- a/modules/rcal/mod.ts +++ b/modules/rcal/mod.ts @@ -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, diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts index 822850d..f08b0a5 100644 --- a/modules/rcart/mod.ts +++ b/modules/rcart/mod.ts @@ -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, diff --git a/modules/rchoices/mod.ts b/modules/rchoices/mod.ts index 3bf430b..acb4563 100644 --- a/modules/rchoices/mod.ts +++ b/modules/rchoices/mod.ts @@ -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, diff --git a/modules/rdata/mod.ts b/modules/rdata/mod.ts index af080d0..3595fdd 100644 --- a/modules/rdata/mod.ts +++ b/modules/rdata/mod.ts @@ -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, diff --git a/modules/rdesign/mod.ts b/modules/rdesign/mod.ts index c08faf2..d560a06 100644 --- a/modules/rdesign/mod.ts +++ b/modules/rdesign/mod.ts @@ -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" }, diff --git a/modules/rdocs/mod.ts b/modules/rdocs/mod.ts index 2d2345d..0e90ab9 100644 --- a/modules/rdocs/mod.ts +++ b/modules/rdocs/mod.ts @@ -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" }, diff --git a/modules/rfiles/mod.ts b/modules/rfiles/mod.ts index 6306a06..b389eb7 100644 --- a/modules/rfiles/mod.ts +++ b/modules/rfiles/mod.ts @@ -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", diff --git a/modules/rforum/mod.ts b/modules/rforum/mod.ts index dfc7c76..d1891b4 100644 --- a/modules/rforum/mod.ts +++ b/modules/rforum/mod.ts @@ -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", diff --git a/modules/rfunds/mod.ts b/modules/rfunds/mod.ts index d2a136a..093faba 100644 --- a/modules/rfunds/mod.ts +++ b/modules/rfunds/mod.ts @@ -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", diff --git a/modules/rinbox/mod.ts b/modules/rinbox/mod.ts index 043fd7e..e8a870f 100644 --- a/modules/rinbox/mod.ts +++ b/modules/rinbox/mod.ts @@ -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", diff --git a/modules/rmaps/mod.ts b/modules/rmaps/mod.ts index 86321c7..fca53a2 100644 --- a/modules/rmaps/mod.ts +++ b/modules/rmaps/mod.ts @@ -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", diff --git a/modules/rnetwork/mod.ts b/modules/rnetwork/mod.ts index 1c66ed5..edde330 100644 --- a/modules/rnetwork/mod.ts +++ b/modules/rnetwork/mod.ts @@ -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", diff --git a/modules/rnotes/mod.ts b/modules/rnotes/mod.ts index 908c9c2..838eab4 100644 --- a/modules/rnotes/mod.ts +++ b/modules/rnotes/mod.ts @@ -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", diff --git a/modules/rphotos/mod.ts b/modules/rphotos/mod.ts index 22c8ea4..d95495f 100644 --- a/modules/rphotos/mod.ts +++ b/modules/rphotos/mod.ts @@ -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", diff --git a/modules/rpubs/mod.ts b/modules/rpubs/mod.ts index 71ebc5c..b03c2dd 100644 --- a/modules/rpubs/mod.ts +++ b/modules/rpubs/mod.ts @@ -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, diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index 9a3acce..f8b1c84 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -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, diff --git a/modules/rspace/mod.ts b/modules/rspace/mod.ts index 47d4b4f..5e33444 100644 --- a/modules/rspace/mod.ts +++ b/modules/rspace/mod.ts @@ -110,6 +110,7 @@ export const canvasModule: RSpaceModule = { name: "rSpace", icon: "🎨", description: "Real-time collaborative canvas", + scoping: { defaultScope: 'space', userConfigurable: false }, routes, feeds: [ { diff --git a/modules/rsplat/mod.ts b/modules/rsplat/mod.ts index 7d42e79..a46fe97 100644 --- a/modules/rsplat/mod.ts +++ b/modules/rsplat/mod.ts @@ -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. }, }; diff --git a/modules/rswag/mod.ts b/modules/rswag/mod.ts index 5f4ac01..0d08039 100644 --- a/modules/rswag/mod.ts +++ b/modules/rswag/mod.ts @@ -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", diff --git a/modules/rtrips/mod.ts b/modules/rtrips/mod.ts index 60aa538..9b058ba 100644 --- a/modules/rtrips/mod.ts +++ b/modules/rtrips/mod.ts @@ -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", diff --git a/modules/rtube/mod.ts b/modules/rtube/mod.ts index 557de0f..7900cfd 100644 --- a/modules/rtube/mod.ts +++ b/modules/rtube/mod.ts @@ -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", diff --git a/modules/rvote/mod.ts b/modules/rvote/mod.ts index 44d1caf..be6d53a 100644 --- a/modules/rvote/mod.ts +++ b/modules/rvote/mod.ts @@ -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, diff --git a/modules/rwallet/mod.ts b/modules/rwallet/mod.ts index 7f38807..ef7654a 100644 --- a/modules/rwallet/mod.ts +++ b/modules/rwallet/mod.ts @@ -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, diff --git a/modules/rwork/mod.ts b/modules/rwork/mod.ts index d586a1d..441fd33 100644 --- a/modules/rwork/mod.ts +++ b/modules/rwork/mod.ts @@ -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, diff --git a/server/community-store.ts b/server/community-store.ts index 1003096..075205d 100644 --- a/server/community-store.ts +++ b/server/community-store.ts @@ -122,7 +122,8 @@ export interface CommunityMeta { createdAt: string; visibility: SpaceVisibility; ownerDID: string | null; - enabledModules?: string[]; + enabledModules?: string[]; // null = all enabled + moduleScopeOverrides?: Record; description?: string; avatar?: string; nestPolicy?: NestPolicy; @@ -399,7 +400,13 @@ export async function deleteCommunity(slug: string): Promise { */ export function updateSpaceMeta( slug: string, - fields: { name?: string; visibility?: SpaceVisibility; description?: string }, + fields: { + name?: string; + visibility?: SpaceVisibility; + description?: string; + enabledModules?: string[]; + moduleScopeOverrides?: Record; + }, ): 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); diff --git a/server/index.ts b/server/index.ts index b0a50f1..0954bd4 100644 --- a/server/index.ts +++ b/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 { 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", "; 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({ 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({ }); // ── 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)); diff --git a/server/spaces.ts b/server/spaces.ts index 007e22a..66adb78 100644 --- a/server/spaces.ts +++ b/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 { + 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): 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(); @@ -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; + }>(); - // 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 = {}; + + // 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 = { ...(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); } } diff --git a/shared/module.ts b/shared/module.ts index b74e976..ed12f28 100644 --- a/shared/module.ts +++ b/shared/module.ts @@ -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 { + /** 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; + /** Called when a new space is created */ + onSpaceCreate?: (ctx: SpaceLifecycleContext) => Promise; + /** Called when a space is deleted */ + onSpaceDelete?: (ctx: SpaceLifecycleContext) => Promise; + /** Called when this module is enabled for a space */ + onSpaceEnable?: (ctx: SpaceLifecycleContext) => Promise; + /** Called when this module is disabled for a space */ + onSpaceDisable?: (ctx: SpaceLifecycleContext) => Promise; + + // ── 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; - /** Called when a space is deleted (e.g. to clean up module-specific data) */ - onSpaceDelete?: (spaceSlug: string) => Promise; /** 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 } : {}),