feat: object visibility membrane — per-object access filtering

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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-12 11:09:44 -04:00
parent 5c88922b13
commit 0eb721d12e
29 changed files with 604 additions and 53 deletions

View File

@ -129,6 +129,8 @@ class FolkCalendarView extends HTMLElement {
private _offlineUnsub: (() => void) | null = null; private _offlineUnsub: (() => void) | null = null;
private _subscribedDocIds: string[] = []; private _subscribedDocIds: string[] = [];
private _stopPresence: (() => void) | null = null; private _stopPresence: (() => void) | null = null;
// Membrane — caller's role for visibility filtering
private _myRole: string = 'viewer';
// Spatio-temporal state // Spatio-temporal state
private temporalGranularity = 4; // MONTH private temporalGranularity = 4; // MONTH
@ -199,7 +201,7 @@ class FolkCalendarView extends HTMLElement {
this.boundKeyHandler = (e: KeyboardEvent) => this.handleKeydown(e); this.boundKeyHandler = (e: KeyboardEvent) => this.handleKeydown(e);
document.addEventListener("keydown", this.boundKeyHandler); document.addEventListener("keydown", this.boundKeyHandler);
if (this.space === "demo") { this.loadDemoData(); } 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")) { if (!localStorage.getItem("rcal_tour_done")) {
setTimeout(() => this._tour.start(), 1200); 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<string, number> = { 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() { private async subscribeOffline() {
const runtime = (window as any).__rspaceOfflineRuntime; const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return; if (!runtime?.isInitialized) return;
@ -435,6 +457,8 @@ class FolkCalendarView extends HTMLElement {
return this.events.filter(e => { return this.events.filter(e => {
if (e.latitude == null || e.longitude == null) return false; if (e.latitude == null || e.longitude == null) return false;
if (this.filteredSources.has(e.source_name)) 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(); const t = new Date(e.start_time).getTime();
return t >= start && t < end; return t >= start && t < end;
}); });
@ -481,6 +505,8 @@ class FolkCalendarView extends HTMLElement {
const end = new Date(year, month + 1, 1).getTime(); const end = new Date(year, month + 1, 1).getTime();
return this.events.filter(e => { return this.events.filter(e => {
const t = new Date(e.start_time).getTime(); 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); return t >= start && t < end && !this.filteredSources.has(e.source_name);
}); });
} }
@ -909,6 +935,8 @@ class FolkCalendarView extends HTMLElement {
private getEventsForDate(dateStr: string): any[] { private getEventsForDate(dateStr: string): any[] {
return this.events.filter(e => { return this.events.filter(e => {
if (!e.start_time || this.filteredSources.has(e.source_name)) return false; 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 startDay = e.start_time.slice(0, 10);
const endDay = e.end_time ? e.end_time.slice(0, 10) : startDay; const endDay = e.end_time ? e.end_time.slice(0, 10) : startDay;
return dateStr >= startDay && dateStr <= endDay; return dateStr >= startDay && dateStr <= endDay;
@ -982,7 +1010,8 @@ class FolkCalendarView extends HTMLElement {
const cityHtml = city ? `<span class="ev-loc">${this.esc(city)}</span>` : ""; const cityHtml = city ? `<span class="ev-loc">${this.esc(city)}</span>` : "";
const virtualHtml = e.is_virtual ? `<span class="ev-virtual" title="Virtual">\u{1F4BB}</span>` : ""; const virtualHtml = e.is_virtual ? `<span class="ev-virtual" title="Virtual">\u{1F4BB}</span>` : "";
const likelihoodHtml = es.isTentative ? `<span class="ev-likelihood">${es.likelihoodLabel}</span>` : ""; const likelihoodHtml = es.isTentative ? `<span class="ev-likelihood">${es.likelihoodLabel}</span>` : "";
return `<div class="ev-label" style="border-left:2px ${es.borderStyle} ${evColor};background:${es.bgColor};opacity:${es.opacity}" data-event-id="${e.id}">${e.rToolSource === "rSchedule" ? '<span class="ev-bell">&#128276;</span>' : ""}${virtualHtml}<span class="ev-time">${this.formatTime(e.start_time)}</span>${this.esc(e.title)}${likelihoodHtml}${cityHtml}</div>`; const lockHtml = e.accessVisibility && e.accessVisibility !== 'viewer' ? `<span class="membrane-lock" title="Restricted: ${e.accessVisibility} only">&#128274;</span> ` : "";
return `<div class="ev-label" style="border-left:2px ${es.borderStyle} ${evColor};background:${es.bgColor};opacity:${es.opacity}" data-event-id="${e.id}">${e.rToolSource === "rSchedule" ? '<span class="ev-bell">&#128276;</span>' : ""}${lockHtml}${virtualHtml}<span class="ev-time">${this.formatTime(e.start_time)}</span>${this.esc(e.title)}${likelihoodHtml}${cityHtml}</div>`;
}).join(""); }).join("");
} }
@ -2202,10 +2231,11 @@ class FolkCalendarView extends HTMLElement {
const srcTag = e.source_name ? `<span class="dd-source" style="border-color:${e.source_color || '#666'};color:${e.source_color || '#aaa'}">${this.esc(e.source_name)}</span>` : ""; const srcTag = e.source_name ? `<span class="dd-source" style="border-color:${e.source_color || '#666'};color:${e.source_color || '#aaa'}">${this.esc(e.source_name)}</span>` : "";
const es = this.getEventStyles(e); const es = this.getEventStyles(e);
const likelihoodBadge = es.isTentative ? `<span class="dd-likelihood">${es.likelihoodLabel}</span>` : ""; const likelihoodBadge = es.isTentative ? `<span class="dd-likelihood">${es.likelihoodLabel}</span>` : "";
const ddLockBadge = e.accessVisibility && e.accessVisibility !== 'viewer' ? `<span class="membrane-lock" title="Restricted: ${e.accessVisibility} only">&#128274;</span> ` : "";
return `<div class="dd-event" data-event-id="${e.id}" data-collab-id="event:${e.id}" style="opacity:${es.opacity}"> return `<div class="dd-event" data-event-id="${e.id}" data-collab-id="event:${e.id}" style="opacity:${es.opacity}">
<div class="dd-color" style="background:${e.source_color || "#6366f1"};${es.isTentative ? "border:1px dashed " + (e.source_color || "#6366f1") + ";background:transparent" : ""}"></div> <div class="dd-color" style="background:${e.source_color || "#6366f1"};${es.isTentative ? "border:1px dashed " + (e.source_color || "#6366f1") + ";background:transparent" : ""}"></div>
<div class="dd-info"> <div class="dd-info">
<div class="dd-title">${this.esc(e.title)}${likelihoodBadge}${srcTag}</div> <div class="dd-title">${ddLockBadge}${this.esc(e.title)}${likelihoodBadge}${srcTag}</div>
<div class="dd-meta">${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" : ""}</div> <div class="dd-meta">${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" : ""}</div>
${ddDesc ? `<div class="dd-desc">${this.esc(ddDesc)}</div>` : ""} ${ddDesc ? `<div class="dd-desc">${this.esc(ddDesc)}</div>` : ""}
</div> </div>

View File

@ -14,6 +14,10 @@ import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module";
import { verifyToken, extractToken } from "../../server/auth"; 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 { renderLanding } from "./landing";
import type { SyncServer } from '../../server/local-first/sync-server'; import type { SyncServer } from '../../server/local-first/sync-server';
import { calendarSchema, calendarDocId } from './schemas'; 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 { start, end, source, search, rTool, rEntityId, upcoming, tags: tagsParam } = c.req.query();
const doc = ensureDoc(dataSpace); 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 // Apply filters
if (tagsParam) { if (tagsParam) {

View File

@ -78,6 +78,7 @@ export interface CalendarEvent {
metadata: unknown | null; metadata: unknown | null;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
accessVisibility?: import('../../shared/membrane').ObjectVisibility;
} }
export interface SavedCalendarViewFilter { export interface SavedCalendarViewFilter {

View File

@ -11,6 +11,9 @@ import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module";
import { verifyToken, extractToken } from "../../server/auth"; 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 { renderLanding } from "./landing";
import type { SyncServer } from '../../server/local-first/sync-server'; import type { SyncServer } from '../../server/local-first/sync-server';
import { chatsDirectorySchema, chatChannelSchema, chatsDirectoryDocId, chatChannelDocId } from './schemas'; import { chatsDirectorySchema, chatChannelSchema, chatsDirectoryDocId, chatChannelDocId } from './schemas';
@ -53,11 +56,24 @@ function ensureChannelDoc(space: string, channelId: string): ChatChannelDoc {
// ── CRUD: Channels ── // ── CRUD: Channels ──
routes.get("/api/channels", (c) => { routes.get("/api/channels", async (c) => {
if (!_syncServer) return c.json({ channels: [] }); if (!_syncServer) return c.json({ channels: [] });
const space = c.req.param("space") || "demo"; 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); 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) => { routes.post("/api/channels", async (c) => {
@ -80,12 +96,26 @@ routes.post("/api/channels", async (c) => {
// ── CRUD: Messages ── // ── CRUD: Messages ──
routes.get("/api/channels/:channelId/messages", (c) => { routes.get("/api/channels/:channelId/messages", async (c) => {
if (!_syncServer) return c.json({ messages: [] }); if (!_syncServer) return c.json({ messages: [] });
const space = c.req.param("space") || "demo"; const space = c.req.param("space") || "demo";
const channelId = c.req.param("channelId"); 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 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 }); return c.json({ messages });
}); });

View File

@ -18,6 +18,7 @@ export interface ChannelInfo {
createdBy: string | null; createdBy: string | null;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
visibility?: import('../../shared/membrane').ObjectVisibility;
} }
export interface ChatsDirectoryDoc { export interface ChatsDirectoryDoc {
@ -46,6 +47,7 @@ export interface ChatMessage {
replyTo: string | null; replyTo: string | null;
editedAt: number | null; editedAt: number | null;
createdAt: number; createdAt: number;
visibility?: import('../../shared/membrane').ObjectVisibility;
} }
export interface ChatChannelDoc { export interface ChatChannelDoc {

View File

@ -180,6 +180,9 @@ class FolkDocsApp extends HTMLElement {
private audioRecordingTimer: ReturnType<typeof setInterval> | null = null; private audioRecordingTimer: ReturnType<typeof setInterval> | null = null;
private audioRecordingDictation: SpeechDictation | null = null; private audioRecordingDictation: SpeechDictation | null = null;
// Membrane — caller's role for visibility filtering
private _myRole: string = 'viewer';
// Automerge sync state (via shared runtime) // Automerge sync state (via shared runtime)
private doc: Automerge.Doc<NotebookDoc> | null = null; private doc: Automerge.Doc<NotebookDoc> | null = null;
private subscribedDocId: string | null = null; private subscribedDocId: string | null = null;
@ -220,7 +223,7 @@ class FolkDocsApp extends HTMLElement {
this.space = this.getAttribute("space") || "demo"; this.space = this.getAttribute("space") || "demo";
this.setupShadow(); this.setupShadow();
if (this.space === "demo") { this.loadDemoData(); } 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 // Auto-start tour on first visit
if (!localStorage.getItem("rdocs_tour_done")) { if (!localStorage.getItem("rdocs_tour_done")) {
setTimeout(() => this._tour.start(), 1200); setTimeout(() => this._tour.start(), 1200);
@ -621,6 +624,28 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
this._subscribedDocIds = []; this._subscribedDocIds = [];
} }
// ── Membrane ──
private _isVisibleTo(vis: string | undefined | null, role: string): boolean {
const levels: Record<string, number> = { 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) ── // ── Sync (via shared runtime) ──
private async subscribeNotebook(notebookId: string) { private async subscribeNotebook(notebookId: string) {
@ -3097,13 +3122,15 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
let treeHtml = ''; let treeHtml = '';
if (isSearching && this.searchResults.length > 0) { 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 = `<div class="sidebar-search-results"> treeHtml = `<div class="sidebar-search-results">
${this.searchResults.map(n => { ${visibleResults.map(n => {
const nbId = (n as any).notebook_id || ''; const nbId = (n as any).notebook_id || '';
const nb = this.notebooks.find(x => x.id === nbId); const nb = this.notebooks.find(x => x.id === nbId);
return `<div class="sidebar-search-result" data-note="${n.id}" data-notebook="${nbId}"> return `<div class="sidebar-search-result" data-note="${n.id}" data-notebook="${nbId}">
<span class="sbt-note-icon">${this.getNoteIcon(n.type)}</span> <span class="sbt-note-icon">${this.getNoteIcon(n.type)}</span>
<span class="sbt-note-title">${this.esc(n.title)}</span> <span class="sbt-note-title">${(n as any).visibility && (n as any).visibility !== 'viewer' ? '<span class="membrane-lock" title="Restricted: ' + (n as any).visibility + ' only">&#128274;</span> ' : ''}${this.esc(n.title)}</span>
${nb ? `<span class="sidebar-search-result-nb">${this.esc(nb.title)}</span>` : ''} ${nb ? `<span class="sidebar-search-result-nb">${this.esc(nb.title)}</span>` : ''}
</div>`; </div>`;
}).join('')} }).join('')}
@ -3113,7 +3140,9 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
} else { } else {
treeHtml = this.notebooks.map(nb => { treeHtml = this.notebooks.map(nb => {
const isExpanded = this.expandedNotebooks.has(nb.id); const isExpanded = this.expandedNotebooks.has(nb.id);
const notes = this.notebookNotes.get(nb.id) || []; const allNotes = this.notebookNotes.get(nb.id) || [];
// Client-side membrane filter (defense-in-depth)
const notes = allNotes.filter(n => this._isVisibleTo((n as any).visibility, this._myRole));
return ` return `
<div class="sbt-notebook"> <div class="sbt-notebook">
<div class="sbt-notebook-header" data-toggle-notebook="${nb.id}"> <div class="sbt-notebook-header" data-toggle-notebook="${nb.id}">
@ -3127,7 +3156,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
${notes.length > 0 ? notes.map(n => ` ${notes.length > 0 ? notes.map(n => `
<div class="sbt-note${this.selectedNote?.id === n.id ? ' active' : ''}" data-note="${n.id}" data-notebook="${nb.id}"> <div class="sbt-note${this.selectedNote?.id === n.id ? ' active' : ''}" data-note="${n.id}" data-notebook="${nb.id}">
<span class="sbt-note-icon">${this.getNoteIcon(n.type)}</span> <span class="sbt-note-icon">${this.getNoteIcon(n.type)}</span>
<span class="sbt-note-title">${this.esc(n.title)}</span> <span class="sbt-note-title">${(n as any).visibility && (n as any).visibility !== 'viewer' ? '<span class="membrane-lock" title="Restricted: ' + (n as any).visibility + ' only">&#128274;</span> ' : ''}${this.esc(n.title)}</span>
${n.is_pinned ? '<span class="sbt-note-pin">\u{1F4CC}</span>' : ''} ${n.is_pinned ? '<span class="sbt-note-pin">\u{1F4CC}</span>' : ''}
</div> </div>
`).join('') : '<div class="empty" style="padding:8px 12px;font-size:12px">No notes</div>'} `).join('') : '<div class="empty" style="padding:8px 12px;font-size:12px">No notes</div>'}

View File

@ -14,6 +14,9 @@ import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule, SpaceLifecycleContext } from "../../shared/module"; import type { RSpaceModule, SpaceLifecycleContext } from "../../shared/module";
import { resolveDataSpace } from "../../shared/scope-resolver"; import { resolveDataSpace } from "../../shared/scope-resolver";
import { verifyToken, extractToken } from "../../server/auth"; import { verifyToken, extractToken } from "../../server/auth";
import { resolveCallerRole } from "../../server/spaces";
import type { SpaceRoleString } from "../../server/spaces";
import { filterArrayByVisibility } from "../../shared/membrane";
import { renderLanding } from "./landing"; import { renderLanding } from "./landing";
import { notebookSchema, notebookDocId, connectionsDocId, createNoteItem } from "./schemas"; import { notebookSchema, notebookDocId, connectionsDocId, createNoteItem } from "./schemas";
import type { NotebookDoc, NoteItem, ConnectionsDoc } from "./schemas"; import type { NotebookDoc, NoteItem, ConnectionsDoc } from "./schemas";
@ -273,6 +276,17 @@ routes.get("/api/notebooks", async (c) => {
const space = c.req.param("space") || "demo"; const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space; const dataSpace = c.get("effectiveSpace") || space;
// 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 notebooks = listNotebooks(dataSpace).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()); notebooks.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
return c.json({ notebooks, source: "automerge" }); return c.json({ notebooks, source: "automerge" });
@ -408,8 +422,20 @@ routes.get("/api/notes", async (c) => {
})() })()
: listNotebooks(dataSpace); : listNotebooks(dataSpace);
// 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 {}
}
for (const { doc } of notebooks) { for (const { doc } of notebooks) {
for (const item of Object.values(doc.items)) { const visibleItems = filterArrayByVisibility(Object.values(doc.items), callerRole);
for (const item of visibleItems) {
if (type && item.type !== type) continue; if (type && item.type !== type) continue;
if (q) { if (q) {
const lower = q.toLowerCase(); const lower = q.toLowerCase();

View File

@ -64,6 +64,7 @@ export interface NoteItem {
comments?: Record<string, CommentThread>; comments?: Record<string, CommentThread>;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
visibility?: import('../../shared/membrane').ObjectVisibility;
} }
export interface NotebookMeta { export interface NotebookMeta {

View File

@ -21,6 +21,9 @@ import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule, SpaceLifecycleContext } from "../../shared/module"; import type { RSpaceModule, SpaceLifecycleContext } from "../../shared/module";
import { resolveDataSpace } from "../../shared/scope-resolver"; import { resolveDataSpace } from "../../shared/scope-resolver";
import { verifyToken, extractToken } from "../../server/auth"; import { verifyToken, extractToken } from "../../server/auth";
import { resolveCallerRole } from "../../server/spaces";
import type { SpaceRoleString } from "../../server/spaces";
import { filterArrayByVisibility } from "../../shared/membrane";
import { renderLanding } from "./landing"; import { renderLanding } from "./landing";
import { vaultSchema, vaultDocId } from "./schemas"; import { vaultSchema, vaultDocId } from "./schemas";
import type { VaultDoc, VaultNoteMeta } from "./schemas"; import type { VaultDoc, VaultNoteMeta } from "./schemas";
@ -305,7 +308,7 @@ routes.get("/api/vault/:vaultId/status", (c) => {
}); });
// GET /api/vault/:vaultId/notes — list notes (with ?folder= and ?search=) // GET /api/vault/:vaultId/notes — list notes (with ?folder= and ?search=)
routes.get("/api/vault/:vaultId/notes", (c) => { routes.get("/api/vault/:vaultId/notes", async (c) => {
const space = c.req.param("space") || "demo"; const space = c.req.param("space") || "demo";
const dataSpace = resolveDataSpace("rnotes", space); const dataSpace = resolveDataSpace("rnotes", space);
const vaultId = c.req.param("vaultId"); const vaultId = c.req.param("vaultId");
@ -315,7 +318,18 @@ routes.get("/api/vault/:vaultId/notes", (c) => {
const doc = _syncServer!.getDoc<VaultDoc>(docId); const doc = _syncServer!.getDoc<VaultDoc>(docId);
if (!doc) return c.json({ error: "Vault not found" }, 404); if (!doc) return c.json({ error: "Vault not found" }, 404);
let notes = Object.values(doc.notes); // 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 notes = filterArrayByVisibility(Object.values(doc.notes), callerRole);
if (folder) { if (folder) {
const prefix = folder.endsWith("/") ? folder : `${folder}/`; const prefix = folder.endsWith("/") ? folder : `${folder}/`;

View File

@ -25,6 +25,7 @@ export interface VaultNoteMeta {
lastModifiedAt: number; // file mtime from vault lastModifiedAt: number; // file mtime from vault
syncStatus: 'synced' | 'local-modified' | 'conflict'; syncStatus: 'synced' | 'local-modified' | 'conflict';
frontmatter?: Record<string, any>; // parsed YAML frontmatter frontmatter?: Record<string, any>; // parsed YAML frontmatter
visibility?: import('../../shared/membrane').ObjectVisibility;
} }
export interface VaultMeta { export interface VaultMeta {

View File

@ -12,6 +12,9 @@ import { renderShell, renderExternalAppShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module";
import { verifyToken, extractToken } from "../../server/auth"; import { verifyToken, extractToken } from "../../server/auth";
import { resolveCallerRole } from "../../server/spaces";
import type { SpaceRoleString } from "../../server/spaces";
import { filterArrayByVisibility } from "../../shared/membrane";
import { renderLanding } from "./landing"; import { renderLanding } from "./landing";
import type { SyncServer } from '../../server/local-first/sync-server'; import type { SyncServer } from '../../server/local-first/sync-server';
import { photosSchema, photosDocId } from './schemas'; import { photosSchema, photosDocId } from './schemas';
@ -41,11 +44,23 @@ function ensurePhotosDoc(space: string): PhotosDoc {
// ── CRUD: Curated Albums ── // ── CRUD: Curated Albums ──
routes.get("/api/curations", (c) => { routes.get("/api/curations", async (c) => {
if (!_syncServer) return c.json({ albums: [] }); if (!_syncServer) return c.json({ albums: [] });
const space = c.req.param("space") || "demo"; 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 = ensurePhotosDoc(space); const doc = ensurePhotosDoc(space);
return c.json({ albums: Object.values(doc.sharedAlbums || {}) }); return c.json({ albums: filterArrayByVisibility(Object.values(doc.sharedAlbums || {}), callerRole) });
}); });
routes.post("/api/curations", async (c) => { routes.post("/api/curations", async (c) => {

View File

@ -18,6 +18,7 @@ export interface SharedAlbum {
description: string; description: string;
sharedBy: string | null; sharedBy: string | null;
sharedAt: number; sharedAt: number;
visibility?: import('../../shared/membrane').ObjectVisibility;
} }
export interface PhotoAnnotation { export interface PhotoAnnotation {

View File

@ -43,6 +43,8 @@ class FolkTasksBoard extends HTMLElement {
private _confirmDeleteId: string | null = null; private _confirmDeleteId: string | null = null;
// Space members for assignee dropdown // Space members for assignee dropdown
private _spaceMembers: { did: string; displayName?: string; role: string; isOwner: boolean }[] = []; private _spaceMembers: { did: string; displayName?: string; role: string; isOwner: boolean }[] = [];
// Membrane — caller's role for visibility filtering
private _myRole: string = 'viewer';
// Drag guard — prevents column click from firing after drag // Drag guard — prevents column click from firing after drag
private _justDragged = false; private _justDragged = false;
// ClickUp integration state // ClickUp integration state
@ -89,6 +91,7 @@ class FolkTasksBoard extends HTMLElement {
this.loadTasks(); this.loadTasks();
this.loadClickUpStatus(); this.loadClickUpStatus();
this.loadSpaceMembers(); this.loadSpaceMembers();
this.loadMyRole();
this.render(); this.render();
} }
if (!localStorage.getItem("rtasks_tour_done")) { if (!localStorage.getItem("rtasks_tour_done")) {
@ -106,8 +109,14 @@ class FolkTasksBoard extends HTMLElement {
private _escHandler: ((e: KeyboardEvent) => void) | null = null; private _escHandler: ((e: KeyboardEvent) => void) | null = null;
private _isVisibleTo(vis: string | undefined | null, role: string): boolean {
const levels: Record<string, number> = { viewer: 0, member: 1, moderator: 2, admin: 3 };
return (levels[role] ?? 0) >= (levels[vis ?? 'viewer'] ?? 0);
}
private getFilteredTasks(): any[] { private getFilteredTasks(): any[] {
let filtered = this.tasks; // Client-side membrane filter (defense-in-depth — server already filters API responses)
let filtered = this.tasks.filter(t => this._isVisibleTo(t.visibility, this._myRole));
if (this._searchQuery) { if (this._searchQuery) {
const q = this._searchQuery.toLowerCase(); const q = this._searchQuery.toLowerCase();
filtered = filtered.filter(t => t.title?.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q)); filtered = filtered.filter(t => t.title?.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q));
@ -223,6 +232,21 @@ class FolkTasksBoard extends HTMLElement {
} catch { /* not authenticated or no members — fallback to text input */ } } catch { /* not authenticated or no members — fallback to text input */ }
} }
private async loadMyRole() {
if (this.isDemo) 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 loadClickUpStatus() { private async loadClickUpStatus() {
if (this.isDemo) return; if (this.isDemo) return;
try { try {
@ -430,7 +454,7 @@ class FolkTasksBoard extends HTMLElement {
return ''; return '';
} }
private async submitCreateTask(title: string, priority: string, description: string, opts?: { dueDate?: string; status?: string; assignee?: string }) { private async submitCreateTask(title: string, priority: string, description: string, opts?: { dueDate?: string; status?: string; assignee?: string; visibility?: string }) {
if (!title.trim()) return; if (!title.trim()) return;
const taskStatus = opts?.status || this.statuses[0] || "TODO"; const taskStatus = opts?.status || this.statuses[0] || "TODO";
if (this.isDemo) { if (this.isDemo) {
@ -444,7 +468,7 @@ class FolkTasksBoard extends HTMLElement {
const res = await fetch(`${base}/api/spaces/${this.workspaceSlug}/tasks`, { const res = await fetch(`${base}/api/spaces/${this.workspaceSlug}/tasks`, {
method: "POST", method: "POST",
headers: this.authHeaders({ "Content-Type": "application/json" }), headers: this.authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ title: title.trim(), priority, status: taskStatus, description: description.trim() || undefined, due_date: opts?.dueDate || undefined, assignee_id: opts?.assignee || undefined }), body: JSON.stringify({ title: title.trim(), priority, status: taskStatus, description: description.trim() || undefined, due_date: opts?.dueDate || undefined, assignee_id: opts?.assignee || undefined, visibility: (opts?.visibility && opts.visibility !== 'viewer') ? opts.visibility : undefined }),
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
@ -701,6 +725,7 @@ class FolkTasksBoard extends HTMLElement {
.badge-medium { background: #3b3511; color: #facc15; } .badge-medium { background: #3b3511; color: #facc15; }
.badge-low { background: #112a3b; color: #60a5fa; } .badge-low { background: #112a3b; color: #60a5fa; }
.badge-assignee { background: #1a2332; color: #93c5fd; font-style: italic; } .badge-assignee { background: #1a2332; color: #93c5fd; font-style: italic; }
.membrane-lock { font-size: 11px; opacity: 0.6; cursor: help; }
.move-btns { display: flex; gap: 4px; margin-top: 6px; } .move-btns { display: flex; gap: 4px; margin-top: 6px; }
.move-btn { font-size: 11px; padding: 6px 10px; min-height: 36px; border-radius: 4px; border: 1px solid var(--rs-border-strong); background: var(--rs-bg-surface-sunken); color: var(--rs-text-muted); cursor: pointer; } .move-btn { font-size: 11px; padding: 6px 10px; min-height: 36px; border-radius: 4px; border: 1px solid var(--rs-border-strong); background: var(--rs-bg-surface-sunken); color: var(--rs-text-muted); cursor: pointer; }
@ -902,6 +927,13 @@ class FolkTasksBoard extends HTMLElement {
${this._spaceMembers.map(m => `<option value="${this.esc(m.did)}">${this.esc(m.displayName || m.did.slice(0, 12))}${m.isOwner ? ' (owner)' : ''}</option>`).join('')} ${this._spaceMembers.map(m => `<option value="${this.esc(m.did)}">${this.esc(m.displayName || m.did.slice(0, 12))}${m.isOwner ? ' (owner)' : ''}</option>`).join('')}
</select>` : ''} </select>` : ''}
<textarea id="cf-desc" placeholder="Description (optional)" rows="2"></textarea> <textarea id="cf-desc" placeholder="Description (optional)" rows="2"></textarea>
${this._isVisibleTo('moderator', this._myRole) ? `
<select id="cf-visibility">
<option value="viewer">Everyone</option>
<option value="member">Members only</option>
<option value="moderator">Moderators only</option>
<option value="admin">Admins only</option>
</select>` : ''}
<div class="create-form-actions"> <div class="create-form-actions">
<button class="cf-submit" id="cf-submit">Add Task</button> <button class="cf-submit" id="cf-submit">Add Task</button>
<button class="cf-cancel" id="cf-cancel">Cancel</button> <button class="cf-cancel" id="cf-cancel">Cancel</button>
@ -1036,7 +1068,7 @@ class FolkTasksBoard extends HTMLElement {
<button class="task-delete-btn" data-quick-delete="${task.id}" title="Delete">&times;</button> <button class="task-delete-btn" data-quick-delete="${task.id}" title="Delete">&times;</button>
${isEditing ${isEditing
? `<input class="task-title-input" data-edit-title="${task.id}" value="${this.esc(task.title)}">` ? `<input class="task-title-input" data-edit-title="${task.id}" value="${this.esc(task.title)}">`
: `<div class="task-title" data-open-detail="${task.id}" style="cursor:pointer">${this.esc(task.title)} ${cuBadge}</div>`} : `<div class="task-title" data-open-detail="${task.id}" style="cursor:pointer">${task.visibility && task.visibility !== 'viewer' ? '<span class="membrane-lock" title="Restricted: ' + task.visibility + ' only">&#128274;</span> ' : ''}${this.esc(task.title)} ${cuBadge}</div>`}
${descPreview} ${descPreview}
<div class="task-meta"> <div class="task-meta">
${priorityBadge(task.priority || "")} ${priorityBadge(task.priority || "")}
@ -1108,6 +1140,16 @@ class FolkTasksBoard extends HTMLElement {
<label>Due Date</label> <label>Due Date</label>
<input type="date" id="detail-due" value="${dueDateVal}"> <input type="date" id="detail-due" value="${dueDateVal}">
</div> </div>
${this._isVisibleTo('moderator', this._myRole) ? `
<div class="detail-field">
<label>Visibility</label>
<select id="detail-visibility">
<option value="viewer" ${(!task.visibility || task.visibility === 'viewer') ? 'selected' : ''}>Everyone</option>
<option value="member" ${task.visibility === 'member' ? 'selected' : ''}>Members only</option>
<option value="moderator" ${task.visibility === 'moderator' ? 'selected' : ''}>Moderators only</option>
<option value="admin" ${task.visibility === 'admin' ? 'selected' : ''}>Admins only</option>
</select>
</div>` : ''}
<div class="detail-field"> <div class="detail-field">
<label>Created</label> <label>Created</label>
<span class="readonly">${task.created_at ? new Date(task.created_at).toLocaleString() : new Date(task.createdAt || Date.now()).toLocaleString()}</span> <span class="readonly">${task.created_at ? new Date(task.created_at).toLocaleString() : new Date(task.createdAt || Date.now()).toLocaleString()}</span>
@ -1185,10 +1227,11 @@ class FolkTasksBoard extends HTMLElement {
desc: (this.shadow.getElementById("cf-desc") as HTMLTextAreaElement)?.value || "", desc: (this.shadow.getElementById("cf-desc") as HTMLTextAreaElement)?.value || "",
status: (this.shadow.getElementById("cf-status") as HTMLInputElement)?.value || "", status: (this.shadow.getElementById("cf-status") as HTMLInputElement)?.value || "",
assignee: (this.shadow.getElementById("cf-assignee") as HTMLSelectElement)?.value || "", assignee: (this.shadow.getElementById("cf-assignee") as HTMLSelectElement)?.value || "",
visibility: (this.shadow.getElementById("cf-visibility") as HTMLSelectElement)?.value || "",
}); });
this.shadow.getElementById("cf-submit")?.addEventListener("click", () => { this.shadow.getElementById("cf-submit")?.addEventListener("click", () => {
const v = getCreateFormValues(); const v = getCreateFormValues();
this.submitCreateTask(v.title, v.priority, v.desc, { status: v.status, assignee: v.assignee }); this.submitCreateTask(v.title, v.priority, v.desc, { status: v.status, assignee: v.assignee, visibility: v.visibility });
}); });
this.shadow.getElementById("cf-cancel")?.addEventListener("click", () => { this.shadow.getElementById("cf-cancel")?.addEventListener("click", () => {
this.showCreateForm = false; this.render(); this.showCreateForm = false; this.render();
@ -1196,7 +1239,7 @@ class FolkTasksBoard extends HTMLElement {
this.shadow.getElementById("cf-title")?.addEventListener("keydown", (e) => { this.shadow.getElementById("cf-title")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") { if ((e as KeyboardEvent).key === "Enter") {
const v = getCreateFormValues(); const v = getCreateFormValues();
this.submitCreateTask(v.title, v.priority, v.desc, { status: v.status, assignee: v.assignee }); this.submitCreateTask(v.title, v.priority, v.desc, { status: v.status, assignee: v.assignee, visibility: v.visibility });
} }
}); });
@ -1313,6 +1356,10 @@ class FolkTasksBoard extends HTMLElement {
const v = (e.target as HTMLInputElement).value; const v = (e.target as HTMLInputElement).value;
detailFieldSave('due_date', v ? new Date(v).toISOString() : null); detailFieldSave('due_date', v ? new Date(v).toISOString() : null);
}); });
this.shadow.getElementById("detail-visibility")?.addEventListener("change", (e) => {
const v = (e.target as HTMLSelectElement).value;
detailFieldSave('visibility', v === 'viewer' ? null : v);
});
// Detail label add // Detail label add
this.shadow.getElementById("detail-add-label")?.addEventListener("keydown", (e) => { this.shadow.getElementById("detail-add-label")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") { if ((e as KeyboardEvent).key === "Enter") {

View File

@ -14,6 +14,9 @@ import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule, SpaceLifecycleContext } from "../../shared/module"; import type { RSpaceModule, SpaceLifecycleContext } from "../../shared/module";
import { verifyToken, extractToken } from "../../server/auth"; import { verifyToken, extractToken } from "../../server/auth";
import { resolveCallerRole } from "../../server/spaces";
import type { SpaceRoleString } from "../../server/spaces";
import { filterByVisibility } from "../../shared/membrane";
import { renderLanding } from "./landing"; import { renderLanding } from "./landing";
import type { SyncServer } from '../../server/local-first/sync-server'; import type { SyncServer } from '../../server/local-first/sync-server';
import { boardSchema, boardDocId, createTaskItem, clickupConnectionDocId, clickupConnectionSchema } from './schemas'; import { boardSchema, boardDocId, createTaskItem, clickupConnectionDocId, clickupConnectionSchema } from './schemas';
@ -327,7 +330,19 @@ routes.get("/api/spaces/:slug/tasks", async (c) => {
const slug = c.req.param("slug"); const slug = c.req.param("slug");
const doc = ensureDoc(slug); const doc = ensureDoc(slug);
const tasks = Object.values(doc.tasks).map((t) => ({ // 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(slug, claims);
if (resolved) callerRole = resolved.role;
} catch {}
}
const visibleTasks = filterByVisibility(doc.tasks, callerRole);
const tasks = Object.values(visibleTasks).map((t) => ({
id: t.id, id: t.id,
space_id: t.spaceId, space_id: t.spaceId,
title: t.title, title: t.title,
@ -370,7 +385,7 @@ routes.post("/api/spaces/:slug/tasks", async (c) => {
const slug = c.req.param("slug"); const slug = c.req.param("slug");
const body = await c.req.json(); const body = await c.req.json();
const { title, description, status, priority, labels, due_date, assignee_id } = body; const { title, description, status, priority, labels, due_date, assignee_id, visibility } = body;
if (!title?.trim()) return c.json({ error: "Title required" }, 400); if (!title?.trim()) return c.json({ error: "Title required" }, 400);
const doc = ensureDoc(slug); const doc = ensureDoc(slug);
@ -389,6 +404,7 @@ routes.post("/api/spaces/:slug/tasks", async (c) => {
createdBy, createdBy,
}); });
if (assignee_id) task.assigneeId = assignee_id; if (assignee_id) task.assigneeId = assignee_id;
if (visibility) task.visibility = visibility;
d.tasks[taskId] = task; d.tasks[taskId] = task;
}); });
@ -460,12 +476,12 @@ routes.patch("/api/tasks/:id", async (c) => {
const id = c.req.param("id"); const id = c.req.param("id");
const body = await c.req.json(); const body = await c.req.json();
const { title, description, status, priority, labels, sort_order, assignee_id, due_date } = body; const { title, description, status, priority, labels, sort_order, assignee_id, due_date, visibility } = body;
// Check that at least one field is being updated // Check that at least one field is being updated
if (title === undefined && description === undefined && status === undefined && if (title === undefined && description === undefined && status === undefined &&
priority === undefined && labels === undefined && sort_order === undefined && priority === undefined && labels === undefined && sort_order === undefined &&
assignee_id === undefined && due_date === undefined) { assignee_id === undefined && due_date === undefined && visibility === undefined) {
return c.json({ error: "No fields to update" }, 400); return c.json({ error: "No fields to update" }, 400);
} }
@ -492,6 +508,7 @@ routes.patch("/api/tasks/:id", async (c) => {
if (sort_order !== undefined) task.sortOrder = sort_order; if (sort_order !== undefined) task.sortOrder = sort_order;
if (assignee_id !== undefined) task.assigneeId = assignee_id || null; if (assignee_id !== undefined) task.assigneeId = assignee_id || null;
if (due_date !== undefined) task.dueDate = due_date ? new Date(due_date).getTime() : null; if (due_date !== undefined) task.dueDate = due_date ? new Date(due_date).getTime() : null;
if (visibility !== undefined) task.visibility = visibility || undefined;
task.updatedAt = Date.now(); task.updatedAt = Date.now();
}); });

View File

@ -34,6 +34,7 @@ export interface TaskItem {
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
clickup?: ClickUpTaskMeta; clickup?: ClickUpTaskMeta;
visibility?: import('../../shared/membrane').ObjectVisibility;
} }
export interface ClickUpBoardMeta { export interface ClickUpBoardMeta {

View File

@ -17,6 +17,9 @@ import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module";
import { verifyToken, extractToken } from "../../server/auth"; import { verifyToken, extractToken } from "../../server/auth";
import { resolveCallerRole } from "../../server/spaces";
import type { SpaceRoleString } from "../../server/spaces";
import { filterArrayByVisibility } from "../../shared/membrane";
import { renderLanding } from "./landing"; import { renderLanding } from "./landing";
import { notify } from '../../server/notification-service'; import { notify } from '../../server/notification-service';
import type { SyncServer } from '../../server/local-first/sync-server'; import type { SyncServer } from '../../server/local-first/sync-server';
@ -372,11 +375,23 @@ function seedDemoIfEmpty(space: string = 'demo') {
// ── Commitments API ── // ── Commitments API ──
routes.get("/api/commitments", (c) => { routes.get("/api/commitments", async (c) => {
const space = c.req.param("space") || "demo"; 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 {}
}
ensureCommitmentsDoc(space); ensureCommitmentsDoc(space);
const doc = _syncServer!.getDoc<CommitmentsDoc>(commitmentsDocId(space))!; const doc = _syncServer!.getDoc<CommitmentsDoc>(commitmentsDocId(space))!;
const items = Object.values(doc.items); const items = filterArrayByVisibility(Object.values(doc.items), callerRole);
return c.json({ commitments: items }); return c.json({ commitments: items });
}); });
@ -424,12 +439,24 @@ routes.delete("/api/commitments/:id", async (c) => {
// ── Tasks API ── // ── Tasks API ──
routes.get("/api/tasks", (c) => { routes.get("/api/tasks", async (c) => {
const space = c.req.param("space") || "demo"; 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 {}
}
ensureTasksDoc(space); ensureTasksDoc(space);
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!; const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
return c.json({ return c.json({
tasks: Object.values(doc.tasks), tasks: filterArrayByVisibility(Object.values(doc.tasks), callerRole),
connections: Object.values(doc.connections), connections: Object.values(doc.connections),
execStates: Object.values(doc.execStates), execStates: Object.values(doc.execStates),
}); });

View File

@ -41,6 +41,7 @@ export interface Commitment {
intentId?: string; // links commitment to its intent intentId?: string; // links commitment to its intent
status?: 'active' | 'matched' | 'settled' | 'withdrawn'; status?: 'active' | 'matched' | 'settled' | 'withdrawn';
ownerDid?: string; // DID of the commitment creator (for notifications) ownerDid?: string; // DID of the commitment creator (for notifications)
visibility?: import('../../shared/membrane').ObjectVisibility;
} }
// ── Task / Connection / ExecState ── // ── Task / Connection / ExecState ──
@ -53,6 +54,7 @@ export interface Task {
links: { label: string; url: string }[]; links: { label: string; url: string }[];
notes: string; notes: string;
intentFrameId?: string; // links task to solver result that spawned it intentFrameId?: string; // links task to solver result that spawned it
visibility?: import('../../shared/membrane').ObjectVisibility;
} }
export interface Connection { export interface Connection {

View File

@ -193,6 +193,7 @@ export interface Endorsement {
trustWeight: number; // 0-1 trustWeight: number; // 0-1
createdAt: number; createdAt: number;
accessVisibility?: import('../../shared/membrane').ObjectVisibility;
} }
export interface SpaceConfig { export interface SpaceConfig {

View File

@ -229,6 +229,8 @@ class FolkWalletViewer extends HTMLElement {
private _subscribedDocIds: string[] = []; private _subscribedDocIds: string[] = [];
private watchedAddresses: WatchedAddress[] = []; private watchedAddresses: WatchedAddress[] = [];
private _stopPresence: (() => void) | null = null; private _stopPresence: (() => void) | null = null;
// Membrane — caller's role for visibility filtering
private _myRole: string = 'viewer';
constructor() { constructor() {
super(); super();
@ -257,6 +259,7 @@ class FolkWalletViewer extends HTMLElement {
this.address = params.get("address") || ""; this.address = params.get("address") || "";
this.checkAuthState(); this.checkAuthState();
this.initWalletSync(space); this.initWalletSync(space);
this.loadMyRole();
// Auto-load address from passkey or linked wallet // Auto-load address from passkey or linked wallet
if (!this.address && this.passKeyEOA) { if (!this.address && this.passKeyEOA) {
@ -298,6 +301,28 @@ class FolkWalletViewer extends HTMLElement {
} }
}; };
// ── Membrane ──
private _isVisibleTo(vis: string | undefined | null, role: string): boolean {
const levels: Record<string, number> = { viewer: 0, member: 1, moderator: 2, admin: 3 };
return (levels[role] ?? 0) >= (levels[vis ?? 'viewer'] ?? 0);
}
private async loadMyRole() {
if (this.isDemo || 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 checkAuthState() { private checkAuthState() {
try { try {
const session = localStorage.getItem("encryptid_session"); const session = localStorage.getItem("encryptid_session");
@ -2568,7 +2593,9 @@ class FolkWalletViewer extends HTMLElement {
} }
private renderWatchlist(): string { private renderWatchlist(): string {
if (this.watchedAddresses.length === 0 && !this.lfClient) return ''; // Client-side membrane filter (defense-in-depth)
const visibleAddresses = this.watchedAddresses.filter(w => this._isVisibleTo(w.visibility, this._myRole));
if (visibleAddresses.length === 0 && !this.lfClient) return '';
const isLive = this.lfClient?.isConnected ?? false; const isLive = this.lfClient?.isConnected ?? false;
return ` return `
<div class="watchlist" style="margin-top:1.5rem"> <div class="watchlist" style="margin-top:1.5rem">
@ -2577,12 +2604,13 @@ class FolkWalletViewer extends HTMLElement {
${isLive ? '<span style="font-size:0.65rem;padding:2px 6px;border-radius:999px;background:rgba(34,197,94,0.15);color:#22c55e;font-weight:500">LIVE</span>' : ''} ${isLive ? '<span style="font-size:0.65rem;padding:2px 6px;border-radius:999px;background:rgba(34,197,94,0.15);color:#22c55e;font-weight:500">LIVE</span>' : ''}
<button class="watch-add-btn" style="margin-left:auto;font-size:0.75rem;padding:3px 10px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-family:inherit" data-action="add-watch">+ Watch</button> <button class="watch-add-btn" style="margin-left:auto;font-size:0.75rem;padding:3px 10px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-family:inherit" data-action="add-watch">+ Watch</button>
</div> </div>
${this.watchedAddresses.length === 0 ${visibleAddresses.length === 0
? '<div style="text-align:center;padding:1rem;color:var(--rs-text-muted);font-size:0.8rem">No watched addresses. Click "Watch" to add one.</div>' ? '<div style="text-align:center;padding:1rem;color:var(--rs-text-muted);font-size:0.8rem">No watched addresses. Click "Watch" to add one.</div>'
: `<div style="display:flex;flex-direction:column;gap:4px">${this.watchedAddresses.map(w => { : `<div style="display:flex;flex-direction:column;gap:4px">${visibleAddresses.map(w => {
const key = `${w.chain}:${w.address.toLowerCase()}`; const key = `${w.chain}:${w.address.toLowerCase()}`;
const wLock = w.visibility && w.visibility !== 'viewer' ? '<span class="membrane-lock" title="Restricted: ' + w.visibility + ' only">&#128274;</span> ' : '';
return `<div class="watch-item" style="display:flex;align-items:center;gap:8px;padding:0.5rem 0.75rem;background:var(--rs-bg-surface);border:1px solid var(--rs-border);border-radius:8px;cursor:pointer" data-watch-address="${w.address}"> return `<div class="watch-item" style="display:flex;align-items:center;gap:8px;padding:0.5rem 0.75rem;background:var(--rs-bg-surface);border:1px solid var(--rs-border);border-radius:8px;cursor:pointer" data-watch-address="${w.address}">
<span style="font-weight:600;font-size:0.875rem;color:var(--rs-text-primary);flex:1">${this.esc(w.label || w.address.slice(0, 8) + '...')}</span> <span style="font-weight:600;font-size:0.875rem;color:var(--rs-text-primary);flex:1">${wLock}${this.esc(w.label || w.address.slice(0, 8) + '...')}</span>
<span style="font-size:0.75rem;color:var(--rs-text-muted)">${w.address.slice(0, 6)}...${w.address.slice(-4)}</span> <span style="font-size:0.75rem;color:var(--rs-text-muted)">${w.address.slice(0, 6)}...${w.address.slice(-4)}</span>
<button class="watch-remove" data-watch-key="${key}" style="font-size:0.65rem;color:var(--rs-text-muted);border:none;background:none;cursor:pointer;padding:2px 4px">&times;</button> <button class="watch-remove" data-watch-key="${key}" style="font-size:0.65rem;color:var(--rs-text-muted);border:none;background:none;cursor:pointer;padding:2px 4px">&times;</button>
</div>`; </div>`;
@ -3440,17 +3468,20 @@ class FolkWalletViewer extends HTMLElement {
} }
private renderWatchlistChips(): string { private renderWatchlistChips(): string {
const chips = this.watchedAddresses.map(w => { // Client-side membrane filter (defense-in-depth)
const visibleChipAddresses = this.watchedAddresses.filter(w => this._isVisibleTo(w.visibility, this._myRole));
const chips = visibleChipAddresses.map(w => {
const isActive = this.address && w.address.toLowerCase() === this.address.toLowerCase(); const isActive = this.address && w.address.toLowerCase() === this.address.toLowerCase();
const chipLock = w.visibility && w.visibility !== 'viewer' ? '<span class="membrane-lock" title="Restricted: ' + w.visibility + ' only">&#128274;</span> ' : '';
return `<button class="watchlist-chip ${isActive ? "active" : ""}" data-watch-address="${w.address}" title="${w.address}"> return `<button class="watchlist-chip ${isActive ? "active" : ""}" data-watch-address="${w.address}" title="${w.address}">
<span class="chip-label">${this.esc(w.label || w.address.slice(0, 8) + '...')}</span> <span class="chip-label">${chipLock}${this.esc(w.label || w.address.slice(0, 8) + '...')}</span>
<span class="chip-addr">${w.address.slice(0, 6)}...${w.address.slice(-4)}</span> <span class="chip-addr">${w.address.slice(0, 6)}...${w.address.slice(-4)}</span>
<span class="chip-remove" data-watch-key="${w.chain}:${w.address.toLowerCase()}">&times;</span> <span class="chip-remove" data-watch-key="${w.chain}:${w.address.toLowerCase()}">&times;</span>
</button>`; </button>`;
}).join(''); }).join('');
// Show example wallets as suggestions when watchlist is empty and no wallet loaded // Show example wallets as suggestions when watchlist is empty and no wallet loaded
const showSuggestions = this.watchedAddresses.length === 0 && !this.hasData() && !this.loading; const showSuggestions = visibleChipAddresses.length === 0 && !this.hasData() && !this.loading;
const suggestions = showSuggestions ? EXAMPLE_WALLETS.map(w => ` const suggestions = showSuggestions ? EXAMPLE_WALLETS.map(w => `
<button class="watchlist-chip suggested" data-address="${w.address}" title="${w.name}"> <button class="watchlist-chip suggested" data-address="${w.address}" title="${w.name}">
<span class="chip-label">${w.name}</span> <span class="chip-label">${w.name}</span>

View File

@ -11,6 +11,9 @@ import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module";
import { renderLanding } from "./landing"; import { renderLanding } from "./landing";
import { verifyToken, extractToken } from "../../server/auth"; import { verifyToken, extractToken } from "../../server/auth";
import { resolveCallerRole } from "../../server/spaces";
import type { SpaceRoleString } from "../../server/spaces";
import { filterByVisibility } from "../../shared/membrane";
import { enrichWithPrices } from "./lib/price-feed"; import { enrichWithPrices } from "./lib/price-feed";
import { getDefiPositions } from "./lib/defi-positions"; import { getDefiPositions } from "./lib/defi-positions";
import type { DefiPosition } from "./lib/defi-positions"; import type { DefiPosition } from "./lib/defi-positions";

View File

@ -17,6 +17,7 @@ export interface WatchedAddress {
label: string; label: string;
addedBy: string | null; addedBy: string | null;
addedAt: number; addedAt: number;
visibility?: import('../../shared/membrane').ObjectVisibility;
} }
export interface TxAnnotation { export interface TxAnnotation {

View File

@ -10,6 +10,8 @@ import type { SyncServer } from "../local-first/sync-server";
import { calendarDocId } from "../../modules/rcal/schemas"; import { calendarDocId } from "../../modules/rcal/schemas";
import type { CalendarDoc, CalendarEvent } from "../../modules/rcal/schemas"; import type { CalendarDoc, CalendarEvent } from "../../modules/rcal/schemas";
import { resolveAccess, accessDeniedResponse } from "./_auth"; import { resolveAccess, accessDeniedResponse } from "./_auth";
import { filterArrayByVisibility, isVisibleTo } from "../../shared/membrane";
import type { ObjectVisibility } from "../../shared/membrane";
export function registerCalTools(server: McpServer, syncServer: SyncServer) { export function registerCalTools(server: McpServer, syncServer: SyncServer) {
server.tool( server.tool(
@ -35,7 +37,10 @@ export function registerCalTools(server: McpServer, syncServer: SyncServer) {
return { content: [{ type: "text", text: JSON.stringify({ error: "No calendar found for this space" }) }] }; return { content: [{ type: "text", text: JSON.stringify({ error: "No calendar found for this space" }) }] };
} }
let events = Object.values(doc.events || {}); let events = filterArrayByVisibility(
Object.values(doc.events || {}), access.role,
(e) => e.accessVisibility as ObjectVisibility | undefined,
);
if (upcoming_days) { if (upcoming_days) {
const now = Date.now(); const now = Date.now();
@ -108,7 +113,7 @@ export function registerCalTools(server: McpServer, syncServer: SyncServer) {
return { content: [{ type: "text", text: JSON.stringify({ error: "No calendar found" }) }] }; return { content: [{ type: "text", text: JSON.stringify({ error: "No calendar found" }) }] };
} }
const event = doc.events?.[event_id]; const event = doc.events?.[event_id];
if (!event) { if (!event || !isVisibleTo(event.accessVisibility as ObjectVisibility | undefined, access.role)) {
return { content: [{ type: "text", text: JSON.stringify({ error: "Event not found" }) }] }; return { content: [{ type: "text", text: JSON.stringify({ error: "Event not found" }) }] };
} }
return { content: [{ type: "text", text: JSON.stringify(event, null, 2) }] }; return { content: [{ type: "text", text: JSON.stringify(event, null, 2) }] };
@ -128,8 +133,9 @@ export function registerCalTools(server: McpServer, syncServer: SyncServer) {
all_day: z.boolean().optional().describe("All-day event"), all_day: z.boolean().optional().describe("All-day event"),
location_name: z.string().optional().describe("Location name"), location_name: z.string().optional().describe("Location name"),
tags: z.array(z.string()).optional().describe("Event tags"), tags: z.array(z.string()).optional().describe("Event tags"),
access_visibility: z.enum(['viewer', 'member', 'moderator', 'admin']).optional().describe("Object visibility level (default: viewer = everyone)"),
}, },
async ({ space, token, title, start_time, end_time, description, all_day, location_name, tags }) => { async ({ space, token, title, start_time, end_time, description, all_day, location_name, tags, access_visibility }) => {
const access = await resolveAccess(token, space, true); const access = await resolveAccess(token, space, true);
if (!access.allowed) return accessDeniedResponse(access.reason!); if (!access.allowed) return accessDeniedResponse(access.reason!);
@ -179,6 +185,7 @@ export function registerCalTools(server: McpServer, syncServer: SyncServer) {
metadata: null, metadata: null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
...(access_visibility ? { accessVisibility: access_visibility } : {}),
} as CalendarEvent; } as CalendarEvent;
}); });
@ -201,6 +208,7 @@ export function registerCalTools(server: McpServer, syncServer: SyncServer) {
location_name: z.string().optional().describe("New location"), location_name: z.string().optional().describe("New location"),
tags: z.array(z.string()).optional().describe("New tags"), tags: z.array(z.string()).optional().describe("New tags"),
status: z.string().optional().describe("New status"), status: z.string().optional().describe("New status"),
access_visibility: z.enum(['viewer', 'member', 'moderator', 'admin']).optional().describe("Object visibility level"),
}, },
async ({ space, token, event_id, ...updates }) => { async ({ space, token, event_id, ...updates }) => {
const access = await resolveAccess(token, space, true); const access = await resolveAccess(token, space, true);
@ -222,6 +230,7 @@ export function registerCalTools(server: McpServer, syncServer: SyncServer) {
if (updates.location_name !== undefined) e.locationName = updates.location_name; if (updates.location_name !== undefined) e.locationName = updates.location_name;
if (updates.tags !== undefined) e.tags = updates.tags; if (updates.tags !== undefined) e.tags = updates.tags;
if (updates.status !== undefined) e.status = updates.status; if (updates.status !== undefined) e.status = updates.status;
if (updates.access_visibility !== undefined) e.accessVisibility = updates.access_visibility || undefined;
e.updatedAt = Date.now(); e.updatedAt = Date.now();
}); });

View File

@ -11,6 +11,7 @@ import type { SyncServer } from "../local-first/sync-server";
import { chatsDirectoryDocId, chatChannelDocId } from "../../modules/rchats/schemas"; import { chatsDirectoryDocId, chatChannelDocId } from "../../modules/rchats/schemas";
import type { ChatsDirectoryDoc, ChatChannelDoc } from "../../modules/rchats/schemas"; import type { ChatsDirectoryDoc, ChatChannelDoc } from "../../modules/rchats/schemas";
import { resolveAccess, accessDeniedResponse } from "./_auth"; import { resolveAccess, accessDeniedResponse } from "./_auth";
import { filterArrayByVisibility } from "../../shared/membrane";
export function registerChatsTools(server: McpServer, syncServer: SyncServer) { export function registerChatsTools(server: McpServer, syncServer: SyncServer) {
server.tool( server.tool(
@ -27,7 +28,7 @@ export function registerChatsTools(server: McpServer, syncServer: SyncServer) {
const doc = syncServer.getDoc<ChatsDirectoryDoc>(chatsDirectoryDocId(space)); const doc = syncServer.getDoc<ChatsDirectoryDoc>(chatsDirectoryDocId(space));
if (!doc) return { content: [{ type: "text" as const, text: JSON.stringify({ channels: [] }) }] }; if (!doc) return { content: [{ type: "text" as const, text: JSON.stringify({ channels: [] }) }] };
const channels = Object.values(doc.channels || {}).map(ch => ({ const channels = filterArrayByVisibility(Object.values(doc.channels || {}), access.role).map(ch => ({
id: ch.id, name: ch.name, description: ch.description, id: ch.id, name: ch.name, description: ch.description,
isPrivate: ch.isPrivate, createdBy: ch.createdBy, isPrivate: ch.isPrivate, createdBy: ch.createdBy,
createdAt: ch.createdAt, updatedAt: ch.updatedAt, createdAt: ch.createdAt, updatedAt: ch.updatedAt,
@ -80,7 +81,7 @@ export function registerChatsTools(server: McpServer, syncServer: SyncServer) {
const doc = syncServer.getDoc<ChatChannelDoc>(chatChannelDocId(space, channel_id)); const doc = syncServer.getDoc<ChatChannelDoc>(chatChannelDocId(space, channel_id));
if (!doc) return { content: [{ type: "text" as const, text: JSON.stringify({ error: "Channel not found" }) }] }; if (!doc) return { content: [{ type: "text" as const, text: JSON.stringify({ error: "Channel not found" }) }] };
let messages = Object.values(doc.messages || {}) let messages = filterArrayByVisibility(Object.values(doc.messages || {}), access.role)
.sort((a, b) => b.createdAt - a.createdAt) .sort((a, b) => b.createdAt - a.createdAt)
.slice(0, limit || 50); .slice(0, limit || 50);

View File

@ -11,6 +11,7 @@ import type { SyncServer } from "../local-first/sync-server";
import { notebookDocId, createNoteItem } from "../../modules/rdocs/schemas"; import { notebookDocId, createNoteItem } from "../../modules/rdocs/schemas";
import type { NotebookDoc, NoteItem } from "../../modules/rdocs/schemas"; import type { NotebookDoc, NoteItem } from "../../modules/rdocs/schemas";
import { resolveAccess, accessDeniedResponse } from "./_auth"; import { resolveAccess, accessDeniedResponse } from "./_auth";
import { filterArrayByVisibility, isVisibleTo } from "../../shared/membrane";
const NOTEBOOK_PREFIX = ":notes:notebooks:"; const NOTEBOOK_PREFIX = ":notes:notebooks:";
@ -75,7 +76,8 @@ export function registerDocsTools(server: McpServer, syncServer: SyncServer) {
const doc = syncServer.getDoc<NotebookDoc>(docId); const doc = syncServer.getDoc<NotebookDoc>(docId);
if (!doc?.items) continue; if (!doc?.items) continue;
const nbTitle = doc.notebook?.title || "Untitled"; const nbTitle = doc.notebook?.title || "Untitled";
for (const note of Object.values(doc.items)) { const visibleItems = filterArrayByVisibility(Object.values(doc.items), access.role);
for (const note of visibleItems) {
notes.push({ ...JSON.parse(JSON.stringify(note)), notebookTitle: nbTitle }); notes.push({ ...JSON.parse(JSON.stringify(note)), notebookTitle: nbTitle });
} }
} }
@ -132,6 +134,9 @@ export function registerDocsTools(server: McpServer, syncServer: SyncServer) {
const doc = syncServer.getDoc<NotebookDoc>(notebookDocId(space, notebook_id)); const doc = syncServer.getDoc<NotebookDoc>(notebookDocId(space, notebook_id));
const note = doc?.items?.[note_id]; const note = doc?.items?.[note_id];
if (note) { if (note) {
if (!isVisibleTo(note.visibility, access.role)) {
return { content: [{ type: "text", text: JSON.stringify({ error: "Doc not found" }) }] };
}
return { content: [{ type: "text", text: JSON.stringify(note, null, 2) }] }; return { content: [{ type: "text", text: JSON.stringify(note, null, 2) }] };
} }
} }
@ -140,6 +145,9 @@ export function registerDocsTools(server: McpServer, syncServer: SyncServer) {
const doc = syncServer.getDoc<NotebookDoc>(docId); const doc = syncServer.getDoc<NotebookDoc>(docId);
const note = doc?.items?.[note_id]; const note = doc?.items?.[note_id];
if (note) { if (note) {
if (!isVisibleTo(note.visibility, access.role)) {
return { content: [{ type: "text", text: JSON.stringify({ error: "Doc not found" }) }] };
}
return { content: [{ type: "text", text: JSON.stringify(note, null, 2) }] }; return { content: [{ type: "text", text: JSON.stringify(note, null, 2) }] };
} }
} }
@ -158,8 +166,9 @@ export function registerDocsTools(server: McpServer, syncServer: SyncServer) {
title: z.string().describe("Doc title"), title: z.string().describe("Doc title"),
content: z.string().optional().describe("Doc content (plain text or HTML)"), content: z.string().optional().describe("Doc content (plain text or HTML)"),
tags: z.array(z.string()).optional().describe("Doc tags"), tags: z.array(z.string()).optional().describe("Doc tags"),
visibility: z.enum(['viewer', 'member', 'moderator', 'admin']).optional().describe("Object visibility level (default: viewer = everyone)"),
}, },
async ({ space, token, notebook_id, title, content, tags }) => { async ({ space, token, notebook_id, title, content, tags, visibility }) => {
const access = await resolveAccess(token, space, true); const access = await resolveAccess(token, space, true);
if (!access.allowed) return accessDeniedResponse(access.reason!); if (!access.allowed) return accessDeniedResponse(access.reason!);
@ -177,6 +186,8 @@ export function registerDocsTools(server: McpServer, syncServer: SyncServer) {
tags: tags || [], tags: tags || [],
}); });
if (visibility) noteItem.visibility = visibility;
syncServer.changeDoc<NotebookDoc>(docId, `Create doc ${title}`, (d) => { syncServer.changeDoc<NotebookDoc>(docId, `Create doc ${title}`, (d) => {
if (!d.items) (d as any).items = {}; if (!d.items) (d as any).items = {};
d.items[noteId] = noteItem; d.items[noteId] = noteItem;
@ -198,6 +209,7 @@ export function registerDocsTools(server: McpServer, syncServer: SyncServer) {
content: z.string().optional().describe("New content"), content: z.string().optional().describe("New content"),
tags: z.array(z.string()).optional().describe("New tags"), tags: z.array(z.string()).optional().describe("New tags"),
is_pinned: z.boolean().optional().describe("Pin/unpin doc"), is_pinned: z.boolean().optional().describe("Pin/unpin doc"),
visibility: z.enum(['viewer', 'member', 'moderator', 'admin']).optional().describe("Object visibility level"),
}, },
async ({ space, token, note_id, notebook_id, ...updates }) => { async ({ space, token, note_id, notebook_id, ...updates }) => {
const access = await resolveAccess(token, space, true); const access = await resolveAccess(token, space, true);
@ -220,6 +232,7 @@ export function registerDocsTools(server: McpServer, syncServer: SyncServer) {
} }
if (updates.tags !== undefined) n.tags = updates.tags; if (updates.tags !== undefined) n.tags = updates.tags;
if (updates.is_pinned !== undefined) n.isPinned = updates.is_pinned; if (updates.is_pinned !== undefined) n.isPinned = updates.is_pinned;
if (updates.visibility !== undefined) n.visibility = updates.visibility || undefined;
n.updatedAt = Date.now(); n.updatedAt = Date.now();
}); });

View File

@ -11,6 +11,7 @@ import type { SyncServer } from "../local-first/sync-server";
import { vaultDocId } from "../../modules/rnotes/schemas"; import { vaultDocId } from "../../modules/rnotes/schemas";
import type { VaultDoc } from "../../modules/rnotes/schemas"; import type { VaultDoc } from "../../modules/rnotes/schemas";
import { resolveAccess, accessDeniedResponse } from "./_auth"; import { resolveAccess, accessDeniedResponse } from "./_auth";
import { filterArrayByVisibility, isVisibleTo } from "../../shared/membrane";
import { readFile } from "fs/promises"; import { readFile } from "fs/promises";
import { join } from "path"; import { join } from "path";
@ -85,7 +86,7 @@ export function registerNotesTools(server: McpServer, syncServer: SyncServer) {
const doc = syncServer.getDoc<VaultDoc>(vaultDocId(space, vault_id)); const doc = syncServer.getDoc<VaultDoc>(vaultDocId(space, vault_id));
if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "Vault not found" }) }] }; if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "Vault not found" }) }] };
let notes = Object.values(doc.notes || {}); let notes = filterArrayByVisibility(Object.values(doc.notes || {}), access.role);
if (folder) { if (folder) {
notes = notes.filter(n => n.path.startsWith(folder)); notes = notes.filter(n => n.path.startsWith(folder));
} }
@ -132,7 +133,8 @@ export function registerNotesTools(server: McpServer, syncServer: SyncServer) {
const doc = syncServer.getDoc<VaultDoc>(docId); const doc = syncServer.getDoc<VaultDoc>(docId);
if (!doc?.notes) continue; if (!doc?.notes) continue;
const vaultName = doc.vault?.name || "Unknown"; const vaultName = doc.vault?.name || "Unknown";
for (const note of Object.values(doc.notes)) { const visibleNotes = filterArrayByVisibility(Object.values(doc.notes), access.role);
for (const note of visibleNotes) {
if ( if (
note.title.toLowerCase().includes(q) || note.title.toLowerCase().includes(q) ||
note.path.toLowerCase().includes(q) || note.path.toLowerCase().includes(q) ||
@ -165,7 +167,9 @@ export function registerNotesTools(server: McpServer, syncServer: SyncServer) {
if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "Vault not found" }) }] }; if (!doc) return { content: [{ type: "text", text: JSON.stringify({ error: "Vault not found" }) }] };
const meta = doc.notes?.[notePath]; const meta = doc.notes?.[notePath];
if (!meta) return { content: [{ type: "text", text: JSON.stringify({ error: "Note not found in vault" }) }] }; if (!meta || !isVisibleTo(meta.visibility, access.role)) {
return { content: [{ type: "text", text: JSON.stringify({ error: "Note not found in vault" }) }] };
}
const content = await readVaultFile(vault_id, notePath); const content = await readVaultFile(vault_id, notePath);
if (content === null) { if (content === null) {

View File

@ -11,6 +11,7 @@ import type { SyncServer } from "../local-first/sync-server";
import { boardDocId, createTaskItem } from "../../modules/rtasks/schemas"; import { boardDocId, createTaskItem } from "../../modules/rtasks/schemas";
import type { BoardDoc, TaskItem } from "../../modules/rtasks/schemas"; import type { BoardDoc, TaskItem } from "../../modules/rtasks/schemas";
import { resolveAccess, accessDeniedResponse } from "./_auth"; import { resolveAccess, accessDeniedResponse } from "./_auth";
import { filterArrayByVisibility, isVisibleTo } from "../../shared/membrane";
const BOARD_PREFIX = ":tasks:boards:"; const BOARD_PREFIX = ":tasks:boards:";
@ -75,7 +76,7 @@ export function registerTasksTools(server: McpServer, syncServer: SyncServer) {
return { content: [{ type: "text", text: JSON.stringify({ error: "Board not found" }) }] }; return { content: [{ type: "text", text: JSON.stringify({ error: "Board not found" }) }] };
} }
let tasks = Object.values(doc.tasks || {}); let tasks = filterArrayByVisibility(Object.values(doc.tasks || {}), access.role);
if (status) tasks = tasks.filter(t => t.status === status); if (status) tasks = tasks.filter(t => t.status === status);
if (priority) tasks = tasks.filter(t => t.priority === priority); if (priority) tasks = tasks.filter(t => t.priority === priority);
@ -126,6 +127,9 @@ export function registerTasksTools(server: McpServer, syncServer: SyncServer) {
const doc = syncServer.getDoc<BoardDoc>(boardDocId(space, board_slug)); const doc = syncServer.getDoc<BoardDoc>(boardDocId(space, board_slug));
const task = doc?.tasks?.[task_id]; const task = doc?.tasks?.[task_id];
if (task) { if (task) {
if (!isVisibleTo(task.visibility, access.role)) {
return { content: [{ type: "text", text: JSON.stringify({ error: "Task not found" }) }] };
}
return { content: [{ type: "text", text: JSON.stringify(task, null, 2) }] }; return { content: [{ type: "text", text: JSON.stringify(task, null, 2) }] };
} }
} }
@ -134,6 +138,9 @@ export function registerTasksTools(server: McpServer, syncServer: SyncServer) {
const doc = syncServer.getDoc<BoardDoc>(docId); const doc = syncServer.getDoc<BoardDoc>(docId);
const task = doc?.tasks?.[task_id]; const task = doc?.tasks?.[task_id];
if (task) { if (task) {
if (!isVisibleTo(task.visibility, access.role)) {
return { content: [{ type: "text", text: JSON.stringify({ error: "Task not found" }) }] };
}
return { content: [{ type: "text", text: JSON.stringify(task, null, 2) }] }; return { content: [{ type: "text", text: JSON.stringify(task, null, 2) }] };
} }
} }
@ -156,8 +163,9 @@ export function registerTasksTools(server: McpServer, syncServer: SyncServer) {
labels: z.array(z.string()).optional().describe("Labels/tags"), labels: z.array(z.string()).optional().describe("Labels/tags"),
due_date: z.number().optional().describe("Due date (epoch ms)"), due_date: z.number().optional().describe("Due date (epoch ms)"),
assignee_id: z.string().optional().describe("Assignee DID"), assignee_id: z.string().optional().describe("Assignee DID"),
visibility: z.enum(['viewer', 'member', 'moderator', 'admin']).optional().describe("Object visibility level (default: viewer = everyone)"),
}, },
async ({ space, token, board_slug, title, description, status, priority, labels, due_date, assignee_id }) => { async ({ space, token, board_slug, title, description, status, priority, labels, due_date, assignee_id, visibility }) => {
const access = await resolveAccess(token, space, true); const access = await resolveAccess(token, space, true);
if (!access.allowed) return accessDeniedResponse(access.reason!); if (!access.allowed) return accessDeniedResponse(access.reason!);
@ -177,6 +185,7 @@ export function registerTasksTools(server: McpServer, syncServer: SyncServer) {
assigneeId: assignee_id || null, assigneeId: assignee_id || null,
createdBy: (access.claims?.did as string) ?? null, createdBy: (access.claims?.did as string) ?? null,
}); });
if (visibility) taskItem.visibility = visibility;
syncServer.changeDoc<BoardDoc>(docId, `Create task ${title}`, (d) => { syncServer.changeDoc<BoardDoc>(docId, `Create task ${title}`, (d) => {
if (!d.tasks) (d as any).tasks = {}; if (!d.tasks) (d as any).tasks = {};
@ -202,6 +211,7 @@ export function registerTasksTools(server: McpServer, syncServer: SyncServer) {
labels: z.array(z.string()).optional().describe("New labels"), labels: z.array(z.string()).optional().describe("New labels"),
due_date: z.number().optional().describe("New due date (epoch ms)"), due_date: z.number().optional().describe("New due date (epoch ms)"),
assignee_id: z.string().optional().describe("New assignee DID"), assignee_id: z.string().optional().describe("New assignee DID"),
visibility: z.enum(['viewer', 'member', 'moderator', 'admin']).optional().describe("Object visibility level"),
}, },
async ({ space, token, task_id, board_slug, ...updates }) => { async ({ space, token, task_id, board_slug, ...updates }) => {
const access = await resolveAccess(token, space, true); const access = await resolveAccess(token, space, true);
@ -224,6 +234,7 @@ export function registerTasksTools(server: McpServer, syncServer: SyncServer) {
if (updates.labels !== undefined) t.labels = updates.labels; if (updates.labels !== undefined) t.labels = updates.labels;
if (updates.due_date !== undefined) t.dueDate = updates.due_date; if (updates.due_date !== undefined) t.dueDate = updates.due_date;
if (updates.assignee_id !== undefined) t.assigneeId = updates.assignee_id; if (updates.assignee_id !== undefined) t.assigneeId = updates.assignee_id;
if (updates.visibility !== undefined) t.visibility = updates.visibility || undefined;
t.updatedAt = Date.now(); t.updatedAt = Date.now();
}); });

View File

@ -20,6 +20,7 @@ import type {
Skill, Skill,
} from "../../modules/rtime/schemas"; } from "../../modules/rtime/schemas";
import { resolveAccess, accessDeniedResponse } from "./_auth"; import { resolveAccess, accessDeniedResponse } from "./_auth";
import { filterArrayByVisibility } from "../../shared/membrane";
const VALID_SKILLS: Skill[] = ["facilitation", "design", "tech", "outreach", "logistics"]; const VALID_SKILLS: Skill[] = ["facilitation", "design", "tech", "outreach", "logistics"];
@ -43,7 +44,7 @@ export function registerTimeTools(server: McpServer, syncServer: SyncServer) {
return { content: [{ type: "text", text: JSON.stringify({ error: "No commitments data found" }) }] }; return { content: [{ type: "text", text: JSON.stringify({ error: "No commitments data found" }) }] };
} }
let items = Object.values(doc.items || {}); let items = filterArrayByVisibility(Object.values(doc.items || {}), access.role);
if (skill) items = items.filter(c => c.skill === skill); if (skill) items = items.filter(c => c.skill === skill);
if (status) items = items.filter(c => (c.status || "active") === status); if (status) items = items.filter(c => (c.status || "active") === status);
@ -81,7 +82,7 @@ export function registerTimeTools(server: McpServer, syncServer: SyncServer) {
return { content: [{ type: "text", text: JSON.stringify({ error: "No tasks data found" }) }] }; return { content: [{ type: "text", text: JSON.stringify({ error: "No tasks data found" }) }] };
} }
const tasks = Object.values(doc.tasks || {}) const tasks = filterArrayByVisibility(Object.values(doc.tasks || {}), access.role)
.slice(0, limit || 50) .slice(0, limit || 50)
.map(t => ({ .map(t => ({
id: t.id, id: t.id,

152
shared/membrane.test.ts Normal file
View File

@ -0,0 +1,152 @@
/**
* Unit tests for the Object Visibility Membrane.
*
* Run: bun test shared/membrane.test.ts
*/
import { describe, expect, it } from "bun:test";
import {
isVisibleTo,
filterByVisibility,
filterArrayByVisibility,
DEFAULT_VISIBILITY,
type ObjectVisibility,
} from "./membrane";
// ── isVisibleTo ──
describe("isVisibleTo", () => {
it("treats undefined/null as viewer (open default)", () => {
expect(isVisibleTo(undefined, "viewer")).toBe(true);
expect(isVisibleTo(null, "viewer")).toBe(true);
expect(isVisibleTo(undefined, "member")).toBe(true);
expect(isVisibleTo(null, "admin")).toBe(true);
});
it("viewer visibility is visible to all roles", () => {
expect(isVisibleTo("viewer", "viewer")).toBe(true);
expect(isVisibleTo("viewer", "member")).toBe(true);
expect(isVisibleTo("viewer", "moderator")).toBe(true);
expect(isVisibleTo("viewer", "admin")).toBe(true);
});
it("member visibility is hidden from viewers", () => {
expect(isVisibleTo("member", "viewer")).toBe(false);
expect(isVisibleTo("member", "member")).toBe(true);
expect(isVisibleTo("member", "moderator")).toBe(true);
expect(isVisibleTo("member", "admin")).toBe(true);
});
it("moderator visibility is hidden from viewers and members", () => {
expect(isVisibleTo("moderator", "viewer")).toBe(false);
expect(isVisibleTo("moderator", "member")).toBe(false);
expect(isVisibleTo("moderator", "moderator")).toBe(true);
expect(isVisibleTo("moderator", "admin")).toBe(true);
});
it("admin visibility is only visible to admins", () => {
expect(isVisibleTo("admin", "viewer")).toBe(false);
expect(isVisibleTo("admin", "member")).toBe(false);
expect(isVisibleTo("admin", "moderator")).toBe(false);
expect(isVisibleTo("admin", "admin")).toBe(true);
});
it("unknown roles default to level 0", () => {
expect(isVisibleTo("viewer", "unknown")).toBe(true); // level 0 >= 0
expect(isVisibleTo("member", "unknown")).toBe(false); // level 0 < 1
});
});
// ── filterByVisibility ──
describe("filterByVisibility", () => {
const items: Record<string, { id: string; visibility?: ObjectVisibility }> = {
a: { id: "a" }, // undefined → viewer
b: { id: "b", visibility: "viewer" },
c: { id: "c", visibility: "member" },
d: { id: "d", visibility: "moderator" },
e: { id: "e", visibility: "admin" },
};
it("viewer sees only viewer-level items", () => {
const result = filterByVisibility(items, "viewer");
expect(Object.keys(result).sort()).toEqual(["a", "b"]);
});
it("member sees viewer + member items", () => {
const result = filterByVisibility(items, "member");
expect(Object.keys(result).sort()).toEqual(["a", "b", "c"]);
});
it("moderator sees viewer + member + moderator items", () => {
const result = filterByVisibility(items, "moderator");
expect(Object.keys(result).sort()).toEqual(["a", "b", "c", "d"]);
});
it("admin sees all items", () => {
const result = filterByVisibility(items, "admin");
expect(Object.keys(result).sort()).toEqual(["a", "b", "c", "d", "e"]);
});
it("handles empty record", () => {
const result = filterByVisibility({}, "admin");
expect(Object.keys(result)).toEqual([]);
});
it("supports custom getVis accessor", () => {
const calEvents: Record<string, { id: string; accessVisibility?: string }> = {
x: { id: "x", accessVisibility: "admin" },
y: { id: "y" },
};
const result = filterByVisibility(calEvents, "viewer", (e) => e.accessVisibility as ObjectVisibility | undefined);
expect(Object.keys(result)).toEqual(["y"]);
});
});
// ── filterArrayByVisibility ──
describe("filterArrayByVisibility", () => {
const items = [
{ id: "1", visibility: undefined as ObjectVisibility | undefined },
{ id: "2", visibility: "viewer" as ObjectVisibility },
{ id: "3", visibility: "member" as ObjectVisibility },
{ id: "4", visibility: "admin" as ObjectVisibility },
];
it("filters array correctly for viewer", () => {
const result = filterArrayByVisibility(items, "viewer");
expect(result.map((r) => r.id)).toEqual(["1", "2"]);
});
it("filters array correctly for member", () => {
const result = filterArrayByVisibility(items, "member");
expect(result.map((r) => r.id)).toEqual(["1", "2", "3"]);
});
it("admin sees all", () => {
const result = filterArrayByVisibility(items, "admin");
expect(result.map((r) => r.id)).toEqual(["1", "2", "3", "4"]);
});
it("handles empty array", () => {
const result = filterArrayByVisibility([], "admin");
expect(result).toEqual([]);
});
it("supports custom accessor for non-standard field names", () => {
const events = [
{ id: "e1", accessVisibility: "moderator" as ObjectVisibility },
{ id: "e2" },
];
const result = filterArrayByVisibility(events, "viewer", (e) => (e as any).accessVisibility);
expect(result.map((r) => r.id)).toEqual(["e2"]);
});
});
// ── DEFAULT_VISIBILITY ──
describe("DEFAULT_VISIBILITY", () => {
it("is viewer", () => {
expect(DEFAULT_VISIBILITY).toBe("viewer");
});
});

62
shared/membrane.ts Normal file
View File

@ -0,0 +1,62 @@
/**
* Object Visibility Membrane per-object access filtering.
*
* Provides a soft membrane where individual items (tasks, docs, events, etc.)
* can be restricted to specific role tiers. Items default to 'viewer' (visible
* to everyone) when no visibility is set.
*
* Importable by both server and client code.
*/
export type ObjectVisibility = 'viewer' | 'member' | 'moderator' | 'admin';
export const DEFAULT_VISIBILITY: ObjectVisibility = 'viewer';
const ROLE_LEVELS: Record<string, number> = {
viewer: 0,
member: 1,
moderator: 2,
admin: 3,
};
/** Check if a caller with `callerRole` can see an item with `visibility`. */
export function isVisibleTo(
visibility: ObjectVisibility | null | undefined,
callerRole: string,
): boolean {
const vis = visibility ?? DEFAULT_VISIBILITY;
const required = ROLE_LEVELS[vis] ?? 0;
const actual = ROLE_LEVELS[callerRole] ?? 0;
return actual >= required;
}
/**
* Filter a Record of items by visibility.
* @param getVis - accessor for the visibility field (defaults to `item.visibility`)
*/
export function filterByVisibility<T>(
items: Record<string, T>,
callerRole: string,
getVis: (item: T) => ObjectVisibility | null | undefined = (item) =>
(item as any).visibility,
): Record<string, T> {
const result: Record<string, T> = {};
for (const [key, item] of Object.entries(items)) {
if (isVisibleTo(getVis(item), callerRole)) {
result[key] = item;
}
}
return result;
}
/**
* Filter an array of items by visibility.
* @param getVis - accessor for the visibility field (defaults to `item.visibility`)
*/
export function filterArrayByVisibility<T>(
items: T[],
callerRole: string,
getVis: (item: T) => ObjectVisibility | null | undefined = (item) =>
(item as any).visibility,
): T[] {
return items.filter((item) => isVisibleTo(getVis(item), callerRole));
}