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:
parent
5c88922b13
commit
0eb721d12e
|
|
@ -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">🔔</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("");
|
}).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">🔒</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>
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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">🔒</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">🔒</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>'}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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}/`;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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">×</button>
|
<button class="task-delete-btn" data-quick-delete="${task.id}" title="Delete">×</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">🔒</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") {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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">🔒</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">×</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">×</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">🔒</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()}">×</span>
|
<span class="chip-remove" data-watch-key="${w.chain}:${w.address.toLowerCase()}">×</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>
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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