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