import { Capability, EventDirection, EventKind, MatrixCapabilities, WidgetEventCapability, } from 'matrix-widget-api'; // Capability policy. Benign display capabilities are granted silently. Reading // or sending events/state in the widget's OWN room can be granted by the user // through the consent prompt (see classifyWidgetCapabilities). Everything else // (other rooms' timelines, to-device, account data, uploads, user directory, // delayed events, TURN servers) is always denied. export const ALLOWED_WIDGET_CAPABILITIES: ReadonlySet = new Set([ MatrixCapabilities.AlwaysOnScreen, MatrixCapabilities.RequiresClient, MatrixCapabilities.Screenshots, ]); export const filterWidgetCapabilities = (requested: Set): Set => new Set([...requested].filter((cap) => ALLOWED_WIDGET_CAPABILITIES.has(cap))); export type WidgetUrlError = 'empty' | 'invalid' | 'not-https' | 'same-origin'; // A widget URL to ADD must be https and NOT our own origin: a same-origin frame // with allow-same-origin + allow-scripts can break out of the sandbox against us. export const validateWidgetUrl = (raw: string, appOrigin: string): WidgetUrlError | undefined => { const trimmed = raw.trim(); if (!trimmed) return 'empty'; let url: URL; try { url = new URL(trimmed); } catch { return 'invalid'; } if (url.protocol !== 'https:') return 'not-https'; if (url.origin === appOrigin) return 'same-origin'; return undefined; }; // Is an already-resolved (complete) widget URL safe to render in a sandboxed // iframe that carries allow-same-origin? Rejects same-origin URLs (breakout). export const isWidgetUrlSafe = (completeUrl: string, appOrigin: string): boolean => { try { return new URL(completeUrl).origin !== appOrigin; } catch { return false; } }; export const generateWidgetId = (): string => `lotus_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; // State a widget may never write with the user's authority, even if approved: // these change who can do what in the room, or its encryption/identity. export const PROTECTED_STATE_TYPES: ReadonlySet = new Set([ 'm.room.create', 'm.room.power_levels', 'm.room.join_rules', 'm.room.history_visibility', 'm.room.guest_access', 'm.room.encryption', 'm.room.member', 'm.room.server_acl', 'm.room.tombstone', 'm.room.canonical_alias', 'm.room.third_party_invite', 'im.vector.modular.widgets', 'm.widget', ]); export type WidgetPermissionRequest = { capability: Capability; /** Plain-language description, e.g. "Send messages". */ label: string; /** Sending as you (vs only reading). Shown with a warning tone. */ sends: boolean; }; export type ClassifiedCapabilities = { /** Granted without asking. */ auto: Set; /** Needs the user's OK. */ ask: WidgetPermissionRequest[]; }; const FRIENDLY_EVENT_NAMES: Record = { 'm.room.message': 'messages', 'm.reaction': 'reactions', 'm.sticker': 'stickers', 'm.room.redaction': 'message deletions', 'm.room.name': 'the room name', 'm.room.topic': 'the room topic', 'm.room.avatar': 'the room avatar', 'm.room.pinned_events': 'pinned messages', 'm.room.member': 'the member list', 'm.room.power_levels': 'room permissions', }; const describeEventCapability = (cap: WidgetEventCapability): string => { const verb = cap.direction === EventDirection.Send ? 'Send' : 'See'; const friendly = FRIENDLY_EVENT_NAMES[cap.eventType]; const what = friendly ?? `“${cap.eventType}” ${cap.kind === EventKind.State ? 'state' : 'events'}`; let detail = ''; if (cap.keyStr) { detail = cap.kind === EventKind.State ? ` (key “${cap.keyStr}”)` : ` of type “${cap.keyStr}”`; } if (cap.kind === EventKind.State && cap.direction === EventDirection.Send && friendly) { return `Change ${friendly}${detail}`; } return `${verb} ${what}${detail} in this room`; }; /** * Split a widget's requested capabilities into those granted silently and * those the user may approve. Anything in neither list is denied outright. */ export const classifyWidgetCapabilities = ( requested: Iterable, ): ClassifiedCapabilities => { const list = [...requested]; const auto = new Set(list.filter((cap) => ALLOWED_WIDGET_CAPABILITIES.has(cap))); const ask: WidgetPermissionRequest[] = []; WidgetEventCapability.findEventCapabilities(list).forEach((cap) => { if (cap.kind !== EventKind.Event && cap.kind !== EventKind.State) return; const sends = cap.direction === EventDirection.Send; if (sends && cap.kind === EventKind.State && PROTECTED_STATE_TYPES.has(cap.eventType)) return; ask.push({ capability: cap.raw, label: describeEventCapability(cap), sends }); }); return { auto, ask }; }; /** * Remembered approvals, per viewer (localStorage), keyed by room + widget id and * tied to the widget's URL: if the URL changes, the widget is asked again. */ export type StoredWidgetConsent = { url: string; allowed: Capability[]; denied: Capability[] }; export const widgetConsentKey = (roomId: string, widgetId: string): string => `lotus.widgetConsent.${roomId}.${widgetId}`; export const loadWidgetConsent = ( roomId: string, widgetId: string, url: string, ): StoredWidgetConsent | undefined => { try { const raw = localStorage.getItem(widgetConsentKey(roomId, widgetId)); if (!raw) return undefined; const parsed = JSON.parse(raw) as StoredWidgetConsent; if (parsed.url !== url || !Array.isArray(parsed.allowed) || !Array.isArray(parsed.denied)) { return undefined; } return parsed; } catch { return undefined; } }; export const saveWidgetConsent = ( roomId: string, widgetId: string, consent: StoredWidgetConsent, ) => { try { localStorage.setItem(widgetConsentKey(roomId, widgetId), JSON.stringify(consent)); } catch { // Storage unavailable: the widget just asks again next time. } };