feat: scope system, cross-space navigation, and spaces-as-layers

Phase 1 — Fix scope system: new scope-resolver.ts resolves global vs
space-scoped docId prefixes. Server middleware sets effectiveSpace on
Hono context. All 18 modules updated to use dataSpace for Automerge
doc access while keeping space for display. Client runtime gets
setModuleScopes() and resolveDocSpace() for local-first scope
resolution.

Phase 2 — Seamless cross-space navigation: TabCache now tracks panes
per space:module key. OfflineRuntime maintains lazy WebSocket
connections per space. Space-switcher dispatches space-switch event
handled client-side with history.pushState instead of full reload.

Phase 3 — Spaces as layers: Layer type extended with spaceSlug and
spaceRole. Tab bar gains "Add Space Layer" picker. Canvas renders
cross-space shapes with visual indicators. Space layers persisted as
SpaceRefs via nesting API. Runtime provides getAllActiveSpaces() and
subscribeModuleAcrossSpaces() for module-level data aggregation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-09 06:33:13 +00:00
parent bfb3a588ed
commit 20c26cd3d7
29 changed files with 1066 additions and 186 deletions

View File

@ -79,6 +79,10 @@ export interface Layer {
visible: boolean;
/** Created timestamp */
createdAt: number;
/** If set, this layer shows data from another space (cross-space layer). */
spaceSlug?: string;
/** Resolved permission level in the source space (for cross-space layers). */
spaceRole?: 'viewer' | 'member' | 'moderator' | 'admin';
}
// ── Inter-layer flow ──

View File

@ -104,12 +104,13 @@ const routes = new Hono();
// ── API: List books ──
routes.get("/api/books", async (c) => {
const space = c.req.param("space") || "global";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const search = c.req.query("search")?.toLowerCase();
const tag = c.req.query("tag");
const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100);
const offset = parseInt(c.req.query("offset") || "0");
const doc = ensureDoc(space);
const doc = ensureDoc(dataSpace);
let books = Object.values(doc.items).filter((b) => b.status === "published");
if (search) {
@ -156,6 +157,7 @@ routes.get("/api/books", async (c) => {
// ── API: Upload book ──
routes.post("/api/books", async (c) => {
const space = c.req.param("space") || "global";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
@ -187,7 +189,7 @@ routes.post("/api/books", async (c) => {
let slug = slugify(title);
// Check slug collision
const doc = ensureDoc(space);
const doc = ensureDoc(dataSpace);
const slugExists = Object.values(doc.items).some((b) => b.slug === slug);
if (slugExists) {
slug = `${slug}-${shortId}`;
@ -203,7 +205,7 @@ routes.post("/api/books", async (c) => {
const now = Date.now();
// Insert into Automerge doc
const docId = booksCatalogDocId(space);
const docId = booksCatalogDocId(dataSpace);
_syncServer!.changeDoc<BooksCatalogDoc>(docId, `add book: ${slug}`, (d) => {
d.items[id] = {
id,
@ -242,9 +244,10 @@ routes.post("/api/books", async (c) => {
// ── API: Get book details ──
routes.get("/api/books/:id", async (c) => {
const space = c.req.param("space") || "global";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
const doc = ensureDoc(space);
const doc = ensureDoc(dataSpace);
const book = findBook(doc, id);
if (!book || book.status !== "published") {
@ -252,7 +255,7 @@ routes.get("/api/books/:id", async (c) => {
}
// Increment view count
const docId = booksCatalogDocId(space);
const docId = booksCatalogDocId(dataSpace);
_syncServer!.changeDoc<BooksCatalogDoc>(docId, `view: ${book.slug}`, (d) => {
if (d.items[book.id]) {
d.items[book.id].viewCount += 1;
@ -266,9 +269,10 @@ routes.get("/api/books/:id", async (c) => {
// ── API: Serve PDF ──
routes.get("/api/books/:id/pdf", async (c) => {
const space = c.req.param("space") || "global";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
const doc = ensureDoc(space);
const doc = ensureDoc(dataSpace);
const book = findBook(doc, id);
if (!book || book.status !== "published") {
@ -283,7 +287,7 @@ routes.get("/api/books/:id/pdf", async (c) => {
}
// Increment download count
const docId = booksCatalogDocId(space);
const docId = booksCatalogDocId(dataSpace);
_syncServer!.changeDoc<BooksCatalogDoc>(docId, `download: ${book.slug}`, (d) => {
if (d.items[book.id]) {
d.items[book.id].downloadCount += 1;
@ -303,6 +307,7 @@ routes.get("/api/books/:id/pdf", async (c) => {
// ── Page: Library ──
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "personal";
const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
return c.html(renderShell({
title: `${spaceSlug} — Library | rSpace`,
moduleId: "rbooks",
@ -318,9 +323,10 @@ routes.get("/", (c) => {
// ── Page: Book reader ──
routes.get("/read/:id", async (c) => {
const spaceSlug = c.req.param("space") || "personal";
const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
const id = c.req.param("id");
const doc = ensureDoc(spaceSlug);
const doc = ensureDoc(dataSpace);
const book = findBook(doc, id);
if (!book || book.status !== "published") {
@ -335,7 +341,7 @@ routes.get("/read/:id", async (c) => {
}
// Increment view count
const docId = booksCatalogDocId(spaceSlug);
const docId = booksCatalogDocId(dataSpace);
_syncServer!.changeDoc<BooksCatalogDoc>(docId, `view: ${book.slug}`, (d) => {
if (d.items[book.id]) {
d.items[book.id].viewCount += 1;

View File

@ -259,9 +259,10 @@ function seedDemoIfEmpty(space: string) {
// GET /api/events — query events with filters
routes.get("/api/events", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const { start, end, source, search, rTool, rEntityId, upcoming } = c.req.query();
const doc = ensureDoc(space);
const doc = ensureDoc(dataSpace);
let events = Object.values(doc.events);
// Apply filters
@ -311,14 +312,15 @@ routes.post("/api/events", async (c) => {
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const body = await c.req.json();
const { title, description, start_time, end_time, all_day, timezone, source_id, location_id, location_name,
is_virtual, virtual_url, virtual_platform, r_tool_source, r_tool_entity_id,
is_scheduled_item, provenance, item_preview } = body;
if (!title?.trim() || !start_time) return c.json({ error: "Title and start_time required" }, 400);
const docId = calendarDocId(space);
ensureDoc(space);
const docId = calendarDocId(dataSpace);
ensureDoc(dataSpace);
const eventId = crypto.randomUUID();
const now = Date.now();
@ -394,9 +396,10 @@ routes.post("/api/events", async (c) => {
// GET /api/events/scheduled — query only scheduled knowledge items
routes.get("/api/events/scheduled", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const { date, upcoming, pending_only } = c.req.query();
const doc = ensureDoc(space);
const doc = ensureDoc(dataSpace);
let events = Object.values(doc.events).filter((e) => {
const meta = e.metadata as ScheduledItemMetadata | null;
return meta?.isScheduledItem === true;
@ -429,8 +432,9 @@ routes.get("/api/events/scheduled", async (c) => {
// GET /api/events/:id
routes.get("/api/events/:id", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
const doc = ensureDoc(space);
const doc = ensureDoc(dataSpace);
const ev = doc.events[id];
if (!ev) return c.json({ error: "Event not found" }, 404);
@ -445,11 +449,12 @@ routes.patch("/api/events/:id", async (c) => {
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
const body = await c.req.json();
const docId = calendarDocId(space);
const doc = ensureDoc(space);
const docId = calendarDocId(dataSpace);
const doc = ensureDoc(dataSpace);
if (!doc.events[id]) return c.json({ error: "Not found" }, 404);
// Map of allowed body keys to CalendarEvent fields
@ -497,10 +502,11 @@ routes.patch("/api/events/:id", async (c) => {
// DELETE /api/events/:id
routes.delete("/api/events/:id", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
const docId = calendarDocId(space);
const doc = ensureDoc(space);
const docId = calendarDocId(dataSpace);
const doc = ensureDoc(dataSpace);
if (!doc.events[id]) return c.json({ error: "Not found" }, 404);
_syncServer!.changeDoc<CalendarDoc>(docId, `delete event ${id}`, (d) => {
@ -513,8 +519,9 @@ routes.delete("/api/events/:id", async (c) => {
routes.get("/api/sources", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const { is_active, is_visible, source_type } = c.req.query();
const doc = ensureDoc(space);
const doc = ensureDoc(dataSpace);
let sources = Object.values(doc.sources);
@ -541,9 +548,10 @@ routes.post("/api/sources", async (c) => {
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const body = await c.req.json();
const docId = calendarDocId(space);
ensureDoc(space);
const docId = calendarDocId(dataSpace);
ensureDoc(dataSpace);
const sourceId = crypto.randomUUID();
const now = Date.now();
@ -601,8 +609,9 @@ function deriveLocations(doc: CalendarDoc): DerivedLocation[] {
routes.get("/api/locations", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const { granularity, parent, search, root } = c.req.query();
const doc = ensureDoc(space);
const doc = ensureDoc(dataSpace);
let locations = deriveLocations(doc);
@ -627,7 +636,8 @@ routes.get("/api/locations", async (c) => {
routes.get("/api/locations/tree", async (c) => {
const space = c.req.param("space") || "demo";
const doc = ensureDoc(space);
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const doc = ensureDoc(dataSpace);
// Flat list with depth=0 since hierarchical parent_id data is not stored in Automerge
const locations = deriveLocations(doc).map((l) => ({ ...l, depth: 0 }));
@ -678,7 +688,8 @@ routes.get("/api/lunar", async (c) => {
routes.get("/api/stats", async (c) => {
const space = c.req.param("space") || "demo";
const doc = ensureDoc(space);
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const doc = ensureDoc(dataSpace);
const events = Object.values(doc.events).length;
const sources = Object.values(doc.sources).filter((s) => s.isActive).length;
@ -691,11 +702,12 @@ routes.get("/api/stats", async (c) => {
routes.get("/api/context/:tool", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const tool = c.req.param("tool");
const entityId = c.req.query("entityId");
if (!entityId) return c.json({ error: "entityId required" }, 400);
const doc = ensureDoc(space);
const doc = ensureDoc(dataSpace);
const matching = Object.values(doc.events)
.filter((e) => e.rToolSource === tool && e.rToolEntityId === entityId)
.sort((a, b) => a.startTime - b.startTime);
@ -707,6 +719,7 @@ routes.get("/api/context/:tool", async (c) => {
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
return c.html(renderShell({
title: `${space} — Calendar | rSpace`,
moduleId: "rcal",

View File

@ -121,6 +121,7 @@ routes.post("/api/collect", async (c) => {
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
return c.html(renderShell({
title: `${space} — Data | rSpace`,
moduleId: "rdata",

View File

@ -19,6 +19,7 @@ routes.get("/api/health", (c) => {
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const view = c.req.query("view");
if (view === "demo") {

View File

@ -19,6 +19,7 @@ routes.get("/api/health", (c) => {
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const view = c.req.query("view");
if (view === "demo") {

View File

@ -134,6 +134,7 @@ routes.get("/api/c3nav/:event", async (c) => {
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
return c.html(renderShell({
title: `${space} — Maps | rSpace`,
moduleId: "rmaps",
@ -149,6 +150,7 @@ routes.get("/", (c) => {
// Room-specific page
routes.get("/:room", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const room = c.req.param("room");
return c.html(renderShell({
title: `${room} — Maps | rSpace`,

View File

@ -55,14 +55,16 @@ const CACHE_TTL = 60_000;
// ── API: Health ──
routes.get("/api/health", (c) => {
const space = c.req.param("space") || "demo";
const token = getTokenForSpace(space);
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const token = getTokenForSpace(dataSpace);
return c.json({ ok: true, module: "network", space, twentyConfigured: !!token });
});
// ── API: Info ──
routes.get("/api/info", (c) => {
const space = c.req.param("space") || "demo";
const token = getTokenForSpace(space);
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const token = getTokenForSpace(dataSpace);
return c.json({
module: "network",
description: "Community relationship graph visualization",
@ -76,7 +78,8 @@ routes.get("/api/info", (c) => {
// ── API: People ──
routes.get("/api/people", async (c) => {
const space = c.req.param("space") || "demo";
const token = getTokenForSpace(space);
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const token = getTokenForSpace(dataSpace);
const data = await twentyQuery(`{
people(first: 200) {
edges {
@ -91,7 +94,7 @@ routes.get("/api/people", async (c) => {
}
}
}
}`, undefined, space);
}`, undefined, dataSpace);
if (!data) return c.json({ people: [], error: token ? "Twenty API error" : "Twenty not configured" });
const people = ((data as any).people?.edges || []).map((e: any) => e.node);
c.header("Cache-Control", "public, max-age=60");
@ -101,7 +104,8 @@ routes.get("/api/people", async (c) => {
// ── API: Companies ──
routes.get("/api/companies", async (c) => {
const space = c.req.param("space") || "demo";
const token = getTokenForSpace(space);
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const token = getTokenForSpace(dataSpace);
const data = await twentyQuery(`{
companies(first: 200) {
edges {
@ -115,7 +119,7 @@ routes.get("/api/companies", async (c) => {
}
}
}
}`, undefined, space);
}`, undefined, dataSpace);
if (!data) return c.json({ companies: [], error: token ? "Twenty API error" : "Twenty not configured" });
const companies = ((data as any).companies?.edges || []).map((e: any) => e.node);
c.header("Cache-Control", "public, max-age=60");
@ -125,10 +129,11 @@ routes.get("/api/companies", async (c) => {
// ── API: Graph — transform entities to node/edge format ──
routes.get("/api/graph", async (c) => {
const space = c.req.param("space") || "demo";
const token = getTokenForSpace(space);
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const token = getTokenForSpace(dataSpace);
// Check per-space cache
const cached = graphCaches.get(space);
const cached = graphCaches.get(dataSpace);
if (cached && Date.now() - cached.ts < CACHE_TTL) {
c.header("Cache-Control", "public, max-age=60");
return c.json(cached.data);
@ -186,7 +191,7 @@ routes.get("/api/graph", async (c) => {
}
}
}
}`, undefined, space);
}`, undefined, dataSpace);
if (!data) return c.json({ nodes: [], edges: [], error: "Twenty API error" });
@ -227,7 +232,7 @@ routes.get("/api/graph", async (c) => {
}
const result = { nodes, edges, demo: false };
graphCaches.set(space, { data: result, ts: Date.now() });
graphCaches.set(dataSpace, { data: result, ts: Date.now() });
c.header("Cache-Control", "public, max-age=60");
return c.json(result);
} catch (e) {
@ -246,7 +251,8 @@ routes.get("/api/workspaces", (c) => {
// ── API: Opportunities ──
routes.get("/api/opportunities", async (c) => {
const space = c.req.param("space") || "demo";
const token = getTokenForSpace(space);
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const token = getTokenForSpace(dataSpace);
const data = await twentyQuery(`{
opportunities(first: 200) {
edges {
@ -262,7 +268,7 @@ routes.get("/api/opportunities", async (c) => {
}
}
}
}`, undefined, space);
}`, undefined, dataSpace);
if (!data) return c.json({ opportunities: [], error: token ? "Twenty API error" : "Twenty not configured" });
const opportunities = ((data as any).opportunities?.edges || []).map((e: any) => e.node);
c.header("Cache-Control", "public, max-age=60");
@ -272,6 +278,7 @@ routes.get("/api/opportunities", async (c) => {
// ── CRM sub-route — embed Twenty CRM via iframe ──
routes.get("/crm", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
return c.html(renderExternalAppShell({
title: `${space} — CRM | rSpace`,
moduleId: "rnetwork",
@ -285,6 +292,7 @@ routes.get("/crm", (c) => {
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const view = c.req.query("view");
if (view === "app") {

View File

@ -245,8 +245,9 @@ function extractPlainText(content: string, format?: string): string {
// GET /api/notebooks — list notebooks
routes.get("/api/notebooks", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const notebooks = listNotebooks(space).map(({ doc }) => notebookToRest(doc));
const notebooks = listNotebooks(dataSpace).map(({ doc }) => notebookToRest(doc));
notebooks.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
return c.json({ notebooks, source: "automerge" });
});
@ -254,6 +255,7 @@ routes.get("/api/notebooks", async (c) => {
// POST /api/notebooks — create notebook
routes.post("/api/notebooks", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
@ -266,8 +268,8 @@ routes.post("/api/notebooks", async (c) => {
const notebookId = newId();
const now = Date.now();
const doc = ensureDoc(space, notebookId);
_syncServer!.changeDoc<NotebookDoc>(notebookDocId(space, notebookId), "Create notebook", (d) => {
const doc = ensureDoc(dataSpace, notebookId);
_syncServer!.changeDoc<NotebookDoc>(notebookDocId(dataSpace, notebookId), "Create notebook", (d) => {
d.notebook.id = notebookId;
d.notebook.title = nbTitle;
d.notebook.slug = slugify(nbTitle);
@ -278,16 +280,17 @@ routes.post("/api/notebooks", async (c) => {
d.notebook.updatedAt = now;
});
const updatedDoc = _syncServer!.getDoc<NotebookDoc>(notebookDocId(space, notebookId))!;
const updatedDoc = _syncServer!.getDoc<NotebookDoc>(notebookDocId(dataSpace, notebookId))!;
return c.json(notebookToRest(updatedDoc), 201);
});
// GET /api/notebooks/:id — notebook detail with notes
routes.get("/api/notebooks/:id", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
const docId = notebookDocId(space, id);
const docId = notebookDocId(dataSpace, id);
const doc = _syncServer?.getDoc<NotebookDoc>(docId);
if (!doc || !doc.notebook || !doc.notebook.title) {
return c.json({ error: "Notebook not found" }, 404);
@ -306,6 +309,7 @@ routes.get("/api/notebooks/:id", async (c) => {
// PUT /api/notebooks/:id — update notebook
routes.put("/api/notebooks/:id", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
@ -319,7 +323,7 @@ routes.put("/api/notebooks/:id", async (c) => {
return c.json({ error: "No fields to update" }, 400);
}
const docId = notebookDocId(space, id);
const docId = notebookDocId(dataSpace, id);
const doc = _syncServer?.getDoc<NotebookDoc>(docId);
if (!doc || !doc.notebook || !doc.notebook.title) {
return c.json({ error: "Notebook not found" }, 404);
@ -340,9 +344,10 @@ routes.put("/api/notebooks/:id", async (c) => {
// DELETE /api/notebooks/:id
routes.delete("/api/notebooks/:id", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
const docId = notebookDocId(space, id);
const docId = notebookDocId(dataSpace, id);
const doc = _syncServer?.getDoc<NotebookDoc>(docId);
if (!doc || !doc.notebook || !doc.notebook.title) {
return c.json({ error: "Notebook not found" }, 404);
@ -366,15 +371,16 @@ routes.delete("/api/notebooks/:id", async (c) => {
// GET /api/notes — list all notes
routes.get("/api/notes", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const { notebook_id, type, q, limit = "50", offset = "0" } = c.req.query();
let allNotes: ReturnType<typeof noteToRest>[] = [];
const notebooks = notebook_id
? (() => {
const doc = _syncServer?.getDoc<NotebookDoc>(notebookDocId(space, notebook_id));
const doc = _syncServer?.getDoc<NotebookDoc>(notebookDocId(dataSpace, notebook_id));
return doc ? [{ doc }] : [];
})()
: listNotebooks(space);
: listNotebooks(dataSpace);
for (const { doc } of notebooks) {
for (const item of Object.values(doc.items)) {
@ -401,6 +407,7 @@ routes.get("/api/notes", async (c) => {
// POST /api/notes — create note
routes.post("/api/notes", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
@ -439,8 +446,8 @@ routes.post("/api/notes", async (c) => {
});
// Ensure the notebook doc exists, then add the note
ensureDoc(space, notebook_id);
const docId = notebookDocId(space, notebook_id);
ensureDoc(dataSpace, notebook_id);
const docId = notebookDocId(dataSpace, notebook_id);
_syncServer!.changeDoc<NotebookDoc>(docId, `Create note: ${title.trim()}`, (d) => {
d.items[noteId] = item;
d.notebook.updatedAt = Date.now();
@ -452,9 +459,10 @@ routes.post("/api/notes", async (c) => {
// GET /api/notes/:id — note detail
routes.get("/api/notes/:id", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
const found = findNote(space, id);
const found = findNote(dataSpace, id);
if (!found) return c.json({ error: "Note not found" }, 404);
return c.json({ ...noteToRest(found.item), source: "automerge" });
@ -463,6 +471,7 @@ routes.get("/api/notes/:id", async (c) => {
// PUT /api/notes/:id — update note
routes.put("/api/notes/:id", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
const body = await c.req.json();
const { title, content, content_format, type, url, language, is_pinned, sort_order } = body;
@ -472,7 +481,7 @@ routes.put("/api/notes/:id", async (c) => {
return c.json({ error: "No fields to update" }, 400);
}
const found = findNote(space, id);
const found = findNote(dataSpace, id);
if (!found) return c.json({ error: "Note not found" }, 404);
const contentPlain = content !== undefined
@ -503,9 +512,10 @@ routes.put("/api/notes/:id", async (c) => {
// DELETE /api/notes/:id
routes.delete("/api/notes/:id", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
const found = findNote(space, id);
const found = findNote(dataSpace, id);
if (!found) return c.json({ error: "Note not found" }, 404);
_syncServer!.changeDoc<NotebookDoc>(found.docId, `Delete note ${id}`, (d) => {
@ -586,6 +596,7 @@ function getConnectionDoc(space: string): ConnectionsDoc | null {
// POST /api/import/upload — ZIP upload for Logseq/Obsidian
routes.post("/api/import/upload", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
@ -612,8 +623,8 @@ routes.post("/api/import/upload", async (c) => {
// Create a new notebook with the import title
targetNotebookId = newId();
const now = Date.now();
ensureDoc(space, targetNotebookId);
_syncServer!.changeDoc<NotebookDoc>(notebookDocId(space, targetNotebookId), "Create import notebook", (d) => {
ensureDoc(dataSpace, targetNotebookId);
_syncServer!.changeDoc<NotebookDoc>(notebookDocId(dataSpace, targetNotebookId), "Create import notebook", (d) => {
d.notebook.id = targetNotebookId!;
d.notebook.title = result.notebookTitle;
d.notebook.slug = slugify(result.notebookTitle);
@ -622,7 +633,7 @@ routes.post("/api/import/upload", async (c) => {
});
}
const { imported, updated } = importNotesIntoNotebook(space, targetNotebookId, result.notes);
const { imported, updated } = importNotesIntoNotebook(dataSpace, targetNotebookId, result.notes);
return c.json({
ok: true,
@ -637,6 +648,7 @@ routes.post("/api/import/upload", async (c) => {
// POST /api/import/notion — Import selected Notion pages
routes.post("/api/import/notion", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
@ -648,7 +660,7 @@ routes.post("/api/import/notion", async (c) => {
return c.json({ error: "pageIds array is required" }, 400);
}
const conn = getConnectionDoc(space);
const conn = getConnectionDoc(dataSpace);
if (!conn?.notion?.accessToken) {
return c.json({ error: "Notion not connected. Connect your Notion account first." }, 400);
}
@ -664,8 +676,8 @@ routes.post("/api/import/notion", async (c) => {
if (!targetNotebookId) {
targetNotebookId = newId();
const now = Date.now();
ensureDoc(space, targetNotebookId);
_syncServer!.changeDoc<NotebookDoc>(notebookDocId(space, targetNotebookId), "Create Notion import notebook", (d) => {
ensureDoc(dataSpace, targetNotebookId);
_syncServer!.changeDoc<NotebookDoc>(notebookDocId(dataSpace, targetNotebookId), "Create Notion import notebook", (d) => {
d.notebook.id = targetNotebookId!;
d.notebook.title = result.notebookTitle;
d.notebook.slug = slugify(result.notebookTitle);
@ -674,7 +686,7 @@ routes.post("/api/import/notion", async (c) => {
});
}
const { imported, updated } = importNotesIntoNotebook(space, targetNotebookId, result.notes);
const { imported, updated } = importNotesIntoNotebook(dataSpace, targetNotebookId, result.notes);
return c.json({ ok: true, notebookId: targetNotebookId, imported, updated, warnings: result.warnings });
});
@ -682,6 +694,7 @@ routes.post("/api/import/notion", async (c) => {
// POST /api/import/google-docs — Import selected Google Docs
routes.post("/api/import/google-docs", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
@ -693,7 +706,7 @@ routes.post("/api/import/google-docs", async (c) => {
return c.json({ error: "docIds array is required" }, 400);
}
const conn = getConnectionDoc(space);
const conn = getConnectionDoc(dataSpace);
if (!conn?.google?.accessToken) {
return c.json({ error: "Google not connected. Connect your Google account first." }, 400);
}
@ -708,8 +721,8 @@ routes.post("/api/import/google-docs", async (c) => {
if (!targetNotebookId) {
targetNotebookId = newId();
const now = Date.now();
ensureDoc(space, targetNotebookId);
_syncServer!.changeDoc<NotebookDoc>(notebookDocId(space, targetNotebookId), "Create Google Docs import notebook", (d) => {
ensureDoc(dataSpace, targetNotebookId);
_syncServer!.changeDoc<NotebookDoc>(notebookDocId(dataSpace, targetNotebookId), "Create Google Docs import notebook", (d) => {
d.notebook.id = targetNotebookId!;
d.notebook.title = result.notebookTitle;
d.notebook.slug = slugify(result.notebookTitle);
@ -718,7 +731,7 @@ routes.post("/api/import/google-docs", async (c) => {
});
}
const { imported, updated } = importNotesIntoNotebook(space, targetNotebookId, result.notes);
const { imported, updated } = importNotesIntoNotebook(dataSpace, targetNotebookId, result.notes);
return c.json({ ok: true, notebookId: targetNotebookId, imported, updated, warnings: result.warnings });
});
@ -726,7 +739,8 @@ routes.post("/api/import/google-docs", async (c) => {
// GET /api/import/notion/pages — Browse Notion pages for selection
routes.get("/api/import/notion/pages", async (c) => {
const space = c.req.param("space") || "demo";
const conn = getConnectionDoc(space);
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const conn = getConnectionDoc(dataSpace);
if (!conn?.notion?.accessToken) {
return c.json({ error: "Notion not connected" }, 400);
}
@ -768,7 +782,8 @@ routes.get("/api/import/notion/pages", async (c) => {
// GET /api/import/google-docs/list — Browse Google Docs for selection
routes.get("/api/import/google-docs/list", async (c) => {
const space = c.req.param("space") || "demo";
const conn = getConnectionDoc(space);
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const conn = getConnectionDoc(dataSpace);
if (!conn?.google?.accessToken) {
return c.json({ error: "Google not connected" }, 400);
}
@ -798,10 +813,11 @@ routes.get("/api/import/google-docs/list", async (c) => {
// GET /api/export/obsidian — Download Obsidian-format ZIP
routes.get("/api/export/obsidian", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const notebookId = c.req.query("notebookId");
if (!notebookId) return c.json({ error: "notebookId is required" }, 400);
const docId = notebookDocId(space, notebookId);
const docId = notebookDocId(dataSpace, notebookId);
const doc = _syncServer?.getDoc<NotebookDoc>(docId);
if (!doc || !doc.notebook?.title) return c.json({ error: "Notebook not found" }, 404);
@ -820,10 +836,11 @@ routes.get("/api/export/obsidian", async (c) => {
// GET /api/export/logseq — Download Logseq-format ZIP
routes.get("/api/export/logseq", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const notebookId = c.req.query("notebookId");
if (!notebookId) return c.json({ error: "notebookId is required" }, 400);
const docId = notebookDocId(space, notebookId);
const docId = notebookDocId(dataSpace, notebookId);
const doc = _syncServer?.getDoc<NotebookDoc>(docId);
if (!doc || !doc.notebook?.title) return c.json({ error: "Notebook not found" }, 404);
@ -842,6 +859,7 @@ routes.get("/api/export/logseq", async (c) => {
// GET /api/export/markdown — Download universal Markdown ZIP
routes.get("/api/export/markdown", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const notebookId = c.req.query("notebookId");
const noteIds = c.req.query("noteIds");
@ -849,7 +867,7 @@ routes.get("/api/export/markdown", async (c) => {
let title = "rNotes Export";
if (notebookId) {
const docId = notebookDocId(space, notebookId);
const docId = notebookDocId(dataSpace, notebookId);
const doc = _syncServer?.getDoc<NotebookDoc>(docId);
if (!doc || !doc.notebook?.title) return c.json({ error: "Notebook not found" }, 404);
notes = Object.values(doc.items);
@ -857,7 +875,7 @@ routes.get("/api/export/markdown", async (c) => {
} else if (noteIds) {
const ids = noteIds.split(",").map(id => id.trim());
for (const id of ids) {
const found = findNote(space, id);
const found = findNote(dataSpace, id);
if (found) notes.push(found.item);
}
} else {
@ -881,6 +899,7 @@ routes.get("/api/export/markdown", async (c) => {
// POST /api/export/notion — Push notes to Notion
routes.post("/api/export/notion", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
@ -888,19 +907,19 @@ routes.post("/api/export/notion", async (c) => {
const body = await c.req.json();
const { notebookId, noteIds, parentId } = body;
const conn = getConnectionDoc(space);
const conn = getConnectionDoc(dataSpace);
if (!conn?.notion?.accessToken) {
return c.json({ error: "Notion not connected" }, 400);
}
let notes: NoteItem[] = [];
if (notebookId) {
const docId = notebookDocId(space, notebookId);
const docId = notebookDocId(dataSpace, notebookId);
const doc = _syncServer?.getDoc<NotebookDoc>(docId);
if (doc) notes = Object.values(doc.items);
} else if (noteIds && Array.isArray(noteIds)) {
for (const id of noteIds) {
const found = findNote(space, id);
const found = findNote(dataSpace, id);
if (found) notes.push(found.item);
}
}
@ -920,6 +939,7 @@ routes.post("/api/export/notion", async (c) => {
// POST /api/export/google-docs — Push notes to Google Docs
routes.post("/api/export/google-docs", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
@ -927,19 +947,19 @@ routes.post("/api/export/google-docs", async (c) => {
const body = await c.req.json();
const { notebookId, noteIds, parentId } = body;
const conn = getConnectionDoc(space);
const conn = getConnectionDoc(dataSpace);
if (!conn?.google?.accessToken) {
return c.json({ error: "Google not connected" }, 400);
}
let notes: NoteItem[] = [];
if (notebookId) {
const docId = notebookDocId(space, notebookId);
const docId = notebookDocId(dataSpace, notebookId);
const doc = _syncServer?.getDoc<NotebookDoc>(docId);
if (doc) notes = Object.values(doc.items);
} else if (noteIds && Array.isArray(noteIds)) {
for (const id of noteIds) {
const found = findNote(space, id);
const found = findNote(dataSpace, id);
if (found) notes.push(found.item);
}
}
@ -959,7 +979,8 @@ routes.post("/api/export/google-docs", async (c) => {
// GET /api/connections — Status of all integrations
routes.get("/api/connections", async (c) => {
const space = c.req.param("space") || "demo";
const conn = getConnectionDoc(space);
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const conn = getConnectionDoc(dataSpace);
return c.json({
notion: conn?.notion ? {
@ -980,6 +1001,7 @@ routes.get("/api/connections", async (c) => {
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
return c.html(renderShell({
title: `${space} — Notes | rSpace`,
moduleId: "rnotes",

View File

@ -110,6 +110,7 @@ routes.get("/api/assets/:id/original", async (c) => {
// ── Embedded Immich UI ──
routes.get("/album", (c) => {
const spaceSlug = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
return c.html(renderExternalAppShell({
title: `${spaceSlug} — Immich | rSpace`,
moduleId: "rphotos",
@ -124,6 +125,7 @@ routes.get("/album", (c) => {
// ── Page route ──
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
return c.html(renderShell({
title: `${spaceSlug} — Photos | rSpace`,
moduleId: "rphotos",

View File

@ -322,6 +322,7 @@ routes.get("/api/artifact/:id/pdf", async (c) => {
// ── Page: Editor ──
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "personal";
const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
return c.html(renderShell({
title: `${spaceSlug} — rPubs Editor | rSpace`,
moduleId: "rpubs",

View File

@ -756,6 +756,7 @@ function seedDefaultJobs(space: string) {
// GET / — serve schedule UI
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
return c.html(
renderShell({
title: `${space} — Schedule | rSpace`,
@ -773,7 +774,8 @@ routes.get("/", (c) => {
// GET /api/jobs — list all jobs
routes.get("/api/jobs", (c) => {
const space = c.req.param("space") || "demo";
const doc = ensureDoc(space);
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const doc = ensureDoc(dataSpace);
const jobs = Object.values(doc.jobs).map((j) => ({
...j,
cronHuman: cronToHuman(j.cronExpression),
@ -785,6 +787,7 @@ routes.get("/api/jobs", (c) => {
// POST /api/jobs — create a new job
routes.post("/api/jobs", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const body = await c.req.json();
const { name, description, cronExpression, timezone, actionType, actionConfig, enabled } = body;
@ -798,8 +801,8 @@ routes.post("/api/jobs", async (c) => {
return c.json({ error: "Invalid cron expression" }, 400);
}
const docId = scheduleDocId(space);
ensureDoc(space);
const docId = scheduleDocId(dataSpace);
ensureDoc(dataSpace);
const jobId = crypto.randomUUID();
const now = Date.now();
const tz = timezone || "UTC";
@ -832,8 +835,9 @@ routes.post("/api/jobs", async (c) => {
// GET /api/jobs/:id
routes.get("/api/jobs/:id", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
const doc = ensureDoc(space);
const doc = ensureDoc(dataSpace);
const job = doc.jobs[id];
if (!job) return c.json({ error: "Job not found" }, 404);
@ -843,11 +847,12 @@ routes.get("/api/jobs/:id", (c) => {
// PUT /api/jobs/:id — update a job
routes.put("/api/jobs/:id", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
const body = await c.req.json();
const docId = scheduleDocId(space);
const doc = ensureDoc(space);
const docId = scheduleDocId(dataSpace);
const doc = ensureDoc(dataSpace);
if (!doc.jobs[id]) return c.json({ error: "Job not found" }, 404);
// Validate cron if provided
@ -885,10 +890,11 @@ routes.put("/api/jobs/:id", async (c) => {
// DELETE /api/jobs/:id
routes.delete("/api/jobs/:id", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
const docId = scheduleDocId(space);
const doc = ensureDoc(space);
const docId = scheduleDocId(dataSpace);
const doc = ensureDoc(dataSpace);
if (!doc.jobs[id]) return c.json({ error: "Job not found" }, 404);
_syncServer!.changeDoc<ScheduleDoc>(docId, `delete job ${id}`, (d) => {
@ -901,10 +907,11 @@ routes.delete("/api/jobs/:id", (c) => {
// POST /api/jobs/:id/run — manually trigger a job
routes.post("/api/jobs/:id/run", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
const docId = scheduleDocId(space);
const doc = ensureDoc(space);
const docId = scheduleDocId(dataSpace);
const doc = ensureDoc(dataSpace);
const job = doc.jobs[id];
if (!job) return c.json({ error: "Job not found" }, 404);
@ -912,7 +919,7 @@ routes.post("/api/jobs/:id/run", async (c) => {
let result: { success: boolean; message: string };
try {
result = await executeJob(job, space);
result = await executeJob(job, dataSpace);
} catch (e: any) {
result = { success: false, message: e.message || String(e) };
}
@ -947,7 +954,8 @@ routes.post("/api/jobs/:id/run", async (c) => {
// GET /api/log — execution log
routes.get("/api/log", (c) => {
const space = c.req.param("space") || "demo";
const doc = ensureDoc(space);
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const doc = ensureDoc(dataSpace);
const log = [...doc.log].reverse(); // newest first
return c.json({ count: log.length, results: log });
});
@ -955,8 +963,9 @@ routes.get("/api/log", (c) => {
// GET /api/log/:jobId — execution log filtered by job
routes.get("/api/log/:jobId", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const jobId = c.req.param("jobId");
const doc = ensureDoc(space);
const doc = ensureDoc(dataSpace);
const log = doc.log.filter((e) => e.jobId === jobId).reverse();
return c.json({ count: log.length, results: log });
});
@ -1103,7 +1112,8 @@ async function executeReminderEmail(
// GET /api/reminders — list reminders
routes.get("/api/reminders", (c) => {
const space = c.req.param("space") || "demo";
const doc = ensureDoc(space);
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const doc = ensureDoc(dataSpace);
let reminders = Object.values(doc.reminders);
@ -1131,14 +1141,15 @@ routes.get("/api/reminders", (c) => {
// POST /api/reminders — create a reminder
routes.post("/api/reminders", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const body = await c.req.json();
const { title, description, remindAt, allDay, timezone, notifyEmail, syncToCalendar, cronExpression } = body;
if (!title?.trim() || !remindAt)
return c.json({ error: "title and remindAt required" }, 400);
const docId = scheduleDocId(space);
const doc = ensureDoc(space);
const docId = scheduleDocId(dataSpace);
const doc = ensureDoc(dataSpace);
if (Object.keys(doc.reminders).length >= MAX_REMINDERS)
return c.json({ error: `Maximum ${MAX_REMINDERS} reminders reached` }, 400);
@ -1169,7 +1180,7 @@ routes.post("/api/reminders", async (c) => {
// Sync to calendar if requested
if (syncToCalendar) {
const eventId = syncReminderToCalendar(reminder, space);
const eventId = syncReminderToCalendar(reminder, dataSpace);
if (eventId) reminder.calendarEventId = eventId;
}
@ -1184,8 +1195,9 @@ routes.post("/api/reminders", async (c) => {
// GET /api/reminders/:id — get single reminder
routes.get("/api/reminders/:id", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
const doc = ensureDoc(space);
const doc = ensureDoc(dataSpace);
const reminder = doc.reminders[id];
if (!reminder) return c.json({ error: "Reminder not found" }, 404);
@ -1195,11 +1207,12 @@ routes.get("/api/reminders/:id", (c) => {
// PUT /api/reminders/:id — update a reminder
routes.put("/api/reminders/:id", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
const body = await c.req.json();
const docId = scheduleDocId(space);
const doc = ensureDoc(space);
const docId = scheduleDocId(dataSpace);
const doc = ensureDoc(dataSpace);
if (!doc.reminders[id]) return c.json({ error: "Reminder not found" }, 404);
_syncServer!.changeDoc<ScheduleDoc>(docId, `update reminder ${id}`, (d) => {
@ -1222,16 +1235,17 @@ routes.put("/api/reminders/:id", async (c) => {
// DELETE /api/reminders/:id — delete (cascades to calendar)
routes.delete("/api/reminders/:id", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
const docId = scheduleDocId(space);
const doc = ensureDoc(space);
const docId = scheduleDocId(dataSpace);
const doc = ensureDoc(dataSpace);
const reminder = doc.reminders[id];
if (!reminder) return c.json({ error: "Reminder not found" }, 404);
// Cascade: delete linked calendar event
if (reminder.calendarEventId) {
deleteCalendarEvent(space, reminder.calendarEventId);
deleteCalendarEvent(dataSpace, reminder.calendarEventId);
}
_syncServer!.changeDoc<ScheduleDoc>(docId, `delete reminder ${id}`, (d) => {
@ -1244,10 +1258,11 @@ routes.delete("/api/reminders/:id", (c) => {
// POST /api/reminders/:id/complete — mark completed
routes.post("/api/reminders/:id/complete", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
const docId = scheduleDocId(space);
const doc = ensureDoc(space);
const docId = scheduleDocId(dataSpace);
const doc = ensureDoc(dataSpace);
if (!doc.reminders[id]) return c.json({ error: "Reminder not found" }, 404);
_syncServer!.changeDoc<ScheduleDoc>(docId, `complete reminder ${id}`, (d) => {
@ -1264,11 +1279,12 @@ routes.post("/api/reminders/:id/complete", (c) => {
// POST /api/reminders/:id/snooze — reschedule to a new date
routes.post("/api/reminders/:id/snooze", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
const body = await c.req.json();
const docId = scheduleDocId(space);
const doc = ensureDoc(space);
const docId = scheduleDocId(dataSpace);
const doc = ensureDoc(dataSpace);
if (!doc.reminders[id]) return c.json({ error: "Reminder not found" }, 404);
const newRemindAt = body.remindAt
@ -1287,7 +1303,7 @@ routes.post("/api/reminders/:id/snooze", async (c) => {
const updated = _syncServer!.getDoc<ScheduleDoc>(docId)!;
const reminder = updated.reminders[id];
if (reminder?.calendarEventId) {
const calDocId = calendarDocId(space);
const calDocId = calendarDocId(dataSpace);
const duration = reminder.allDay ? 86400000 : 3600000;
_syncServer!.changeDoc<CalendarDoc>(calDocId, `update reminder event time`, (d) => {
const ev = d.events[reminder.calendarEventId!];

View File

@ -179,10 +179,11 @@ routes.get("/api/feed", (c) =>
routes.post("/api/threads/:id/image", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);
const thread = getThreadFromDoc(space, id);
const thread = getThreadFromDoc(dataSpace, id);
if (!thread) return c.json({ error: "Thread not found" }, 404);
if (!process.env.FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
@ -210,7 +211,7 @@ routes.post("/api/threads/:id/image", async (c) => {
if (!imageUrl) return c.json({ error: "Failed to download image" }, 502);
// Update Automerge doc with image URL
const docId = socialsDocId(space);
const docId = socialsDocId(dataSpace);
_syncServer!.changeDoc<SocialsDoc>(docId, "set thread image", (d) => {
if (d.threads?.[id]) {
d.threads[id].imageUrl = imageUrl;
@ -223,10 +224,11 @@ routes.post("/api/threads/:id/image", async (c) => {
routes.post("/api/threads/:id/upload-image", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);
const thread = getThreadFromDoc(space, id);
const thread = getThreadFromDoc(dataSpace, id);
if (!thread) return c.json({ error: "Thread not found" }, 404);
let formData: FormData;
@ -246,7 +248,7 @@ routes.post("/api/threads/:id/upload-image", async (c) => {
const buffer = Buffer.from(await file.arrayBuffer());
const imageUrl = await saveUploadedFile(buffer, filename);
const docId = socialsDocId(space);
const docId = socialsDocId(dataSpace);
_syncServer!.changeDoc<SocialsDoc>(docId, "upload thread image", (d) => {
if (d.threads?.[id]) {
d.threads[id].imageUrl = imageUrl;
@ -259,12 +261,13 @@ routes.post("/api/threads/:id/upload-image", async (c) => {
routes.post("/api/threads/:id/tweet/:index/upload-image", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
const index = c.req.param("index");
if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);
if (!/^\d+$/.test(index)) return c.json({ error: "Invalid index" }, 400);
const thread = getThreadFromDoc(space, id);
const thread = getThreadFromDoc(dataSpace, id);
if (!thread) return c.json({ error: "Thread not found" }, 404);
let formData: FormData;
@ -285,7 +288,7 @@ routes.post("/api/threads/:id/tweet/:index/upload-image", async (c) => {
const buffer = Buffer.from(await file.arrayBuffer());
const imageUrl = await saveUploadedFile(buffer, filename);
const docId = socialsDocId(space);
const docId = socialsDocId(dataSpace);
_syncServer!.changeDoc<SocialsDoc>(docId, "upload tweet image", (d) => {
if (d.threads?.[id]) {
if (!d.threads[id].tweetImages) d.threads[id].tweetImages = {} as any;
@ -299,12 +302,13 @@ routes.post("/api/threads/:id/tweet/:index/upload-image", async (c) => {
routes.post("/api/threads/:id/tweet/:index/image", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
const index = c.req.param("index");
if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);
if (!/^\d+$/.test(index)) return c.json({ error: "Invalid index" }, 400);
const thread = getThreadFromDoc(space, id);
const thread = getThreadFromDoc(dataSpace, id);
if (!thread) return c.json({ error: "Thread not found" }, 404);
const tweetIndex = parseInt(index, 10);
@ -340,7 +344,7 @@ routes.post("/api/threads/:id/tweet/:index/image", async (c) => {
}
if (!imageUrl) return c.json({ error: "Failed to download image" }, 502);
const docId = socialsDocId(space);
const docId = socialsDocId(dataSpace);
_syncServer!.changeDoc<SocialsDoc>(docId, "generate tweet image", (d) => {
if (d.threads?.[id]) {
if (!d.threads[id].tweetImages) d.threads[id].tweetImages = {} as any;
@ -354,19 +358,20 @@ routes.post("/api/threads/:id/tweet/:index/image", async (c) => {
routes.delete("/api/threads/:id/tweet/:index/image", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
const index = c.req.param("index");
if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);
if (!/^\d+$/.test(index)) return c.json({ error: "Invalid index" }, 400);
const thread = getThreadFromDoc(space, id);
const thread = getThreadFromDoc(dataSpace, id);
if (!thread) return c.json({ error: "Thread not found" }, 404);
if (!thread.tweetImages?.[index]) return c.json({ ok: true });
await deleteImageFile(thread.tweetImages[index]);
const docId = socialsDocId(space);
const docId = socialsDocId(dataSpace);
_syncServer!.changeDoc<SocialsDoc>(docId, "remove tweet image", (d) => {
if (d.threads?.[id]?.tweetImages?.[index]) {
delete d.threads[id].tweetImages![index];
@ -382,10 +387,11 @@ routes.delete("/api/threads/:id/tweet/:index/image", async (c) => {
routes.delete("/api/threads/:id/images", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);
const thread = getThreadFromDoc(space, id);
const thread = getThreadFromDoc(dataSpace, id);
if (!thread) return c.json({ ok: true }); // Thread already gone
// Clean up header image
@ -405,6 +411,7 @@ routes.delete("/api/threads/:id/images", async (c) => {
routes.get("/campaign", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
return c.html(renderShell({
title: `Campaign — rSocials | rSpace`,
moduleId: "rsocials",
@ -419,10 +426,11 @@ routes.get("/campaign", (c) => {
routes.get("/thread/:id", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
if (!id || id.includes("..") || id.includes("/")) return c.text("Not found", 404);
const thread = getThreadFromDoc(space, id);
const thread = getThreadFromDoc(dataSpace, id);
if (!thread) return c.text("Thread not found", 404);
// OG tags for social crawlers (SSR)
@ -461,10 +469,11 @@ routes.get("/thread/:id", async (c) => {
routes.get("/thread/:id/edit", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const id = c.req.param("id");
if (!id || id.includes("..") || id.includes("/")) return c.text("Not found", 404);
const thread = getThreadFromDoc(space, id);
const thread = getThreadFromDoc(dataSpace, id);
if (!thread) return c.text("Thread not found", 404);
const dataScript = `<script>window.__THREAD_DATA__ = ${JSON.stringify(thread).replace(/</g, "\\u003c")};</script>`;
@ -483,6 +492,7 @@ routes.get("/thread/:id/edit", async (c) => {
routes.get("/thread", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
return c.html(renderShell({
title: `Thread Builder — rSocials | rSpace`,
moduleId: "rsocials",
@ -497,6 +507,7 @@ routes.get("/thread", (c) => {
routes.get("/threads", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
return c.html(renderShell({
title: `Threads — rSocials | rSpace`,
moduleId: "rsocials",
@ -511,6 +522,7 @@ routes.get("/threads", (c) => {
routes.get("/campaigns", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
return c.redirect(`/${space}/rsocials/campaign`);
});
@ -558,6 +570,7 @@ const POSTIZ_URL = process.env.POSTIZ_URL || "https://demo.rsocials.online";
routes.get("/scheduler", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
return c.html(renderExternalAppShell({
title: `Post Scheduler — rSocials | rSpace`,
moduleId: "rsocials",
@ -571,6 +584,7 @@ routes.get("/scheduler", (c) => {
routes.get("/feed", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const isDemo = space === "demo";
const body = isDemo ? renderDemoFeedHTML() : renderLanding();
const styles = isDemo
@ -589,6 +603,7 @@ routes.get("/feed", (c) => {
routes.get("/landing", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
return c.html(renderShell({
title: `${space} — rSocials | rSpace`,
moduleId: "rsocials",
@ -604,6 +619,7 @@ routes.get("/landing", (c) => {
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
return c.html(renderShell({
title: `${space} — rSocials | rSpace`,
moduleId: "rsocials",

View File

@ -205,11 +205,12 @@ const routes = new Hono();
// ── API: List splats ──
routes.get("/api/splats", async (c) => {
const spaceSlug = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
const tag = c.req.query("tag");
const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100);
const offset = parseInt(c.req.query("offset") || "0");
const doc = ensureDoc(spaceSlug);
const doc = ensureDoc(dataSpace);
let items = Object.values(doc.items)
.filter((item) => item.status === 'published');
@ -230,9 +231,10 @@ routes.get("/api/splats", async (c) => {
// ── API: Get splat details ──
routes.get("/api/splats/:id", async (c) => {
const spaceSlug = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
const id = c.req.param("id");
const doc = ensureDoc(spaceSlug);
const doc = ensureDoc(dataSpace);
const found = findItem(doc, id);
if (!found || found[1].status !== 'published') {
@ -242,7 +244,7 @@ routes.get("/api/splats/:id", async (c) => {
const [itemKey, item] = found;
// Increment view count
const docId = splatScenesDocId(spaceSlug);
const docId = splatScenesDocId(dataSpace);
_syncServer!.changeDoc<SplatScenesDoc>(docId, 'increment view count', (d) => {
d.items[itemKey].viewCount += 1;
});
@ -254,9 +256,10 @@ routes.get("/api/splats/:id", async (c) => {
// Matches both /api/splats/:id/file and /api/splats/:id/:filename (e.g. rainbow-sphere.splat)
routes.get("/api/splats/:id/:filename", async (c) => {
const spaceSlug = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
const id = c.req.param("id");
const doc = ensureDoc(spaceSlug);
const doc = ensureDoc(dataSpace);
const found = findItem(doc, id);
if (!found || found[1].status !== 'published') {
@ -307,6 +310,7 @@ routes.post("/api/splats", async (c) => {
}
const spaceSlug = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
const formData = await c.req.formData();
const file = formData.get("file") as File | null;
const title = (formData.get("title") as string || "").trim();
@ -336,7 +340,7 @@ routes.post("/api/splats", async (c) => {
let slug = slugify(title);
// Check slug collision in Automerge doc
const doc = ensureDoc(spaceSlug);
const doc = ensureDoc(dataSpace);
const slugExists = Object.values(doc.items).some((item) => item.slug === slug);
if (slugExists) {
slug = `${slug}-${shortId}`;
@ -354,7 +358,7 @@ routes.post("/api/splats", async (c) => {
const now = Date.now();
const paymentTx = (c as any).get("x402Payment") || null;
const docId = splatScenesDocId(spaceSlug);
const docId = splatScenesDocId(dataSpace);
_syncServer!.changeDoc<SplatScenesDoc>(docId, 'add splat', (d) => {
d.items[splatId] = {
id: splatId,
@ -418,6 +422,7 @@ routes.post("/api/splats/from-media", async (c) => {
}
const spaceSlug = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
const formData = await c.req.formData();
const title = (formData.get("title") as string || "").trim();
const description = (formData.get("description") as string || "").trim() || null;
@ -463,7 +468,7 @@ routes.post("/api/splats/from-media", async (c) => {
let slug = slugify(title);
// Check slug collision in Automerge doc
const doc = ensureDoc(spaceSlug);
const doc = ensureDoc(dataSpace);
const slugExists = Object.values(doc.items).some((item) => item.slug === slug);
if (slugExists) {
slug = `${slug}-${shortId}`;
@ -496,7 +501,7 @@ routes.post("/api/splats/from-media", async (c) => {
// Insert splat record (pending processing) into Automerge doc
const paymentTx = (c as any).get("x402Payment") || null;
const docId = splatScenesDocId(spaceSlug);
const docId = splatScenesDocId(dataSpace);
_syncServer!.changeDoc<SplatScenesDoc>(docId, 'add splat from media', (d) => {
d.items[splatId] = {
id: splatId,
@ -549,9 +554,10 @@ routes.delete("/api/splats/:id", async (c) => {
}
const spaceSlug = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
const id = c.req.param("id");
const doc = ensureDoc(spaceSlug);
const doc = ensureDoc(dataSpace);
const found = findItem(doc, id);
if (!found || found[1].status !== 'published') {
@ -563,7 +569,7 @@ routes.delete("/api/splats/:id", async (c) => {
return c.json({ error: "Not authorized" }, 403);
}
const docId = splatScenesDocId(spaceSlug);
const docId = splatScenesDocId(dataSpace);
_syncServer!.changeDoc<SplatScenesDoc>(docId, 'remove splat', (d) => {
d.items[itemKey].status = 'removed';
});
@ -574,8 +580,9 @@ routes.delete("/api/splats/:id", async (c) => {
// ── Page: Gallery ──
routes.get("/", async (c) => {
const spaceSlug = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
const doc = ensureDoc(spaceSlug);
const doc = ensureDoc(dataSpace);
const items = Object.values(doc.items)
.filter((item) => item.status === 'published')
@ -612,9 +619,10 @@ routes.get("/", async (c) => {
// ── Page: Viewer ──
routes.get("/view/:id", async (c) => {
const spaceSlug = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
const id = c.req.param("id");
const doc = ensureDoc(spaceSlug);
const doc = ensureDoc(dataSpace);
const found = findItem(doc, id);
if (!found || found[1].status !== 'published') {
@ -632,7 +640,7 @@ routes.get("/view/:id", async (c) => {
const [itemKey, splat] = found;
// Increment view count
const docId = splatScenesDocId(spaceSlug);
const docId = splatScenesDocId(dataSpace);
_syncServer!.changeDoc<SplatScenesDoc>(docId, 'increment view count', (d) => {
d.items[itemKey].viewCount += 1;
});

View File

@ -230,6 +230,7 @@ routes.get("/api/artifact/:id", async (c) => {
// ── Page route: swag designer ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
return c.html(renderShell({
title: `Swag Designer | rSpace`,
moduleId: "rswag",

View File

@ -68,7 +68,8 @@ const routes = new Hono();
// GET /api/trips — list trips
routes.get("/api/trips", async (c) => {
const space = c.req.param("space") || "demo";
const docIds = listTripDocIds(space);
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const docIds = listTripDocIds(dataSpace);
const rows = docIds.map((docId) => {
const doc = _syncServer!.getDoc<TripDoc>(docId);
@ -102,11 +103,12 @@ routes.post("/api/trips", async (c) => {
if (!title?.trim()) return c.json({ error: "Title required" }, 400);
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const tripId = newId();
const slug = title.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
const now = Date.now();
const docId = tripDocId(space, tripId);
const docId = tripDocId(dataSpace, tripId);
let doc = Automerge.change(Automerge.init<TripDoc>(), 'create trip', (d) => {
const init = tripSchema.init();
d.meta = init.meta;
@ -140,8 +142,9 @@ routes.post("/api/trips", async (c) => {
// GET /api/trips/:id — trip detail with all sub-resources
routes.get("/api/trips/:id", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const tripId = c.req.param("id");
const docId = tripDocId(space, tripId);
const docId = tripDocId(dataSpace, tripId);
const doc = _syncServer!.getDoc<TripDoc>(docId);
if (!doc) return c.json({ error: "Trip not found" }, 404);
@ -163,8 +166,9 @@ routes.get("/api/trips/:id", async (c) => {
// PUT /api/trips/:id — update trip
routes.put("/api/trips/:id", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const tripId = c.req.param("id");
const docId = tripDocId(space, tripId);
const docId = tripDocId(dataSpace, tripId);
const doc = _syncServer!.getDoc<TripDoc>(docId);
if (!doc) return c.json({ error: "Not found" }, 404);
@ -198,9 +202,10 @@ routes.post("/api/trips/:id/destinations", async (c) => {
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const tripId = c.req.param("id");
ensureDoc(space, tripId);
const docId = tripDocId(space, tripId);
ensureDoc(dataSpace, tripId);
const docId = tripDocId(dataSpace, tripId);
const body = await c.req.json();
const destId = newId();
@ -234,9 +239,10 @@ routes.post("/api/trips/:id/itinerary", async (c) => {
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const tripId = c.req.param("id");
ensureDoc(space, tripId);
const docId = tripDocId(space, tripId);
ensureDoc(dataSpace, tripId);
const docId = tripDocId(dataSpace, tripId);
const body = await c.req.json();
const itemId = newId();
@ -270,9 +276,10 @@ routes.post("/api/trips/:id/bookings", async (c) => {
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const tripId = c.req.param("id");
ensureDoc(space, tripId);
const docId = tripDocId(space, tripId);
ensureDoc(dataSpace, tripId);
const docId = tripDocId(dataSpace, tripId);
const body = await c.req.json();
const bookingId = newId();
@ -307,9 +314,10 @@ routes.post("/api/trips/:id/expenses", async (c) => {
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const tripId = c.req.param("id");
ensureDoc(space, tripId);
const docId = tripDocId(space, tripId);
ensureDoc(dataSpace, tripId);
const docId = tripDocId(dataSpace, tripId);
const body = await c.req.json();
const expenseId = newId();
@ -338,8 +346,9 @@ routes.post("/api/trips/:id/expenses", async (c) => {
routes.get("/api/trips/:id/packing", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const tripId = c.req.param("id");
const docId = tripDocId(space, tripId);
const docId = tripDocId(dataSpace, tripId);
const doc = _syncServer!.getDoc<TripDoc>(docId);
if (!doc) return c.json([]);
@ -356,9 +365,10 @@ routes.post("/api/trips/:id/packing", async (c) => {
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const tripId = c.req.param("id");
ensureDoc(space, tripId);
const docId = tripDocId(space, tripId);
ensureDoc(dataSpace, tripId);
const docId = tripDocId(dataSpace, tripId);
const body = await c.req.json();
const itemId = newId();
@ -384,10 +394,11 @@ routes.post("/api/trips/:id/packing", async (c) => {
routes.patch("/api/packing/:id", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const packingId = c.req.param("id");
// Find the trip doc containing this packing item
const docIds = listTripDocIds(space);
const docIds = listTripDocIds(dataSpace);
for (const docId of docIds) {
const doc = _syncServer!.getDoc<TripDoc>(docId);
if (!doc || !doc.packingItems[packingId]) continue;
@ -423,6 +434,7 @@ routes.post("/api/route", async (c) => {
// ── Route planner page ──
routes.get("/routes", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
return c.html(renderShell({
title: `${space} — Route Planner | rTrips`,
moduleId: "rtrips",
@ -438,6 +450,7 @@ routes.get("/routes", (c) => {
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
return c.html(renderShell({
title: `${space} — Trips | rSpace`,
moduleId: "rtrips",

View File

@ -192,6 +192,7 @@ routes.get("/api/health", (c) => c.json({ ok: true }));
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
return c.html(renderShell({
title: `${space} — Tube | rSpace`,
moduleId: "rtube",

View File

@ -383,6 +383,7 @@ interface BalanceItem {
// ── Page route ──
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || spaceSlug;
return c.html(renderShell({
title: `${spaceSlug} — Wallet | rSpace`,
moduleId: "rwallet",

View File

@ -393,6 +393,7 @@ routes.get("/api/spaces/:slug/activity", async (c) => {
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
return c.html(renderShell({
title: `${space} — Work | rSpace`,
moduleId: "rwork",

View File

@ -1660,6 +1660,7 @@ app.get("/:space/:moduleId/template", async (c) => {
// ── Empty-state detection for onboarding ──
import type { RSpaceModule } from "../shared/module";
import { resolveDataSpace } from "../shared/scope-resolver";
function moduleHasData(space: string, mod: RSpaceModule): boolean {
if (space === "demo") return true; // demo always has data
@ -1681,13 +1682,18 @@ for (const mod of getAllModules()) {
if (!space || space === "api" || space.includes(".")) return next();
// Check enabled modules (skip for core rspace module)
const doc = getDocumentData(space);
if (mod.id !== "rspace") {
const doc = getDocumentData(space);
if (doc?.meta?.enabledModules && !doc.meta.enabledModules.includes(mod.id)) {
return c.json({ error: "Module not enabled for this space" }, 404);
}
}
// Resolve effective data space (global vs space-scoped)
const overrides = doc?.meta?.moduleScopeOverrides ?? null;
const effectiveSpace = resolveDataSpace(mod.id, space, overrides);
c.set("effectiveSpace" as any, effectiveSpace);
// Resolve caller's role for write-method blocking
const method = c.req.method;
if (!mod.publicWrite && (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE")) {

View File

@ -87,6 +87,10 @@ export function renderShell(opts: ShellOptions): string {
const enabledModules = opts.enabledModules ?? spaceMeta?.enabledModules ?? null;
const spaceEncrypted = opts.spaceEncrypted ?? spaceMeta?.spaceEncrypted ?? false;
// Build scope config for client-side runtime
const spaceData = getDocumentData(spaceSlug);
const scopeOverrides = spaceData?.meta?.moduleScopeOverrides ?? {};
// Filter modules by enabledModules (null = show all)
const visibleModules = enabledModules
? modules.filter(m => m.id === "rspace" || enabledModules.includes(m.id))
@ -119,7 +123,7 @@ export function renderShell(opts: ShellOptions): string {
<style>${WELCOME_CSS}</style>
<style>${ACCESS_GATE_CSS}</style>
</head>
<body data-space-visibility="${escapeAttr(spaceVisibility)}" data-space-slug="${escapeAttr(spaceSlug)}">
<body data-space-visibility="${escapeAttr(spaceVisibility)}" data-space-slug="${escapeAttr(spaceSlug)}" data-scope-overrides="${escapeAttr(JSON.stringify(scopeOverrides))}">
<header class="rstack-header">
<div class="rstack-header__left">
<a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/favicon.png" alt="rSpace" class="rstack-header__logo"></a>
@ -186,8 +190,9 @@ export function renderShell(opts: ShellOptions): string {
// Restore saved theme preference across header / tab-row
(function(){try{var t=localStorage.getItem('canvas-theme');if(t)document.querySelectorAll('[data-theme]').forEach(function(el){el.setAttribute('data-theme',t)})}catch(e){}})();
// Provide module list to app switcher
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
// Provide module list to app switcher and offline runtime
window.__rspaceModuleList = ${moduleListJSON};
document.querySelector('rstack-app-switcher')?.setModules(window.__rspaceModuleList);
// ── "Try Demo" button visibility ──
// Hidden when logged in. When logged out, shown everywhere except demo.rspace.online
@ -391,10 +396,23 @@ export function renderShell(opts: ShellOptions): string {
tabBar.addEventListener('layer-close', (e) => {
const { layerId } = e.detail;
const closedLayer = layers.find(l => l.id === layerId);
const closedModuleId = layerId.replace('layer-', '');
tabBar.removeLayer(layerId);
layers = layers.filter(l => l.id !== layerId);
saveTabs();
// If this was a space layer with a persisted SpaceRef, clean it up
if (closedLayer?.spaceSlug && closedLayer._spaceRefId) {
const token = document.cookie.match(/encryptid_token=([^;]+)/)?.[1];
if (token) {
fetch('/api/spaces/' + encodeURIComponent(spaceSlug) + '/nest/' + encodeURIComponent(closedLayer._spaceRefId), {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token },
}).catch(() => { /* best-effort cleanup */ });
}
}
// Remove cached pane from DOM
if (tabCache) tabCache.removePane(closedModuleId);
// If we closed the active tab, switch to the first remaining
@ -415,6 +433,53 @@ export function renderShell(opts: ShellOptions): string {
document.dispatchEvent(new CustomEvent('layer-view-mode', { detail: { mode } }));
});
// ── Space-switch: client-side space navigation ──
// When user clicks a space in the space-switcher, handle it client-side
// if tabCache is available. Updates URL, tabs, and shell chrome.
const spaceSwitcher = document.querySelector('rstack-space-switcher');
if (spaceSwitcher) {
spaceSwitcher.addEventListener('space-switch', (e) => {
const { space: newSpace, moduleId: targetModule, href } = e.detail;
if (!tabCache || !newSpace) return;
e.preventDefault(); // signal to space-switcher that we're handling it
const targetModuleId = targetModule || currentModuleId;
tabCache.switchSpace(newSpace, targetModuleId).then(ok => {
if (!ok) {
// Client-side switch failed — full navigation
window.location.href = href || window.__rspaceNavUrl(newSpace, targetModuleId);
return;
}
// Update runtime to switch space
const runtime = window.__rspaceOfflineRuntime;
if (runtime && runtime.switchSpace) runtime.switchSpace(newSpace);
// Update tab state for the new space
const newTabsKey = 'rspace_tabs_' + newSpace;
let newLayers;
try {
const saved = localStorage.getItem(newTabsKey);
newLayers = saved ? JSON.parse(saved) : [];
if (!Array.isArray(newLayers)) newLayers = [];
} catch(e) { newLayers = []; }
if (!newLayers.find(l => l.moduleId === targetModuleId)) {
newLayers.push(makeLayer(targetModuleId, newLayers.length));
}
localStorage.setItem(newTabsKey, JSON.stringify(newLayers));
// Update layers reference and tab bar
layers = newLayers;
tabBar.setLayers(layers);
tabBar.setAttribute('active', 'layer-' + targetModuleId);
tabBar.setAttribute('space', newSpace);
});
});
}
// ── App-switcher → tab system integration ──
// When user picks a module from the app-switcher, route through tabs
// instead of doing a full page navigation.
@ -445,6 +510,44 @@ export function renderShell(opts: ShellOptions): string {
});
}
// ── Space Layer: cross-space data overlay ──
tabBar.addEventListener('space-layer-add', (e) => {
const { layer, spaceSlug: layerSpace, role } = e.detail;
if (!layers.find(l => l.id === layer.id)) {
layers.push(layer);
}
saveTabs();
// Persist as a SpaceRef via the nesting API (best-effort)
const token = document.cookie.match(/encryptid_token=([^;]+)/)?.[1];
if (token) {
fetch('/api/spaces/' + encodeURIComponent(spaceSlug) + '/nest', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({
sourceSlug: layerSpace,
label: layer.label || layerSpace,
permissions: { read: true, write: role !== 'viewer' },
}),
}).then(res => res.json()).then(data => {
if (data.ref) layer._spaceRefId = data.ref.id;
}).catch(() => { /* best-effort */ });
}
// Connect offline runtime to the source space for cross-space data
const runtime = window.__rspaceOfflineRuntime;
if (runtime && runtime.connectToSpace) {
runtime.connectToSpace(layerSpace).then(() => {
// Dispatch event for canvas and modules to pick up the new space layer
document.dispatchEvent(new CustomEvent('space-layer-added', {
detail: { spaceSlug: layerSpace, role, layerId: layer.id },
}));
}).catch(() => {
console.warn('[shell] Failed to connect to space layer:', layerSpace);
});
}
});
// Expose tabBar for CommunitySync integration
window.__rspaceTabBar = tabBar;

View File

@ -301,11 +301,16 @@ export class RStackAppSwitcher extends HTMLElement {
document.addEventListener("click", this.#outsideClickHandler);
// Intercept same-origin module links → dispatch event for tab system
// Only intercept when the shell tab system is active (window.__rspaceTabBar).
// On landing pages (rspace.online/, rspace.online/{moduleId}), let links
// navigate normally since there's no module-select listener.
this.#shadow.querySelectorAll("a.item").forEach((el) => {
el.addEventListener("click", (e) => {
const moduleId = (el as HTMLElement).dataset.id;
if (!moduleId) return;
// Only intercept same-origin links (skip bare-domain landing pages)
// Skip interception if tab system isn't active (landing pages)
if (!(window as any).__rspaceTabBar) return;
// Only intercept same-origin links
const href = (el as HTMLAnchorElement).href;
try {
const url = new URL(href, window.location.href);

View File

@ -250,6 +250,51 @@ export class RStackSpaceSwitcher extends HTMLElement {
});
});
// Intercept space link clicks — dispatch space-switch event for client-side switching
menu.querySelectorAll("a.item[href]").forEach((link) => {
link.addEventListener("click", (e) => {
const href = link.getAttribute("href") || "";
// Extract space slug from the link
const linkEl = link as HTMLElement;
const row = linkEl.closest(".item-row");
// Find the space slug from the href pattern
const match = href.match(/^(?:https?:\/\/([^.]+)\.rspace\.online)?\/([^/?]+)/);
let spaceSlug = "";
let moduleTarget = moduleId;
if (match) {
if (match[1]) {
spaceSlug = match[1]; // subdomain pattern
} else {
// path pattern: /{space}/{moduleId} or /{moduleId}
spaceSlug = match[2];
}
}
// Also try data attribute on parent
if (!spaceSlug) return; // let default navigation handle it
// If same space, ignore
if (spaceSlug === current) {
menu.classList.remove("open");
return;
}
// Dispatch space-switch event for client-side handling
const event = new CustomEvent("space-switch", {
bubbles: true,
composed: true,
detail: { space: spaceSlug, moduleId: moduleTarget, href },
});
const handled = this.dispatchEvent(event);
// If a listener called preventDefault(), we handled it client-side
if (event.defaultPrevented) {
e.preventDefault();
menu.classList.remove("open");
}
// Otherwise, let the default navigation happen
});
});
// Attach Edit Space gear button listeners
menu.querySelectorAll(".item-gear").forEach((btn) => {
btn.addEventListener("click", (e) => {

View File

@ -234,6 +234,114 @@ export class RStackTabBar extends HTMLElement {
}
}
// ── Space Layer Picker ──
/** Show a popup to pick a space to add as a composable layer. */
async #showSpaceLayerPicker() {
// Remove any existing picker
this.#shadow.querySelector(".space-layer-picker")?.remove();
// Fetch spaces from the API
let spaces: Array<{ slug: string; name: string; icon?: string; role?: string }> = [];
try {
const token = localStorage.getItem("encryptid_session");
const headers: Record<string, string> = {};
if (token) {
try {
const session = JSON.parse(token);
if (session?.accessToken) headers["Authorization"] = `Bearer ${session.accessToken}`;
} catch { /* ignore */ }
}
const res = await fetch("/api/spaces", { headers });
if (res.ok) {
const data = await res.json();
spaces = (data.spaces || []).filter(
(s: any) => s.slug !== this.space && s.role // only spaces user has access to
);
}
} catch { /* offline */ }
// Filter out spaces already added as layers
const existingSpaceSlugs = new Set(
this.#layers.filter(l => l.spaceSlug).map(l => l.spaceSlug)
);
spaces = spaces.filter(s => !existingSpaceSlugs.has(s.slug));
// Build picker HTML
let pickerHtml = `<div class="space-layer-picker">
<div class="space-layer-picker__header">
<span>Add Space Layer</span>
<button class="space-layer-picker__close" id="slp-close">&times;</button>
</div>`;
if (spaces.length === 0) {
pickerHtml += `<div class="space-layer-picker__empty">No other spaces available</div>`;
} else {
for (const s of spaces) {
const roleLabel = s.role === "viewer" ? "view only" : s.role || "";
pickerHtml += `
<button class="space-layer-picker__item" data-slug="${s.slug}" data-name="${(s.name || s.slug).replace(/"/g, '&quot;')}" data-role="${s.role || 'viewer'}">
<span class="space-layer-picker__icon">${s.icon || "🌐"}</span>
<span class="space-layer-picker__name">${s.name || s.slug}</span>
<span class="space-layer-picker__role">${roleLabel}</span>
</button>`;
}
}
pickerHtml += `</div>`;
// Inject into shadow DOM
const container = document.createElement("div");
container.innerHTML = pickerHtml;
const picker = container.firstElementChild!;
this.#shadow.appendChild(picker);
// Close button
this.#shadow.getElementById("slp-close")?.addEventListener("click", () => {
picker.remove();
});
// Space item clicks
picker.querySelectorAll<HTMLElement>(".space-layer-picker__item").forEach(item => {
item.addEventListener("click", () => {
const slug = item.dataset.slug!;
const name = item.dataset.name || slug;
const role = (item.dataset.role || "viewer") as Layer["spaceRole"];
const layer: Layer = {
id: `layer-space-${slug}`,
moduleId: "rspace",
label: `${name}`,
order: this.#layers.length,
color: "",
visible: true,
createdAt: Date.now(),
spaceSlug: slug,
spaceRole: role,
};
this.addLayer(layer);
this.dispatchEvent(new CustomEvent("space-layer-add", {
detail: { layer, spaceSlug: slug, role },
bubbles: true,
}));
picker.remove();
});
});
// Close on outside click
setTimeout(() => {
const handler = (e: Event) => {
if (!picker.contains(e.target as Node)) {
picker.remove();
document.removeEventListener("click", handler);
}
};
document.addEventListener("click", handler);
}, 0);
}
// ── Feed compatibility helpers ──
/** Get the set of FlowKinds a module can output (from its feeds) */
@ -318,14 +426,25 @@ export class RStackTabBar extends HTMLElement {
const isActive = layer.id === activeId;
const badgeColor = layer.color || badge?.color || "#94a3b8";
const isSpaceLayer = !!layer.spaceSlug;
const spaceLayerClass = isSpaceLayer ? " tab--space-layer" : "";
const spaceTag = isSpaceLayer
? `<span class="tab-space-tag" title="From ${layer.spaceSlug}">${layer.spaceSlug}</span>`
: "";
const readOnlyTag = isSpaceLayer && layer.spaceRole === "viewer"
? `<span class="tab-readonly-tag" title="View only">👁</span>`
: "";
return `
<div class="tab ${isActive ? "active" : ""}"
<div class="tab ${isActive ? "active" : ""}${spaceLayerClass}"
data-layer-id="${layer.id}"
data-module-id="${layer.moduleId}"
${isSpaceLayer ? `data-space-slug="${layer.spaceSlug}"` : ""}
draggable="true">
<span class="tab-indicator" style="background:${badgeColor}"></span>
<span class="tab-badge" style="background:${badgeColor}">${badge?.badge || layer.moduleId.slice(0, 2)}</span>
<span class="tab-label">${layer.label}</span>
${spaceTag}${readOnlyTag}
${this.#layers.length > 1 ? `<button class="tab-close" data-close="${layer.id}">&times;</button>` : ""}
</div>
`;
@ -385,6 +504,16 @@ export class RStackTabBar extends HTMLElement {
html += uncategorized.map(m => this.#renderAddMenuItem(m, existingModuleIds.has(m.id))).join("");
}
// ── Add Space Layer section ──
html += `<div class="add-menu-divider"></div>`;
html += `<button class="add-menu-item add-menu-item--space-layer" id="add-space-layer-btn">
<span class="add-menu-icon">🌐</span>
<div class="add-menu-text">
<span class="add-menu-name">Add Space Layer</span>
<span class="add-menu-desc">Overlay data from another space</span>
</div>
</button>`;
return `<div class="add-menu" id="add-menu">${html}</div>`;
}
@ -898,6 +1027,16 @@ export class RStackTabBar extends HTMLElement {
item.addEventListener("touchend", handleSelect);
});
// Add Space Layer button handler
const spaceLayerBtn = this.#shadow.getElementById("add-space-layer-btn");
if (spaceLayerBtn) {
spaceLayerBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.#addMenuOpen = false;
this.#showSpaceLayerPicker();
});
}
// Close add menu on outside click/touch
if (this.#addMenuOpen) {
const handler = () => {
@ -1310,6 +1449,33 @@ const STYLES = `
.tab:hover .tab-close { opacity: 0.6; }
.tab-close:hover { opacity: 1 !important; background: rgba(239,68,68,0.2); color: #ef4444; }
/* Space layer tab styling */
.tab--space-layer {
border: 1px dashed rgba(99,102,241,0.4);
background: rgba(99,102,241,0.05);
}
.tab--space-layer.active {
border-color: rgba(99,102,241,0.6);
background: rgba(99,102,241,0.1);
}
.tab-space-tag {
font-size: 0.6rem;
padding: 1px 4px;
border-radius: 3px;
background: rgba(99,102,241,0.15);
color: #a5b4fc;
max-width: 50px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
}
.tab-readonly-tag {
font-size: 0.65rem;
flex-shrink: 0;
opacity: 0.7;
}
/* ── Drag states ── */
.tab.dragging { opacity: 0.4; }
@ -1469,6 +1635,98 @@ const STYLES = `
opacity: 0.5;
}
.add-menu-divider {
height: 1px;
margin: 4px 8px;
background: var(--rs-border-subtle);
}
.add-menu-item--space-layer {
border-top: none;
}
.add-menu-item--space-layer .add-menu-icon {
font-size: 1rem;
}
/* ── Space Layer Picker ── */
.space-layer-picker {
position: absolute;
top: 100%;
right: 0;
min-width: 240px;
max-height: 320px;
overflow-y: auto;
background: var(--rs-bg-surface);
border: 1px solid var(--rs-border);
border-radius: 12px;
box-shadow: var(--rs-shadow-lg);
z-index: 10002;
margin-top: 4px;
}
.space-layer-picker__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px 6px;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--rs-text-secondary);
}
.space-layer-picker__close {
background: none;
border: none;
color: var(--rs-text-muted);
font-size: 1.1rem;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
line-height: 1;
}
.space-layer-picker__close:hover {
color: var(--rs-text-primary);
background: var(--rs-bg-hover);
}
.space-layer-picker__item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 14px;
border: none;
background: none;
cursor: pointer;
transition: background 0.12s;
text-align: left;
font-family: inherit;
color: var(--rs-text-primary);
}
.space-layer-picker__item:hover {
background: var(--rs-bg-hover);
}
.space-layer-picker__icon {
font-size: 1.1rem;
flex-shrink: 0;
}
.space-layer-picker__name {
font-size: 0.85rem;
font-weight: 500;
flex: 1;
}
.space-layer-picker__role {
font-size: 0.7rem;
color: var(--rs-text-muted);
flex-shrink: 0;
}
.space-layer-picker__empty {
padding: 16px;
text-align: center;
font-size: 0.8rem;
color: var(--rs-text-muted);
}
/* ── View toggle ── */
.view-toggle {

View File

@ -46,13 +46,17 @@ export class RSpaceOfflineRuntime {
#store: EncryptedDocStore;
#sync: DocSyncManager;
#crypto: DocCrypto;
#space: string;
#activeSpace: string;
#status: RuntimeStatus = 'idle';
#statusListeners = new Set<StatusCallback>();
#initialized = false;
/** Module scope config: moduleId → 'global' | 'space'. Set from page's module list. */
#moduleScopes = new Map<string, 'global' | 'space'>();
/** Lazy WebSocket connections per space slug (for cross-space subscriptions). */
#spaceConnections = new Map<string, DocSyncManager>();
constructor(space: string) {
this.#space = space;
this.#activeSpace = space;
this.#crypto = new DocCrypto();
this.#documents = new DocumentManager();
this.#store = new EncryptedDocStore(space);
@ -64,7 +68,7 @@ export class RSpaceOfflineRuntime {
// ── Getters ──
get space(): string { return this.#space; }
get space(): string { return this.#activeSpace; }
get isInitialized(): boolean { return this.#initialized; }
get isOnline(): boolean { return this.#sync.isConnected; }
get status(): RuntimeStatus { return this.#status; }
@ -85,13 +89,13 @@ export class RSpaceOfflineRuntime {
// 2. Connect WebSocket (non-blocking — works offline)
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;
const wsUrl = `${proto}//${location.host}/ws/${this.#activeSpace}`;
this.#sync.onConnect(() => this.#setStatus('online'));
this.#sync.onDisconnect(() => this.#setStatus('offline'));
try {
await this.#sync.connect(wsUrl, this.#space);
await this.#sync.connect(wsUrl, this.#activeSpace);
this.#setStatus('online');
} catch {
// WebSocket failed — still usable offline
@ -175,10 +179,32 @@ export class RSpaceOfflineRuntime {
}
/**
* Build a DocumentId for a module using the current space.
* Configure module scope information from the page's module list.
* Call once after init with the modules config for this space.
*/
setModuleScopes(scopes: Array<{ id: string; scope: 'global' | 'space' }>): void {
this.#moduleScopes.clear();
for (const { id, scope } of scopes) {
this.#moduleScopes.set(id, scope);
}
}
/**
* Resolve the data-space prefix for a module.
* Returns 'global' for global-scoped modules, the current space otherwise.
*/
resolveDocSpace(moduleId: string): string {
const scope = this.#moduleScopes.get(moduleId);
return scope === 'global' ? 'global' : this.#activeSpace;
}
/**
* Build a DocumentId for a module, respecting scope resolution.
* Global-scoped modules get `global:module:collection` prefix.
*/
makeDocId(module: string, collection: string, itemId?: string): DocumentId {
return makeDocumentId(this.#space, module, collection, itemId);
const dataSpace = this.resolveDocSpace(module);
return makeDocumentId(dataSpace, module, collection, itemId);
}
/**
@ -200,7 +226,8 @@ export class RSpaceOfflineRuntime {
): Promise<Map<DocumentId, Automerge.Doc<T>>> {
this.#documents.registerSchema(schema);
const prefix = `${this.#space}:${module}:${collection}`;
const dataSpace = this.resolveDocSpace(module);
const prefix = `${dataSpace}:${module}:${collection}`;
const docIds = await this.#sync.requestDocList(prefix);
const results = new Map<DocumentId, Automerge.Doc<T>>();
@ -231,10 +258,133 @@ export class RSpaceOfflineRuntime {
}
/**
* Tear down: flush, disconnect, clear listeners.
* Switch the active space context without destroying existing connections.
* Previous space's WebSocket stays alive for cross-space subscriptions.
*/
switchSpace(newSpace: string): void {
if (newSpace === this.#activeSpace) return;
// Store the current connection for cross-space access
if (!this.#spaceConnections.has(this.#activeSpace)) {
this.#spaceConnections.set(this.#activeSpace, this.#sync);
}
this.#activeSpace = newSpace;
// Re-use existing connection if we've visited this space before
const existing = this.#spaceConnections.get(newSpace);
if (existing) {
this.#sync = existing;
return;
}
// Create a new sync manager for the new space
const newSync = new DocSyncManager({
documents: this.#documents,
store: this.#store,
});
this.#sync = newSync;
this.#spaceConnections.set(newSpace, newSync);
// Connect lazily — will connect when first doc is subscribed
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${newSpace}`;
newSync.onConnect(() => this.#setStatus('online'));
newSync.onDisconnect(() => this.#setStatus('offline'));
newSync.connect(wsUrl, newSpace).catch(() => {
this.#setStatus('offline');
});
}
/**
* Get all spaces that have active connections (current + cross-space layers).
* Modules use this to aggregate data from all active space layers.
* Returns array of { space, sync } pairs for iteration.
*/
getAllActiveSpaces(): Array<{ space: string; sync: DocSyncManager }> {
const result: Array<{ space: string; sync: DocSyncManager }> = [
{ space: this.#activeSpace, sync: this.#sync },
];
for (const [space, sync] of this.#spaceConnections) {
if (space !== this.#activeSpace) {
result.push({ space, sync });
}
}
return result;
}
/**
* Subscribe to a module's docs across all active spaces.
* Returns merged results from the current space + all space layers.
*/
async subscribeModuleAcrossSpaces<T extends Record<string, any>>(
module: string,
collection: string,
schema: DocSchema<T>,
): Promise<Map<DocumentId, { doc: Automerge.Doc<T>; space: string }>> {
this.#documents.registerSchema(schema);
const results = new Map<DocumentId, { doc: Automerge.Doc<T>; space: string }>();
for (const { space, sync } of this.getAllActiveSpaces()) {
const dataSpace = this.#moduleScopes.get(module) === 'global' ? 'global' : space;
const prefix = `${dataSpace}:${module}:${collection}`;
try {
const docIds = await sync.requestDocList(prefix);
for (const id of docIds) {
const docId = id as DocumentId;
if (results.has(docId)) continue; // global docs already seen
const doc = await this.subscribe<T>(docId, schema);
results.set(docId, { doc, space });
}
} catch {
// Cross-space fetch failed — skip silently
}
}
return results;
}
/**
* Get a sync manager for a specific space (lazy connect).
* Used for cross-space subscriptions (space layers).
*/
async connectToSpace(spaceSlug: string): Promise<DocSyncManager> {
// Already connected?
const existing = this.#spaceConnections.get(spaceSlug);
if (existing) return existing;
// Current space
if (spaceSlug === this.#activeSpace) return this.#sync;
// Create new connection
const sync = new DocSyncManager({
documents: this.#documents,
store: this.#store,
});
this.#spaceConnections.set(spaceSlug, sync);
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${spaceSlug}`;
sync.onConnect(() => { /* cross-space connected */ });
sync.onDisconnect(() => { /* cross-space disconnected */ });
try {
await sync.connect(wsUrl, spaceSlug);
} catch {
// Cross-space connection failed — operate offline for this space
}
return sync;
}
/**
* Tear down: flush, disconnect all connections, clear listeners.
*/
destroy(): void {
this.#sync.disconnect();
for (const [, sync] of this.#spaceConnections) {
sync.disconnect();
}
this.#spaceConnections.clear();
this.#statusListeners.clear();
this.#initialized = false;
this.#setStatus('idle');

28
shared/scope-resolver.ts Normal file
View File

@ -0,0 +1,28 @@
/**
* Scope resolver determines whether a module's data lives per-space or globally.
*
* When a module declares `defaultScope: 'global'`, its Automerge documents
* use the `global:` prefix instead of `{space}:`, so data is shared across
* all spaces. Space owners can override this per-module via `moduleScopeOverrides`.
*/
import { getModule } from './module';
import type { ModuleScope } from './module';
/**
* Resolve the effective data-space prefix for a module.
*
* Returns 'global' if the module should use global-scoped data,
* otherwise returns the current space slug for space-scoped data.
*/
export function resolveDataSpace(
moduleId: string,
spaceSlug: string,
scopeOverrides?: Record<string, ModuleScope> | null,
): string {
const mod = getModule(moduleId);
if (!mod) return spaceSlug;
const effectiveScope = scopeOverrides?.[moduleId] ?? mod.scoping.defaultScope;
return effectiveScope === 'global' ? 'global' : spaceSlug;
}

View File

@ -1,10 +1,14 @@
/**
* Client-side tab cache for instant tab switching.
* Client-side tab cache for instant tab and space switching.
*
* Instead of full page navigations, previously loaded tabs are kept in the DOM
* and shown/hidden via CSS. New tabs are fetched via fetch() + DOMParser on
* first visit, then stay in the DOM forever.
*
* Pane keys are `${spaceSlug}:${moduleId}` so panes from different spaces
* coexist in the DOM. On space switch, current space's panes are hidden and
* the target space's panes are shown (or fetched on first visit).
*
* Hiding preserves ALL component state: Shadow DOM, event listeners,
* WebSocket connections, timers.
*/
@ -19,22 +23,40 @@ export class TabCache {
this.currentModuleId = moduleId;
}
/** Current space slug */
get currentSpace(): string {
return this.spaceSlug;
}
/** Current module ID */
get currentModule(): string {
return this.currentModuleId;
}
/** Build the composite pane key */
private paneKey(space: string, moduleId: string): string {
return `${space}:${moduleId}`;
}
/** Wrap the initial #app content in a pane div and set up popstate */
init(): boolean {
const app = document.getElementById("app");
if (!app) return false;
const key = this.paneKey(this.spaceSlug, this.currentModuleId);
// Wrap existing content in a pane
const pane = document.createElement("div");
pane.className = "rspace-tab-pane rspace-tab-pane--active";
pane.dataset.moduleId = this.currentModuleId;
pane.dataset.spaceSlug = this.spaceSlug;
pane.dataset.pageTitle = document.title;
while (app.firstChild) {
pane.appendChild(app.firstChild);
}
app.appendChild(pane);
this.panes.set(this.currentModuleId, pane);
this.panes.set(key, pane);
// Push initial state into history
history.replaceState(
@ -46,8 +68,18 @@ export class TabCache {
// Handle browser back/forward
window.addEventListener("popstate", (e) => {
const state = e.state;
if (state?.moduleId && this.panes.has(state.moduleId)) {
this.showPane(state.moduleId);
if (!state?.moduleId) {
window.location.reload();
return;
}
const stateSpace = state.spaceSlug || this.spaceSlug;
const key = this.paneKey(stateSpace, state.moduleId);
if (this.panes.has(key)) {
if (stateSpace !== this.spaceSlug) {
this.hideAllPanes();
this.spaceSlug = stateSpace;
}
this.showPane(stateSpace, state.moduleId);
} else {
window.location.reload();
}
@ -56,43 +88,84 @@ export class TabCache {
return true;
}
/** Switch to a module tab. Returns true if handled client-side. */
/** Switch to a module tab within the current space. Returns true if handled client-side. */
async switchTo(moduleId: string): Promise<boolean> {
if (moduleId === this.currentModuleId) return true;
const key = this.paneKey(this.spaceSlug, moduleId);
if (moduleId === this.currentModuleId && this.panes.has(key)) return true;
if (this.panes.has(moduleId)) {
this.showPane(moduleId);
this.updateUrl(moduleId);
if (this.panes.has(key)) {
this.showPane(this.spaceSlug, moduleId);
this.updateUrl(this.spaceSlug, moduleId);
return true;
}
return this.fetchAndInject(moduleId);
return this.fetchAndInject(this.spaceSlug, moduleId);
}
/**
* Switch to a different space, loading a module within it.
* Hides all panes from the current space, shows/fetches the target space's pane.
* Returns true if handled client-side.
*/
async switchSpace(newSpace: string, moduleId: string): Promise<boolean> {
if (newSpace === this.spaceSlug && moduleId === this.currentModuleId) return true;
const key = this.paneKey(newSpace, moduleId);
// Hide all current panes
this.hideAllPanes();
// Update active space
const oldSpace = this.spaceSlug;
this.spaceSlug = newSpace;
if (this.panes.has(key)) {
this.showPane(newSpace, moduleId);
this.updateUrl(newSpace, moduleId);
this.updateSpaceChrome(newSpace);
return true;
}
const ok = await this.fetchAndInject(newSpace, moduleId);
if (ok) {
this.updateSpaceChrome(newSpace);
return true;
}
// Rollback on failure
this.spaceSlug = oldSpace;
return false;
}
/** Check if a pane exists in the DOM cache */
has(moduleId: string): boolean {
return this.panes.has(moduleId);
return this.panes.has(this.paneKey(this.spaceSlug, moduleId));
}
/** Remove a cached pane from the DOM */
removePane(moduleId: string): void {
const pane = this.panes.get(moduleId);
const key = this.paneKey(this.spaceSlug, moduleId);
const pane = this.panes.get(key);
if (pane) {
pane.remove();
this.panes.delete(moduleId);
this.panes.delete(key);
}
}
/** Fetch a module page, extract content, and inject into a new pane */
private async fetchAndInject(moduleId: string): Promise<boolean> {
private async fetchAndInject(space: string, moduleId: string): Promise<boolean> {
const navUrl = (window as any).__rspaceNavUrl;
if (!navUrl) return false;
const url: string = navUrl(this.spaceSlug, moduleId);
const url: string = navUrl(space, moduleId);
// Cross-origin URLs → fall back to full navigation
// For cross-space fetches, use the path-based API to stay same-origin
let fetchUrl = url;
try {
const resolved = new URL(url, window.location.href);
if (resolved.origin !== window.location.origin) return false;
if (resolved.origin !== window.location.origin) {
// Rewrite subdomain URL to path format: /{space}/{moduleId}
fetchUrl = `/${space}/${moduleId}`;
}
} catch {
return false;
}
@ -109,7 +182,7 @@ export class TabCache {
app.appendChild(loadingPane);
try {
const resp = await fetch(url);
const resp = await fetch(fetchUrl);
if (!resp.ok) {
loadingPane.remove();
return false;
@ -125,23 +198,26 @@ export class TabCache {
// Remove loading spinner
loadingPane.remove();
const key = this.paneKey(space, moduleId);
// Create the real pane
const pane = document.createElement("div");
pane.className = "rspace-tab-pane rspace-tab-pane--active";
pane.dataset.moduleId = moduleId;
pane.dataset.spaceSlug = space;
pane.dataset.pageTitle = content.title;
pane.innerHTML = content.body;
app.appendChild(pane);
this.panes.set(moduleId, pane);
this.panes.set(key, pane);
// Load module-specific assets
this.loadAssets(content.scripts, content.styles, content.inlineStyles, moduleId);
this.loadAssets(content.scripts, content.styles, content.inlineStyles, `${space}-${moduleId}`);
// Update shell state
this.currentModuleId = moduleId;
document.title = content.title;
this.updateShellState(moduleId);
this.updateUrl(moduleId);
this.updateUrl(space, moduleId);
this.updateCanvasLayout(moduleId);
return true;
@ -240,9 +316,10 @@ export class TabCache {
}
/** Show a specific pane and update shell state */
private showPane(moduleId: string): void {
private showPane(space: string, moduleId: string): void {
this.hideAllPanes();
const pane = this.panes.get(moduleId);
const key = this.paneKey(space, moduleId);
const pane = this.panes.get(key);
if (pane) {
pane.classList.add("rspace-tab-pane--active");
const storedTitle = pane.dataset.pageTitle;
@ -282,13 +359,26 @@ export class TabCache {
}
}
/** Update space-specific shell chrome (space switcher, body attrs) */
private updateSpaceChrome(newSpace: string): void {
// Update space-switcher attribute
const spaceSwitcher = document.querySelector("rstack-space-switcher");
if (spaceSwitcher) {
spaceSwitcher.setAttribute("current", newSpace);
spaceSwitcher.setAttribute("name", newSpace);
}
// Update body data attribute
document.body?.setAttribute("data-space-slug", newSpace);
}
/** Push new URL to browser history */
private updateUrl(moduleId: string): void {
private updateUrl(space: string, moduleId: string): void {
const navUrl = (window as any).__rspaceNavUrl;
if (!navUrl) return;
const url: string = navUrl(this.spaceSlug, moduleId);
const url: string = navUrl(space, moduleId);
history.pushState(
{ moduleId, spaceSlug: this.spaceSlug },
{ moduleId, spaceSlug: space },
"",
url,
);

View File

@ -1339,6 +1339,25 @@
display: none !important;
}
/* Cross-space shape styling — colored border + source badge */
.rspace-cross-space-shape {
outline: 2px dashed rgba(99, 102, 241, 0.5) !important;
outline-offset: 2px;
}
.rspace-cross-space-shape::after {
content: attr(data-source-space);
position: absolute;
top: -18px;
right: 0;
font-size: 0.55rem;
padding: 1px 5px;
border-radius: 3px;
background: rgba(99, 102, 241, 0.8);
color: white;
pointer-events: none;
white-space: nowrap;
}
#select-rect {
position: fixed;
border: 1.5px solid #3b82f6;
@ -2438,6 +2457,49 @@
});
}
// ── Space Layer: overlay shapes from other spaces ──
document.addEventListener('space-layer-added', (e) => {
const { spaceSlug: layerSpace, role, layerId } = e.detail;
if (!sync || !layerSpace) return;
// Fetch shapes from the other space's canvas
fetch(`/${layerSpace}/rspace/api/shapes`)
.then(res => res.ok ? res.json() : [])
.then(shapes => {
if (!Array.isArray(shapes)) return;
const canvasEl = document.getElementById('canvas-content');
if (!canvasEl) return;
for (const data of shapes) {
const el = newShapeElement(data);
if (!el) continue;
// Mark as cross-space shape
el.dataset.sourceSpace = layerSpace;
el.dataset.sourceLayerId = layerId;
el.classList.add('rspace-cross-space-shape');
// View-only: make non-interactive
if (role === 'viewer') {
el.style.pointerEvents = 'none';
el.style.opacity = '0.8';
}
canvasEl.appendChild(el);
}
})
.catch(() => {
console.warn('[Canvas] Failed to load shapes from space layer:', layerSpace);
});
});
// Clean up shapes when a space layer is removed
document.addEventListener('layer-close', (e) => {
const layerId = e.detail?.layerId;
if (!layerId) return;
document.querySelectorAll(`[data-source-layer-id="${layerId}"]`).forEach(el => el.remove());
});
// ── "Try Demo" button visibility ──
// Hide on demo space, show on bare domain
(function() {

View File

@ -42,6 +42,21 @@ const spaceSlug = document.body?.getAttribute("data-space-slug");
if (spaceSlug && spaceSlug !== "demo") {
const runtime = new RSpaceOfflineRuntime(spaceSlug);
(window as any).__rspaceOfflineRuntime = runtime;
// Configure module scope resolution from server-rendered data
try {
const scopeJson = document.body?.getAttribute("data-scope-overrides");
const overrides: Record<string, string> = scopeJson ? JSON.parse(scopeJson) : {};
// Build scope config: merge module defaults with space overrides
const moduleList: Array<{ id: string; scoping?: { defaultScope: string } }> =
(window as any).__rspaceModuleList || [];
const scopes: Array<{ id: string; scope: 'global' | 'space' }> = moduleList.map(m => ({
id: m.id,
scope: (overrides[m.id] || m.scoping?.defaultScope || 'space') as 'global' | 'space',
}));
runtime.setModuleScopes(scopes);
} catch { /* scope config unavailable — defaults to space-scoped */ }
runtime.init().catch((e: unknown) => {
console.warn("[shell] Offline runtime init failed — REST fallback only:", e);
});