From 07f525436f0d2dcf90221bfa0d4b451b95f20c5e Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sun, 12 Apr 2026 11:09:44 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20object=20visibility=20membrane=20?= =?UTF-8?q?=E2=80=94=20per-object=20access=20filtering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add per-object visibility levels (viewer/member/moderator/admin) across all rSpace modules. Objects default to 'viewer' (open), so existing data remains visible. Server-side GET handlers resolve caller role and filter; MCP tools filter lists and check single-item access; frontend components do defense-in-depth filtering with visibility picker (mod+) and lock badges. - shared/membrane.ts: types + isVisibleTo, filterByVisibility, filterArrayByVisibility - 9 schema files: visibility field on TaskItem, NoteItem, CalendarEvent, etc. - 8 module routes: GET handlers filter by caller role - 6 MCP tool files: list filtering + single-item visibility checks - 4 frontend components: client filtering, picker, lock badges - 18 unit tests (all passing) Co-Authored-By: Claude Opus 4.6 --- modules/rcal/components/folk-calendar-view.ts | 36 ++++- modules/rcal/mod.ts | 20 ++- modules/rcal/schemas.ts | 1 + modules/rchats/mod.ts | 38 ++++- modules/rchats/schemas.ts | 2 + modules/rdocs/components/folk-docs-app.ts | 39 ++++- modules/rdocs/mod.ts | 28 +++- modules/rdocs/schemas.ts | 1 + modules/rnotes/mod.ts | 18 ++- modules/rnotes/schemas.ts | 1 + modules/rphotos/mod.ts | 19 ++- modules/rphotos/schemas.ts | 1 + modules/rtasks/components/folk-tasks-board.ts | 59 ++++++- modules/rtasks/mod.ts | 25 ++- modules/rtasks/schemas.ts | 1 + modules/rtime/mod.ts | 35 +++- modules/rtime/schemas.ts | 2 + modules/rvnb/schemas.ts | 1 + .../rwallet/components/folk-wallet-viewer.ts | 45 +++++- modules/rwallet/mod.ts | 3 + modules/rwallet/schemas.ts | 1 + server/mcp-tools/rcal.ts | 15 +- server/mcp-tools/rchats.ts | 5 +- server/mcp-tools/rdocs.ts | 17 +- server/mcp-tools/rnotes.ts | 10 +- server/mcp-tools/rtasks.ts | 15 +- server/mcp-tools/rtime.ts | 5 +- shared/membrane.test.ts | 152 ++++++++++++++++++ shared/membrane.ts | 62 +++++++ 29 files changed, 604 insertions(+), 53 deletions(-) create mode 100644 shared/membrane.test.ts create mode 100644 shared/membrane.ts 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 = `