From cea684c065ffd5893cb20bc255e44e217f199bc3 Mon Sep 17 00:00:00 2001 From: David Langley Date: Tue, 3 Mar 2026 17:06:39 +0000 Subject: [PATCH] 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 --- .../src/components/views/elements/AppTile.tsx | 95 +++++++-- apps/web/src/modules/Api.ts | 2 + apps/web/src/modules/WidgetLifecycleApi.ts | 121 +++++++++++ .../src/stores/widgets/ElementWidgetDriver.ts | 37 +++- .../views/elements/AppTile-test.tsx | 25 +++ .../modules/WidgetLifecycleApi-test.ts | 201 ++++++++++++++++++ .../widgets/ElementWidgetDriver-test.ts | 71 +++++++ pnpm-lock.yaml | 20 +- pnpm-workspace.yaml | 2 +- 9 files changed, 536 insertions(+), 38 deletions(-) create mode 100644 apps/web/src/modules/WidgetLifecycleApi.ts create mode 100644 apps/web/test/unit-tests/modules/WidgetLifecycleApi-test.ts diff --git a/apps/web/src/components/views/elements/AppTile.tsx b/apps/web/src/components/views/elements/AppTile.tsx index 46de406d84..3442bac1c3 100644 --- a/apps/web/src/components/views/elements/AppTile.tsx +++ b/apps/web/src/components/views/elements/AppTile.tsx @@ -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 { 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 { 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 { + // 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 { // 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 { } 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 { 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(); diff --git a/apps/web/src/modules/Api.ts b/apps/web/src/modules/Api.ts index 057cbf71b0..bcb29f385e 100644 --- a/apps/web/src/modules/Api.ts +++ b/apps/web/src/modules/Api.ts @@ -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 = (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(); diff --git a/apps/web/src/modules/WidgetLifecycleApi.ts b/apps/web/src/modules/WidgetLifecycleApi.ts new file mode 100644 index 0000000000..f20c3c2be0 --- /dev/null +++ b/apps/web/src/modules/WidgetLifecycleApi.ts @@ -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(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 { + 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 { + 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, + ): Promise | undefined> { + if (!this.capabilitiesApprover) return undefined; + try { + return await this.capabilitiesApprover(widget, requestedCapabilities); + } catch (error) { + console.error("Widget capabilities approver failed", error); + return undefined; + } + } +} diff --git a/apps/web/src/stores/widgets/ElementWidgetDriver.ts b/apps/web/src/stores/widgets/ElementWidgetDriver.ts index d1d373ea38..157991e860 100644 --- a/apps/web/src/stores/widgets/ElementWidgetDriver.ts +++ b/apps/web/src/stores/widgets/ElementWidgetDriver.ts @@ -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 | 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): Promise { - 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(), diff --git a/apps/web/test/unit-tests/components/views/elements/AppTile-test.tsx b/apps/web/test/unit-tests/components/views/elements/AppTile-test.tsx index 981e3c80ce..73242b2014 100644 --- a/apps/web/test/unit-tests/components/views/elements/AppTile-test.tsx +++ b/apps/web/test/unit-tests/components/views/elements/AppTile-test.tsx @@ -37,6 +37,7 @@ import { ElementWidgetCapabilities } from "../../../../../src/stores/widgets/Ele import { ElementWidget, type WidgetMessaging } from "../../../../../src/stores/widgets/WidgetMessaging"; import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMessagingStore"; import { ModuleRunner } from "../../../../../src/modules/ModuleRunner"; +import { ModuleApi } from "../../../../../src/modules/Api"; import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks"; import { SdkContextClass } from "../../../../../src/contexts/SDKContext"; @@ -426,6 +427,30 @@ describe("AppTile", () => { expect(renderResult.queryByRole("button", { name: "Continue" })).not.toBeInTheDocument(); }); + it("should auto-approve preload via new widget lifecycle API", async () => { + // Legacy module API denies preload + jest.spyOn(ModuleRunner.instance, "invoke").mockImplementation((lifecycleEvent, opts, widgetInfo) => { + if (lifecycleEvent === WidgetLifecycle.PreLoadRequest && (widgetInfo as WidgetInfo).id === app1.id) { + (opts as ApprovalOpts).approved = false; + } + }); + + // New API approves preload + jest.spyOn(ModuleApi.instance.widgetLifecycle, "preapprovePreload").mockResolvedValue(true); + + // userId and creatorUserId are different so legacy path would show "Continue" + const renderResult = render( + + + , + ); + + // The new API runs async in componentDidMount, so wait for it to take effect + await waitFor(() => { + expect(renderResult.queryByRole("button", { name: "Continue" })).not.toBeInTheDocument(); + }); + }); + describe("for a maximised (centered) widget", () => { beforeEach(() => { jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockImplementation( diff --git a/apps/web/test/unit-tests/modules/WidgetLifecycleApi-test.ts b/apps/web/test/unit-tests/modules/WidgetLifecycleApi-test.ts new file mode 100644 index 0000000000..d817c4c9d3 --- /dev/null +++ b/apps/web/test/unit-tests/modules/WidgetLifecycleApi-test.ts @@ -0,0 +1,201 @@ +/* +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 { Widget } from "matrix-widget-api"; + +import type { WidgetDescriptor } from "@element-hq/element-web-module-api"; +import { WidgetLifecycleApi, toWidgetDescriptor } from "../../../src/modules/WidgetLifecycleApi"; + +const mkDescriptor = (overrides: Partial = {}): WidgetDescriptor => ({ + id: "test-widget", + templateUrl: "https://example.org/widget", + creatorUserId: "@alice:example.org", + type: "m.custom", + origin: "https://example.org", + roomId: "!room:example.org", + ...overrides, +}); + +describe("WidgetLifecycleApi", () => { + let api: WidgetLifecycleApi; + + beforeEach(() => { + api = new WidgetLifecycleApi(); + }); + + describe("toWidgetDescriptor", () => { + it("converts a Widget to a WidgetDescriptor", () => { + const widget = new Widget({ + id: "w1", + creatorUserId: "@bob:example.org", + type: "m.jitsi", + url: "https://jitsi.example.org/meet?conf=$matrix_room_id", + }); + const descriptor = toWidgetDescriptor(widget, "!room:example.org"); + expect(descriptor).toEqual({ + id: "w1", + templateUrl: "https://jitsi.example.org/meet?conf=$matrix_room_id", + creatorUserId: "@bob:example.org", + type: "m.jitsi", + origin: "https://jitsi.example.org", + roomId: "!room:example.org", + }); + }); + + it("omits roomId when not provided", () => { + const widget = new Widget({ + id: "w1", + creatorUserId: "@bob:example.org", + type: "m.custom", + url: "https://example.org", + }); + const descriptor = toWidgetDescriptor(widget); + expect(descriptor.roomId).toBeUndefined(); + }); + }); + + describe("registerPreloadApprover", () => { + it("accepts a single registration", () => { + expect(() => api.registerPreloadApprover(() => true)).not.toThrow(); + }); + + it("throws on double registration", () => { + api.registerPreloadApprover(() => true); + expect(() => api.registerPreloadApprover(() => true)).toThrow( + "Widget lifecycle preload approver already registered", + ); + }); + }); + + describe("registerIdentityApprover", () => { + it("accepts a single registration", () => { + expect(() => api.registerIdentityApprover(() => true)).not.toThrow(); + }); + + it("throws on double registration", () => { + api.registerIdentityApprover(() => true); + expect(() => api.registerIdentityApprover(() => true)).toThrow( + "Widget lifecycle identity approver already registered", + ); + }); + }); + + describe("registerCapabilitiesApprover", () => { + it("accepts a single registration", () => { + expect(() => api.registerCapabilitiesApprover(() => new Set())).not.toThrow(); + }); + + it("throws on double registration", () => { + api.registerCapabilitiesApprover(() => new Set()); + expect(() => api.registerCapabilitiesApprover(() => new Set())).toThrow( + "Widget lifecycle capabilities approver already registered", + ); + }); + }); + + describe("preapprovePreload", () => { + const widget = mkDescriptor(); + + it("returns false when no approver registered", async () => { + expect(await api.preapprovePreload(widget)).toBe(false); + }); + + it("returns true when approver returns true", async () => { + api.registerPreloadApprover(() => true); + expect(await api.preapprovePreload(widget)).toBe(true); + }); + + it("returns false when approver returns false", async () => { + api.registerPreloadApprover(() => false); + expect(await api.preapprovePreload(widget)).toBe(false); + }); + + it("returns false when approver returns undefined", async () => { + api.registerPreloadApprover(() => undefined); + expect(await api.preapprovePreload(widget)).toBe(false); + }); + + it("returns false and logs error when approver throws", async () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(); + api.registerPreloadApprover(() => { + throw new Error("boom"); + }); + expect(await api.preapprovePreload(widget)).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith("Widget preload approver failed", expect.any(Error)); + consoleSpy.mockRestore(); + }); + }); + + describe("preapproveIdentity", () => { + const widget = mkDescriptor(); + + it("returns false when no approver registered", async () => { + expect(await api.preapproveIdentity(widget)).toBe(false); + }); + + it("returns true when approver returns true", async () => { + api.registerIdentityApprover(() => true); + expect(await api.preapproveIdentity(widget)).toBe(true); + }); + + it("returns false when approver returns false", async () => { + api.registerIdentityApprover(() => false); + expect(await api.preapproveIdentity(widget)).toBe(false); + }); + + it("returns false when approver returns undefined", async () => { + api.registerIdentityApprover(() => undefined); + expect(await api.preapproveIdentity(widget)).toBe(false); + }); + + it("returns false and logs error when approver throws", async () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(); + api.registerIdentityApprover(() => { + throw new Error("boom"); + }); + expect(await api.preapproveIdentity(widget)).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith("Widget identity approver failed", expect.any(Error)); + consoleSpy.mockRestore(); + }); + }); + + describe("preapproveCapabilities", () => { + const widget = mkDescriptor(); + const requested = new Set(["cap1", "cap2", "cap3"]); + + it("returns undefined when no approver registered", async () => { + expect(await api.preapproveCapabilities(widget, requested)).toBeUndefined(); + }); + + it("returns the set from the approver", async () => { + api.registerCapabilitiesApprover(() => new Set(["cap1", "cap2"])); + expect(await api.preapproveCapabilities(widget, requested)).toEqual(new Set(["cap1", "cap2"])); + }); + + it("passes widget and requested capabilities to the approver", async () => { + const approver = jest.fn().mockReturnValue(new Set(["cap1"])); + api.registerCapabilitiesApprover(approver); + await api.preapproveCapabilities(widget, requested); + expect(approver).toHaveBeenCalledWith(widget, requested); + }); + + it("returns undefined when approver returns undefined", async () => { + api.registerCapabilitiesApprover(() => undefined); + expect(await api.preapproveCapabilities(widget, requested)).toBeUndefined(); + }); + + it("returns undefined and logs error when approver throws", async () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(); + api.registerCapabilitiesApprover(() => { + throw new Error("boom"); + }); + expect(await api.preapproveCapabilities(widget, requested)).toBeUndefined(); + expect(consoleSpy).toHaveBeenCalledWith("Widget capabilities approver failed", expect.any(Error)); + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/apps/web/test/unit-tests/stores/widgets/ElementWidgetDriver-test.ts b/apps/web/test/unit-tests/stores/widgets/ElementWidgetDriver-test.ts index 7820cd6783..b658aa68d4 100644 --- a/apps/web/test/unit-tests/stores/widgets/ElementWidgetDriver-test.ts +++ b/apps/web/test/unit-tests/stores/widgets/ElementWidgetDriver-test.ts @@ -39,6 +39,7 @@ import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import { ElementWidgetDriver } from "../../../../src/stores/widgets/ElementWidgetDriver"; import { mkEvent, stubClient } from "../../../test-utils"; import { ModuleRunner } from "../../../../src/modules/ModuleRunner"; +import { ModuleApi } from "../../../../src/modules/Api"; import dis from "../../../../src/dispatcher/dispatcher"; import Modal from "../../../../src/Modal"; import SettingsStore from "../../../../src/settings/SettingsStore"; @@ -187,6 +188,76 @@ describe("ElementWidgetDriver", () => { expect(listener).toHaveBeenCalledWith(openIdUpdate); }); + it("approves capabilities via new widget lifecycle API", async () => { + const driver = mkDefaultDriver(); + + const requestedCapabilities = new Set(["org.matrix.msc2931.navigate", "org.matrix.msc2762.timeline:*"]); + + jest.spyOn(ModuleApi.instance.widgetLifecycle, "preapproveCapabilities").mockResolvedValue( + requestedCapabilities, + ); + + const approvedCapabilities = await driver.validateCapabilities(requestedCapabilities); + expect(approvedCapabilities).toEqual(requestedCapabilities); + }); + + it("falls back to legacy module API when new API returns undefined for capabilities", async () => { + const driver = mkDefaultDriver(); + + const requestedCapabilities = new Set(["org.matrix.msc2931.navigate", "org.matrix.msc2762.timeline:*"]); + + jest.spyOn(ModuleApi.instance.widgetLifecycle, "preapproveCapabilities").mockResolvedValue(undefined); + jest.spyOn(ModuleRunner.instance, "invoke").mockImplementation( + (lifecycleEvent, opts, widgetInfo, requested) => { + if (lifecycleEvent === WidgetLifecycle.CapabilitiesRequest) { + (opts as CapabilitiesOpts).approvedCapabilities = requested; + } + }, + ); + + const approvedCapabilities = await driver.validateCapabilities(requestedCapabilities); + expect(approvedCapabilities).toEqual(requestedCapabilities); + }); + + it("approves identity via new widget lifecycle API", async () => { + const driver = mkDefaultDriver(); + + jest.spyOn(ModuleApi.instance.widgetLifecycle, "preapproveIdentity").mockResolvedValue(true); + + const listener = jest.fn(); + const observer = new SimpleObservable(); + observer.onUpdate(listener); + await driver.askOpenID(observer); + + const openIdUpdate: IOpenIDUpdate = { + state: OpenIDRequestState.Allowed, + token: await client.getOpenIdToken(), + }; + expect(listener).toHaveBeenCalledWith(openIdUpdate); + }); + + it("falls back to legacy module API when new API returns false for identity", async () => { + const driver = mkDefaultDriver(); + + jest.spyOn(ModuleApi.instance.widgetLifecycle, "preapproveIdentity").mockResolvedValue(false); + jest.spyOn(ModuleRunner.instance, "invoke").mockImplementation((lifecycleEvent, opts, widgetInfo) => { + if (lifecycleEvent === WidgetLifecycle.IdentityRequest) { + (opts as ApprovalOpts).approved = true; + } + }); + + const listener = jest.fn(); + const observer = new SimpleObservable(); + observer.onUpdate(listener); + await driver.askOpenID(observer); + + const openIdUpdate: IOpenIDUpdate = { + state: OpenIDRequestState.Allowed, + token: await client.getOpenIdToken(), + }; + expect(listener).toHaveBeenCalledWith(openIdUpdate); + }); + describe("sendToDevice", () => { const contentMap = { "@alice:example.org": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e30a7f45b8..0f88eaf8d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,8 +7,8 @@ settings: catalogs: default: '@element-hq/element-web-module-api': - specifier: 1.9.1 - version: 1.9.1 + specifier: 1.10.0 + version: 1.10.0 '@element-hq/element-web-playwright-common': specifier: 2.2.7 version: 2.2.7 @@ -143,7 +143,7 @@ importers: version: 7.28.6 '@element-hq/element-web-module-api': specifier: 'catalog:' - version: 1.9.1(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4) + version: 1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4) '@element-hq/web-shared-components': specifier: workspace:* version: link:../../packages/shared-components @@ -417,7 +417,7 @@ importers: version: 0.16.3 '@element-hq/element-web-playwright-common': specifier: 'catalog:' - version: 2.2.7(@element-hq/element-web-module-api@1.9.1(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.58.2)(playwright-core@1.58.2) + version: 2.2.7(@element-hq/element-web-module-api@1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.58.2)(playwright-core@1.58.2) '@element-hq/element-web-playwright-common-local': specifier: workspace:* version: link:../../packages/playwright-common @@ -756,7 +756,7 @@ importers: dependencies: '@element-hq/element-web-module-api': specifier: 'catalog:' - version: 1.9.1(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4) + version: 1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4) '@matrix-org/spec': specifier: ^1.7.0 version: 1.16.0 @@ -2034,8 +2034,8 @@ packages: '@element-hq/element-call-embedded@0.16.3': resolution: {integrity: sha512-OViKJonDaDNVBUW9WdV9mk78/Ruh34C7XsEgt3O8D9z+64C39elbIgllHSoH5S12IRlv9RYrrV37FZLo6QWsDQ==} - '@element-hq/element-web-module-api@1.9.1': - resolution: {integrity: sha512-eCHHBkaDWc7Ai10b2VmOAkWIQAsur+YZ2kpFrPFVG41wMXn0PbFL+n6wvpbN+mU5Mg7uVIqXhQn4jflHESBUrA==} + '@element-hq/element-web-module-api@1.10.0': + resolution: {integrity: sha512-XIl6E73dn0cmR/03TRCpq7epyFQAa93GUz1j7EBP2pv5Erh59gq788ajFM2XYl7W2afRU0aasvJoI3iZIHbRig==} engines: {node: '>=20.0.0'} peerDependencies: '@matrix-org/react-sdk-module-api': '*' @@ -12081,7 +12081,7 @@ snapshots: '@element-hq/element-call-embedded@0.16.3': {} - '@element-hq/element-web-module-api@1.9.1(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)': + '@element-hq/element-web-module-api@1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)': dependencies: '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) @@ -12090,10 +12090,10 @@ snapshots: '@matrix-org/react-sdk-module-api': 2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4) matrix-web-i18n: 3.6.0 - '@element-hq/element-web-playwright-common@2.2.7(@element-hq/element-web-module-api@1.9.1(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.58.2)(playwright-core@1.58.2)': + '@element-hq/element-web-playwright-common@2.2.7(@element-hq/element-web-module-api@1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.58.2)(playwright-core@1.58.2)': dependencies: '@axe-core/playwright': 4.11.1(playwright-core@1.58.2) - '@element-hq/element-web-module-api': 1.9.1(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4) + '@element-hq/element-web-module-api': 1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4) '@playwright/test': 1.58.2 '@testcontainers/postgresql': 11.11.0 glob: 13.0.6 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 21402a0fe4..8ab02c37c7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,7 +16,7 @@ catalog: "@element-hq/element-web-playwright-common": 2.2.7 "@playwright/test": 1.58.2 # Module API - "@element-hq/element-web-module-api": 1.9.1 + "@element-hq/element-web-module-api": 1.10.0 # Compound "@vector-im/compound-design-tokens": 6.10.0 "@vector-im/compound-web": 8.4.0