Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions knip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ Please see LICENSE files in the repository root for full details.

import { KnipConfig } from "knip";

// Specify this as knip loads config files which may conditionally add reporters, e.g. `vitest-sonar-reporter`
process.env.GITHUB_ACTIONS = "1";

export default {
ignoreDependencies: [
// Needed for lint:workflows
Expand Down
63 changes: 63 additions & 0 deletions modules/widget-lifecycle/element-web/README.md
Original file line number Diff line number Diff line change
@@ -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#*"
]
}
}
}
}
```
76 changes: 76 additions & 0 deletions modules/widget-lifecycle/element-web/e2e/fixture/widget.html
Original file line number Diff line number Diff line change
@@ -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>
236 changes: 236 additions & 0 deletions modules/widget-lifecycle/element-web/e2e/widget-lifecycle.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Loading
Loading