diff --git a/modules/rcal/components/folk-calendar-view.ts b/modules/rcal/components/folk-calendar-view.ts index 071b30d6..34299f7d 100644 --- a/modules/rcal/components/folk-calendar-view.ts +++ b/modules/rcal/components/folk-calendar-view.ts @@ -129,6 +129,8 @@ class FolkCalendarView extends HTMLElement { private _offlineUnsub: (() => void) | null = null; private _subscribedDocIds: string[] = []; private _stopPresence: (() => void) | null = null; + // Membrane — caller's role for visibility filtering + private _myRole: string = 'viewer'; // Spatio-temporal state private temporalGranularity = 4; // MONTH @@ -199,7 +201,7 @@ class FolkCalendarView extends HTMLElement { this.boundKeyHandler = (e: KeyboardEvent) => this.handleKeydown(e); document.addEventListener("keydown", this.boundKeyHandler); if (this.space === "demo") { this.loadDemoData(); } - else { this.subscribeOffline(); this.loadMonth(); this.render(); } + else { this.subscribeOffline(); this.loadMonth(); this.loadMyRole(); this.render(); } if (!localStorage.getItem("rcal_tour_done")) { setTimeout(() => this._tour.start(), 1200); } @@ -246,6 +248,26 @@ class FolkCalendarView extends HTMLElement { } } + private _isVisibleTo(vis: string | undefined | null, role: string): boolean { + const levels: Record = { viewer: 0, member: 1, moderator: 2, admin: 3 }; + return (levels[role] ?? 0) >= (levels[vis ?? 'viewer'] ?? 0); + } + + private async loadMyRole() { + if (this.space === 'demo') return; + const token = localStorage.getItem('encryptid-token'); + if (!token) return; + try { + const res = await fetch(`/api/space-access/${encodeURIComponent(this.space)}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + const data = await res.json(); + this._myRole = data.role || 'viewer'; + } + } catch {} + } + private async subscribeOffline() { const runtime = (window as any).__rspaceOfflineRuntime; if (!runtime?.isInitialized) return; @@ -435,6 +457,8 @@ class FolkCalendarView extends HTMLElement { return this.events.filter(e => { if (e.latitude == null || e.longitude == null) return false; if (this.filteredSources.has(e.source_name)) return false; + // Client-side membrane filter + if (!this._isVisibleTo(e.accessVisibility, this._myRole)) return false; const t = new Date(e.start_time).getTime(); return t >= start && t < end; }); @@ -481,6 +505,8 @@ class FolkCalendarView extends HTMLElement { const end = new Date(year, month + 1, 1).getTime(); return this.events.filter(e => { const t = new Date(e.start_time).getTime(); + // Client-side membrane filter + if (!this._isVisibleTo(e.accessVisibility, this._myRole)) return false; return t >= start && t < end && !this.filteredSources.has(e.source_name); }); } @@ -909,6 +935,8 @@ class FolkCalendarView extends HTMLElement { private getEventsForDate(dateStr: string): any[] { return this.events.filter(e => { if (!e.start_time || this.filteredSources.has(e.source_name)) return false; + // Client-side membrane filter (defense-in-depth) + if (!this._isVisibleTo(e.accessVisibility, this._myRole)) return false; const startDay = e.start_time.slice(0, 10); const endDay = e.end_time ? e.end_time.slice(0, 10) : startDay; return dateStr >= startDay && dateStr <= endDay; @@ -982,7 +1010,8 @@ class FolkCalendarView extends HTMLElement { const cityHtml = city ? `${this.esc(city)}` : ""; const virtualHtml = e.is_virtual ? `\u{1F4BB}` : ""; const likelihoodHtml = es.isTentative ? `${es.likelihoodLabel}` : ""; - return `
${e.rToolSource === "rSchedule" ? '🔔' : ""}${virtualHtml}${this.formatTime(e.start_time)}${this.esc(e.title)}${likelihoodHtml}${cityHtml}
`; + const lockHtml = e.accessVisibility && e.accessVisibility !== 'viewer' ? `🔒 ` : ""; + return `
${e.rToolSource === "rSchedule" ? '🔔' : ""}${lockHtml}${virtualHtml}${this.formatTime(e.start_time)}${this.esc(e.title)}${likelihoodHtml}${cityHtml}
`; }).join(""); } @@ -2202,10 +2231,11 @@ class FolkCalendarView extends HTMLElement { const srcTag = e.source_name ? `${this.esc(e.source_name)}` : ""; const es = this.getEventStyles(e); const likelihoodBadge = es.isTentative ? `${es.likelihoodLabel}` : ""; + const ddLockBadge = e.accessVisibility && e.accessVisibility !== 'viewer' ? `🔒 ` : ""; return `
-
${this.esc(e.title)}${likelihoodBadge}${srcTag}
+
${ddLockBadge}${this.esc(e.title)}${likelihoodBadge}${srcTag}
${this.formatTime(e.start_time)}${e.end_time ? ` \u2013 ${this.formatTime(e.end_time)}` : ""}${e.location_name ? ` \u00B7 ${this.esc(e.location_name)}` : ""}${e.is_virtual ? " \u00B7 Virtual" : ""}
${ddDesc ? `
${this.esc(ddDesc)}
` : ""}
diff --git a/modules/rcal/mod.ts b/modules/rcal/mod.ts index 8362ee9c..f7eacf84 100644 --- a/modules/rcal/mod.ts +++ b/modules/rcal/mod.ts @@ -14,6 +14,10 @@ import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyToken, extractToken } from "../../server/auth"; +import { resolveCallerRole } from "../../server/spaces"; +import type { SpaceRoleString } from "../../server/spaces"; +import { filterArrayByVisibility } from "../../shared/membrane"; +import type { ObjectVisibility } from "../../shared/membrane"; import { renderLanding } from "./landing"; import type { SyncServer } from '../../server/local-first/sync-server'; import { calendarSchema, calendarDocId } from './schemas'; @@ -301,7 +305,21 @@ routes.get("/api/events", async (c) => { const { start, end, source, search, rTool, rEntityId, upcoming, tags: tagsParam } = c.req.query(); const doc = ensureDoc(dataSpace); - let events = Object.values(doc.events); + + // Resolve caller role for membrane filtering + let callerRole: SpaceRoleString = 'viewer'; + const token = extractToken(c.req.raw.headers); + if (token) { + try { + const claims = await verifyToken(token); + const resolved = await resolveCallerRole(space, claims); + if (resolved) callerRole = resolved.role; + } catch {} + } + let events = filterArrayByVisibility( + Object.values(doc.events), callerRole, + (e) => e.accessVisibility as ObjectVisibility | undefined, + ); // Apply filters if (tagsParam) { diff --git a/modules/rcal/schemas.ts b/modules/rcal/schemas.ts index d302cd4a..36c4b04b 100644 --- a/modules/rcal/schemas.ts +++ b/modules/rcal/schemas.ts @@ -78,6 +78,7 @@ export interface CalendarEvent { metadata: unknown | null; createdAt: number; updatedAt: number; + accessVisibility?: import('../../shared/membrane').ObjectVisibility; } export interface SavedCalendarViewFilter { diff --git a/modules/rchats/mod.ts b/modules/rchats/mod.ts index ad718ecd..c1a449d9 100644 --- a/modules/rchats/mod.ts +++ b/modules/rchats/mod.ts @@ -11,6 +11,9 @@ import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyToken, extractToken } from "../../server/auth"; +import { resolveCallerRole } from "../../server/spaces"; +import type { SpaceRoleString } from "../../server/spaces"; +import { filterByVisibility, filterArrayByVisibility } from "../../shared/membrane"; import { renderLanding } from "./landing"; import type { SyncServer } from '../../server/local-first/sync-server'; import { chatsDirectorySchema, chatChannelSchema, chatsDirectoryDocId, chatChannelDocId } from './schemas'; @@ -53,11 +56,24 @@ function ensureChannelDoc(space: string, channelId: string): ChatChannelDoc { // ── CRUD: Channels ── -routes.get("/api/channels", (c) => { +routes.get("/api/channels", async (c) => { if (!_syncServer) return c.json({ channels: [] }); const space = c.req.param("space") || "demo"; + + // Resolve caller role for membrane filtering + let callerRole: SpaceRoleString = 'viewer'; + const token = extractToken(c.req.raw.headers); + if (token) { + try { + const claims = await verifyToken(token); + const resolved = await resolveCallerRole(space, claims); + if (resolved) callerRole = resolved.role; + } catch {} + } + const doc = ensureDirectoryDoc(space); - return c.json({ channels: Object.values(doc.channels || {}) }); + const visibleChannels = filterByVisibility(doc.channels || {}, callerRole); + return c.json({ channels: Object.values(visibleChannels) }); }); routes.post("/api/channels", async (c) => { @@ -80,12 +96,26 @@ routes.post("/api/channels", async (c) => { // ── CRUD: Messages ── -routes.get("/api/channels/:channelId/messages", (c) => { +routes.get("/api/channels/:channelId/messages", async (c) => { if (!_syncServer) return c.json({ messages: [] }); const space = c.req.param("space") || "demo"; const channelId = c.req.param("channelId"); + + // Resolve caller role for membrane filtering + let callerRole: SpaceRoleString = 'viewer'; + const token = extractToken(c.req.raw.headers); + if (token) { + try { + const claims = await verifyToken(token); + const resolved = await resolveCallerRole(space, claims); + if (resolved) callerRole = resolved.role; + } catch {} + } + const doc = ensureChannelDoc(space, channelId); - const messages = Object.values(doc.messages || {}).sort((a, b) => a.createdAt - b.createdAt); + const messages = filterArrayByVisibility( + Object.values(doc.messages || {}), callerRole, + ).sort((a, b) => a.createdAt - b.createdAt); return c.json({ messages }); }); diff --git a/modules/rchats/schemas.ts b/modules/rchats/schemas.ts index be5a418f..c84aa2bb 100644 --- a/modules/rchats/schemas.ts +++ b/modules/rchats/schemas.ts @@ -18,6 +18,7 @@ export interface ChannelInfo { createdBy: string | null; createdAt: number; updatedAt: number; + visibility?: import('../../shared/membrane').ObjectVisibility; } export interface ChatsDirectoryDoc { @@ -46,6 +47,7 @@ export interface ChatMessage { replyTo: string | null; editedAt: number | null; createdAt: number; + visibility?: import('../../shared/membrane').ObjectVisibility; } export interface ChatChannelDoc { diff --git a/modules/rdocs/components/folk-docs-app.ts b/modules/rdocs/components/folk-docs-app.ts index d90bb1ae..e2929980 100644 --- a/modules/rdocs/components/folk-docs-app.ts +++ b/modules/rdocs/components/folk-docs-app.ts @@ -180,6 +180,9 @@ class FolkDocsApp extends HTMLElement { private audioRecordingTimer: ReturnType | null = null; private audioRecordingDictation: SpeechDictation | null = null; + // Membrane — caller's role for visibility filtering + private _myRole: string = 'viewer'; + // Automerge sync state (via shared runtime) private doc: Automerge.Doc | null = null; private subscribedDocId: string | null = null; @@ -220,7 +223,7 @@ class FolkDocsApp extends HTMLElement { this.space = this.getAttribute("space") || "demo"; this.setupShadow(); if (this.space === "demo") { this.loadDemoData(); } - else { this.subscribeOfflineRuntime(); this.loadNotebooks(); this.setupPresence(); } + else { this.subscribeOfflineRuntime(); this.loadNotebooks(); this.setupPresence(); this.loadMyRole(); } // Auto-start tour on first visit if (!localStorage.getItem("rdocs_tour_done")) { setTimeout(() => this._tour.start(), 1200); @@ -621,6 +624,28 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF this._subscribedDocIds = []; } + // ── Membrane ── + + private _isVisibleTo(vis: string | undefined | null, role: string): boolean { + const levels: Record = { viewer: 0, member: 1, moderator: 2, admin: 3 }; + return (levels[role] ?? 0) >= (levels[vis ?? 'viewer'] ?? 0); + } + + private async loadMyRole() { + if (this.space === 'demo') return; + const token = localStorage.getItem('encryptid-token'); + if (!token) return; + try { + const res = await fetch(`/api/space-access/${encodeURIComponent(this.space)}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + const data = await res.json(); + this._myRole = data.role || 'viewer'; + } + } catch {} + } + // ── Sync (via shared runtime) ── private async subscribeNotebook(notebookId: string) { @@ -3097,13 +3122,15 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF let treeHtml = ''; if (isSearching && this.searchResults.length > 0) { + // Client-side membrane filter on search results + const visibleResults = this.searchResults.filter(n => this._isVisibleTo((n as any).visibility, this._myRole)); treeHtml = `