diff --git a/modules/widget-lifecycle/element-web/README.md b/modules/widget-lifecycle/element-web/README.md
new file mode 100644
index 0000000000..cfe2569e4e
--- /dev/null
+++ b/modules/widget-lifecycle/element-web/README.md
@@ -0,0 +1,63 @@
+# @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
+
+- 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#*"
+ ]
+ }
+ }
+ }
+}
+```
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..4c293c5c6d
--- /dev/null
+++ b/modules/widget-lifecycle/element-web/e2e/fixture/widget.html
@@ -0,0 +1,76 @@
+
+
+
+
+ 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..83ad62ec65
--- /dev/null
+++ b/modules/widget-lifecycle/element-web/e2e/widget-lifecycle.spec.ts
@@ -0,0 +1,236 @@
+/*
+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", () => {
+ // 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": {
+ 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) => {
+ // 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",
+ "/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();
+
+ // 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"]')
+ .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",
+ },
+ );
+ // 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`,
+ 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();
+
+ // 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": {
+ widget_permissions: {},
+ },
+ },
+ });
+
+ 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",
+ "/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();
+
+ // 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/package.json b/modules/widget-lifecycle/element-web/package.json
new file mode 100644
index 0000000000..4611eaecda
--- /dev/null
+++ b/modules/widget-lifecycle/element-web/package.json
@@ -0,0 +1,26 @@
+{
+ "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 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-sonar-reporter": "^2.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..6ad15e08bd
--- /dev/null
+++ b/modules/widget-lifecycle/element-web/src/WidgetLifecycleModule.ts
@@ -0,0 +1,81 @@
+/*
+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";
+
+/** 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.10.0";
+
+ private config: WidgetLifecycleModuleConfig = {};
+
+ public constructor(private api: ModuleApi) {}
+
+ public async load(): Promise {
+ 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.",
+ );
+ }
+
+ 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 configuration = constructWidgetPermissions(this.config, widget.templateUrl);
+ return configuration.preload_approved === true;
+ }
+
+ private preapproveIdentity(widget: WidgetDescriptor): boolean {
+ const configuration = constructWidgetPermissions(this.config, widget.templateUrl);
+ return configuration.identity_approved === true;
+ }
+
+ private preapproveCapabilities(
+ widget: WidgetDescriptor,
+ requestedCapabilities: Set,
+ ): Set | undefined {
+ const configuration = constructWidgetPermissions(this.config, widget.templateUrl);
+ 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;
+ }
+}
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..ac22f816b2
--- /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;
+
+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 {};
+
+ 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..7cff3faacc
--- /dev/null
+++ b/modules/widget-lifecycle/element-web/src/index.ts
@@ -0,0 +1,11 @@
+/*
+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..07943c1b82
--- /dev/null
+++ b/modules/widget-lifecycle/element-web/src/utils/constructWidgetPermissions.ts
@@ -0,0 +1,28 @@
+/*
+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 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..8271649291
--- /dev/null
+++ b/modules/widget-lifecycle/element-web/src/utils/matchPattern.ts
@@ -0,0 +1,14 @@
+/*
+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.
+*/
+
+/**
+ * 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/tests/WidgetLifecycleModule.test.ts b/modules/widget-lifecycle/element-web/tests/WidgetLifecycleModule.test.ts
new file mode 100644
index 0000000000..d513a083f9
--- /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..25bc60660d
--- /dev/null
+++ b/modules/widget-lifecycle/element-web/tests/constructWidgetPermissions.test.ts
@@ -0,0 +1,76 @@
+/*
+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 { 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/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);
+ });
+});
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..cc1fa98eb7
--- /dev/null
+++ b/modules/widget-lifecycle/element-web/vite.config.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 { 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..783608fb44
--- /dev/null
+++ b/modules/widget-lifecycle/element-web/vitest.config.ts
@@ -0,0 +1,26 @@
+/*
+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 { 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", { projectRoot: "../../../" }], "text"],
+ },
+ },
+});