feat: object visibility membrane — per-object access filtering
CI/CD / deploy (push) Failing after 2m2s
Details
CI/CD / deploy (push) Failing after 2m2s
Details
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:
parent
d2019b6732
commit
07f525436f
|
|
@ -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<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() {
|
||||
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 ? `<span class="ev-loc">${this.esc(city)}</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>` : "";
|
||||
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">🔔</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">🔒</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">🔔</span>' : ""}${lockHtml}${virtualHtml}<span class="ev-time">${this.formatTime(e.start_time)}</span>${this.esc(e.title)}${likelihoodHtml}${cityHtml}</div>`;
|
||||
}).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 es = this.getEventStyles(e);
|
||||
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">🔒</span> ` : "";
|
||||
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-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>
|
||||
${ddDesc ? `<div class="dd-desc">${this.esc(ddDesc)}</div>` : ""}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ export interface CalendarEvent {
|
|||
metadata: unknown | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
accessVisibility?: import('../../shared/membrane').ObjectVisibility;
|
||||
}
|
||||
|
||||
export interface SavedCalendarViewFilter {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -180,6 +180,9 @@ class FolkDocsApp extends HTMLElement {
|
|||
private audioRecordingTimer: ReturnType<typeof setInterval> | 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<NotebookDoc> | 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%)</code></pre><p><em>Maya is tracking expenses in rF
|
|||
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) ──
|
||||
|
||||
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 = '';
|
||||
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">
|
||||
${this.searchResults.map(n => {
|
||||
${visibleResults.map(n => {
|
||||
const nbId = (n as any).notebook_id || '';
|
||||
const nb = this.notebooks.find(x => x.id === 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-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">🔒</span> ' : ''}${this.esc(n.title)}</span>
|
||||
${nb ? `<span class="sidebar-search-result-nb">${this.esc(nb.title)}</span>` : ''}
|
||||
</div>`;
|
||||
}).join('')}
|
||||
|
|
@ -3113,7 +3140,9 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
|||
} else {
|
||||
treeHtml = this.notebooks.map(nb => {
|
||||
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 `
|
||||
<div class="sbt-notebook">
|
||||
<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 => `
|
||||
<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-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">🔒</span> ' : ''}${this.esc(n.title)}</span>
|
||||
${n.is_pinned ? '<span class="sbt-note-pin">\u{1F4CC}</span>' : ''}
|
||||
</div>
|
||||
`).join('') : '<div class="empty" style="padding:8px 12px;font-size:12px">No notes</div>'}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ import { getModuleInfoList } from "../../shared/module";
|
|||
import type { RSpaceModule, SpaceLifecycleContext } from "../../shared/module";
|
||||
import { resolveDataSpace } from "../../shared/scope-resolver";
|
||||
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 { notebookSchema, notebookDocId, connectionsDocId, createNoteItem } 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 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));
|
||||
notebooks.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
|
||||
return c.json({ notebooks, source: "automerge" });
|
||||
|
|
@ -408,8 +422,20 @@ routes.get("/api/notes", async (c) => {
|
|||
})()
|
||||
: 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 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 (q) {
|
||||
const lower = q.toLowerCase();
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ export interface NoteItem {
|
|||
comments?: Record<string, CommentThread>;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
visibility?: import('../../shared/membrane').ObjectVisibility;
|
||||
}
|
||||
|
||||
export interface NotebookMeta {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ import { getModuleInfoList } from "../../shared/module";
|
|||
import type { RSpaceModule, SpaceLifecycleContext } from "../../shared/module";
|
||||
import { resolveDataSpace } from "../../shared/scope-resolver";
|
||||
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 { vaultSchema, vaultDocId } 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=)
|
||||
routes.get("/api/vault/:vaultId/notes", (c) => {
|
||||
routes.get("/api/vault/:vaultId/notes", async (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const dataSpace = resolveDataSpace("rnotes", space);
|
||||
const vaultId = c.req.param("vaultId");
|
||||
|
|
@ -315,7 +318,18 @@ routes.get("/api/vault/:vaultId/notes", (c) => {
|
|||
const doc = _syncServer!.getDoc<VaultDoc>(docId);
|
||||
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) {
|
||||
const prefix = folder.endsWith("/") ? folder : `${folder}/`;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export interface VaultNoteMeta {
|
|||
lastModifiedAt: number; // file mtime from vault
|
||||
syncStatus: 'synced' | 'local-modified' | 'conflict';
|
||||
frontmatter?: Record<string, any>; // parsed YAML frontmatter
|
||||
visibility?: import('../../shared/membrane').ObjectVisibility;
|
||||
}
|
||||
|
||||
export interface VaultMeta {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ import { renderShell, renderExternalAppShell } 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 { renderLanding } from "./landing";
|
||||
import type { SyncServer } from '../../server/local-first/sync-server';
|
||||
import { photosSchema, photosDocId } from './schemas';
|
||||
|
|
@ -41,11 +44,23 @@ function ensurePhotosDoc(space: string): PhotosDoc {
|
|||
|
||||
// ── CRUD: Curated Albums ──
|
||||
|
||||
routes.get("/api/curations", (c) => {
|
||||
routes.get("/api/curations", async (c) => {
|
||||
if (!_syncServer) return c.json({ albums: [] });
|
||||
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);
|
||||
return c.json({ albums: Object.values(doc.sharedAlbums || {}) });
|
||||
return c.json({ albums: filterArrayByVisibility(Object.values(doc.sharedAlbums || {}), callerRole) });
|
||||
});
|
||||
|
||||
routes.post("/api/curations", async (c) => {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export interface SharedAlbum {
|
|||
description: string;
|
||||
sharedBy: string | null;
|
||||
sharedAt: number;
|
||||
visibility?: import('../../shared/membrane').ObjectVisibility;
|
||||
}
|
||||
|
||||
export interface PhotoAnnotation {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ class FolkTasksBoard extends HTMLElement {
|
|||
private _confirmDeleteId: string | null = null;
|
||||
// Space members for assignee dropdown
|
||||
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
|
||||
private _justDragged = false;
|
||||
// ClickUp integration state
|
||||
|
|
@ -89,6 +91,7 @@ class FolkTasksBoard extends HTMLElement {
|
|||
this.loadTasks();
|
||||
this.loadClickUpStatus();
|
||||
this.loadSpaceMembers();
|
||||
this.loadMyRole();
|
||||
this.render();
|
||||
}
|
||||
if (!localStorage.getItem("rtasks_tour_done")) {
|
||||
|
|
@ -106,8 +109,14 @@ class FolkTasksBoard extends HTMLElement {
|
|||
|
||||
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[] {
|
||||
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) {
|
||||
const q = this._searchQuery.toLowerCase();
|
||||
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 */ }
|
||||
}
|
||||
|
||||
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() {
|
||||
if (this.isDemo) return;
|
||||
try {
|
||||
|
|
@ -430,7 +454,7 @@ class FolkTasksBoard extends HTMLElement {
|
|||
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;
|
||||
const taskStatus = opts?.status || this.statuses[0] || "TODO";
|
||||
if (this.isDemo) {
|
||||
|
|
@ -444,7 +468,7 @@ class FolkTasksBoard extends HTMLElement {
|
|||
const res = await fetch(`${base}/api/spaces/${this.workspaceSlug}/tasks`, {
|
||||
method: "POST",
|
||||
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) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
|
@ -701,6 +725,7 @@ class FolkTasksBoard extends HTMLElement {
|
|||
.badge-medium { background: #3b3511; color: #facc15; }
|
||||
.badge-low { background: #112a3b; color: #60a5fa; }
|
||||
.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-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('')}
|
||||
</select>` : ''}
|
||||
<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">
|
||||
<button class="cf-submit" id="cf-submit">Add Task</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">×</button>
|
||||
${isEditing
|
||||
? `<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">🔒</span> ' : ''}${this.esc(task.title)} ${cuBadge}</div>`}
|
||||
${descPreview}
|
||||
<div class="task-meta">
|
||||
${priorityBadge(task.priority || "")}
|
||||
|
|
@ -1108,6 +1140,16 @@ class FolkTasksBoard extends HTMLElement {
|
|||
<label>Due Date</label>
|
||||
<input type="date" id="detail-due" value="${dueDateVal}">
|
||||
</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">
|
||||
<label>Created</label>
|
||||
<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 || "",
|
||||
status: (this.shadow.getElementById("cf-status") as HTMLInputElement)?.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", () => {
|
||||
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.showCreateForm = false; this.render();
|
||||
|
|
@ -1196,7 +1239,7 @@ class FolkTasksBoard extends HTMLElement {
|
|||
this.shadow.getElementById("cf-title")?.addEventListener("keydown", (e) => {
|
||||
if ((e as KeyboardEvent).key === "Enter") {
|
||||
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;
|
||||
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
|
||||
this.shadow.getElementById("detail-add-label")?.addEventListener("keydown", (e) => {
|
||||
if ((e as KeyboardEvent).key === "Enter") {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ import { renderShell } from "../../server/shell";
|
|||
import { getModuleInfoList } from "../../shared/module";
|
||||
import type { RSpaceModule, SpaceLifecycleContext } from "../../shared/module";
|
||||
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 type { SyncServer } from '../../server/local-first/sync-server';
|
||||
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 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,
|
||||
space_id: t.spaceId,
|
||||
title: t.title,
|
||||
|
|
@ -370,7 +385,7 @@ routes.post("/api/spaces/:slug/tasks", async (c) => {
|
|||
|
||||
const slug = c.req.param("slug");
|
||||
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);
|
||||
|
||||
const doc = ensureDoc(slug);
|
||||
|
|
@ -389,6 +404,7 @@ routes.post("/api/spaces/:slug/tasks", async (c) => {
|
|||
createdBy,
|
||||
});
|
||||
if (assignee_id) task.assigneeId = assignee_id;
|
||||
if (visibility) task.visibility = visibility;
|
||||
d.tasks[taskId] = task;
|
||||
});
|
||||
|
||||
|
|
@ -460,12 +476,12 @@ routes.patch("/api/tasks/:id", async (c) => {
|
|||
|
||||
const id = c.req.param("id");
|
||||
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
|
||||
if (title === undefined && description === undefined && status === 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);
|
||||
}
|
||||
|
||||
|
|
@ -492,6 +508,7 @@ routes.patch("/api/tasks/:id", async (c) => {
|
|||
if (sort_order !== undefined) task.sortOrder = sort_order;
|
||||
if (assignee_id !== undefined) task.assigneeId = assignee_id || 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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export interface TaskItem {
|
|||
createdAt: number;
|
||||
updatedAt: number;
|
||||
clickup?: ClickUpTaskMeta;
|
||||
visibility?: import('../../shared/membrane').ObjectVisibility;
|
||||
}
|
||||
|
||||
export interface ClickUpBoardMeta {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,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 { filterArrayByVisibility } from "../../shared/membrane";
|
||||
import { renderLanding } from "./landing";
|
||||
import { notify } from '../../server/notification-service';
|
||||
import type { SyncServer } from '../../server/local-first/sync-server';
|
||||
|
|
@ -372,11 +375,23 @@ function seedDemoIfEmpty(space: string = 'demo') {
|
|||
|
||||
// ── Commitments API ──
|
||||
|
||||
routes.get("/api/commitments", (c) => {
|
||||
routes.get("/api/commitments", async (c) => {
|
||||
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);
|
||||
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 });
|
||||
});
|
||||
|
||||
|
|
@ -424,12 +439,24 @@ routes.delete("/api/commitments/:id", async (c) => {
|
|||
|
||||
// ── Tasks API ──
|
||||
|
||||
routes.get("/api/tasks", (c) => {
|
||||
routes.get("/api/tasks", async (c) => {
|
||||
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);
|
||||
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
|
||||
return c.json({
|
||||
tasks: Object.values(doc.tasks),
|
||||
tasks: filterArrayByVisibility(Object.values(doc.tasks), callerRole),
|
||||
connections: Object.values(doc.connections),
|
||||
execStates: Object.values(doc.execStates),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export interface Commitment {
|
|||
intentId?: string; // links commitment to its intent
|
||||
status?: 'active' | 'matched' | 'settled' | 'withdrawn';
|
||||
ownerDid?: string; // DID of the commitment creator (for notifications)
|
||||
visibility?: import('../../shared/membrane').ObjectVisibility;
|
||||
}
|
||||
|
||||
// ── Task / Connection / ExecState ──
|
||||
|
|
@ -53,6 +54,7 @@ export interface Task {
|
|||
links: { label: string; url: string }[];
|
||||
notes: string;
|
||||
intentFrameId?: string; // links task to solver result that spawned it
|
||||
visibility?: import('../../shared/membrane').ObjectVisibility;
|
||||
}
|
||||
|
||||
export interface Connection {
|
||||
|
|
|
|||
|
|
@ -193,6 +193,7 @@ export interface Endorsement {
|
|||
trustWeight: number; // 0-1
|
||||
|
||||
createdAt: number;
|
||||
accessVisibility?: import('../../shared/membrane').ObjectVisibility;
|
||||
}
|
||||
|
||||
export interface SpaceConfig {
|
||||
|
|
|
|||
|
|
@ -229,6 +229,8 @@ class FolkWalletViewer extends HTMLElement {
|
|||
private _subscribedDocIds: string[] = [];
|
||||
private watchedAddresses: WatchedAddress[] = [];
|
||||
private _stopPresence: (() => void) | null = null;
|
||||
// Membrane — caller's role for visibility filtering
|
||||
private _myRole: string = 'viewer';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -257,6 +259,7 @@ class FolkWalletViewer extends HTMLElement {
|
|||
this.address = params.get("address") || "";
|
||||
this.checkAuthState();
|
||||
this.initWalletSync(space);
|
||||
this.loadMyRole();
|
||||
|
||||
// Auto-load address from passkey or linked wallet
|
||||
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() {
|
||||
try {
|
||||
const session = localStorage.getItem("encryptid_session");
|
||||
|
|
@ -2568,7 +2593,9 @@ class FolkWalletViewer extends HTMLElement {
|
|||
}
|
||||
|
||||
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;
|
||||
return `
|
||||
<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>' : ''}
|
||||
<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>
|
||||
${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="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 wLock = w.visibility && w.visibility !== 'viewer' ? '<span class="membrane-lock" title="Restricted: ' + w.visibility + ' only">🔒</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}">
|
||||
<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>
|
||||
<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">×</button>
|
||||
</div>`;
|
||||
|
|
@ -3440,17 +3468,20 @@ class FolkWalletViewer extends HTMLElement {
|
|||
}
|
||||
|
||||
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 chipLock = w.visibility && w.visibility !== 'viewer' ? '<span class="membrane-lock" title="Restricted: ' + w.visibility + ' only">🔒</span> ' : '';
|
||||
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-remove" data-watch-key="${w.chain}:${w.address.toLowerCase()}">×</span>
|
||||
</button>`;
|
||||
}).join('');
|
||||
|
||||
// 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 => `
|
||||
<button class="watchlist-chip suggested" data-address="${w.address}" title="${w.name}">
|
||||
<span class="chip-label">${w.name}</span>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ import { getModuleInfoList } from "../../shared/module";
|
|||
import type { RSpaceModule } from "../../shared/module";
|
||||
import { renderLanding } from "./landing";
|
||||
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 { getDefiPositions } from "./lib/defi-positions";
|
||||
import type { DefiPosition } from "./lib/defi-positions";
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export interface WatchedAddress {
|
|||
label: string;
|
||||
addedBy: string | null;
|
||||
addedAt: number;
|
||||
visibility?: import('../../shared/membrane').ObjectVisibility;
|
||||
}
|
||||
|
||||
export interface TxAnnotation {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import type { SyncServer } from "../local-first/sync-server";
|
|||
import { calendarDocId } from "../../modules/rcal/schemas";
|
||||
import type { CalendarDoc, CalendarEvent } from "../../modules/rcal/schemas";
|
||||
import { resolveAccess, accessDeniedResponse } from "./_auth";
|
||||
import { filterArrayByVisibility, isVisibleTo } from "../../shared/membrane";
|
||||
import type { ObjectVisibility } from "../../shared/membrane";
|
||||
|
||||
export function registerCalTools(server: McpServer, syncServer: SyncServer) {
|
||||
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" }) }] };
|
||||
}
|
||||
|
||||
let events = Object.values(doc.events || {});
|
||||
let events = filterArrayByVisibility(
|
||||
Object.values(doc.events || {}), access.role,
|
||||
(e) => e.accessVisibility as ObjectVisibility | undefined,
|
||||
);
|
||||
|
||||
if (upcoming_days) {
|
||||
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" }) }] };
|
||||
}
|
||||
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(event, null, 2) }] };
|
||||
|
|
@ -128,8 +133,9 @@ export function registerCalTools(server: McpServer, syncServer: SyncServer) {
|
|||
all_day: z.boolean().optional().describe("All-day event"),
|
||||
location_name: z.string().optional().describe("Location name"),
|
||||
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);
|
||||
if (!access.allowed) return accessDeniedResponse(access.reason!);
|
||||
|
||||
|
|
@ -179,6 +185,7 @@ export function registerCalTools(server: McpServer, syncServer: SyncServer) {
|
|||
metadata: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...(access_visibility ? { accessVisibility: access_visibility } : {}),
|
||||
} as CalendarEvent;
|
||||
});
|
||||
|
||||
|
|
@ -201,6 +208,7 @@ export function registerCalTools(server: McpServer, syncServer: SyncServer) {
|
|||
location_name: z.string().optional().describe("New location"),
|
||||
tags: z.array(z.string()).optional().describe("New tags"),
|
||||
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 }) => {
|
||||
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.tags !== undefined) e.tags = updates.tags;
|
||||
if (updates.status !== undefined) e.status = updates.status;
|
||||
if (updates.access_visibility !== undefined) e.accessVisibility = updates.access_visibility || undefined;
|
||||
e.updatedAt = Date.now();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type { SyncServer } from "../local-first/sync-server";
|
|||
import { chatsDirectoryDocId, chatChannelDocId } from "../../modules/rchats/schemas";
|
||||
import type { ChatsDirectoryDoc, ChatChannelDoc } from "../../modules/rchats/schemas";
|
||||
import { resolveAccess, accessDeniedResponse } from "./_auth";
|
||||
import { filterArrayByVisibility } from "../../shared/membrane";
|
||||
|
||||
export function registerChatsTools(server: McpServer, syncServer: SyncServer) {
|
||||
server.tool(
|
||||
|
|
@ -27,7 +28,7 @@ export function registerChatsTools(server: McpServer, syncServer: SyncServer) {
|
|||
const doc = syncServer.getDoc<ChatsDirectoryDoc>(chatsDirectoryDocId(space));
|
||||
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,
|
||||
isPrivate: ch.isPrivate, createdBy: ch.createdBy,
|
||||
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));
|
||||
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)
|
||||
.slice(0, limit || 50);
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type { SyncServer } from "../local-first/sync-server";
|
|||
import { notebookDocId, createNoteItem } from "../../modules/rdocs/schemas";
|
||||
import type { NotebookDoc, NoteItem } from "../../modules/rdocs/schemas";
|
||||
import { resolveAccess, accessDeniedResponse } from "./_auth";
|
||||
import { filterArrayByVisibility, isVisibleTo } from "../../shared/membrane";
|
||||
|
||||
const NOTEBOOK_PREFIX = ":notes:notebooks:";
|
||||
|
||||
|
|
@ -75,7 +76,8 @@ export function registerDocsTools(server: McpServer, syncServer: SyncServer) {
|
|||
const doc = syncServer.getDoc<NotebookDoc>(docId);
|
||||
if (!doc?.items) continue;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -132,6 +134,9 @@ export function registerDocsTools(server: McpServer, syncServer: SyncServer) {
|
|||
const doc = syncServer.getDoc<NotebookDoc>(notebookDocId(space, notebook_id));
|
||||
const note = doc?.items?.[note_id];
|
||||
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) }] };
|
||||
}
|
||||
}
|
||||
|
|
@ -140,6 +145,9 @@ export function registerDocsTools(server: McpServer, syncServer: SyncServer) {
|
|||
const doc = syncServer.getDoc<NotebookDoc>(docId);
|
||||
const note = doc?.items?.[note_id];
|
||||
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) }] };
|
||||
}
|
||||
}
|
||||
|
|
@ -158,8 +166,9 @@ export function registerDocsTools(server: McpServer, syncServer: SyncServer) {
|
|||
title: z.string().describe("Doc title"),
|
||||
content: z.string().optional().describe("Doc content (plain text or HTML)"),
|
||||
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);
|
||||
if (!access.allowed) return accessDeniedResponse(access.reason!);
|
||||
|
||||
|
|
@ -177,6 +186,8 @@ export function registerDocsTools(server: McpServer, syncServer: SyncServer) {
|
|||
tags: tags || [],
|
||||
});
|
||||
|
||||
if (visibility) noteItem.visibility = visibility;
|
||||
|
||||
syncServer.changeDoc<NotebookDoc>(docId, `Create doc ${title}`, (d) => {
|
||||
if (!d.items) (d as any).items = {};
|
||||
d.items[noteId] = noteItem;
|
||||
|
|
@ -198,6 +209,7 @@ export function registerDocsTools(server: McpServer, syncServer: SyncServer) {
|
|||
content: z.string().optional().describe("New content"),
|
||||
tags: z.array(z.string()).optional().describe("New tags"),
|
||||
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 }) => {
|
||||
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.is_pinned !== undefined) n.isPinned = updates.is_pinned;
|
||||
if (updates.visibility !== undefined) n.visibility = updates.visibility || undefined;
|
||||
n.updatedAt = Date.now();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type { SyncServer } from "../local-first/sync-server";
|
|||
import { vaultDocId } from "../../modules/rnotes/schemas";
|
||||
import type { VaultDoc } from "../../modules/rnotes/schemas";
|
||||
import { resolveAccess, accessDeniedResponse } from "./_auth";
|
||||
import { filterArrayByVisibility, isVisibleTo } from "../../shared/membrane";
|
||||
import { readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
|
||||
|
|
@ -85,7 +86,7 @@ export function registerNotesTools(server: McpServer, syncServer: SyncServer) {
|
|||
const doc = syncServer.getDoc<VaultDoc>(vaultDocId(space, vault_id));
|
||||
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) {
|
||||
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);
|
||||
if (!doc?.notes) continue;
|
||||
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 (
|
||||
note.title.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" }) }] };
|
||||
|
||||
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);
|
||||
if (content === null) {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type { SyncServer } from "../local-first/sync-server";
|
|||
import { boardDocId, createTaskItem } from "../../modules/rtasks/schemas";
|
||||
import type { BoardDoc, TaskItem } from "../../modules/rtasks/schemas";
|
||||
import { resolveAccess, accessDeniedResponse } from "./_auth";
|
||||
import { filterArrayByVisibility, isVisibleTo } from "../../shared/membrane";
|
||||
|
||||
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" }) }] };
|
||||
}
|
||||
|
||||
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 (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 task = doc?.tasks?.[task_id];
|
||||
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) }] };
|
||||
}
|
||||
}
|
||||
|
|
@ -134,6 +138,9 @@ export function registerTasksTools(server: McpServer, syncServer: SyncServer) {
|
|||
const doc = syncServer.getDoc<BoardDoc>(docId);
|
||||
const task = doc?.tasks?.[task_id];
|
||||
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) }] };
|
||||
}
|
||||
}
|
||||
|
|
@ -156,8 +163,9 @@ export function registerTasksTools(server: McpServer, syncServer: SyncServer) {
|
|||
labels: z.array(z.string()).optional().describe("Labels/tags"),
|
||||
due_date: z.number().optional().describe("Due date (epoch ms)"),
|
||||
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);
|
||||
if (!access.allowed) return accessDeniedResponse(access.reason!);
|
||||
|
||||
|
|
@ -177,6 +185,7 @@ export function registerTasksTools(server: McpServer, syncServer: SyncServer) {
|
|||
assigneeId: assignee_id || null,
|
||||
createdBy: (access.claims?.did as string) ?? null,
|
||||
});
|
||||
if (visibility) taskItem.visibility = visibility;
|
||||
|
||||
syncServer.changeDoc<BoardDoc>(docId, `Create task ${title}`, (d) => {
|
||||
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"),
|
||||
due_date: z.number().optional().describe("New due date (epoch ms)"),
|
||||
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 }) => {
|
||||
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.due_date !== undefined) t.dueDate = updates.due_date;
|
||||
if (updates.assignee_id !== undefined) t.assigneeId = updates.assignee_id;
|
||||
if (updates.visibility !== undefined) t.visibility = updates.visibility || undefined;
|
||||
t.updatedAt = Date.now();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import type {
|
|||
Skill,
|
||||
} from "../../modules/rtime/schemas";
|
||||
import { resolveAccess, accessDeniedResponse } from "./_auth";
|
||||
import { filterArrayByVisibility } from "../../shared/membrane";
|
||||
|
||||
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" }) }] };
|
||||
}
|
||||
|
||||
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 (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" }) }] };
|
||||
}
|
||||
|
||||
const tasks = Object.values(doc.tasks || {})
|
||||
const tasks = filterArrayByVisibility(Object.values(doc.tasks || {}), access.role)
|
||||
.slice(0, limit || 50)
|
||||
.map(t => ({
|
||||
id: t.id,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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));
|
||||
}
|
||||
Loading…
Reference in New Issue