From 6aa454c646e8046d400f6e7344e0f24bb5e36838 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 25 Feb 2026 09:36:58 +0000 Subject: [PATCH 01/11] Add widget lifecycle module --- .../widget-lifecycle/element-web/README.md | 75 +++++++ .../element-web/e2e/fixture/widget.html | 61 +++++ .../element-web/e2e/widget-lifecycle.spec.ts | 210 ++++++++++++++++++ .../widget-lifecycle/element-web/package.json | 24 ++ .../element-web/src/WidgetLifecycleModule.ts | 88 ++++++++ .../element-web/src/config.ts | 54 +++++ .../widget-lifecycle/element-web/src/index.ts | 12 + .../src/utils/constructWidgetPermissions.ts | 27 +++ .../element-web/src/utils/matchPattern.ts | 13 ++ .../src/utils/normalizeWidgetUrl.ts | 16 ++ .../tests/WidgetLifecycleModule.test.ts | 140 ++++++++++++ .../element-web/tests/config.test.ts | 88 ++++++++ .../tests/constructWidgetPermissions.test.ts | 75 +++++++ .../element-web/tsconfig.json | 7 + .../element-web/vite.config.ts | 27 +++ .../element-web/vitest.config.ts | 15 ++ 16 files changed, 932 insertions(+) create mode 100644 modules/widget-lifecycle/element-web/README.md create mode 100644 modules/widget-lifecycle/element-web/e2e/fixture/widget.html create mode 100644 modules/widget-lifecycle/element-web/e2e/widget-lifecycle.spec.ts create mode 100644 modules/widget-lifecycle/element-web/package.json create mode 100644 modules/widget-lifecycle/element-web/src/WidgetLifecycleModule.ts create mode 100644 modules/widget-lifecycle/element-web/src/config.ts create mode 100644 modules/widget-lifecycle/element-web/src/index.ts create mode 100644 modules/widget-lifecycle/element-web/src/utils/constructWidgetPermissions.ts create mode 100644 modules/widget-lifecycle/element-web/src/utils/matchPattern.ts create mode 100644 modules/widget-lifecycle/element-web/src/utils/normalizeWidgetUrl.ts create mode 100644 modules/widget-lifecycle/element-web/tests/WidgetLifecycleModule.test.ts create mode 100644 modules/widget-lifecycle/element-web/tests/config.test.ts create mode 100644 modules/widget-lifecycle/element-web/tests/constructWidgetPermissions.test.ts create mode 100644 modules/widget-lifecycle/element-web/tsconfig.json create mode 100644 modules/widget-lifecycle/element-web/vite.config.ts create mode 100644 modules/widget-lifecycle/element-web/vitest.config.ts diff --git a/modules/widget-lifecycle/element-web/README.md b/modules/widget-lifecycle/element-web/README.md new file mode 100644 index 0000000000..dd4c5ab6d1 --- /dev/null +++ b/modules/widget-lifecycle/element-web/README.md @@ -0,0 +1,75 @@ +# @element-hq/element-web-module-widget-lifecycle + +Widget lifecycle module for Element Web. + +Supports the following configuration options under the configuration key `io.element.element-web-modules.widget-lifecycle`: + +| Key | Type | Description | +| ------------------ | ------ | ------------------------------------------------ | +| widget_permissions | object | Map of widget URL patterns to approval settings. | + +Each widget configuration can use the following options: + +- `preload_approved` - if true, the preload dialog is not displayed for this widget. +- `identity_approved` - if true, requests for an identity token are automatically accepted. +- `capabilities_approved` - a list of capabilities that should be approved for this widget. + +The widget URL and capability strings can use a trailing `*` to match multiple widgets or capabilities. +This is useful when widgets have multiple routes or capabilities include variable state keys. + +## Matching and precedence + +- Widget URLs are normalized by stripping query parameters and hash fragments before matching. +- Patterns ending in `*` are matched by prefix; other patterns must match exactly. +- If multiple rules match, the most specific match wins per field. +- The capabilities allow-list is not merged across rules; the most specific rule that defines it wins. + +## Example configuration (exact match) + +```json +{ + "io.element.element-web-modules.widget-lifecycle": { + "widget_permissions": { + "https://widget.example.com/": { + "preload_approved": true, + "identity_approved": true, + "capabilities_approved": [ + "org.matrix.msc2931.navigate", + "org.matrix.msc2762.receive.state_event:m.room.power_levels" + ] + } + } + } +} +``` + +## Example configuration (wildcards) + +```json +{ + "io.element.element-web-modules.widget-lifecycle": { + "widget_permissions": { + "https://widget.example.com/*": { + "preload_approved": true, + "identity_approved": true, + "capabilities_approved": [ + "org.matrix.msc2931.navigate", + "org.matrix.msc2762.receive.state_event:m.room.power_levels", + "org.matrix.msc2762.send.state_event:net.custom_event#*" + ] + } + } + } +} +``` + +## Copyright & License + +Copyright (c) 2025 New Vector Ltd + +This software is multi licensed by New Vector Ltd (Element). It can be used either: + +(1) for free under the terms of the GNU Affero General Public License (as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version); OR + +(2) under the terms of a paid-for Element Commercial License agreement between you and Element (the terms of which may vary depending on what you and Element have agreed to). +Unless required by applicable law or agreed to in writing, software distributed under the Licenses is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the Licenses for the specific language governing permissions and limitations under the Licenses. diff --git a/modules/widget-lifecycle/element-web/e2e/fixture/widget.html b/modules/widget-lifecycle/element-web/e2e/fixture/widget.html new file mode 100644 index 0000000000..271e18d163 --- /dev/null +++ b/modules/widget-lifecycle/element-web/e2e/fixture/widget.html @@ -0,0 +1,61 @@ + + + + Demo Widget + + + +

Hello unknown!

+ + diff --git a/modules/widget-lifecycle/element-web/e2e/widget-lifecycle.spec.ts b/modules/widget-lifecycle/element-web/e2e/widget-lifecycle.spec.ts new file mode 100644 index 0000000000..268a9dece5 --- /dev/null +++ b/modules/widget-lifecycle/element-web/e2e/widget-lifecycle.spec.ts @@ -0,0 +1,210 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { type SynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers/index.js"; + +import { test, expect } from "../../../../playwright/element-web-test.ts"; + +declare module "@element-hq/element-web-module-api" { + interface Config { + "io.element.element-web-modules.widget-lifecycle": { + widget_permissions: { + [url: string]: { + preload_approved?: boolean; + identity_approved?: boolean; + capabilities_approved?: string[]; + }; + }; + }; + } +} + +const WIDGET_URL = "http://localhost:8080/widget.html"; + +test.use({ + displayName: "Timmy", + synapseConfig: async ({ synapseConfig, _homeserver: homeserver }, use) => { + (homeserver as SynapseContainer).withConfigField("listeners[0].resources[0].names", ["client", "openid"]); + await use(synapseConfig); + }, + page: async ({ context, page, moduleDir }, use) => { + await context.route(`${WIDGET_URL}*`, async (route) => { + await route.fulfill({ path: `${moduleDir}/e2e/fixture/widget.html`, contentType: "text/html" }); + }); + + await page.goto("/"); + await use(page); + }, + bypassCSP: true, + launchOptions: { + args: ["--disable-web-security"], + }, +}); + +test.describe("Widget Lifecycle", () => { + test.describe("trusted widgets", () => { + test.use({ + config: { + "io.element.element-web-modules.widget-lifecycle": { + widget_permissions: { + [WIDGET_URL]: { + preload_approved: true, + identity_approved: true, + capabilities_approved: ["org.matrix.msc2762.receive.state_event:m.room.topic"], + }, + }, + }, + }, + }); + + test("auto-approves preload and identity", async ({ page, user, homeserver }, testInfo) => { + const bot = await homeserver.registerUser(`bot_${testInfo.testId}`, "password", "Bot"); + const { room_id: roomId } = await homeserver.csApi.request<{ room_id: string }>( + "POST", + "/v3/createRoom", + bot.accessToken, + { + name: "Trusted Widget", + }, + ); + await homeserver.csApi.request<{ event_id: string }>( + "PUT", + `/v3/rooms/${encodeURIComponent(roomId)}/state/im.vector.modular.widgets/1`, + bot.accessToken, + { + id: "1", + creatorUserId: bot.userId, + type: "custom", + name: "Trusted Widget", + url: `${WIDGET_URL}?hsUrl=${encodeURIComponent(homeserver.baseUrl)}&caps=org.matrix.msc2762.receive.state_event:m.room.topic`, + }, + ); + await homeserver.csApi.request( + "PUT", + `/v3/rooms/${encodeURIComponent(roomId)}/state/io.element.widgets.layout/`, + bot.accessToken, + { + widgets: { + "1": { + container: "top", + }, + }, + }, + ); + await homeserver.csApi.request("POST", `/v3/rooms/${encodeURIComponent(roomId)}/invite`, bot.accessToken, { + user_id: user.userId, + }); + + await page.getByText("Trusted Widget").click(); + await page.getByRole("button", { name: "Accept" }).click(); + + await expect( + page + .frameLocator('iframe[title="Trusted Widget"]') + .getByRole("heading", { name: `Hello ${user.userId}!` }), + ).toBeVisible(); + }); + + test("prompts for capabilities not in the allowlist", async ({ page, user, homeserver }, testInfo) => { + const bot = await homeserver.registerUser(`bot_${testInfo.testId}`, "password", "Bot"); + const { room_id: roomId } = await homeserver.csApi.request<{ room_id: string }>( + "POST", + "/v3/createRoom", + bot.accessToken, + { + name: "Capabilities Widget", + }, + ); + await homeserver.csApi.request<{ event_id: string }>( + "PUT", + `/v3/rooms/${encodeURIComponent(roomId)}/state/im.vector.modular.widgets/1`, + bot.accessToken, + { + id: "1", + creatorUserId: bot.userId, + type: "custom", + name: "Capabilities Widget", + url: `${WIDGET_URL}?hsUrl=${encodeURIComponent(homeserver.baseUrl)}&caps=org.matrix.msc2762.receive.state_event:m.room.topic,org.matrix.msc2762.receive.state_event:m.room.name`, + }, + ); + await homeserver.csApi.request( + "PUT", + `/v3/rooms/${encodeURIComponent(roomId)}/state/io.element.widgets.layout/`, + bot.accessToken, + { + widgets: { + "1": { + container: "top", + }, + }, + }, + ); + await homeserver.csApi.request("POST", `/v3/rooms/${encodeURIComponent(roomId)}/invite`, bot.accessToken, { + user_id: user.userId, + }); + + await page.getByText("Capabilities Widget").click(); + await page.getByRole("button", { name: "Accept" }).click(); + + await expect(page.getByRole("button", { name: "Approve" })).toBeVisible(); + }); + }); + + test.describe("untrusted widgets", () => { + test.use({ + config: { + "io.element.element-web-modules.widget-lifecycle": { + widget_permissions: {}, + }, + }, + }); + + test("shows dialogs for untrusted widgets", async ({ page, user, homeserver }, testInfo) => { + const bot = await homeserver.registerUser(`bot_${testInfo.testId}`, "password", "Bot"); + const { room_id: roomId } = await homeserver.csApi.request<{ room_id: string }>( + "POST", + "/v3/createRoom", + bot.accessToken, + { + name: "Untrusted Widget", + }, + ); + await homeserver.csApi.request<{ event_id: string }>( + "PUT", + `/v3/rooms/${encodeURIComponent(roomId)}/state/im.vector.modular.widgets/1`, + bot.accessToken, + { + id: "1", + creatorUserId: bot.userId, + type: "custom", + name: "Untrusted Widget", + url: `${WIDGET_URL}?hsUrl=${encodeURIComponent(homeserver.baseUrl)}&caps=org.matrix.msc2762.receive.state_event:m.room.topic`, + }, + ); + await homeserver.csApi.request( + "PUT", + `/v3/rooms/${encodeURIComponent(roomId)}/state/io.element.widgets.layout/`, + bot.accessToken, + { + widgets: { + "1": { + container: "top", + }, + }, + }, + ); + await homeserver.csApi.request("POST", `/v3/rooms/${encodeURIComponent(roomId)}/invite`, bot.accessToken, { + user_id: user.userId, + }); + + await page.getByText("Untrusted Widget").click(); + await page.getByRole("button", { name: "Accept" }).click(); + + await expect(page.getByRole("button", { name: "Continue" })).toBeVisible(); + }); + }); +}); diff --git a/modules/widget-lifecycle/element-web/package.json b/modules/widget-lifecycle/element-web/package.json new file mode 100644 index 0000000000..b469ee086c --- /dev/null +++ b/modules/widget-lifecycle/element-web/package.json @@ -0,0 +1,24 @@ +{ + "name": "@element-hq/element-web-module-widget-lifecycle", + "private": true, + "version": "0.0.0", + "type": "module", + "main": "lib/index.js", + "license": "SEE LICENSE IN README.md", + "scripts": { + "prepare": "vite build", + "lint:types": "tsc --noEmit", + "lint:codestyle": "echo 'handled by lint:eslint'", + "test": "vitest" + }, + "devDependencies": { + "@element-hq/element-web-module-api": "^1.0.0", + "@types/node": "^22.10.7", + "typescript": "^5.7.3", + "vite": "^7.1.11", + "vitest": "^4.0.0" + }, + "dependencies": { + "zod": "^4.0.0" + } +} diff --git a/modules/widget-lifecycle/element-web/src/WidgetLifecycleModule.ts b/modules/widget-lifecycle/element-web/src/WidgetLifecycleModule.ts new file mode 100644 index 0000000000..17453af6bc --- /dev/null +++ b/modules/widget-lifecycle/element-web/src/WidgetLifecycleModule.ts @@ -0,0 +1,88 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import type { Api, Module, WidgetDescriptor, WidgetLifecycleApi } from "@element-hq/element-web-module-api"; + +import { CONFIG_KEY, parseWidgetLifecycleConfig, type WidgetLifecycleModuleConfig } from "./config"; +import { constructWidgetPermissions } from "./utils/constructWidgetPermissions"; +import { matchPattern } from "./utils/matchPattern"; +import { normalizeWidgetUrl } from "./utils/normalizeWidgetUrl"; + +/** Subset of {@link WidgetLifecycleApi} used by the module for registration only. */ +export type WidgetLifecycleApiAdapter = Pick< + WidgetLifecycleApi, + "registerPreloadApprover" | "registerIdentityApprover" | "registerCapabilitiesApprover" +>; + +type ModuleApi = Pick; + +/** + * Module that auto-approves widget preloading, identity token requests, and capability + * requests based on URL-pattern rules defined in config.json. + */ +export default class WidgetLifecycleModule implements Module { + public static readonly moduleApiVersion = "^1.0.0"; + + private config: WidgetLifecycleModuleConfig = {}; + + public constructor(private api: ModuleApi) {} + + public async load(): Promise { + if (!this.api.widgetLifecycle?.registerPreloadApprover) { + throw new Error( + "Widget lifecycle API is not available. Update Element Web to a build that provides widget lifecycle module support.", + ); + } + + try { + this.config = parseWidgetLifecycleConfig(this.api.config.get(CONFIG_KEY)); + } catch (error) { + console.error("[WidgetLifecycle] Failed to init module", error); + this.config = {}; + } + + this.api.widgetLifecycle.registerPreloadApprover((widget) => this.preapprovePreload(widget)); + this.api.widgetLifecycle.registerIdentityApprover((widget) => this.preapproveIdentity(widget)); + this.api.widgetLifecycle.registerCapabilitiesApprover((widget, requested) => + this.preapproveCapabilities(widget, requested), + ); + } + + private preapprovePreload(widget: WidgetDescriptor): boolean { + const normalizedUrl = normalizeWidgetUrl(widget.templateUrl); + const configuration = constructWidgetPermissions(this.config, normalizedUrl); + return configuration.preload_approved === true; + } + + private preapproveIdentity(widget: WidgetDescriptor): boolean { + const normalizedUrl = normalizeWidgetUrl(widget.templateUrl); + const configuration = constructWidgetPermissions(this.config, normalizedUrl); + return configuration.identity_approved === true; + } + + private preapproveCapabilities( + widget: WidgetDescriptor, + requestedCapabilities: Set, + ): Set | undefined { + const normalizedUrl = normalizeWidgetUrl(widget.templateUrl); + const configuration = constructWidgetPermissions(this.config, normalizedUrl); + const capabilitiesApproved = configuration.capabilities_approved; + + if (!capabilitiesApproved) return undefined; + + const approvedCapabilities = new Set(); + for (const requestedCapability of requestedCapabilities) { + if (capabilitiesApproved.some((capability) => matchPattern(requestedCapability, capability))) { + approvedCapabilities.add(requestedCapability); + } + } + + return approvedCapabilities.size > 0 ? approvedCapabilities : undefined; + } +} + +export { WidgetLifecycleModule }; diff --git a/modules/widget-lifecycle/element-web/src/config.ts b/modules/widget-lifecycle/element-web/src/config.ts new file mode 100644 index 0000000000..0746ae37f1 --- /dev/null +++ b/modules/widget-lifecycle/element-web/src/config.ts @@ -0,0 +1,54 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { z } from "zod/mini"; + +z.config(z.locales.en()); + +/** The config.json key under which widget lifecycle module configuration is stored. */ +export const CONFIG_KEY = "io.element.element-web-modules.widget-lifecycle"; + +export const WidgetConfigurationSchema = z.partial( + z.looseObject({ + preload_approved: z.boolean(), + identity_approved: z.boolean(), + capabilities_approved: z.array(z.string().check(z.minLength(1))), + }), +); + +/** Per-widget approval settings: preload, identity, and capabilities. */ +export type WidgetConfiguration = z.infer; + +const ModuleConfigSchema = z.partial( + z.looseObject({ + widget_permissions: z.record(z.string(), WidgetConfigurationSchema), + }), +); + +/** Map from URL patterns to their widget approval configuration. */ +export type WidgetLifecycleModuleConfig = Record; + +/** + * Parse and validate the widget lifecycle module configuration. + * Returns an empty config if the input is falsy; throws on schema violations. + */ +declare module "@element-hq/element-web-module-api" { + export interface Config { + [CONFIG_KEY]: z.input; + } +} + +export const parseWidgetLifecycleConfig = (value: unknown): WidgetLifecycleModuleConfig => { + if (!value) return {}; + + const result = ModuleConfigSchema.safeParse(value); + if (!result.success) { + throw new Error(`Errors in the module configuration for "${CONFIG_KEY}": ${result.error.message}`); + } + + return result.data.widget_permissions ?? {}; +}; diff --git a/modules/widget-lifecycle/element-web/src/index.ts b/modules/widget-lifecycle/element-web/src/index.ts new file mode 100644 index 0000000000..843638fc29 --- /dev/null +++ b/modules/widget-lifecycle/element-web/src/index.ts @@ -0,0 +1,12 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import type { ModuleFactory } from "@element-hq/element-web-module-api"; + +import WidgetLifecycleModule from "./WidgetLifecycleModule"; + +export default WidgetLifecycleModule satisfies ModuleFactory; diff --git a/modules/widget-lifecycle/element-web/src/utils/constructWidgetPermissions.ts b/modules/widget-lifecycle/element-web/src/utils/constructWidgetPermissions.ts new file mode 100644 index 0000000000..9f4ae1346d --- /dev/null +++ b/modules/widget-lifecycle/element-web/src/utils/constructWidgetPermissions.ts @@ -0,0 +1,27 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import type { WidgetLifecycleModuleConfig, WidgetConfiguration } from "../config"; +import { matchPattern } from "./matchPattern"; + +/** + * Returns the WidgetConfiguration for a widget. + * If multiple WidgetConfigurations match, the most specific match wins per field. + */ +export function constructWidgetPermissions( + config: WidgetLifecycleModuleConfig, + widgetUrl: string, +): WidgetConfiguration { + const widgetPermissionsMatched = Object.keys(config).filter((pattern) => matchPattern(widgetUrl, pattern)); + + return widgetPermissionsMatched.sort(sortLongestMatchLast).reduce((prev, key) => ({ ...prev, ...config[key] }), {}); +} + +/** Sort strings alphabetically so longer, more-specific patterns are applied last. */ +export function sortLongestMatchLast(a: string, b: string): number { + return a.localeCompare(b, "en", { sensitivity: "base" }); +} diff --git a/modules/widget-lifecycle/element-web/src/utils/matchPattern.ts b/modules/widget-lifecycle/element-web/src/utils/matchPattern.ts new file mode 100644 index 0000000000..7531474569 --- /dev/null +++ b/modules/widget-lifecycle/element-web/src/utils/matchPattern.ts @@ -0,0 +1,13 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +/** + * Checks if string matches pattern. Pattern can end with '*' to support prefix matching. + */ +export function matchPattern(value: string, pattern: string): boolean { + return pattern.endsWith("*") ? value.startsWith(pattern.slice(0, pattern.length - 1)) : value === pattern; +} diff --git a/modules/widget-lifecycle/element-web/src/utils/normalizeWidgetUrl.ts b/modules/widget-lifecycle/element-web/src/utils/normalizeWidgetUrl.ts new file mode 100644 index 0000000000..794db023bf --- /dev/null +++ b/modules/widget-lifecycle/element-web/src/utils/normalizeWidgetUrl.ts @@ -0,0 +1,16 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +/** + * Strips query parameters and fragment from a widget URL for matching against config patterns. + */ +export function normalizeWidgetUrl(widgetUrl: string): string { + const url = new URL(widgetUrl); + url.search = ""; + url.hash = ""; + return url.toString(); +} diff --git a/modules/widget-lifecycle/element-web/tests/WidgetLifecycleModule.test.ts b/modules/widget-lifecycle/element-web/tests/WidgetLifecycleModule.test.ts new file mode 100644 index 0000000000..9970a9aa00 --- /dev/null +++ b/modules/widget-lifecycle/element-web/tests/WidgetLifecycleModule.test.ts @@ -0,0 +1,140 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { describe, expect, it } from "vitest"; + +import WidgetLifecycleModule, { type WidgetLifecycleApiAdapter } from "../src/WidgetLifecycleModule"; +import type { + CapabilitiesApprover, + IdentityApprover, + PreloadApprover, + WidgetDescriptor, +} from "@element-hq/element-web-module-api"; + +const createApi = (config: unknown = {}) => { + const handlers: { + preload?: PreloadApprover; + identity?: IdentityApprover; + capabilities?: CapabilitiesApprover; + } = {}; + + const widgetLifecycle: WidgetLifecycleApiAdapter = { + registerPreloadApprover: (approver) => { + handlers.preload = approver; + }, + registerIdentityApprover: (approver) => { + handlers.identity = approver; + }, + registerCapabilitiesApprover: (approver) => { + handlers.capabilities = approver; + }, + }; + + return { + api: { + config: { + get: () => config, + }, + widgetLifecycle, + }, + handlers, + }; +}; + +const widget: WidgetDescriptor = { + id: "widget-id", + templateUrl: "https://example.com", + creatorUserId: "@user-id", + kind: "room", +}; + +describe("WidgetLifecycleModule", () => { + it("approves preload when configured", async () => { + const { api, handlers } = createApi({ + widget_permissions: { + "https://example.com/": { preload_approved: true }, + }, + }); + + const module = new WidgetLifecycleModule(api); + await module.load(); + + expect(await handlers.preload?.(widget)).toBe(true); + }); + + it("approves identity when configured", async () => { + const { api, handlers } = createApi({ + widget_permissions: { + "https://example.com/": { identity_approved: true }, + }, + }); + + const module = new WidgetLifecycleModule(api); + await module.load(); + + expect(await handlers.identity?.(widget)).toBe(true); + }); + + it("approves configured capabilities", async () => { + const { api, handlers } = createApi({ + widget_permissions: { + "https://example.com/": { + capabilities_approved: ["org.matrix.msc2931.navigate"], + }, + }, + }); + + const module = new WidgetLifecycleModule(api); + await module.load(); + + const approved = await handlers.capabilities?.( + widget, + new Set(["org.matrix.msc2931.navigate", "org.matrix.msc2762.timeline:*"]), + ); + + expect(approved).toEqual(new Set(["org.matrix.msc2931.navigate"])); + }); + + it("returns no approvals when not configured", async () => { + const { api, handlers } = createApi({}); + + const module = new WidgetLifecycleModule(api); + await module.load(); + + expect(await handlers.preload?.(widget)).toBe(false); + expect(await handlers.identity?.(widget)).toBe(false); + expect(await handlers.capabilities?.(widget, new Set(["org.matrix.msc2931.navigate"]))).toBeUndefined(); + }); + + it("returns no approvals when config is missing", async () => { + const { api, handlers } = createApi(undefined); + + const module = new WidgetLifecycleModule(api); + await module.load(); + + expect(await handlers.preload?.(widget)).toBe(false); + expect(await handlers.identity?.(widget)).toBe(false); + expect(await handlers.capabilities?.(widget, new Set(["org.matrix.msc2931.navigate"]))).toBeUndefined(); + }); + + it("fails closed on invalid config", async () => { + const { api, handlers } = createApi({ + widget_permissions: { + "https://example.com/": { + preload_approved: null, + }, + }, + }); + + const module = new WidgetLifecycleModule(api); + await module.load(); + + expect(await handlers.preload?.(widget)).toBe(false); + expect(await handlers.identity?.(widget)).toBe(false); + expect(await handlers.capabilities?.(widget, new Set(["org.matrix.msc2931.navigate"]))).toBeUndefined(); + }); +}); diff --git a/modules/widget-lifecycle/element-web/tests/config.test.ts b/modules/widget-lifecycle/element-web/tests/config.test.ts new file mode 100644 index 0000000000..ed131db338 --- /dev/null +++ b/modules/widget-lifecycle/element-web/tests/config.test.ts @@ -0,0 +1,88 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { describe, expect, it } from "vitest"; + +import { parseWidgetLifecycleConfig } from "../src/config"; + +describe("parseWidgetLifecycleConfig", () => { + it("accepts missing configuration", () => { + expect(parseWidgetLifecycleConfig(undefined)).toEqual({}); + }); + + it("accepts empty configuration", () => { + expect(parseWidgetLifecycleConfig({})).toEqual({}); + }); + + it("accepts valid configuration", () => { + expect( + parseWidgetLifecycleConfig({ + widget_permissions: { + "https://localhost": { + preload_approved: true, + identity_approved: false, + capabilities_approved: [], + }, + }, + }), + ).toEqual({ + "https://localhost": { + preload_approved: true, + identity_approved: false, + capabilities_approved: [], + }, + }); + }); + + it("accepts additional properties", () => { + expect( + parseWidgetLifecycleConfig({ + widget_permissions: { + "https://localhost": { + preload_approved: true, + identity_approved: false, + capabilities_approved: ["capability"], + additional: "tmp", + }, + }, + }), + ).toEqual({ + "https://localhost": { + preload_approved: true, + identity_approved: false, + capabilities_approved: ["capability"], + additional: "tmp", + }, + }); + }); + + it.each([ + { preload_approved: null }, + { preload_approved: 123 }, + { identity_approved: null }, + { identity_approved: 123 }, + { capabilities_approved: null }, + { capabilities_approved: 123 }, + { capabilities_approved: [undefined] }, + { capabilities_approved: [null] }, + { capabilities_approved: [123] }, + { capabilities_approved: [""] }, + ])("rejects invalid widget configuration %j", (patch) => { + expect(() => + parseWidgetLifecycleConfig({ + widget_permissions: { + "https://localhost": { + preload_approved: true, + identity_approved: false, + capabilities_approved: ["capability"], + ...patch, + }, + }, + }), + ).toThrow(); + }); +}); diff --git a/modules/widget-lifecycle/element-web/tests/constructWidgetPermissions.test.ts b/modules/widget-lifecycle/element-web/tests/constructWidgetPermissions.test.ts new file mode 100644 index 0000000000..e84881acd0 --- /dev/null +++ b/modules/widget-lifecycle/element-web/tests/constructWidgetPermissions.test.ts @@ -0,0 +1,75 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { describe, expect, it } from "vitest"; + +import { constructWidgetPermissions, sortLongestMatchLast } from "../src/utils/constructWidgetPermissions"; + +describe("constructWidgetPermissions", () => { + it("finds exact match", () => { + expect(constructWidgetPermissions({ "https://a.com/": { preload_approved: true } }, "https://a.com/")).toEqual({ + preload_approved: true, + }); + }); + + it("finds prefix match", () => { + expect( + constructWidgetPermissions({ "https://a.com/*": { preload_approved: true } }, "https://a.com/some"), + ).toEqual({ preload_approved: true }); + }); + + it("merges multiple permissions", () => { + expect( + constructWidgetPermissions( + { + "https://b.com/path": { + preload_approved: false, + capabilities_approved: ["org.matrix.msc2762.timeline:*"], + }, + "https://b.com/*": { + preload_approved: true, + identity_approved: true, + capabilities_approved: ["org.matrix.msc2931.navigate"], + }, + }, + "https://b.com/path", + ), + ).toEqual({ + preload_approved: false, + identity_approved: true, + capabilities_approved: ["org.matrix.msc2762.timeline:*"], + }); + }); + + it("skips unknown url", () => { + expect(constructWidgetPermissions({ "https://a.com/": { preload_approved: true } }, "https://a.com/x")).toEqual( + {}, + ); + }); +}); + +describe("sortLongestMatchLast", () => { + it("sorts longest match last", () => { + expect( + [ + "org.matrix.msc2762.receive.state_event:*", + "org.matrix.msc2762.receive.*", + "org.matrix.msc2762.receive.state_event:m.custom*", + "org.matrix.msc2762.receive.state_event:m.custom#state_key", + "org.matrix.msc2762.receive.state_event:m.custom#*", + "*", + ].sort(sortLongestMatchLast), + ).toEqual([ + "*", + "org.matrix.msc2762.receive.*", + "org.matrix.msc2762.receive.state_event:*", + "org.matrix.msc2762.receive.state_event:m.custom*", + "org.matrix.msc2762.receive.state_event:m.custom#*", + "org.matrix.msc2762.receive.state_event:m.custom#state_key", + ]); + }); +}); diff --git a/modules/widget-lifecycle/element-web/tsconfig.json b/modules/widget-lifecycle/element-web/tsconfig.json new file mode 100644 index 0000000000..8f4f6b8ace --- /dev/null +++ b/modules/widget-lifecycle/element-web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "lib" + }, + "include": ["src"] +} diff --git a/modules/widget-lifecycle/element-web/vite.config.ts b/modules/widget-lifecycle/element-web/vite.config.ts new file mode 100644 index 0000000000..3e4bed43a0 --- /dev/null +++ b/modules/widget-lifecycle/element-web/vite.config.ts @@ -0,0 +1,27 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + build: { + lib: { + entry: resolve(__dirname, "src/index.ts"), + name: "element-web-module-widget-lifecycle", + fileName: "index", + formats: ["es"], + }, + outDir: "lib", + target: "esnext", + sourcemap: true, + minify: false, + }, +}); diff --git a/modules/widget-lifecycle/element-web/vitest.config.ts b/modules/widget-lifecycle/element-web/vitest.config.ts new file mode 100644 index 0000000000..29cb2a09bd --- /dev/null +++ b/modules/widget-lifecycle/element-web/vitest.config.ts @@ -0,0 +1,15 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + exclude: ["./e2e/**/*", "./node_modules/**/*"], + }, +}); From 5d091e91c3306f22a5e258bd26ed97b78e882fc3 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 4 Mar 2026 18:02:17 +0000 Subject: [PATCH 02/11] Update e2e tests and header Don't add normalisation of widget headers either --- .../widget-lifecycle/element-web/README.md | 1 - .../element-web/e2e/widget-lifecycle.spec.ts | 28 ++++++++++++++- .../element-web/src/WidgetLifecycleModule.ts | 11 ++---- .../widget-lifecycle/element-web/src/index.ts | 1 - .../src/utils/constructWidgetPermissions.ts | 1 + .../element-web/src/utils/matchPattern.ts | 1 + .../src/utils/normalizeWidgetUrl.ts | 16 --------- .../tests/WidgetLifecycleModule.test.ts | 2 +- .../tests/constructWidgetPermissions.test.ts | 1 + .../element-web/tests/matchPattern.test.ts | 35 +++++++++++++++++++ 10 files changed, 69 insertions(+), 28 deletions(-) delete mode 100644 modules/widget-lifecycle/element-web/src/utils/normalizeWidgetUrl.ts create mode 100644 modules/widget-lifecycle/element-web/tests/matchPattern.test.ts diff --git a/modules/widget-lifecycle/element-web/README.md b/modules/widget-lifecycle/element-web/README.md index dd4c5ab6d1..7cc0bc734c 100644 --- a/modules/widget-lifecycle/element-web/README.md +++ b/modules/widget-lifecycle/element-web/README.md @@ -19,7 +19,6 @@ This is useful when widgets have multiple routes or capabilities include variabl ## Matching and precedence -- Widget URLs are normalized by stripping query parameters and hash fragments before matching. - Patterns ending in `*` are matched by prefix; other patterns must match exactly. - If multiple rules match, the most specific match wins per field. - The capabilities allow-list is not merged across rules; the most specific rule that defines it wins. diff --git a/modules/widget-lifecycle/element-web/e2e/widget-lifecycle.spec.ts b/modules/widget-lifecycle/element-web/e2e/widget-lifecycle.spec.ts index 268a9dece5..ed3ac965d8 100644 --- a/modules/widget-lifecycle/element-web/e2e/widget-lifecycle.spec.ts +++ b/modules/widget-lifecycle/element-web/e2e/widget-lifecycle.spec.ts @@ -47,6 +47,8 @@ test.use({ test.describe("Widget Lifecycle", () => { test.describe("trusted widgets", () => { + // Configure the module to pre-approve the widget URL for preloading, identity tokens, + // and the m.room.topic state event capability. test.use({ config: { "io.element.element-web-modules.widget-lifecycle": { @@ -62,6 +64,9 @@ test.describe("Widget Lifecycle", () => { }); test("auto-approves preload and identity", async ({ page, user, homeserver }, testInfo) => { + // A bot creates a room with the widget pinned to the top panel, then invites the test user. + // Because the widget was added by a different user (the bot), Element would normally show a + // preload consent dialog before loading it — this test verifies that dialog is skipped. const bot = await homeserver.registerUser(`bot_${testInfo.testId}`, "password", "Bot"); const { room_id: roomId } = await homeserver.csApi.request<{ room_id: string }>( "POST", @@ -102,6 +107,11 @@ test.describe("Widget Lifecycle", () => { await page.getByText("Trusted Widget").click(); await page.getByRole("button", { name: "Accept" }).click(); + // No preload dialog should appear — the widget loads immediately. + await expect(page.getByRole("button", { name: "Continue" })).not.toBeVisible(); + + // The widget greets the user by ID, proving the identity token was also auto-approved + // and passed to the widget without any consent prompts. await expect( page .frameLocator('iframe[title="Trusted Widget"]') @@ -119,6 +129,7 @@ test.describe("Widget Lifecycle", () => { name: "Capabilities Widget", }, ); + // The widget requests two capabilities: m.room.topic (in the allowlist) and m.room.name (not in the allowlist). await homeserver.csApi.request<{ event_id: string }>( "PUT", `/v3/rooms/${encodeURIComponent(roomId)}/state/im.vector.modular.widgets/1`, @@ -150,11 +161,13 @@ test.describe("Widget Lifecycle", () => { await page.getByText("Capabilities Widget").click(); await page.getByRole("button", { name: "Accept" }).click(); + // A capabilities approval dialog should appear since m.room.name was not pre-approved. await expect(page.getByRole("button", { name: "Approve" })).toBeVisible(); }); }); test.describe("untrusted widgets", () => { + // No widget URLs are pre-approved, so all lifecycle prompts should be shown to the user. test.use({ config: { "io.element.element-web-modules.widget-lifecycle": { @@ -163,7 +176,11 @@ test.describe("Widget Lifecycle", () => { }, }); - test("shows dialogs for untrusted widgets", async ({ page, user, homeserver }, testInfo) => { + test("shows preload, capabilities, and OpenID dialogs for untrusted widgets", async ({ + page, + user, + homeserver, + }, testInfo) => { const bot = await homeserver.registerUser(`bot_${testInfo.testId}`, "password", "Bot"); const { room_id: roomId } = await homeserver.csApi.request<{ room_id: string }>( "POST", @@ -204,6 +221,15 @@ test.describe("Widget Lifecycle", () => { await page.getByText("Untrusted Widget").click(); await page.getByRole("button", { name: "Accept" }).click(); + // 1. Preload consent dialog — shown because the widget was added by another user (the bot). + await expect(page.getByRole("button", { name: "Continue" })).toBeVisible(); + await page.getByRole("button", { name: "Continue" }).click(); + + // 2. Capabilities dialog — the widget requests m.room.topic once it loads. + await expect(page.getByRole("button", { name: "Approve" })).toBeVisible(); + await page.getByRole("button", { name: "Approve" }).click(); + + // 3. OpenID identity dialog — the widget requests an identity token after capabilities are granted. await expect(page.getByRole("button", { name: "Continue" })).toBeVisible(); }); }); diff --git a/modules/widget-lifecycle/element-web/src/WidgetLifecycleModule.ts b/modules/widget-lifecycle/element-web/src/WidgetLifecycleModule.ts index 17453af6bc..ce17d783f8 100644 --- a/modules/widget-lifecycle/element-web/src/WidgetLifecycleModule.ts +++ b/modules/widget-lifecycle/element-web/src/WidgetLifecycleModule.ts @@ -6,11 +6,9 @@ Please see LICENSE files in the repository root for full details. */ import type { Api, Module, WidgetDescriptor, WidgetLifecycleApi } from "@element-hq/element-web-module-api"; - import { CONFIG_KEY, parseWidgetLifecycleConfig, type WidgetLifecycleModuleConfig } from "./config"; import { constructWidgetPermissions } from "./utils/constructWidgetPermissions"; import { matchPattern } from "./utils/matchPattern"; -import { normalizeWidgetUrl } from "./utils/normalizeWidgetUrl"; /** Subset of {@link WidgetLifecycleApi} used by the module for registration only. */ export type WidgetLifecycleApiAdapter = Pick< @@ -53,14 +51,12 @@ export default class WidgetLifecycleModule implements Module { } private preapprovePreload(widget: WidgetDescriptor): boolean { - const normalizedUrl = normalizeWidgetUrl(widget.templateUrl); - const configuration = constructWidgetPermissions(this.config, normalizedUrl); + const configuration = constructWidgetPermissions(this.config, widget.templateUrl); return configuration.preload_approved === true; } private preapproveIdentity(widget: WidgetDescriptor): boolean { - const normalizedUrl = normalizeWidgetUrl(widget.templateUrl); - const configuration = constructWidgetPermissions(this.config, normalizedUrl); + const configuration = constructWidgetPermissions(this.config, widget.templateUrl); return configuration.identity_approved === true; } @@ -68,8 +64,7 @@ export default class WidgetLifecycleModule implements Module { widget: WidgetDescriptor, requestedCapabilities: Set, ): Set | undefined { - const normalizedUrl = normalizeWidgetUrl(widget.templateUrl); - const configuration = constructWidgetPermissions(this.config, normalizedUrl); + const configuration = constructWidgetPermissions(this.config, widget.templateUrl); const capabilitiesApproved = configuration.capabilities_approved; if (!capabilitiesApproved) return undefined; diff --git a/modules/widget-lifecycle/element-web/src/index.ts b/modules/widget-lifecycle/element-web/src/index.ts index 843638fc29..7cff3faacc 100644 --- a/modules/widget-lifecycle/element-web/src/index.ts +++ b/modules/widget-lifecycle/element-web/src/index.ts @@ -6,7 +6,6 @@ Please see LICENSE files in the repository root for full details. */ import type { ModuleFactory } from "@element-hq/element-web-module-api"; - import WidgetLifecycleModule from "./WidgetLifecycleModule"; export default WidgetLifecycleModule satisfies ModuleFactory; diff --git a/modules/widget-lifecycle/element-web/src/utils/constructWidgetPermissions.ts b/modules/widget-lifecycle/element-web/src/utils/constructWidgetPermissions.ts index 9f4ae1346d..07943c1b82 100644 --- a/modules/widget-lifecycle/element-web/src/utils/constructWidgetPermissions.ts +++ b/modules/widget-lifecycle/element-web/src/utils/constructWidgetPermissions.ts @@ -1,5 +1,6 @@ /* Copyright 2026 Element Creations Ltd. +Copyright 2023 Nordeck IT + Consulting GmbH SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. diff --git a/modules/widget-lifecycle/element-web/src/utils/matchPattern.ts b/modules/widget-lifecycle/element-web/src/utils/matchPattern.ts index 7531474569..8271649291 100644 --- a/modules/widget-lifecycle/element-web/src/utils/matchPattern.ts +++ b/modules/widget-lifecycle/element-web/src/utils/matchPattern.ts @@ -1,5 +1,6 @@ /* Copyright 2026 Element Creations Ltd. +Copyright 2023 Nordeck IT + Consulting GmbH SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. diff --git a/modules/widget-lifecycle/element-web/src/utils/normalizeWidgetUrl.ts b/modules/widget-lifecycle/element-web/src/utils/normalizeWidgetUrl.ts deleted file mode 100644 index 794db023bf..0000000000 --- a/modules/widget-lifecycle/element-web/src/utils/normalizeWidgetUrl.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* -Copyright 2026 Element Creations Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -/** - * Strips query parameters and fragment from a widget URL for matching against config patterns. - */ -export function normalizeWidgetUrl(widgetUrl: string): string { - const url = new URL(widgetUrl); - url.search = ""; - url.hash = ""; - return url.toString(); -} diff --git a/modules/widget-lifecycle/element-web/tests/WidgetLifecycleModule.test.ts b/modules/widget-lifecycle/element-web/tests/WidgetLifecycleModule.test.ts index 9970a9aa00..d513a083f9 100644 --- a/modules/widget-lifecycle/element-web/tests/WidgetLifecycleModule.test.ts +++ b/modules/widget-lifecycle/element-web/tests/WidgetLifecycleModule.test.ts @@ -47,7 +47,7 @@ const createApi = (config: unknown = {}) => { const widget: WidgetDescriptor = { id: "widget-id", - templateUrl: "https://example.com", + templateUrl: "https://example.com/", creatorUserId: "@user-id", kind: "room", }; diff --git a/modules/widget-lifecycle/element-web/tests/constructWidgetPermissions.test.ts b/modules/widget-lifecycle/element-web/tests/constructWidgetPermissions.test.ts index e84881acd0..25bc60660d 100644 --- a/modules/widget-lifecycle/element-web/tests/constructWidgetPermissions.test.ts +++ b/modules/widget-lifecycle/element-web/tests/constructWidgetPermissions.test.ts @@ -1,5 +1,6 @@ /* Copyright 2026 Element Creations Ltd. +Copyright 2023 Nordeck IT + Consulting GmbH SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. diff --git a/modules/widget-lifecycle/element-web/tests/matchPattern.test.ts b/modules/widget-lifecycle/element-web/tests/matchPattern.test.ts new file mode 100644 index 0000000000..0cc2c25c03 --- /dev/null +++ b/modules/widget-lifecycle/element-web/tests/matchPattern.test.ts @@ -0,0 +1,35 @@ +/* +Copyright 2026 Element Creations Ltd. +Copyright 2023 Nordeck IT + Consulting GmbH + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { describe, expect, it } from "vitest"; + +import { matchPattern } from "../src/utils/matchPattern"; + +describe("matchPattern", () => { + it.each([ + "*", + "org.matrix.msc2762.receive.*", + "org.matrix.msc2762.receive.state_event:*", + "org.matrix.msc2762.receive.state_event:m.custom*", + "org.matrix.msc2762.receive.state_event:m.custom#*", + "org.matrix.msc2762.receive.state_event:m.custom#state_key", + "org.matrix.msc2762.receive.state_event:m.custom#state_key*", + ])("matches %s", (pattern) => { + expect(matchPattern("org.matrix.msc2762.receive.state_event:m.custom#state_key", pattern)).toBe(true); + }); + + it.each([ + "org.matrix.msc2762.receive.state_event:", + "org.matrix.msc2762.receive.state_event:m.custom", + "org.matrix.msc2762.receive.state_event:m.custom#other_key", + "org.matrix.msc2762.receive.*:m.custom#state_key", + "org.matrix.msc2762.receive.room_event:m.custom", + ])("does not match %s", (pattern) => { + expect(matchPattern("org.matrix.msc2762.receive.state_event:m.custom#state_key", pattern)).toBe(false); + }); +}); From 782b008b54906a90af58cb92929049047d551f00 Mon Sep 17 00:00:00 2001 From: David Langley Date: Fri, 6 Mar 2026 13:56:30 +0000 Subject: [PATCH 03/11] Fix e2e We need wildcard to match query params etc --- .../widget-lifecycle/element-web/e2e/widget-lifecycle.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/widget-lifecycle/element-web/e2e/widget-lifecycle.spec.ts b/modules/widget-lifecycle/element-web/e2e/widget-lifecycle.spec.ts index ed3ac965d8..83ad62ec65 100644 --- a/modules/widget-lifecycle/element-web/e2e/widget-lifecycle.spec.ts +++ b/modules/widget-lifecycle/element-web/e2e/widget-lifecycle.spec.ts @@ -53,7 +53,7 @@ test.describe("Widget Lifecycle", () => { config: { "io.element.element-web-modules.widget-lifecycle": { widget_permissions: { - [WIDGET_URL]: { + [`${WIDGET_URL}*`]: { preload_approved: true, identity_approved: true, capabilities_approved: ["org.matrix.msc2762.receive.state_event:m.room.topic"], From c2da26542e12a7844a1dd8e39ea48ae877521a02 Mon Sep 17 00:00:00 2001 From: David Langley Date: Fri, 6 Mar 2026 13:56:44 +0000 Subject: [PATCH 04/11] Remove double export --- .../widget-lifecycle/element-web/src/WidgetLifecycleModule.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/modules/widget-lifecycle/element-web/src/WidgetLifecycleModule.ts b/modules/widget-lifecycle/element-web/src/WidgetLifecycleModule.ts index ce17d783f8..144a799291 100644 --- a/modules/widget-lifecycle/element-web/src/WidgetLifecycleModule.ts +++ b/modules/widget-lifecycle/element-web/src/WidgetLifecycleModule.ts @@ -79,5 +79,3 @@ export default class WidgetLifecycleModule implements Module { return approvedCapabilities.size > 0 ? approvedCapabilities : undefined; } } - -export { WidgetLifecycleModule }; From 7d183ed15b2024acf2c2db18d14a4751413b68e9 Mon Sep 17 00:00:00 2001 From: David Langley Date: Fri, 6 Mar 2026 16:20:40 +0000 Subject: [PATCH 05/11] Fix coverage reporting --- modules/widget-lifecycle/element-web/package.json | 6 ++++-- modules/widget-lifecycle/element-web/vitest.config.ts | 11 +++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/modules/widget-lifecycle/element-web/package.json b/modules/widget-lifecycle/element-web/package.json index b469ee086c..4611eaecda 100644 --- a/modules/widget-lifecycle/element-web/package.json +++ b/modules/widget-lifecycle/element-web/package.json @@ -9,14 +9,16 @@ "prepare": "vite build", "lint:types": "tsc --noEmit", "lint:codestyle": "echo 'handled by lint:eslint'", - "test": "vitest" + "test": "vitest run --coverage" }, "devDependencies": { "@element-hq/element-web-module-api": "^1.0.0", "@types/node": "^22.10.7", + "@vitest/coverage-v8": "^4.0.0", "typescript": "^5.7.3", "vite": "^7.1.11", - "vitest": "^4.0.0" + "vitest": "^4.0.0", + "vitest-sonar-reporter": "^2.0.0" }, "dependencies": { "zod": "^4.0.0" diff --git a/modules/widget-lifecycle/element-web/vitest.config.ts b/modules/widget-lifecycle/element-web/vitest.config.ts index 29cb2a09bd..c9ca3971b0 100644 --- a/modules/widget-lifecycle/element-web/vitest.config.ts +++ b/modules/widget-lifecycle/element-web/vitest.config.ts @@ -6,10 +6,21 @@ Please see LICENSE files in the repository root for full details. */ import { defineConfig } from "vitest/config"; +import { env } from "node:process"; + +const isGHA = env["GITHUB_ACTIONS"] !== undefined; export default defineConfig({ test: { include: ["tests/**/*.test.ts"], exclude: ["./e2e/**/*", "./node_modules/**/*"], + reporters: isGHA + ? ["default", ["vitest-sonar-reporter", { outputFile: "coverage/sonar-report.xml" }]] + : ["default"], + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + reporter: ["lcov", "text"], + }, }, }); From 75464c163493bf1a8ade6c6830390b24856ed994 Mon Sep 17 00:00:00 2001 From: David Langley Date: Tue, 10 Mar 2026 14:19:31 +0000 Subject: [PATCH 06/11] Make sonar happy --- modules/widget-lifecycle/element-web/e2e/fixture/widget.html | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/widget-lifecycle/element-web/e2e/fixture/widget.html b/modules/widget-lifecycle/element-web/e2e/fixture/widget.html index 271e18d163..1667a73c44 100644 --- a/modules/widget-lifecycle/element-web/e2e/fixture/widget.html +++ b/modules/widget-lifecycle/element-web/e2e/fixture/widget.html @@ -1,4 +1,5 @@ + Demo Widget From 610a9494d141ffa54fe440274ae6a333753a7c6b Mon Sep 17 00:00:00 2001 From: David Langley Date: Tue, 10 Mar 2026 15:37:50 +0000 Subject: [PATCH 07/11] Fix coverage paths --- modules/widget-lifecycle/element-web/vitest.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/widget-lifecycle/element-web/vitest.config.ts b/modules/widget-lifecycle/element-web/vitest.config.ts index c9ca3971b0..7ec0ff7fd7 100644 --- a/modules/widget-lifecycle/element-web/vitest.config.ts +++ b/modules/widget-lifecycle/element-web/vitest.config.ts @@ -20,7 +20,7 @@ export default defineConfig({ coverage: { provider: "v8", include: ["src/**/*.ts"], - reporter: ["lcov", "text"], + reporter: [["lcov", { projectRoot: "../../../" }], "text"], }, }, }); From c058c187c46d0d1516fad859a6cab025600512d7 Mon Sep 17 00:00:00 2001 From: David Langley Date: Fri, 13 Mar 2026 16:19:27 +0000 Subject: [PATCH 08/11] Tidy up --- modules/widget-lifecycle/element-web/README.md | 11 ----------- .../element-web/e2e/fixture/widget.html | 16 +++++++++++++++- .../element-web/src/WidgetLifecycleModule.ts | 2 +- .../widget-lifecycle/element-web/src/config.ts | 8 ++++---- .../widget-lifecycle/element-web/vite.config.ts | 2 +- .../element-web/vitest.config.ts | 2 +- 6 files changed, 22 insertions(+), 19 deletions(-) diff --git a/modules/widget-lifecycle/element-web/README.md b/modules/widget-lifecycle/element-web/README.md index 7cc0bc734c..cfe2569e4e 100644 --- a/modules/widget-lifecycle/element-web/README.md +++ b/modules/widget-lifecycle/element-web/README.md @@ -61,14 +61,3 @@ This is useful when widgets have multiple routes or capabilities include variabl } } ``` - -## Copyright & License - -Copyright (c) 2025 New Vector Ltd - -This software is multi licensed by New Vector Ltd (Element). It can be used either: - -(1) for free under the terms of the GNU Affero General Public License (as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version); OR - -(2) under the terms of a paid-for Element Commercial License agreement between you and Element (the terms of which may vary depending on what you and Element have agreed to). -Unless required by applicable law or agreed to in writing, software distributed under the Licenses is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the Licenses for the specific language governing permissions and limitations under the Licenses. diff --git a/modules/widget-lifecycle/element-web/e2e/fixture/widget.html b/modules/widget-lifecycle/element-web/e2e/fixture/widget.html index 1667a73c44..4c293c5c6d 100644 --- a/modules/widget-lifecycle/element-web/e2e/fixture/widget.html +++ b/modules/widget-lifecycle/element-web/e2e/fixture/widget.html @@ -1,4 +1,13 @@ - + @@ -19,6 +28,7 @@ let sendEventCount = 0; window.onmessage = async (ev) => { if (ev.data.action === "capabilities") { + // Step 1: Element is asking what capabilities this widget needs. window.parent.postMessage( Object.assign( { @@ -31,6 +41,8 @@ "*", ); } else if (ev.data.action === "notify_capabilities") { + // Step 2: Capabilities have been approved. Request an OpenID token + // so we can identify the current user in step 3. window.parent.postMessage( { api: "fromWidget", @@ -42,6 +54,8 @@ "*", ); } else if (ev.data.action === "get_openid" && ev.data.response?.state === "allowed") { + // Step 3: Token granted — exchange it for the user's Matrix ID and display it. + // Tests assert on this heading to verify identity was passed through correctly. const { access_token } = ev.data.response; const hsUrl = new URLSearchParams(window.location.search).get("hsUrl"); diff --git a/modules/widget-lifecycle/element-web/src/WidgetLifecycleModule.ts b/modules/widget-lifecycle/element-web/src/WidgetLifecycleModule.ts index 144a799291..ec2ca1034b 100644 --- a/modules/widget-lifecycle/element-web/src/WidgetLifecycleModule.ts +++ b/modules/widget-lifecycle/element-web/src/WidgetLifecycleModule.ts @@ -30,7 +30,7 @@ export default class WidgetLifecycleModule implements Module { public constructor(private api: ModuleApi) {} public async load(): Promise { - if (!this.api.widgetLifecycle?.registerPreloadApprover) { + if (!this.api.widgetLifecycle) { throw new Error( "Widget lifecycle API is not available. Update Element Web to a build that provides widget lifecycle module support.", ); diff --git a/modules/widget-lifecycle/element-web/src/config.ts b/modules/widget-lifecycle/element-web/src/config.ts index 0746ae37f1..ac22f816b2 100644 --- a/modules/widget-lifecycle/element-web/src/config.ts +++ b/modules/widget-lifecycle/element-web/src/config.ts @@ -32,16 +32,16 @@ const ModuleConfigSchema = z.partial( /** Map from URL patterns to their widget approval configuration. */ export type WidgetLifecycleModuleConfig = Record; -/** - * Parse and validate the widget lifecycle module configuration. - * Returns an empty config if the input is falsy; throws on schema violations. - */ declare module "@element-hq/element-web-module-api" { export interface Config { [CONFIG_KEY]: z.input; } } +/** + * Parse and validate the widget lifecycle module configuration. + * Returns an empty config if the input is falsy; throws on schema violations. + */ export const parseWidgetLifecycleConfig = (value: unknown): WidgetLifecycleModuleConfig => { if (!value) return {}; diff --git a/modules/widget-lifecycle/element-web/vite.config.ts b/modules/widget-lifecycle/element-web/vite.config.ts index 3e4bed43a0..cc1fa98eb7 100644 --- a/modules/widget-lifecycle/element-web/vite.config.ts +++ b/modules/widget-lifecycle/element-web/vite.config.ts @@ -1,5 +1,5 @@ /* -Copyright 2025 New Vector Ltd. +Copyright 2026 Element Creations Ltd. SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. diff --git a/modules/widget-lifecycle/element-web/vitest.config.ts b/modules/widget-lifecycle/element-web/vitest.config.ts index 7ec0ff7fd7..783608fb44 100644 --- a/modules/widget-lifecycle/element-web/vitest.config.ts +++ b/modules/widget-lifecycle/element-web/vitest.config.ts @@ -1,5 +1,5 @@ /* -Copyright 2025 New Vector Ltd. +Copyright 2026 Element Creations Ltd. SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. From 242d04cef62869039a3a9ee695101bcd1ab81273 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Mon, 23 Mar 2026 12:41:34 +0530 Subject: [PATCH 09/11] Update screenshots --- .../preview-bar-linux.png | Bin 4055 -> 4048 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/preview-bar-linux.png b/modules/restricted-guests/element-web/e2e/snapshots/restricted-guests.spec.ts/preview-bar-linux.png index 1771b8e67d756bdab76549a8b0fe543af2a71d07..877e466ef5fd5aec6eb00816dd1d9bc1a6e90ede 100644 GIT binary patch literal 4048 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 Date: Mon, 23 Mar 2026 11:38:04 +0000 Subject: [PATCH 10/11] update moduleApiVersion to enforce module is actually present. --- .../widget-lifecycle/element-web/src/WidgetLifecycleModule.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/widget-lifecycle/element-web/src/WidgetLifecycleModule.ts b/modules/widget-lifecycle/element-web/src/WidgetLifecycleModule.ts index ec2ca1034b..6ad15e08bd 100644 --- a/modules/widget-lifecycle/element-web/src/WidgetLifecycleModule.ts +++ b/modules/widget-lifecycle/element-web/src/WidgetLifecycleModule.ts @@ -23,7 +23,7 @@ type ModuleApi = Pick; * requests based on URL-pattern rules defined in config.json. */ export default class WidgetLifecycleModule implements Module { - public static readonly moduleApiVersion = "^1.0.0"; + public static readonly moduleApiVersion = "^1.10.0"; private config: WidgetLifecycleModuleConfig = {}; From 5d25bd08a1340f2a3a51b9504a634c1771bf0c18 Mon Sep 17 00:00:00 2001 From: David Langley Date: Mon, 23 Mar 2026 23:46:48 +0000 Subject: [PATCH 11/11] Trigger build