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/**/*"],
+ },
+});