Merge remote-tracking branch 'origin/main' into dbkr/widget-toggles-module
@@ -162,6 +162,13 @@ test.describe("Banner", () => {
|
|||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Verify that the #matrixchat root got shrunk to fit rather than exploding the viewport
|
||||||
|
const bodyBoundingBox = await page.locator("body").boundingBox();
|
||||||
|
const rootBoundingBox = await page.locator("#matrixchat").boundingBox();
|
||||||
|
const headerBoundingBox = await page.getByTestId("banner").boundingBox();
|
||||||
|
expect(rootBoundingBox!.height).toBeLessThan(bodyBoundingBox!.height);
|
||||||
|
expect(rootBoundingBox!.height + headerBoundingBox!.height).toEqual(bodyBoundingBox!.height);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("close menu", async () => {
|
await test.step("close menu", async () => {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 5.7 KiB |
@@ -20,13 +20,13 @@
|
|||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"rollup-plugin-external-globals": "^0.13.0",
|
"rollup-plugin-external-globals": "^0.13.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vite": "^7.1.11",
|
"vite": "^7.3.2",
|
||||||
"vite-plugin-node-polyfills": "^0.25.0",
|
"vite-plugin-node-polyfills": "^0.25.0",
|
||||||
"vite-plugin-svgr": "^4.3.0"
|
"vite-plugin-svgr": "^4.3.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-dialog": "^1.1.6",
|
"@radix-ui/react-dialog": "^1.1.6",
|
||||||
"@vector-im/compound-design-tokens": "^6.0.0",
|
"@vector-im/compound-design-tokens": "^8.0.0",
|
||||||
"@vector-im/compound-web": "^8.0.0",
|
"@vector-im/compound-web": "^8.0.0",
|
||||||
"framer-motion": "^12.4.10",
|
"framer-motion": "^12.4.10",
|
||||||
"styled-components": "^6.1.18",
|
"styled-components": "^6.1.18",
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ const CloseButton = styled.button`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const CategoryHeading = styled.h2`
|
const CategoryHeading = styled.h2`
|
||||||
|
line-height: var(--cpd-font-line-height-tight);
|
||||||
|
letter-spacing: var(--cpd-font-letter-spacing-heading-lg);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: ${({ theme }): string => theme.subheadingColor};
|
color: ${({ theme }): string => theme.subheadingColor};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import Translations from "./translations.json";
|
|||||||
import { ModuleConfig, CONFIG_KEY } from "./config";
|
import { ModuleConfig, CONFIG_KEY } from "./config";
|
||||||
import Banner from "./Banner";
|
import Banner from "./Banner";
|
||||||
import { name as ModuleName } from "../package.json";
|
import { name as ModuleName } from "../package.json";
|
||||||
|
import style from "./style.css" with { type: "css" };
|
||||||
|
|
||||||
class BannerModule implements Module {
|
class BannerModule implements Module {
|
||||||
public static readonly moduleApiVersion = "^1.0.0";
|
public static readonly moduleApiVersion = "^1.0.0";
|
||||||
@@ -23,6 +24,7 @@ class BannerModule implements Module {
|
|||||||
|
|
||||||
public async load(): Promise<void> {
|
public async load(): Promise<void> {
|
||||||
document.adoptedStyleSheets.push(compound);
|
document.adoptedStyleSheets.push(compound);
|
||||||
|
document.adoptedStyleSheets.push(style);
|
||||||
|
|
||||||
this.api.i18n.register(Translations);
|
this.api.i18n.register(Translations);
|
||||||
|
|
||||||
@@ -34,6 +36,7 @@ class BannerModule implements Module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
|
div.dataset.testid = "banner";
|
||||||
this.api.rootNode.before(div);
|
this.api.rootNode.before(div);
|
||||||
|
|
||||||
const root = this.api.createRoot(div);
|
const root = this.api.createRoot(div);
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Styles to ensure the banner does not push the app out of the viewport */
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
#matrixchat {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"rollup-plugin-external-globals": "^0.13.0",
|
"rollup-plugin-external-globals": "^0.13.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vite": "^7.1.11",
|
"vite": "^7.3.2",
|
||||||
"vite-plugin-node-polyfills": "^0.25.0"
|
"vite-plugin-node-polyfills": "^0.25.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
@@ -20,11 +20,11 @@
|
|||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"rollup-plugin-external-globals": "^0.13.0",
|
"rollup-plugin-external-globals": "^0.13.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vite": "^7.1.11",
|
"vite": "^7.3.2",
|
||||||
"vite-plugin-node-polyfills": "^0.25.0"
|
"vite-plugin-node-polyfills": "^0.25.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vector-im/compound-design-tokens": "^6.0.0",
|
"@vector-im/compound-design-tokens": "^8.0.0",
|
||||||
"@vector-im/compound-web": "^8.0.0",
|
"@vector-im/compound-web": "^8.0.0",
|
||||||
"styled-components": "^6.1.18",
|
"styled-components": "^6.1.18",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
|
|||||||
@@ -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#*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<!--
|
||||||
|
Minimal fixture widget used by e2e tests to exercise the Matrix Widget API lifecycle:
|
||||||
|
1. Element requests capabilities → widget declares what it needs
|
||||||
|
2. Element notifies approval → widget requests an OpenID token
|
||||||
|
3. Element grants the token → widget resolves the user's Matrix ID and displays it
|
||||||
|
|
||||||
|
The `caps` query parameter controls which capabilities the widget requests (comma-separated).
|
||||||
|
Pass `caps=none` to request none. Defaults to m.room.topic if omitted.
|
||||||
|
The `hsUrl` query parameter is required for the OpenID userinfo lookup in step 3.
|
||||||
|
-->
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Demo Widget</title>
|
||||||
|
<script>
|
||||||
|
const getCapabilities = () => {
|
||||||
|
const caps = new URLSearchParams(window.location.search).get("caps");
|
||||||
|
if (!caps) {
|
||||||
|
return ["org.matrix.msc2762.receive.state_event:m.room.topic"];
|
||||||
|
}
|
||||||
|
if (caps === "none") return [];
|
||||||
|
return caps
|
||||||
|
.split(",")
|
||||||
|
.map((cap) => cap.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
let sendEventCount = 0;
|
||||||
|
window.onmessage = async (ev) => {
|
||||||
|
if (ev.data.action === "capabilities") {
|
||||||
|
// Step 1: Element is asking what capabilities this widget needs.
|
||||||
|
window.parent.postMessage(
|
||||||
|
Object.assign(
|
||||||
|
{
|
||||||
|
response: {
|
||||||
|
capabilities: getCapabilities(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ev.data,
|
||||||
|
),
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
} else if (ev.data.action === "notify_capabilities") {
|
||||||
|
// Step 2: Capabilities have been approved. Request an OpenID token
|
||||||
|
// so we can identify the current user in step 3.
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
api: "fromWidget",
|
||||||
|
widgetId: ev.data.widgetId,
|
||||||
|
requestId: "widget-" + sendEventCount,
|
||||||
|
action: "get_openid",
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
} else if (ev.data.action === "get_openid" && ev.data.response?.state === "allowed") {
|
||||||
|
// Step 3: Token granted — exchange it for the user's Matrix ID and display it.
|
||||||
|
// Tests assert on this heading to verify identity was passed through correctly.
|
||||||
|
const { access_token } = ev.data.response;
|
||||||
|
|
||||||
|
const hsUrl = new URLSearchParams(window.location.search).get("hsUrl");
|
||||||
|
const response = await fetch(
|
||||||
|
`${hsUrl}/_matrix/federation/v1/openid/userinfo?access_token=${access_token}`,
|
||||||
|
);
|
||||||
|
const { sub } = await response.json();
|
||||||
|
|
||||||
|
const titleElement = document.getElementById("title");
|
||||||
|
titleElement.innerText = `Hello ${sub}!`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1 id="title">Hello unknown!</h1>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.3.2",
|
||||||
|
"vitest": "^4.0.0",
|
||||||
|
"vitest-sonar-reporter": "^2.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Api, "config" | "widgetLifecycle">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void> {
|
||||||
|
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<string>,
|
||||||
|
): Set<string> | undefined {
|
||||||
|
const configuration = constructWidgetPermissions(this.config, widget.templateUrl);
|
||||||
|
const capabilitiesApproved = configuration.capabilities_approved;
|
||||||
|
|
||||||
|
if (!capabilitiesApproved) return undefined;
|
||||||
|
|
||||||
|
const approvedCapabilities = new Set<string>();
|
||||||
|
for (const requestedCapability of requestedCapabilities) {
|
||||||
|
if (capabilitiesApproved.some((capability) => matchPattern(requestedCapability, capability))) {
|
||||||
|
approvedCapabilities.add(requestedCapability);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return approvedCapabilities.size > 0 ? approvedCapabilities : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<typeof WidgetConfigurationSchema>;
|
||||||
|
|
||||||
|
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<string, WidgetConfiguration>;
|
||||||
|
|
||||||
|
declare module "@element-hq/element-web-module-api" {
|
||||||
|
export interface Config {
|
||||||
|
[CONFIG_KEY]: z.input<typeof ModuleConfigSchema>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 ?? {};
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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" });
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "lib"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||