From c91aff32939ade7203c95c095d84a80f73aba966 Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Tue, 11 Nov 2025 17:26:13 +0000 Subject: [PATCH 1/6] Fix toDevice message type --- src/ClientWidgetApi.ts | 8 ++++---- src/interfaces/IToDeviceMessage.ts | 21 +++++++++++++++++++++ src/interfaces/SendToDeviceAction.ts | 3 ++- 3 files changed, 27 insertions(+), 5 deletions(-) create mode 100644 src/interfaces/IToDeviceMessage.ts diff --git a/src/ClientWidgetApi.ts b/src/ClientWidgetApi.ts index 87f4cff..86248b5 100644 --- a/src/ClientWidgetApi.ts +++ b/src/ClientWidgetApi.ts @@ -109,6 +109,7 @@ import { } from "./interfaces/DownloadFileAction"; import { IThemeChangeActionRequestData } from "./interfaces/ThemeChangeAction"; import { IUpdateStateToWidgetRequestData } from "./interfaces/UpdateStateAction"; +import { IToDeviceMessage } from "./interfaces/IToDeviceMessage"; /** * API handler for the client side of widgets. This raises events @@ -1122,12 +1123,11 @@ export class ClientWidgetApi extends EventEmitter { * able to receive the event due to permissions, rejects if the widget * failed to handle the event. */ - public async feedToDevice(rawEvent: IRoomEvent, encrypted: boolean): Promise { - if (this.canReceiveToDeviceEvent(rawEvent.type)) { + public async feedToDevice(message: IToDeviceMessage, encrypted: boolean): Promise { + if (this.canReceiveToDeviceEvent(message.type)) { await this.transport.send( WidgetApiToWidgetAction.SendToDevice, - // it's compatible, but missing the index signature - { ...rawEvent, encrypted } as ISendToDeviceToWidgetRequestData, + { ...message, encrypted }, ); } } diff --git a/src/interfaces/IToDeviceMessage.ts b/src/interfaces/IToDeviceMessage.ts new file mode 100644 index 0000000..6078ca2 --- /dev/null +++ b/src/interfaces/IToDeviceMessage.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2025 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface IToDeviceMessage { + content: Record; + sender: string; + type: string; +} diff --git a/src/interfaces/SendToDeviceAction.ts b/src/interfaces/SendToDeviceAction.ts index e7507b3..2b669b5 100644 --- a/src/interfaces/SendToDeviceAction.ts +++ b/src/interfaces/SendToDeviceAction.ts @@ -18,6 +18,7 @@ import { IWidgetApiRequest, IWidgetApiRequestData } from "./IWidgetApiRequest"; import { WidgetApiFromWidgetAction, WidgetApiToWidgetAction } from "./WidgetApiAction"; import { IWidgetApiResponseData } from "./IWidgetApiResponse"; import { IRoomEvent } from "./IRoomEvent"; +import { IToDeviceMessage } from "./IToDeviceMessage"; export interface ISendToDeviceFromWidgetRequestData extends IWidgetApiRequestData { type: string; @@ -38,7 +39,7 @@ export interface ISendToDeviceFromWidgetActionResponse extends ISendToDeviceFrom response: ISendToDeviceFromWidgetResponseData; } -export interface ISendToDeviceToWidgetRequestData extends IWidgetApiRequestData, IRoomEvent { +export interface ISendToDeviceToWidgetRequestData extends IWidgetApiRequestData, IToDeviceMessage { encrypted: boolean; } From c7c92e54aa9ef9447205fb83ee9f87bf0f8c3e0e Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Tue, 11 Nov 2025 17:26:22 +0000 Subject: [PATCH 2/6] Fix existing content types. --- src/interfaces/IRoomAccountData.ts | 2 +- src/interfaces/IRoomEvent.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/interfaces/IRoomAccountData.ts b/src/interfaces/IRoomAccountData.ts index 750bdef..284ff11 100644 --- a/src/interfaces/IRoomAccountData.ts +++ b/src/interfaces/IRoomAccountData.ts @@ -17,5 +17,5 @@ export interface IRoomAccountData { type: string; room_id: string; // eslint-disable-line camelcase - content: unknown; + content: Record; } diff --git a/src/interfaces/IRoomEvent.ts b/src/interfaces/IRoomEvent.ts index 5e90005..c1d29a8 100644 --- a/src/interfaces/IRoomEvent.ts +++ b/src/interfaces/IRoomEvent.ts @@ -21,6 +21,6 @@ export interface IRoomEvent { room_id: string; // eslint-disable-line camelcase state_key?: string; // eslint-disable-line camelcase origin_server_ts: number; // eslint-disable-line camelcase - content: unknown; - unsigned: unknown; + content: Record; + unsigned: Record; } From e71190b915db514990e328e85b82dd8726f0f034 Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Tue, 11 Nov 2025 17:27:00 +0000 Subject: [PATCH 3/6] lint --- src/ClientWidgetApi.ts | 8 ++++---- src/interfaces/SendToDeviceAction.ts | 1 - 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/ClientWidgetApi.ts b/src/ClientWidgetApi.ts index 86248b5..1284d9e 100644 --- a/src/ClientWidgetApi.ts +++ b/src/ClientWidgetApi.ts @@ -1125,10 +1125,10 @@ export class ClientWidgetApi extends EventEmitter { */ public async feedToDevice(message: IToDeviceMessage, encrypted: boolean): Promise { if (this.canReceiveToDeviceEvent(message.type)) { - await this.transport.send( - WidgetApiToWidgetAction.SendToDevice, - { ...message, encrypted }, - ); + await this.transport.send(WidgetApiToWidgetAction.SendToDevice, { + ...message, + encrypted, + }); } } diff --git a/src/interfaces/SendToDeviceAction.ts b/src/interfaces/SendToDeviceAction.ts index 2b669b5..f94a02d 100644 --- a/src/interfaces/SendToDeviceAction.ts +++ b/src/interfaces/SendToDeviceAction.ts @@ -17,7 +17,6 @@ import { IWidgetApiRequest, IWidgetApiRequestData } from "./IWidgetApiRequest"; import { WidgetApiFromWidgetAction, WidgetApiToWidgetAction } from "./WidgetApiAction"; import { IWidgetApiResponseData } from "./IWidgetApiResponse"; -import { IRoomEvent } from "./IRoomEvent"; import { IToDeviceMessage } from "./IToDeviceMessage"; export interface ISendToDeviceFromWidgetRequestData extends IWidgetApiRequestData { From ad566a5ef9271e36013c45673d2f977f9ce3f4f7 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 14 Nov 2025 10:31:45 +0000 Subject: [PATCH 4/6] Add and update tests --- test/ClientWidgetApi-test.ts | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/test/ClientWidgetApi-test.ts b/test/ClientWidgetApi-test.ts index f576e57..a861c7f 100644 --- a/test/ClientWidgetApi-test.ts +++ b/test/ClientWidgetApi-test.ts @@ -48,6 +48,7 @@ import { } from "../src"; import { IGetMediaConfigActionFromWidgetActionRequest } from "../src/interfaces/GetMediaConfigAction"; import { IReadRoomAccountDataFromWidgetActionRequest } from "../src/interfaces/ReadRoomAccountDataAction"; +import { IToDeviceMessage } from "../src/interfaces/IToDeviceMessage"; jest.mock("../src/transport/PostmessageTransport"); @@ -886,11 +887,11 @@ describe("ClientWidgetApi", () => { describe("receiving events", () => { const roomId = "!room:example.org"; const otherRoomId = "!other-room:example.org"; - const event = createRoomEvent({ room_id: roomId, type: "m.room.message", content: "hello" }); + const event = createRoomEvent({ room_id: roomId, type: "m.room.message", content: { hello: "there" } }); const eventFromOtherRoom = createRoomEvent({ room_id: otherRoomId, type: "m.room.message", - content: "test", + content: { test: "test" }, }); it("forwards events to the widget from one room only", async () => { @@ -1095,6 +1096,22 @@ describe("ClientWidgetApi", () => { }); }); + describe.only("receiving to device messages", () => { + it.each([true, false])("forwards device messages to the widget", async (encrypted) => { + const event: IToDeviceMessage = { + content: { foo: "bar" }, + type: "org.example.mytype", + sender: "@alice:example.org", + }; + // Give the widget capabilities to receive from just one room + await loadIframe(["org.matrix.msc3819.receive.to_device:org.example.mytype"]); + + // Event from the matching room should be forwarded + await clientWidgetApi.feedToDevice(event, encrypted); + expect(transport.send).toHaveBeenCalledWith(WidgetApiToWidgetAction.SendToDevice, { ...event, encrypted }); + }); + }); + describe("update_delayed_event action", () => { it("fails to cancel delayed events", async () => { const event: IUpdateDelayedEventFromWidgetActionRequest = { @@ -1727,7 +1744,7 @@ describe("ClientWidgetApi", () => { it("reads events from a specific room", async () => { const roomId = "!room:example.org"; jest.spyOn(clientWidgetApi, "getWidgetVersions").mockResolvedValue([]); - const event = createRoomEvent({ room_id: roomId, type: "net.example.test", content: "test" }); + const event = createRoomEvent({ room_id: roomId, type: "net.example.test", content: { test: "test" } }); driver.readRoomTimeline.mockImplementation(async (rId) => { if (rId === roomId) return [event]; return []; @@ -1772,8 +1789,12 @@ describe("ClientWidgetApi", () => { const roomId = "!room:example.org"; const otherRoomId = "!other-room:example.org"; jest.spyOn(clientWidgetApi, "getWidgetVersions").mockResolvedValue([]); - const event = createRoomEvent({ room_id: roomId, type: "net.example.test", content: "test" }); - const otherRoomEvent = createRoomEvent({ room_id: otherRoomId, type: "net.example.test", content: "hi" }); + const event = createRoomEvent({ room_id: roomId, type: "net.example.test", content: { test: "test" } }); + const otherRoomEvent = createRoomEvent({ + room_id: otherRoomId, + type: "net.example.test", + content: { hi: "there" }, + }); driver.getKnownRooms.mockReturnValue([roomId, otherRoomId]); driver.readRoomTimeline.mockImplementation(async (rId) => { if (rId === roomId) return [event]; From 51265c502b67fc0dedd4752bb6539f60102c6b35 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 14 Nov 2025 10:42:59 +0000 Subject: [PATCH 5/6] whoops remove only --- test/ClientWidgetApi-test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/ClientWidgetApi-test.ts b/test/ClientWidgetApi-test.ts index a861c7f..8f4ef6d 100644 --- a/test/ClientWidgetApi-test.ts +++ b/test/ClientWidgetApi-test.ts @@ -1096,7 +1096,7 @@ describe("ClientWidgetApi", () => { }); }); - describe.only("receiving to device messages", () => { + describe("receiving to device messages", () => { it.each([true, false])("forwards device messages to the widget", async (encrypted) => { const event: IToDeviceMessage = { content: { foo: "bar" }, From a500ad98776742b087976fd996ed1234dd8b8b42 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 14 Nov 2025 10:48:35 +0000 Subject: [PATCH 6/6] Add a test for a discarded message. --- test/ClientWidgetApi-test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/ClientWidgetApi-test.ts b/test/ClientWidgetApi-test.ts index 8f4ef6d..76116e4 100644 --- a/test/ClientWidgetApi-test.ts +++ b/test/ClientWidgetApi-test.ts @@ -1110,6 +1110,20 @@ describe("ClientWidgetApi", () => { await clientWidgetApi.feedToDevice(event, encrypted); expect(transport.send).toHaveBeenCalledWith(WidgetApiToWidgetAction.SendToDevice, { ...event, encrypted }); }); + it("ignores messages not allowed by capabilities", async () => { + const event: IToDeviceMessage = { + content: { foo: "bar" }, + type: "org.example.othertype", + sender: "@alice:example.org", + }; + // Give the widget capabilities to receive from just one room + await loadIframe(["org.matrix.msc3819.receive.to_device:org.example.mytype"]); + // Clear all prior messages. + jest.mocked(transport.send).mockClear(); + // Event from the matching room should be forwarded + await clientWidgetApi.feedToDevice(event, false); + expect(transport.send).not.toHaveBeenCalled(); + }); }); describe("update_delayed_event action", () => {