CI / Build & Quality Checks (push) Successful in 1m54s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 13s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 9m15s
Room widgets were limited to display-only capabilities because the driver
couldn't serve anything else. Now:
- classifyWidgetCapabilities: display caps are still granted silently; reading
or sending events/state in the widget's OWN room is offered to the user;
everything else (other rooms' timelines, to-device, account data, uploads,
user directory, delayed events) stays denied. Writing protected state
(power levels, join rules, encryption, membership, ACLs, widgets, …) is
never offered, and the driver refuses it again at send time.
- WidgetPermissionPrompt names the widget, the host that runs it and who added
it; each request in plain words ("Send messages of type m.text in this
room · as you"); reading is pre-ticked, sending is not; Deny / Escape grant
nothing extra. "Remember my choice" stores it per viewer (localStorage),
tied to the widget URL, so a changed URL asks again.
- GeneralWidgetDriver implements sendEvent / readRoomTimeline / readRoomState
/ readEventRelations, each refusing any room but the widget's own;
RoomWidgetView feeds the room's live (decrypted) events and state updates,
which ClientWidgetApi forwards only if the widget holds the capability.
Verified in Chromium with a cross-origin test widget against a local Synapse:
power-levels and timeline:* requests are never shown; after allowing
send+read the widget's message lands on the server, its power-levels write
is rejected, it receives live messages, and after a reload the remembered
choice skips the prompt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
191 lines
6.1 KiB
TypeScript
191 lines
6.1 KiB
TypeScript
import {
|
|
type Capability,
|
|
type IReadEventRelationsResult,
|
|
type IRoomEvent,
|
|
type ISendEventDetails,
|
|
WidgetDriver,
|
|
} from 'matrix-widget-api';
|
|
import {
|
|
Direction,
|
|
EventType,
|
|
type IContent,
|
|
type MatrixClient,
|
|
type MatrixEvent,
|
|
type StateEvents,
|
|
type TimelineEvents,
|
|
} from 'matrix-js-sdk';
|
|
import {
|
|
PROTECTED_STATE_TYPES,
|
|
type WidgetPermissionRequest,
|
|
classifyWidgetCapabilities,
|
|
loadWidgetConsent,
|
|
saveWidgetConsent,
|
|
} from './widgetUtils';
|
|
|
|
/** Shows the consent prompt; resolves with the capabilities the user allowed. */
|
|
export type AskWidgetPermissions = (
|
|
requests: WidgetPermissionRequest[],
|
|
) => Promise<{ allowed: Set<Capability>; remember: boolean }>;
|
|
|
|
type WidgetIdentity = { id: string; url: string };
|
|
|
|
/**
|
|
* WidgetDriver for general room widgets. Display-only capabilities are granted
|
|
* silently. Reading or sending events/state in the widget's own room needs the
|
|
* user's OK through `ask` (remembered per viewer when they choose). Everything
|
|
* else is denied — see classifyWidgetCapabilities. The data methods below are
|
|
* only reached for capabilities the user granted (ClientWidgetApi checks), and
|
|
* additionally refuse any room other than the widget's own.
|
|
*/
|
|
export class GeneralWidgetDriver extends WidgetDriver {
|
|
public constructor(
|
|
private readonly mx: MatrixClient,
|
|
private readonly roomId: string,
|
|
private readonly widget: WidgetIdentity,
|
|
private readonly ask: AskWidgetPermissions,
|
|
) {
|
|
super();
|
|
}
|
|
|
|
public async validateCapabilities(requested: Set<Capability>): Promise<Set<Capability>> {
|
|
const { auto, ask } = classifyWidgetCapabilities(requested);
|
|
const granted = new Set(auto);
|
|
if (ask.length === 0) return granted;
|
|
|
|
const stored = loadWidgetConsent(this.roomId, this.widget.id, this.widget.url);
|
|
const remembered = new Set([...(stored?.allowed ?? []), ...(stored?.denied ?? [])]);
|
|
ask.forEach((r) => {
|
|
if (stored?.allowed.includes(r.capability)) granted.add(r.capability);
|
|
});
|
|
const unanswered = ask.filter((r) => !remembered.has(r.capability));
|
|
if (unanswered.length === 0) return granted;
|
|
|
|
const { allowed, remember } = await this.ask(unanswered);
|
|
unanswered.forEach((r) => {
|
|
if (allowed.has(r.capability)) granted.add(r.capability);
|
|
});
|
|
if (remember) {
|
|
saveWidgetConsent(this.roomId, this.widget.id, {
|
|
url: this.widget.url,
|
|
allowed: [
|
|
...(stored?.allowed ?? []),
|
|
...unanswered.filter((r) => allowed.has(r.capability)).map((r) => r.capability),
|
|
],
|
|
denied: [
|
|
...(stored?.denied ?? []),
|
|
...unanswered.filter((r) => !allowed.has(r.capability)).map((r) => r.capability),
|
|
],
|
|
});
|
|
}
|
|
return granted;
|
|
}
|
|
|
|
private assertOwnRoom(roomId: string | null | undefined): string {
|
|
const target = roomId || this.roomId;
|
|
if (target !== this.roomId) throw new Error('Widgets can only access their own room');
|
|
return target;
|
|
}
|
|
|
|
public async sendEvent(
|
|
eventType: string,
|
|
content: IContent,
|
|
stateKey: string | null = null,
|
|
targetRoomId: string | null = null,
|
|
): Promise<ISendEventDetails> {
|
|
const roomId = this.assertOwnRoom(targetRoomId);
|
|
let r: { event_id: string };
|
|
if (typeof stateKey === 'string') {
|
|
if (PROTECTED_STATE_TYPES.has(eventType)) {
|
|
throw new Error(`Widgets may not change ${eventType}`);
|
|
}
|
|
r = await this.mx.sendStateEvent(
|
|
roomId,
|
|
eventType as keyof StateEvents,
|
|
content as StateEvents[keyof StateEvents],
|
|
stateKey,
|
|
);
|
|
} else if (eventType === EventType.RoomRedaction) {
|
|
r = await this.mx.redactEvent(roomId, content.redacts);
|
|
} else {
|
|
r = await this.mx.sendEvent(
|
|
roomId,
|
|
eventType as keyof TimelineEvents,
|
|
content as TimelineEvents[keyof TimelineEvents],
|
|
);
|
|
}
|
|
return { roomId, eventId: r.event_id };
|
|
}
|
|
|
|
public async readRoomTimeline(
|
|
roomId: string,
|
|
eventType: string,
|
|
msgtype: string | undefined,
|
|
stateKey: string | undefined,
|
|
limit: number,
|
|
since: string | undefined,
|
|
): Promise<IRoomEvent[]> {
|
|
const room = this.mx.getRoom(this.assertOwnRoom(roomId));
|
|
if (!room) return [];
|
|
const max = limit > 0 ? limit : Number.MAX_SAFE_INTEGER;
|
|
const results: MatrixEvent[] = [];
|
|
const events = room.getLiveTimeline().getEvents();
|
|
for (let i = events.length - 1; i >= 0 && results.length < max; i -= 1) {
|
|
const ev = events[i];
|
|
if (since !== undefined && ev.getId() === since) break;
|
|
if (
|
|
ev.getType() === eventType &&
|
|
!ev.isState() &&
|
|
(eventType !== EventType.RoomMessage || !msgtype || msgtype === ev.getContent().msgtype) &&
|
|
(ev.getStateKey() === undefined || stateKey === undefined || ev.getStateKey() === stateKey)
|
|
) {
|
|
results.push(ev);
|
|
}
|
|
}
|
|
return results.map((e) => e.getEffectiveEvent() as IRoomEvent);
|
|
}
|
|
|
|
public async readRoomState(
|
|
roomId: string,
|
|
eventType: string,
|
|
stateKey: string | undefined,
|
|
): Promise<IRoomEvent[]> {
|
|
const room = this.mx.getRoom(this.assertOwnRoom(roomId));
|
|
const state = room?.getLiveTimeline().getState(Direction.Forward);
|
|
if (!state) return [];
|
|
if (stateKey === undefined) {
|
|
return state.getStateEvents(eventType).map((e) => e.getEffectiveEvent() as IRoomEvent);
|
|
}
|
|
const ev = state.getStateEvents(eventType, stateKey);
|
|
return ev ? [ev.getEffectiveEvent() as IRoomEvent] : [];
|
|
}
|
|
|
|
public async readEventRelations(
|
|
eventId: string,
|
|
roomId?: string,
|
|
relationType?: string,
|
|
eventType?: string,
|
|
from?: string,
|
|
to?: string,
|
|
limit?: number,
|
|
direction?: 'f' | 'b',
|
|
): Promise<IReadEventRelationsResult> {
|
|
const target = this.assertOwnRoom(roomId);
|
|
const { events, nextBatch, prevBatch } = await this.mx.relations(
|
|
target,
|
|
eventId,
|
|
relationType ?? null,
|
|
eventType ?? null,
|
|
{ from, to, limit, dir: direction as Direction },
|
|
);
|
|
return {
|
|
chunk: events.map((e) => e.getEffectiveEvent() as IRoomEvent),
|
|
nextBatch: nextBatch ?? undefined,
|
|
prevBatch: prevBatch ?? undefined,
|
|
};
|
|
}
|
|
|
|
public getKnownRooms(): string[] {
|
|
return [this.roomId];
|
|
}
|
|
}
|