/* * Copyright 2020 - 2021 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. */ import { EventEmitter } from "events"; import { ITransport } from "./transport/ITransport"; import { Widget } from "./models/Widget"; import { PostmessageTransport } from "./transport/PostmessageTransport"; import { WidgetApiDirection } from "./interfaces/WidgetApiDirection"; import { IWidgetApiRequest, IWidgetApiRequestEmptyData } from "./interfaces/IWidgetApiRequest"; import { IContentLoadedActionRequest } from "./interfaces/ContentLoadedAction"; import { WidgetApiFromWidgetAction, WidgetApiToWidgetAction } from "./interfaces/WidgetApiAction"; import { IWidgetApiErrorResponseData } from "./interfaces/IWidgetApiErrorResponse"; import { Capability, MatrixCapabilities } from "./interfaces/Capabilities"; import { IOpenIDUpdate, ISendEventDetails, WidgetDriver } from "./driver/WidgetDriver"; import { ICapabilitiesActionResponseData, INotifyCapabilitiesActionRequestData, IRenegotiateCapabilitiesActionRequest, } from "./interfaces/CapabilitiesAction"; import { ISupportedVersionsActionRequest, ISupportedVersionsActionResponseData, } from "./interfaces/SupportedVersionsAction"; import { CurrentApiVersions } from "./interfaces/ApiVersion"; import { IScreenshotActionResponseData } from "./interfaces/ScreenshotAction"; import { IVisibilityActionRequestData } from "./interfaces/VisibilityAction"; import { IWidgetApiAcknowledgeResponseData, IWidgetApiResponseData } from "./interfaces/IWidgetApiResponse"; import { IModalWidgetButtonClickedRequestData, IModalWidgetOpenRequestData, IModalWidgetOpenRequestDataButton, IModalWidgetReturnData, } from "./interfaces/ModalWidgetActions"; import { ISendEventFromWidgetActionRequest, ISendEventFromWidgetResponseData, ISendEventToWidgetRequestData, } from "./interfaces/SendEventAction"; import { ISendToDeviceFromWidgetActionRequest, ISendToDeviceFromWidgetResponseData, ISendToDeviceToWidgetRequestData, } from "./interfaces/SendToDeviceAction"; import { EventDirection, WidgetEventCapability } from "./models/WidgetEventCapability"; import { IRoomEvent } from "./interfaces/IRoomEvent"; import { IGetOpenIDActionRequest, IGetOpenIDActionResponseData, IOpenIDCredentials, OpenIDRequestState, } from "./interfaces/GetOpenIDAction"; import { SimpleObservable } from "./util/SimpleObservable"; import { IOpenIDCredentialsActionRequestData } from "./interfaces/OpenIDCredentialsAction"; import { INavigateActionRequest } from "./interfaces/NavigateAction"; import { IReadEventFromWidgetActionRequest, IReadEventFromWidgetResponseData } from "./interfaces/ReadEventAction"; import { ITurnServer, IWatchTurnServersRequest, IUnwatchTurnServersRequest, IUpdateTurnServersRequestData, } from "./interfaces/TurnServerActions"; import { Symbols } from "./Symbols"; import { IReadRelationsFromWidgetActionRequest, IReadRelationsFromWidgetResponseData, } from "./interfaces/ReadRelationsAction"; import { IUserDirectorySearchFromWidgetActionRequest, IUserDirectorySearchFromWidgetResponseData, } from "./interfaces/UserDirectorySearchAction"; /** * API handler for the client side of widgets. This raises events * for each action received as `action:${action}` (eg: "action:screenshot"). * Default handling can be prevented by using preventDefault() on the * raised event. The default handling varies for each action: ones * which the SDK can handle safely are acknowledged appropriately and * ones which are unhandled (custom or require the client to do something) * are rejected with an error. * * Events which are preventDefault()ed must reply using the transport. * The events raised will have a default of an IWidgetApiRequest * interface. * * When the ClientWidgetApi is ready to start sending requests, it will * raise a "ready" CustomEvent. After the ready event fires, actions can * be sent and the transport will be ready. * * When the widget has indicated it has loaded, this class raises a * "preparing" CustomEvent. The preparing event does not indicate that * the widget is ready to receive communications - that is signified by * the ready event exclusively. * * This class only handles one widget at a time. */ export class ClientWidgetApi extends EventEmitter { public readonly transport: ITransport; // contentLoadedActionSent is used to check that only one ContentLoaded request is send. private contentLoadedActionSent = false; private allowedCapabilities = new Set(); private allowedEvents: WidgetEventCapability[] = []; private isStopped = false; private turnServers: AsyncGenerator | null = null; /** * Creates a new client widget API. This will instantiate the transport * and start everything. When the iframe is loaded under the widget's * conditions, a "ready" event will be raised. * @param {Widget} widget The widget to communicate with. * @param {HTMLIFrameElement} iframe The iframe the widget is in. * @param {WidgetDriver} driver The driver for this widget/client. */ public constructor( public readonly widget: Widget, private iframe: HTMLIFrameElement, private driver: WidgetDriver, ) { super(); if (!iframe?.contentWindow) { throw new Error("No iframe supplied"); } if (!widget) { throw new Error("Invalid widget"); } if (!driver) { throw new Error("Invalid driver"); } this.transport = new PostmessageTransport( WidgetApiDirection.ToWidget, widget.id, iframe.contentWindow, window, ); this.transport.targetOrigin = widget.origin; this.transport.on("message", this.handleMessage.bind(this)); iframe.addEventListener("load", this.onIframeLoad.bind(this)); this.transport.start(); } public hasCapability(capability: Capability): boolean { return this.allowedCapabilities.has(capability); } public canUseRoomTimeline(roomId: string | Symbols.AnyRoom): boolean { return this.hasCapability(`org.matrix.msc2762.timeline:${Symbols.AnyRoom}`) || this.hasCapability(`org.matrix.msc2762.timeline:${roomId}`); } public canSendRoomEvent(eventType: string, msgtype: string | null = null): boolean { return this.allowedEvents.some(e => e.matchesAsRoomEvent(EventDirection.Send, eventType, msgtype)); } public canSendStateEvent(eventType: string, stateKey: string): boolean { return this.allowedEvents.some(e => e.matchesAsStateEvent(EventDirection.Send, eventType, stateKey)); } public canSendToDeviceEvent(eventType: string): boolean { return this.allowedEvents.some(e => e.matchesAsToDeviceEvent(EventDirection.Send, eventType)); } public canReceiveRoomEvent(eventType: string, msgtype: string | null = null): boolean { return this.allowedEvents.some(e => e.matchesAsRoomEvent(EventDirection.Receive, eventType, msgtype)); } public canReceiveStateEvent(eventType: string, stateKey: string | null): boolean { return this.allowedEvents.some(e => e.matchesAsStateEvent(EventDirection.Receive, eventType, stateKey)); } public canReceiveToDeviceEvent(eventType: string): boolean { return this.allowedEvents.some(e => e.matchesAsToDeviceEvent(EventDirection.Receive, eventType)); } public stop() { this.isStopped = true; this.transport.stop(); } private beginCapabilities() { // widget has loaded - tell all the listeners that this.emit("preparing"); let requestedCaps: Capability[]; this.transport.send( WidgetApiToWidgetAction.Capabilities, {}, ).then(caps => { requestedCaps = caps.capabilities; return this.driver.validateCapabilities(new Set(caps.capabilities)); }).then(allowedCaps => { console.log(`Widget ${this.widget.id} is allowed capabilities:`, Array.from(allowedCaps)); this.allowedCapabilities = allowedCaps; this.allowedEvents = WidgetEventCapability.findEventCapabilities(allowedCaps); this.notifyCapabilities(requestedCaps); this.emit("ready"); }); } private notifyCapabilities(requested: Capability[]) { this.transport.send(WidgetApiToWidgetAction.NotifyCapabilities, { requested: requested, approved: Array.from(this.allowedCapabilities), }).catch(e => { console.warn("non-fatal error notifying widget of approved capabilities:", e); }).then(() => { this.emit("capabilitiesNotified") }); } private onIframeLoad(ev: Event) { if (this.widget.waitForIframeLoad) { // If the widget is set to waitForIframeLoad the capabilities immediatly get setup after load. // The client does not wait for the ContentLoaded action. this.beginCapabilities(); } else { // Reaching this means, that the Iframe got reloaded/loaded and // the clientApi is awaiting the FIRST ContentLoaded action. this.contentLoadedActionSent = false; } } private handleContentLoadedAction(action: IContentLoadedActionRequest) { if (this.contentLoadedActionSent) { throw new Error("Improper sequence: ContentLoaded Action can only be send once after the widget loaded " +"and should only be used if waitForIframeLoad is false (default=true)"); } if (this.widget.waitForIframeLoad) { this.transport.reply(action, { error: { message: "Improper sequence: not expecting ContentLoaded event if " +"waitForIframLoad is true (default=true)", }, }); } else { this.transport.reply(action, {}); this.beginCapabilities(); } this.contentLoadedActionSent = true; } private replyVersions(request: ISupportedVersionsActionRequest) { this.transport.reply(request, { supported_versions: CurrentApiVersions, }); } private handleCapabilitiesRenegotiate(request: IRenegotiateCapabilitiesActionRequest) { // acknowledge first this.transport.reply(request, {}); const requested = request.data?.capabilities || []; const newlyRequested = new Set(requested.filter(r => !this.hasCapability(r))); if (newlyRequested.size === 0) { // Nothing to do - notify capabilities return this.notifyCapabilities([]); } this.driver.validateCapabilities(newlyRequested).then(allowed => { allowed.forEach(c => this.allowedCapabilities.add(c)); const allowedEvents = WidgetEventCapability.findEventCapabilities(allowed); allowedEvents.forEach(c => this.allowedEvents.push(c)); return this.notifyCapabilities(Array.from(newlyRequested)); }); } private handleNavigate(request: INavigateActionRequest) { if (!this.hasCapability(MatrixCapabilities.MSC2931Navigate)) { return this.transport.reply(request, { error: {message: "Missing capability"}, }); } if (!request.data?.uri || !request.data?.uri.toString().startsWith("https://matrix.to/#")) { return this.transport.reply(request, { error: {message: "Invalid matrix.to URI"}, }); } const onErr = (e: any) => { console.error("[ClientWidgetApi] Failed to handle navigation: ", e); return this.transport.reply(request, { error: {message: "Error handling navigation"}, }); }; try { this.driver.navigate(request.data.uri.toString()).catch(e => onErr(e)).then(() => { return this.transport.reply(request, {}); }); } catch (e) { return onErr(e); } } private handleOIDC(request: IGetOpenIDActionRequest) { let phase = 1; // 1 = initial request, 2 = after user manual confirmation const replyState = (state: OpenIDRequestState, credential?: IOpenIDCredentials) => { credential = credential || {}; if (phase > 1) { return this.transport.send( WidgetApiToWidgetAction.OpenIDCredentials, { state: state, original_request_id: request.requestId, ...credential, }, ); } else { return this.transport.reply(request, { state: state, ...credential, }); } }; const replyError = (msg: string) => { console.error("[ClientWidgetApi] Failed to handle OIDC: ", msg); if (phase > 1) { // We don't have a way to indicate that a random error happened in this flow, so // just block the attempt. return replyState(OpenIDRequestState.Blocked); } else { return this.transport.reply(request, { error: {message: msg}, }); } }; const observer = new SimpleObservable(update => { if (update.state === OpenIDRequestState.PendingUserConfirmation && phase > 1) { observer.close(); return replyError("client provided out-of-phase response to OIDC flow"); } if (update.state === OpenIDRequestState.PendingUserConfirmation) { replyState(update.state); phase++; return; } if (update.state === OpenIDRequestState.Allowed && !update.token) { return replyError("client provided invalid OIDC token for an allowed request"); } if (update.state === OpenIDRequestState.Blocked) { update.token = undefined; // just in case the client did something weird } observer.close(); return replyState(update.state, update.token); }); this.driver.askOpenID(observer); } private handleReadEvents(request: IReadEventFromWidgetActionRequest) { if (!request.data.type) { return this.transport.reply(request, { error: {message: "Invalid request - missing event type"}, }); } if (request.data.limit !== undefined && (!request.data.limit || request.data.limit < 0)) { return this.transport.reply(request, { error: {message: "Invalid request - limit out of range"}, }); } let askRoomIds: string[] | null = null; // null denotes current room only if (request.data.room_ids) { askRoomIds = request.data.room_ids as string[]; if (!Array.isArray(askRoomIds)) { askRoomIds = [askRoomIds as any as string]; } for (const roomId of askRoomIds) { if (!this.canUseRoomTimeline(roomId)) { return this.transport.reply(request, { error: {message: `Unable to access room timeline: ${roomId}`}, }); } } } const limit = request.data.limit || 0; let events: Promise = Promise.resolve([]); if (request.data.state_key !== undefined) { const stateKey = request.data.state_key === true ? undefined : request.data.state_key.toString(); if (!this.canReceiveStateEvent(request.data.type, stateKey ?? null)) { return this.transport.reply(request, { error: {message: "Cannot read state events of this type"}, }); } events = this.driver.readStateEvents(request.data.type, stateKey, limit, askRoomIds); } else { if (!this.canReceiveRoomEvent(request.data.type, request.data.msgtype)) { return this.transport.reply(request, { error: {message: "Cannot read room events of this type"}, }); } events = this.driver.readRoomEvents(request.data.type, request.data.msgtype, limit, askRoomIds); } return events.then(evs => this.transport.reply(request, {events: evs})); } private handleSendEvent(request: ISendEventFromWidgetActionRequest) { if (!request.data.type) { return this.transport.reply(request, { error: {message: "Invalid request - missing event type"}, }); } if (!!request.data.room_id && !this.canUseRoomTimeline(request.data.room_id)) { return this.transport.reply(request, { error: {message: `Unable to access room timeline: ${request.data.room_id}`}, }); } const isState = request.data.state_key !== null && request.data.state_key !== undefined; let sendEventPromise: Promise; if (isState) { if (!this.canSendStateEvent(request.data.type, request.data.state_key!)) { return this.transport.reply(request, { error: {message: "Cannot send state events of this type"}, }); } sendEventPromise = this.driver.sendEvent( request.data.type, request.data.content || {}, request.data.state_key, request.data.room_id, ); } else { const content = request.data.content as { msgtype?: string } || {}; const msgtype = content['msgtype']; if (!this.canSendRoomEvent(request.data.type, msgtype)) { return this.transport.reply(request, { error: {message: "Cannot send room events of this type"}, }); } sendEventPromise = this.driver.sendEvent( request.data.type, content, null, // not sending a state event request.data.room_id, ); } sendEventPromise.then(sentEvent => { return this.transport.reply(request, { room_id: sentEvent.roomId, event_id: sentEvent.eventId, }); }).catch(e => { console.error("error sending event: ", e); return this.transport.reply(request, { error: {message: "Error sending event"}, }); }); } private async handleSendToDevice(request: ISendToDeviceFromWidgetActionRequest): Promise { if (!request.data.type) { await this.transport.reply(request, { error: {message: "Invalid request - missing event type"}, }); } else if (!request.data.messages) { await this.transport.reply(request, { error: {message: "Invalid request - missing event contents"}, }); } else if (typeof request.data.encrypted !== "boolean") { await this.transport.reply(request, { error: {message: "Invalid request - missing encryption flag"}, }); } else if (!this.canSendToDeviceEvent(request.data.type)) { await this.transport.reply(request, { error: {message: "Cannot send to-device events of this type"}, }); } else { try { await this.driver.sendToDevice(request.data.type, request.data.encrypted, request.data.messages); await this.transport.reply(request, {}); } catch (e) { console.error("error sending to-device event", e); await this.transport.reply(request, { error: {message: "Error sending event"}, }); } } } private async pollTurnServers(turnServers: AsyncGenerator, initialServer: ITurnServer) { try { await this.transport.send( WidgetApiToWidgetAction.UpdateTurnServers, initialServer as IUpdateTurnServersRequestData, // it's compatible, but missing the index signature ); // Pick the generator up where we left off for await (const server of turnServers) { await this.transport.send( WidgetApiToWidgetAction.UpdateTurnServers, server as IUpdateTurnServersRequestData, // it's compatible, but missing the index signature ); } } catch (e) { console.error("error polling for TURN servers", e); } } private async handleWatchTurnServers(request: IWatchTurnServersRequest): Promise { if (!this.hasCapability(MatrixCapabilities.MSC3846TurnServers)) { await this.transport.reply(request, { error: {message: "Missing capability"}, }); } else if (this.turnServers) { // We're already polling, so this is a no-op await this.transport.reply(request, {}); } else { try { const turnServers = this.driver.getTurnServers(); // Peek at the first result, so we can at least verify that the // client isn't banned from getting TURN servers entirely const { done, value } = await turnServers.next(); if (done) throw new Error("Client refuses to provide any TURN servers"); await this.transport.reply(request, {}); // Start the poll loop, sending the widget the initial result this.pollTurnServers(turnServers, value); this.turnServers = turnServers; } catch (e) { console.error("error getting first TURN server results", e); await this.transport.reply(request, { error: {message: "TURN servers not available"}, }); } } } private async handleUnwatchTurnServers(request: IUnwatchTurnServersRequest): Promise { if (!this.hasCapability(MatrixCapabilities.MSC3846TurnServers)) { await this.transport.reply(request, { error: {message: "Missing capability"}, }); } else if (!this.turnServers) { // We weren't polling anyways, so this is a no-op await this.transport.reply(request, {}); } else { // Stop the generator, allowing it to clean up await this.turnServers.return(undefined); this.turnServers = null; await this.transport.reply(request, {}); } } private async handleReadRelations(request: IReadRelationsFromWidgetActionRequest) { if (!request.data.event_id) { return this.transport.reply(request, { error: { message: "Invalid request - missing event ID" }, }); } if (request.data.limit !== undefined && request.data.limit < 0) { return this.transport.reply(request, { error: { message: "Invalid request - limit out of range" }, }); } if (request.data.room_id !== undefined && !this.canUseRoomTimeline(request.data.room_id)) { return this.transport.reply(request, { error: { message: `Unable to access room timeline: ${request.data.room_id}` }, }); } try { const result = await this.driver.readEventRelations( request.data.event_id, request.data.room_id, request.data.rel_type, request.data.event_type, request.data.from, request.data.to, request.data.limit, request.data.direction, ); // only return events that the user has the permission to receive const chunk = result.chunk.filter(e => { if (e.state_key !== undefined) { return this.canReceiveStateEvent(e.type, e.state_key); } else { return this.canReceiveRoomEvent(e.type, (e.content as { msgtype?: string })['msgtype']); } }); return this.transport.reply( request, { chunk, prev_batch: result.prevBatch, next_batch: result.nextBatch, }, ); } catch (e) { console.error("error getting the relations", e); await this.transport.reply(request, { error: { message: "Unexpected error while reading relations" }, }); } } private async handleUserDirectorySearch(request: IUserDirectorySearchFromWidgetActionRequest) { if (!this.hasCapability(MatrixCapabilities.MSC3973UserDirectorySearch)) { return this.transport.reply(request, { error: { message: "Missing capability" }, }); } if (typeof request.data.search_term !== 'string') { return this.transport.reply(request, { error: { message: "Invalid request - missing search term" }, }); } if (request.data.limit !== undefined && request.data.limit < 0) { return this.transport.reply(request, { error: { message: "Invalid request - limit out of range" }, }); } try { const result = await this.driver.searchUserDirectory( request.data.search_term, request.data.limit, ); return this.transport.reply( request, { limited: result.limited, results: result.results.map(r => ({ user_id: r.userId, display_name: r.displayName, avatar_url: r.avatarUrl, })), }, ); } catch (e) { console.error("error searching in the user directory", e); await this.transport.reply(request, { error: { message: "Unexpected error while searching in the user directory" }, }); } } private handleMessage(ev: CustomEvent) { if (this.isStopped) return; const actionEv = new CustomEvent(`action:${ev.detail.action}`, { detail: ev.detail, cancelable: true, }); this.emit(`action:${ev.detail.action}`, actionEv); if (!actionEv.defaultPrevented) { switch (ev.detail.action) { case WidgetApiFromWidgetAction.ContentLoaded: return this.handleContentLoadedAction(ev.detail); case WidgetApiFromWidgetAction.SupportedApiVersions: return this.replyVersions(ev.detail); case WidgetApiFromWidgetAction.SendEvent: return this.handleSendEvent(ev.detail); case WidgetApiFromWidgetAction.SendToDevice: return this.handleSendToDevice(ev.detail); case WidgetApiFromWidgetAction.GetOpenIDCredentials: return this.handleOIDC(ev.detail); case WidgetApiFromWidgetAction.MSC2931Navigate: return this.handleNavigate(ev.detail); case WidgetApiFromWidgetAction.MSC2974RenegotiateCapabilities: return this.handleCapabilitiesRenegotiate(ev.detail); case WidgetApiFromWidgetAction.MSC2876ReadEvents: return this.handleReadEvents(ev.detail); case WidgetApiFromWidgetAction.WatchTurnServers: return this.handleWatchTurnServers(ev.detail); case WidgetApiFromWidgetAction.UnwatchTurnServers: return this.handleUnwatchTurnServers(ev.detail); case WidgetApiFromWidgetAction.MSC3869ReadRelations: return this.handleReadRelations(ev.detail); case WidgetApiFromWidgetAction.MSC3973UserDirectorySearch: return this.handleUserDirectorySearch(ev.detail) default: return this.transport.reply(ev.detail, { error: { message: "Unknown or unsupported action: " + ev.detail.action, }, }); } } } /** * Takes a screenshot of the widget. * @returns Resolves to the widget's screenshot. * @throws Throws if there is a problem. */ public takeScreenshot(): Promise { return this.transport.send(WidgetApiToWidgetAction.TakeScreenshot, {}); } /** * Alerts the widget to whether or not it is currently visible. * @param {boolean} isVisible Whether the widget is visible or not. * @returns {Promise} Resolves when the widget acknowledges the update. */ public updateVisibility(isVisible: boolean): Promise { return this.transport.send(WidgetApiToWidgetAction.UpdateVisibility, { visible: isVisible, }); } public sendWidgetConfig(data: IModalWidgetOpenRequestData): Promise { return this.transport.send(WidgetApiToWidgetAction.WidgetConfig, data).then(); } public notifyModalWidgetButtonClicked(id: IModalWidgetOpenRequestDataButton["id"]): Promise { return this.transport.send( WidgetApiToWidgetAction.ButtonClicked, {id}, ).then(); } public notifyModalWidgetClose(data: IModalWidgetReturnData): Promise { return this.transport.send( WidgetApiToWidgetAction.CloseModalWidget, data, ).then(); } /** * Feeds an event to the widget. If the widget is not able to accept the event due to * permissions, this will no-op and return calmly. If the widget failed to handle the * event, this will raise an error. * @param {IRoomEvent} rawEvent The event to (try to) send to the widget. * @param {string} currentViewedRoomId The room ID the user is currently interacting with. * Not the room ID of the event. * @returns {Promise} Resolves when complete, rejects if there was an error sending. */ public async feedEvent(rawEvent: IRoomEvent, currentViewedRoomId: string): Promise { if (rawEvent.room_id !== currentViewedRoomId && !this.canUseRoomTimeline(rawEvent.room_id)) { return; // no-op } if (rawEvent.state_key !== undefined && rawEvent.state_key !== null) { // state event if (!this.canReceiveStateEvent(rawEvent.type, rawEvent.state_key)) { return; // no-op } } else { // message event if (!this.canReceiveRoomEvent(rawEvent.type, (rawEvent.content as { msgtype?: string })?.["msgtype"])) { return; // no-op } } // Feed the event into the widget await this.transport.send( WidgetApiToWidgetAction.SendEvent, rawEvent as ISendEventToWidgetRequestData, // it's compatible, but missing the index signature ); } /** * Feeds a to-device event to the widget. If the widget is not able to accept the * event due to permissions, this will no-op and return calmly. If the widget failed * to handle the event, this will raise an error. * @param {IRoomEvent} rawEvent The event to (try to) send to the widget. * @param {boolean} encrypted Whether the event contents were encrypted. * @returns {Promise} Resolves when complete, rejects if there was an error sending. */ public async feedToDevice(rawEvent: IRoomEvent, encrypted: boolean): Promise { if (this.canReceiveToDeviceEvent(rawEvent.type)) { await this.transport.send( WidgetApiToWidgetAction.SendToDevice, // it's compatible, but missing the index signature { ...rawEvent, encrypted } as ISendToDeviceToWidgetRequestData, ); } } }