Implement new widget permissions module api (#32565)

* Add widget lifecycle API at top level

* Integrate while still falling back to the legacy api

* Remove WidgetKind

* Update module api

to the one that includes the new widget lifecycle api

* lint

* Make preload checks easier to understand

- Have single code path for preload checks.
- Remove duplicated logic for preapproveIdentity check
- Fix headers

* lint
This commit is contained in:
David Langley
2026-03-03 17:06:39 +00:00
committed by GitHub
parent 611e924dc2
commit cea684c065
9 changed files with 536 additions and 38 deletions
@@ -58,6 +58,8 @@ import { ElementWidgetCapabilities } from "../../../stores/widgets/ElementWidget
import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { ModuleRunner } from "../../../modules/ModuleRunner";
import { ModuleApi } from "../../../modules/Api";
import { toWidgetDescriptor } from "../../../modules/WidgetLifecycleApi";
import { parseUrl } from "../../../utils/UrlUtils";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore.ts";
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases.ts";
@@ -191,10 +193,21 @@ export default class AppTile extends React.Component<IProps, IState> {
this.setState({ isUserProfileReady: true });
};
// This is a function to make the impact of calling SettingsStore slightly less
private hasPermissionToLoad = (props: IProps): boolean => {
/**
* Synchronous permission check (fast path).
*
* Returns true when the widget can be loaded immediately, based on:
* - local (Jitsi) widgets,
* - account-level (user) widgets (no room),
* - the **legacy** module API ({@link WidgetLifecycle.PreLoadRequest}),
* - explicit user consent stored in the `allowedWidgets` setting,
* - the current user being the widget creator.
*/
private hasPermissionToLoadSync = (props: IProps): boolean => {
if (this.usingLocalWidget()) return true;
if (!props.room) return true; // user widgets always have permissions
// Legacy module API (synchronous)
const opts: ApprovalOpts = { approved: undefined };
ModuleRunner.instance.invoke(WidgetLifecycle.PreLoadRequest, opts, new ElementWidget(this.props.app));
if (opts.approved) return true;
@@ -207,6 +220,36 @@ export default class AppTile extends React.Component<IProps, IState> {
return allowed || props.userId === props.creatorUserId;
};
/**
* Unified permission check that consults **both** the legacy (sync) and
* new (async) module APIs.
*
* 1. Runs the fast synchronous checks ({@link hasPermissionToLoadSync}).
* If any approve, resolves `true` immediately.
* 2. Falls back to the new module API
* ({@link ModuleApi.widgetLifecycle.preapprovePreload}) which may be
* async (config look-ups, network calls, etc.).
* 3. Returns `false` only when **neither** API grants permission.
*
* Every call site that needs to know "can this widget load?" should go
* through this method so the two APIs are never accidentally divergent.
*/
private async resolvePermissionToLoad(props: IProps): Promise<boolean> {
// Phase 1 synchronous checks (legacy module API, settings, creator)
if (this.hasPermissionToLoadSync(props)) return true;
// Phase 2 async new module API
if (!props.room) return false; // only room widgets go through the async path
try {
const descriptor = toWidgetDescriptor(this.widget, props.room.roomId);
const approved = await ModuleApi.instance.widgetLifecycle.preapprovePreload(descriptor);
return approved === true;
} catch (err) {
logger.error("Module API preload approval check failed", err);
return false;
}
}
private onUserLeftRoom(): void {
const isActiveWidget = ActiveWidgetStore.instance.getWidgetPersistence(
this.props.app.id,
@@ -269,9 +312,9 @@ export default class AppTile extends React.Component<IProps, IState> {
// Don't show loading at all if the widget is ready once the IFrame is loaded (waitForIframeLoad = true).
// We only need the loading screen if the widget sends a contentLoaded event (waitForIframeLoad = false).
loading: !this.props.waitForIframeLoad && !PersistedElement.isMounted(this.persistKey),
// Assume that widget has permission to load if we are the user who
// added it to the room, or if explicitly granted by the user
hasPermissionToLoad: this.hasPermissionToLoad(newProps),
// Use the sync check for the initial render (constructor can't await).
// componentDidMount will immediately follow up with the full async check.
hasPermissionToLoad: this.hasPermissionToLoadSync(newProps),
isUserProfileReady: OwnProfileStore.instance.isProfileInfoFetched,
error: null,
menuDisplayed: false,
@@ -280,19 +323,25 @@ export default class AppTile extends React.Component<IProps, IState> {
}
private onAllowedWidgetsChange = (): void => {
const hasPermissionToLoad = this.hasPermissionToLoad(this.props);
this.resolvePermissionToLoad(this.props).then((hasPermissionToLoad) => {
if (this.unmounted) return;
if (this.state.hasPermissionToLoad && !hasPermissionToLoad) {
// Force the widget to be non-persistent (able to be deleted/forgotten)
ActiveWidgetStore.instance.destroyPersistentWidget(
this.props.app.id,
isAppWidget(this.props.app) ? this.props.app.roomId : null,
);
PersistedElement.destroyElement(this.persistKey);
this.messaging?.stop();
}
if (this.state.hasPermissionToLoad && !hasPermissionToLoad) {
// Force the widget to be non-persistent (able to be deleted/forgotten)
ActiveWidgetStore.instance.destroyPersistentWidget(
this.props.app.id,
isAppWidget(this.props.app) ? this.props.app.roomId : null,
);
PersistedElement.destroyElement(this.persistKey);
this.messaging?.stop();
}
this.setState({ hasPermissionToLoad });
if (!this.state.hasPermissionToLoad && hasPermissionToLoad) {
this.startWidget();
}
this.setState({ hasPermissionToLoad });
});
};
private isMixedContent(): boolean {
@@ -327,9 +376,19 @@ export default class AppTile extends React.Component<IProps, IState> {
this.setupMessagingListeners();
}
// Only fetch IM token on mount if we're showing and have permission to load
if (this.messaging && this.state.hasPermissionToLoad) {
// The constructor used the sync-only fast path for initial state.
// Now run the full check (sync + async) to catch any module API approvals.
if (this.state.hasPermissionToLoad && this.messaging) {
// Sync check already approved — start immediately, no need to re-check.
this.startWidget();
} else {
this.resolvePermissionToLoad(this.props).then((hasPermissionToLoad) => {
if (this.unmounted) return;
this.setState({ hasPermissionToLoad });
if (hasPermissionToLoad) {
this.startWidget();
}
});
}
this.watchUserReady();
+2
View File
@@ -30,6 +30,7 @@ import { ElementWebExtrasApi } from "./ExtrasApi.ts";
import { ElementWebBuiltinsApi } from "./BuiltinsApi.tsx";
import { ClientApi } from "./ClientApi.ts";
import { StoresApi } from "./StoresApi.ts";
import { WidgetLifecycleApi } from "./WidgetLifecycleApi.ts";
const legacyCustomisationsFactory = <T extends object>(baseCustomisations: T) => {
let used = false;
@@ -85,6 +86,7 @@ export class ModuleApi implements Api {
public readonly customComponents = new CustomComponentsApi();
public readonly extras = new ElementWebExtrasApi();
public readonly builtins = new ElementWebBuiltinsApi();
public readonly widgetLifecycle = new WidgetLifecycleApi();
public readonly rootNode = document.getElementById("matrixchat")!;
public readonly client = new ClientApi();
public readonly stores = new StoresApi();
+121
View File
@@ -0,0 +1,121 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import type { Widget } from "matrix-widget-api";
import type {
CapabilitiesApprover,
IdentityApprover,
PreloadApprover,
WidgetDescriptor,
WidgetLifecycleApi as WidgetLifecycleApiInterface,
} from "@element-hq/element-web-module-api";
/**
* Converts a matrix-widget-api {@link Widget} into a {@link WidgetDescriptor} for the module API.
* @param widget - The widget instance from matrix-widget-api.
* @param roomId - The room ID the widget belongs to, if applicable.
*/
export const toWidgetDescriptor = (widget: Widget, roomId?: string): WidgetDescriptor => {
return {
id: widget.id,
templateUrl: widget.templateUrl,
creatorUserId: widget.creatorUserId,
type: widget.type,
origin: widget.origin,
roomId,
};
};
/**
* Host-side implementation of the widget lifecycle API.
* Allows a single module to register approver callbacks for widget preloading,
* identity token requests, and capability requests. Only one approver per slot
* is supported; attempting to register a second throws an error.
*/
export class WidgetLifecycleApi implements WidgetLifecycleApiInterface {
private preloadApprover?: PreloadApprover;
private identityApprover?: IdentityApprover;
private capabilitiesApprover?: CapabilitiesApprover;
private ensureApproverUnset<T>(current: T | undefined, name: string): void {
if (current) {
throw new Error(`Widget lifecycle ${name} approver already registered`);
}
}
/**
* Register a handler that can auto-approve widget preloading.
* Only one preload approver may be registered; a second call throws.
*/
public registerPreloadApprover(approver: PreloadApprover): void {
this.ensureApproverUnset(this.preloadApprover, "preload");
this.preloadApprover = approver;
}
/**
* Register a handler that can auto-approve identity token requests.
* Only one identity approver may be registered; a second call throws.
*/
public registerIdentityApprover(approver: IdentityApprover): void {
this.ensureApproverUnset(this.identityApprover, "identity");
this.identityApprover = approver;
}
/**
* Register a handler that can auto-approve widget capabilities.
* Only one capabilities approver may be registered; a second call throws.
*/
public registerCapabilitiesApprover(approver: CapabilitiesApprover): void {
this.ensureApproverUnset(this.capabilitiesApprover, "capabilities");
this.capabilitiesApprover = approver;
}
/**
* Invoke the registered preload approver for the given widget.
* @returns `true` if the module approved preloading, `false` otherwise.
*/
public async preapprovePreload(widget: WidgetDescriptor): Promise<boolean> {
if (!this.preloadApprover) return false;
try {
return (await this.preloadApprover(widget)) === true;
} catch (error) {
console.error("Widget preload approver failed", error);
return false;
}
}
/**
* Invoke the registered identity approver for the given widget.
* @returns `true` if the module approved the identity token request, `false` otherwise.
*/
public async preapproveIdentity(widget: WidgetDescriptor): Promise<boolean> {
if (!this.identityApprover) return false;
try {
return (await this.identityApprover(widget)) === true;
} catch (error) {
console.error("Widget identity approver failed", error);
return false;
}
}
/**
* Invoke the registered capabilities approver for the given widget.
* @returns The set of approved capabilities, or `undefined` to defer to the default consent flow.
*/
public async preapproveCapabilities(
widget: WidgetDescriptor,
requestedCapabilities: Set<string>,
): Promise<Set<string> | undefined> {
if (!this.capabilitiesApprover) return undefined;
try {
return await this.capabilitiesApprover(widget, requestedCapabilities);
} catch (error) {
console.error("Widget capabilities approver failed", error);
return undefined;
}
}
}
@@ -64,6 +64,8 @@ import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities";
import { navigateToPermalink } from "../../utils/permalinks/navigator";
import { SdkContextClass } from "../../contexts/SDKContext";
import { ModuleRunner } from "../../modules/ModuleRunner";
import { ModuleApi } from "../../modules/Api";
import { toWidgetDescriptor } from "../../modules/WidgetLifecycleApi";
import SettingsStore from "../../settings/SettingsStore";
import { mediaFromMxc } from "../../customisations/Media";
@@ -250,13 +252,20 @@ export class ElementWidgetDriver extends WidgetDriver {
missing.delete(cap);
});
// Try the new module API first, then fall back to legacy paths
let approved: Set<string> | undefined;
if (WidgetPermissionCustomisations.preapproveCapabilities) {
approved = await WidgetPermissionCustomisations.preapproveCapabilities(this.forWidget, requested);
} else {
const opts: CapabilitiesOpts = { approvedCapabilities: undefined };
ModuleRunner.instance.invoke(WidgetLifecycle.CapabilitiesRequest, opts, this.forWidget, requested);
approved = opts.approvedCapabilities;
approved = await ModuleApi.instance.widgetLifecycle.preapproveCapabilities(
toWidgetDescriptor(this.forWidget, this.inRoomId),
requested,
);
if (!approved) {
if (WidgetPermissionCustomisations.preapproveCapabilities) {
approved = await WidgetPermissionCustomisations.preapproveCapabilities(this.forWidget, requested);
} else {
const opts: CapabilitiesOpts = { approvedCapabilities: undefined };
ModuleRunner.instance.invoke(WidgetLifecycle.CapabilitiesRequest, opts, this.forWidget, requested);
approved = opts.approvedCapabilities;
}
}
if (approved) {
approved.forEach((cap) => {
@@ -663,9 +672,19 @@ export class ElementWidgetDriver extends WidgetDriver {
}
public async askOpenID(observer: SimpleObservable<IOpenIDUpdate>): Promise<void> {
const opts: ApprovalOpts = { approved: undefined };
ModuleRunner.instance.invoke(WidgetLifecycle.IdentityRequest, opts, this.forWidget);
if (opts.approved) {
// Try the new module API first, then fall back to legacy path
let approved: boolean | undefined = await ModuleApi.instance.widgetLifecycle.preapproveIdentity(
toWidgetDescriptor(this.forWidget, this.inRoomId),
);
if (!approved) {
// Legacy module API fallback
const legacyOpts: ApprovalOpts = { approved: undefined };
ModuleRunner.instance.invoke(WidgetLifecycle.IdentityRequest, legacyOpts, this.forWidget);
approved = legacyOpts.approved;
}
if (approved) {
return observer.update({
state: OpenIDRequestState.Allowed,
token: await MatrixClientPeg.safeGet().getOpenIdToken(),