2026-09-24 21:17:18 -04:00
|
|
|
import {
|
|
|
|
|
Capability,
|
|
|
|
|
EventDirection,
|
|
|
|
|
EventKind,
|
|
|
|
|
MatrixCapabilities,
|
|
|
|
|
WidgetEventCapability,
|
|
|
|
|
} from 'matrix-widget-api';
|
2026-07-03 13:27:23 -04:00
|
|
|
|
2026-09-24 21:17:18 -04:00
|
|
|
// 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.
|
2026-07-03 13:27:23 -04:00
|
|
|
export const ALLOWED_WIDGET_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
|
|
|
|
|
MatrixCapabilities.AlwaysOnScreen,
|
|
|
|
|
MatrixCapabilities.RequiresClient,
|
|
|
|
|
MatrixCapabilities.Screenshots,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
export const filterWidgetCapabilities = (requested: Set<Capability>): Set<Capability> =>
|
|
|
|
|
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)}`;
|
2026-09-24 21:17:18 -04:00
|
|
|
|
|
|
|
|
// 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<string> = 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<Capability>;
|
|
|
|
|
/** Needs the user's OK. */
|
|
|
|
|
ask: WidgetPermissionRequest[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const FRIENDLY_EVENT_NAMES: Record<string, string> = {
|
|
|
|
|
'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<Capability>,
|
|
|
|
|
): 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.
|
|
|
|
|
}
|
|
|
|
|
};
|