feat: complete Ancestor quote bot with production-ready Docker support

This commit is contained in:
unfunny
2026-09-13 14:14:38 -04:00
parent f2016da05a
commit fe7351a7dc
1145 changed files with 30890 additions and 97684 deletions

View File

@@ -1,29 +0,0 @@
/*
* Copyright 2024 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.
*/
/**
* An AES-encrypted secret storage payload.
* See https://spec.matrix.org/v1.11/client-server-api/#msecret_storagev1aes-hmac-sha2-1
*/
export interface AESEncryptedSecretStoragePayload {
[key: string]: any; // extensible
/** the initialization vector in base64 */
iv: string;
/** the ciphertext in base64 */
ciphertext: string;
/** the HMAC in base64 */
mac: string;
}

View File

@@ -14,6 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
// allow camelcase as these are things that go onto the wire
/* eslint-disable camelcase */
export enum PushRuleActionName {
DontNotify = "dont_notify",
Notify = "notify",
@@ -99,7 +102,8 @@ export interface IRoomMemberCountCondition extends IPushRuleCondition<ConditionK
is: AnyMemberCountCondition;
}
export interface ISenderNotificationPermissionCondition extends IPushRuleCondition<ConditionKind.SenderNotificationPermission> {
export interface ISenderNotificationPermissionCondition
extends IPushRuleCondition<ConditionKind.SenderNotificationPermission> {
key: string;
}
@@ -201,3 +205,5 @@ export interface IPusher {
export interface IPusherRequest extends Omit<IPusher, "device_id" | "org.matrix.msc3881.device_id"> {
append?: boolean;
}
/* eslint-enable camelcase */

View File

@@ -14,8 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { UnstableValue } from "../NamespacedValue.ts";
import { type IClientWellKnown } from "../client.ts";
import { UnstableValue } from "../NamespacedValue";
import { IClientWellKnown } from "../client";
// disable lint because these are wire responses
/* eslint-disable camelcase */
/**
* Represents a response to the CSAPI `/refresh` endpoint.
@@ -26,6 +29,8 @@ export interface IRefreshTokenResponse {
refresh_token: string;
}
/* eslint-enable camelcase */
/**
* Response to GET login flows as per https://spec.matrix.org/v1.3/client-server-api/#get_matrixclientv3login
*/
@@ -43,8 +48,8 @@ export interface IPasswordFlow extends ILoginFlow {
type: "m.login.password";
}
export const OAUTH_AWARE_PREFERRED_FLOW_FIELD = new UnstableValue(
"oauth_aware_preferred",
export const DELEGATED_OIDC_COMPATIBILITY = new UnstableValue(
"delegated_oidc_compatibility",
"org.matrix.msc3824.delegated_oidc_compatibility",
);
@@ -53,9 +58,10 @@ export const OAUTH_AWARE_PREFERRED_FLOW_FIELD = new UnstableValue(
*/
export interface ISSOFlow extends ILoginFlow {
type: "m.login.sso" | "m.login.cas";
// eslint-disable-next-line camelcase
identity_providers?: IIdentityProvider[];
[OAUTH_AWARE_PREFERRED_FLOW_FIELD.name]?: boolean;
[OAUTH_AWARE_PREFERRED_FLOW_FIELD.altName]?: boolean;
[DELEGATED_OIDC_COMPATIBILITY.name]?: boolean;
[DELEGATED_OIDC_COMPATIBILITY.altName]?: boolean;
}
export enum IdentityProviderBrand {
@@ -141,6 +147,11 @@ export interface LoginRequest {
* The login type being used.
*/
type: "m.login.password" | "m.login.token" | string;
/**
* Third-party identifier for the user.
* @deprecated in favour of `identifier`.
*/
address?: string;
/**
* ID of the client device.
* If this does not correspond to a known client device, a new device will be created.
@@ -232,13 +243,21 @@ export interface LoginResponse {
}
/**
* The result of a successful `m.login.token` issuance request as per https://spec.matrix.org/v1.7/client-server-api/#post_matrixclientv1loginget_token
* The result of a successful [MSC3882](https://github.com/matrix-org/matrix-spec-proposals/pull/3882)
* `m.login.token` issuance request.
* Note that this is UNSTABLE and subject to breaking changes without notice.
*/
export interface LoginTokenPostResponse {
/**
* The token to use with `m.login.token` to authenticate.
*/
login_token: string;
/**
* Expiration in seconds.
*
* @deprecated this is only provided for compatibility with original revision of the MSC.
*/
expires_in: number;
/**
* Expiration in milliseconds.
*/

View File

@@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type RelatesToRelationship, type REFERENCE_RELATION } from "./extensible_events.ts";
import { UnstableValue } from "../NamespacedValue.ts";
import { type MAssetEvent, type MLocationEvent, type MTimestampEvent } from "./location.ts";
import { RelatesToRelationship, REFERENCE_RELATION } from "./extensible_events";
import { UnstableValue } from "../NamespacedValue";
import { MAssetEvent, MLocationEvent, MTimestampEvent } from "./location";
/**
* Beacon info and beacon event types as described in MSC3672
@@ -30,7 +30,7 @@ import { type MAssetEvent, type MLocationEvent, type MTimestampEvent } from "./l
* 2. a user can have an arbitrary number of beacon_info events
*
* 1. is achieved by setting the state_key to the owners mxid.
* Event keys in room state are a combination of `typestate_key`.
* Event keys in room state are a combination of `type` + `state_key`.
* To achieve an arbitrary number of only owner-writable state events
* we introduce a variable suffix to the event type
*

View File

@@ -1,24 +0,0 @@
/*
Copyright 2024 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 type NonEmptyArray<T> = [T, ...T[]];
// Based on https://stackoverflow.com/a/53229857/3532235
export type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
export type XOR<T, U> = T | U extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;
export type Writeable<T> = { -readonly [P in keyof T]: T[P] };
export type EmptyObject = Record<PropertyKey, never>;

View File

@@ -14,19 +14,49 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import type { ISignatures } from "./signed.ts";
import type { EventDecryptionResult } from "../common-crypto/CryptoBackend.ts";
import type { IClearEvent } from "../models/event";
import type { ISignatures } from "./signed";
// Backwards compatible re-export
/** @deprecated This is an internal type and should not be used. */
type IEventDecryptionResult = EventDecryptionResult;
export type { IEventDecryptionResult };
export type OlmGroupSessionExtraData = {
untrusted?: boolean;
sharedHistory?: boolean;
};
/**
* The result of a (successful) call to {@link Crypto.decryptEvent}
*/
export interface IEventDecryptionResult {
/**
* The plaintext payload for the event (typically containing <tt>type</tt> and <tt>content</tt> fields).
*/
clearEvent: IClearEvent;
/**
* List of curve25519 keys involved in telling us about the senderCurve25519Key and claimedEd25519Key.
* See {@link MatrixEvent#getForwardingCurve25519KeyChain}.
*/
forwardingCurve25519KeyChain?: string[];
/**
* Key owned by the sender of this event. See {@link MatrixEvent#getSenderKey}.
*/
senderCurve25519Key?: string;
/**
* ed25519 key claimed by the sender of this event. See {@link MatrixEvent#getClaimedEd25519Key}.
*/
claimedEd25519Key?: string;
untrusted?: boolean;
/**
* The sender doesn't authorize the unverified devices to decrypt his messages
*/
encryptedDisabledForUnverifiedDevices?: boolean;
}
interface Extensible {
[key: string]: any;
}
/** The result of a call to {@link crypto-api!CryptoApi.exportRoomKeys} */
/* eslint-disable camelcase */
/** The result of a call to {@link MatrixClient.exportRoomKeys} */
export interface IMegolmSessionData extends Extensible {
/** Sender's Curve25519 device key */
sender_key: string;
@@ -44,14 +74,16 @@ export interface IMegolmSessionData extends Extensible {
untrusted?: boolean;
}
/* eslint-enable camelcase */
/** the type of the `device_keys` parameter on `/_matrix/client/v3/keys/upload`
*
* @see https://spec.matrix.org/v1.5/client-server-api/#post_matrixclientv3keysupload
*/
export interface IDeviceKeys {
algorithms: Array<string>;
device_id: string;
user_id: string;
device_id: string; // eslint-disable-line camelcase
user_id: string; // eslint-disable-line camelcase
keys: Record<string, string>;
signatures?: ISignatures;
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2020-2026 The Matrix.org Foundation C.I.C.
Copyright 2020 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.
@@ -14,59 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type EitherAnd } from "matrix-events-sdk";
import { NamespacedValue, UnstableValue } from "../NamespacedValue.ts";
import {
type PolicyRuleEventContent,
type RoomAvatarEventContent,
type RoomCanonicalAliasEventContent,
type RoomCreateEventContent,
type RoomEncryptionEventContent,
type RoomGuestAccessEventContent,
type RoomHistoryVisibilityEventContent,
type RoomJoinRulesEventContent,
type RoomMemberEventContent,
type RoomNameEventContent,
type RoomPinnedEventsEventContent,
type RoomPolicyContent,
type RoomPowerLevelsEventContent,
type RoomServerAclEventContent,
type RoomThirdPartyInviteEventContent,
type RoomTombstoneEventContent,
type RoomTopicEventContent,
type SpaceChildEventContent,
type SpaceParentEventContent,
} from "./state_events.ts";
import { type IGroupCallRoomMemberState, type IGroupCallRoomState } from "../webrtc/groupCall.ts";
import { type MSC3089EventContent } from "../models/MSC3089Branch.ts";
import { type M_BEACON, type M_BEACON_INFO, type MBeaconEventContent, type MBeaconInfoEventContent } from "./beacon.ts";
import { type EmptyObject } from "./common.ts";
import { type ReactionEventContent, type RoomMessageEventContent, type StickerEventContent } from "./events.ts";
import {
type MCallAnswer,
type MCallBase,
type MCallCandidates,
type MCallHangupReject,
type MCallInviteNegotiate,
type MCallReplacesEvent,
type MCallSelectAnswer,
type SDPStreamMetadata,
} from "../webrtc/callEventTypes.ts";
import {
type IRTCNotificationContent,
type IRTCDeclineContent,
type EncryptionKeysEventContent,
type ICallNotifyContent,
type RtcSlotEventContent,
} from "../matrixrtc/types.ts";
import { type M_POLL_END, type M_POLL_START, type PollEndEventContent, type PollStartEventContent } from "./polls.ts";
import { type RtcMembershipData, type SessionMembershipData } from "../matrixrtc/membershipData/index.ts";
import { type LocalNotificationSettings } from "./local_notifications.ts";
import { type IPushRules } from "./PushRules.ts";
import { type SecretInfo, type SecretStorageKeyDescription } from "../secret-storage.ts";
import { type POLICIES_ACCOUNT_EVENT_TYPE } from "../models/invites-ignorer-types.ts";
import type { ROOM_RETENTION_TYPE, RoomRetentionContent } from "./retention.ts";
import { UnstableValue } from "../NamespacedValue";
export enum EventType {
// Room state events
@@ -87,11 +35,6 @@ export enum EventType {
RoomTombstone = "m.room.tombstone",
RoomPredecessor = "org.matrix.msc3946.room_predecessor",
// Moderation policy lists
PolicyRuleUser = "m.policy.rule.user",
PolicyRuleRoom = "m.policy.rule.room",
PolicyRuleServer = "m.policy.rule.server",
SpaceChild = "m.space.child",
SpaceParent = "m.space.parent",
@@ -112,7 +55,6 @@ export enum EventType {
CallReplaces = "m.call.replaces",
CallAssertedIdentity = "m.call.asserted_identity",
CallAssertedIdentityPrefix = "org.matrix.call.asserted_identity",
CallEncryptionKeysPrefix = "io.element.call.encryption_keys",
KeyVerificationRequest = "m.key.verification.request",
KeyVerificationStart = "m.key.verification.start",
KeyVerificationCancel = "m.key.verification.cancel",
@@ -136,49 +78,27 @@ export enum EventType {
FullyRead = "m.fully_read",
Tag = "m.tag",
SpaceOrder = "org.matrix.msc3230.space_order", // MSC3230
MarkedUnread = "m.marked_unread",
// User account_data events
PushRules = "m.push_rules",
Direct = "m.direct",
IgnoredUserList = "m.ignored_user_list",
InvitePermissionConfig = "m.invite_permission_config", // MSC4380
// to_device events
RoomKey = "m.room_key",
RoomKeyRequest = "m.room_key_request",
ForwardedRoomKey = "m.forwarded_room_key",
Dummy = "m.dummy",
SecretRequest = "m.secret.request",
SecretSend = "m.secret.send",
// Group call events
GroupCallPrefix = "org.matrix.msc3401.call",
GroupCallMemberPrefix = "org.matrix.msc3401.call.member",
// MatrixRTC events
RTCSlot = "org.matrix.msc4143.rtc.slot",
RTCMembership = "org.matrix.msc4143.rtc.member",
CallNotify = "org.matrix.msc4075.call.notify",
RTCNotification = "org.matrix.msc4075.rtc.notification",
RTCDecline = "org.matrix.msc4310.rtc.decline",
// Policy servers
RoomPolicy = "org.matrix.msc4284.policy",
// Retention
RetentionPolicy = "m.room.retention",
RetentionPolicyUnstable = "org.matrix.msc1763.retention",
}
export enum RelationType {
Annotation = "m.annotation",
Replace = "m.replace",
Reference = "m.reference",
// Don't use this yet: it's only the stable version. The code still assumes we support the unstable prefix and,
// moreover, our tests currently use the unstable prefix. Use THREAD_RELATION_TYPE.name.
// Once we support *only* the stable prefix, THREAD_RELATION_TYPE can die and we can switch to this.
Thread = "m.thread",
}
@@ -322,152 +242,17 @@ export const LOCAL_NOTIFICATION_SETTINGS_PREFIX = new UnstableValue(
*/
export const UNSIGNED_THREAD_ID_FIELD = new UnstableValue("thread_id", "org.matrix.msc4023.thread_id");
/**
* https://github.com/matrix-org/matrix-spec-proposals/pull/4115
*
* @experimental
*/
export const UNSIGNED_MEMBERSHIP_FIELD = new NamespacedValue("membership", "io.element.msc4115.membership");
/**
* Mapped type from event type to content type for all specified non-state room events.
*/
export interface TimelineEvents {
[EventType.RoomMessage]: RoomMessageEventContent;
[EventType.Sticker]: StickerEventContent;
[EventType.Reaction]: ReactionEventContent;
[EventType.CallReplaces]: MCallReplacesEvent;
[EventType.CallAnswer]: MCallAnswer;
[EventType.CallSelectAnswer]: MCallSelectAnswer;
[EventType.CallNegotiate]: Omit<MCallInviteNegotiate, "offer">;
[EventType.CallInvite]: MCallInviteNegotiate;
[EventType.CallCandidates]: MCallCandidates;
[EventType.CallHangup]: MCallHangupReject;
[EventType.CallReject]: MCallHangupReject;
[EventType.CallSDPStreamMetadataChangedPrefix]: MCallBase &
EitherAnd<
{ sdp_stream_metadata: SDPStreamMetadata },
{ "org.matrix.msc3077.sdp_stream_metadata": SDPStreamMetadata }
>;
[EventType.CallSDPStreamMetadataChanged]: MCallBase &
EitherAnd<
{ sdp_stream_metadata: SDPStreamMetadata },
{ "org.matrix.msc3077.sdp_stream_metadata": SDPStreamMetadata }
>;
[EventType.CallEncryptionKeysPrefix]: EncryptionKeysEventContent;
[EventType.CallNotify]: ICallNotifyContent;
[EventType.RTCNotification]: IRTCNotificationContent;
[EventType.RTCDecline]: IRTCDeclineContent;
[M_BEACON.name]: MBeaconEventContent;
[M_POLL_START.name]: PollStartEventContent;
[M_POLL_END.name]: PollEndEventContent;
[EventType.RTCMembership]: RtcMembershipData | { msc4354_sticky_key: string }; // An object containing just the sticky key is empty.
}
/**
* Mapped type from event type to content type for all specified room state events.
*/
export interface StateEvents {
[EventType.RoomCanonicalAlias]: RoomCanonicalAliasEventContent;
[EventType.RoomCreate]: RoomCreateEventContent;
[EventType.RoomJoinRules]: RoomJoinRulesEventContent;
[EventType.RoomMember]: RoomMemberEventContent;
// XXX: Spec says this event has 3 required fields but kicking such an invitation requires sending `{}`
[EventType.RoomThirdPartyInvite]: RoomThirdPartyInviteEventContent | EmptyObject;
[EventType.RoomPowerLevels]: RoomPowerLevelsEventContent;
[EventType.RoomName]: RoomNameEventContent;
[EventType.RoomTopic]: RoomTopicEventContent;
[EventType.RoomAvatar]: RoomAvatarEventContent;
[EventType.RoomPinnedEvents]: RoomPinnedEventsEventContent;
[EventType.RoomEncryption]: RoomEncryptionEventContent;
[EventType.RoomHistoryVisibility]: RoomHistoryVisibilityEventContent;
[EventType.RoomGuestAccess]: RoomGuestAccessEventContent;
[EventType.RoomServerAcl]: RoomServerAclEventContent;
[EventType.RoomTombstone]: RoomTombstoneEventContent;
[EventType.SpaceChild]: SpaceChildEventContent;
[EventType.SpaceParent]: SpaceParentEventContent;
[EventType.PolicyRuleUser]: PolicyRuleEventContent | EmptyObject;
[EventType.PolicyRuleRoom]: PolicyRuleEventContent | EmptyObject;
[EventType.PolicyRuleServer]: PolicyRuleEventContent | EmptyObject;
// MSC4284: Policy servers
[EventType.RoomPolicy]: RoomPolicyContent | EmptyObject;
// MSC3401
[EventType.GroupCallPrefix]: IGroupCallRoomState;
[EventType.GroupCallMemberPrefix]: IGroupCallRoomMemberState | SessionMembershipData | EmptyObject;
[EventType.RTCMembership]: RtcMembershipData | EmptyObject;
[EventType.RTCSlot]: RtcSlotEventContent | EmptyObject;
// MSC3089
[UNSTABLE_MSC3089_BRANCH.name]: MSC3089EventContent;
// MSC3672
[M_BEACON_INFO.name]: MBeaconInfoEventContent;
// MSC1763
[ROOM_RETENTION_TYPE.name]: RoomRetentionContent | EmptyObject;
[ROOM_RETENTION_TYPE.altName]: RoomRetentionContent | EmptyObject;
}
/**
* Mapped type from event type to content type for all specified room-specific account_data events.
*/
export interface RoomAccountDataEvents extends SecretStorageAccountDataEvents {
[EventType.FullyRead]: { event_id: string };
[EventType.Tag]: { tags: { [name: string]: { order?: number } } };
[EventType.SpaceOrder]: { order: string };
[EventType.MarkedUnread]: { unread: boolean };
}
/**
* Mapped type from event type to content type for all specified global account_data events.
*/
export interface AccountDataEvents extends SecretStorageAccountDataEvents {
[EventType.PushRules]: IPushRules;
[EventType.Direct]: { [userId: string]: string[] };
[EventType.IgnoredUserList]: { ignored_users: { [userId: string]: EmptyObject } };
"m.secret_storage.default_key": { key: string };
// MSC4287: Sharing key backup preference between clients - used to mark that the user opted out of key storage
"m.key_backup": { enabled: boolean };
// MSC4287 unstable prefix (note the boolean property has the opposite sense)
"m.org.matrix.custom.backup_disabled": { disabled: boolean };
"m.identity_server": { base_url: string | null };
[key: `${typeof LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${string}`]: LocalNotificationSettings;
[key: `m.secret_storage.key.${string}`]: SecretStorageKeyDescription;
// Invites-ignorer events
[POLICIES_ACCOUNT_EVENT_TYPE.name]: { [key: string]: any };
[POLICIES_ACCOUNT_EVENT_TYPE.altName]: { [key: string]: any };
[EventType.InvitePermissionConfig]: { default_action?: string };
// List of recently used reaction emojis
// https://spec.matrix.org/v1.18/client-server-api/#mrecent_emoji
"m.recent_emoji": {
recent_emoji: Array<{
emoji: string;
total: number;
}>;
export interface IEncryptedFile {
url: string;
mimetype?: string;
key: {
alg: string;
key_ops: string[]; // eslint-disable-line camelcase
kty: string;
k: string;
ext: boolean;
};
}
/**
* Subset of AccountDataEvents, excluding events specified in https://spec.matrix.org/v1.17/client-server-api/#server-behaviour-12
*/
export type WritableAccountDataEvents = Exclude<AccountDataEvents, "m.fully_read" | "m.push_rules">;
/**
* Mapped type from event type to content type for all specified global events encrypted by secret storage.
*
* See https://spec.matrix.org/v1.13/client-server-api/#msecret_storagev1aes-hmac-sha2-1
*/
export interface SecretStorageAccountDataEvents {
"m.megolm_backup.v1": SecretInfo;
"m.cross_signing.master": SecretInfo;
"m.cross_signing.self_signing": SecretInfo;
"m.cross_signing.user_signing": SecretInfo;
"org.matrix.msc3814": SecretInfo;
iv: string;
hashes: { [alg: string]: string };
v: string;
}

View File

@@ -1,119 +0,0 @@
/*
Copyright 2024 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 { type MsgType, type RelationType } from "./event.ts";
import { type FileInfo, type ImageInfo, type MediaEventContent } from "./media.ts";
import { type XOR } from "./common.ts";
interface BaseTimelineEvent {
"body": string;
"m.mentions"?: {
user_ids?: string[];
room?: boolean;
};
}
interface ReplyEvent {
"m.relates_to"?: {
"m.in_reply_to"?: {
event_id: string;
};
};
}
interface NoRelationEvent {
"m.new_content"?: never;
"m.relates_to"?: never;
}
/**
* Partial content format of timeline events with rel_type `m.replace`
*
* @see https://spec.matrix.org/v1.9/client-server-api/#event-replacements
*/
export interface ReplacementEvent<T> {
"m.new_content": T;
"m.relates_to": {
event_id: string;
rel_type: RelationType.Replace;
};
}
/**
* Partial content format of timeline events with rel_type other than `m.replace`
*
* @see https://spec.matrix.org/v1.9/client-server-api/#forming-relationships-between-events
*/
export interface RelationEvent {
"m.new_content"?: never;
"m.relates_to": {
event_id: string;
rel_type: Exclude<RelationType, RelationType.Replace>;
};
}
/**
* Content format of timeline events with type `m.room.message` and `msgtype` `m.text`, `m.emote`, or `m.notice`
*
* @see https://spec.matrix.org/v1.9/client-server-api/#mroommessage
*/
export interface RoomMessageTextEventContent extends BaseTimelineEvent {
msgtype: MsgType.Text | MsgType.Emote | MsgType.Notice;
format?: "org.matrix.custom.html";
formatted_body?: string;
}
/**
* Content format of timeline events with type `m.room.message` and `msgtype` `m.location`
*
* @see https://spec.matrix.org/v1.9/client-server-api/#mlocation
*/
export interface RoomMessageLocationEventContent extends BaseTimelineEvent {
body: string;
geo_uri: string;
info: Pick<FileInfo, "thumbnail_info" | "thumbnail_file" | "thumbnail_url">;
msgtype: MsgType.Location;
}
type MessageEventContent = RoomMessageTextEventContent | RoomMessageLocationEventContent | MediaEventContent;
export type RoomMessageEventContent = BaseTimelineEvent &
XOR<XOR<ReplacementEvent<MessageEventContent>, RelationEvent>, XOR<ReplyEvent, NoRelationEvent>> &
MessageEventContent;
/**
* Content format of timeline events with type `m.sticker`
*
* @see https://spec.matrix.org/v1.9/client-server-api/#msticker
*/
export interface StickerEventContent extends BaseTimelineEvent {
body: string;
info: ImageInfo;
url: string;
}
/**
* Content format of timeline events with type `m.reaction`
*
* @see https://spec.matrix.org/v1.9/client-server-api/#mreaction
*/
export interface ReactionEventContent {
"m.relates_to": {
event_id: string;
key: string;
rel_type: RelationType.Annotation;
};
}

View File

@@ -14,17 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type EitherAnd, NamespacedValue, UnstableValue } from "matrix-events-sdk";
import { EitherAnd, NamespacedValue, Optional, UnstableValue } from "matrix-events-sdk";
import { isProvided } from "../extensible_events_v1/utilities.ts";
import { isProvided } from "../extensible_events_v1/utilities";
// Types and utilities for MSC1767: Extensible events (version 1) in Matrix
/**
* Represents the stable and unstable values of a given namespace.
*/
export type TSNamespace<N> =
N extends NamespacedValue<infer S, infer U> ? TSNamespaceValue<S> | TSNamespaceValue<U> : never;
export type TSNamespace<N> = N extends NamespacedValue<infer S, infer U>
? TSNamespaceValue<S> | TSNamespaceValue<U>
: never;
/**
* Represents a namespaced value, if the value is a string. Used to extract provided types
@@ -97,12 +98,12 @@ export type AnyRelation = TSNamespace<typeof REFERENCE_RELATION> | string;
/**
* An m.relates_to relationship
*/
export type RelatesToRelationship<R = never> = {
export type RelatesToRelationship<R = never, C = never> = {
"m.relates_to": {
// See https://github.com/microsoft/TypeScript/issues/23182#issuecomment-379091887 for array syntax
rel_type: [R] extends [never] ? AnyRelation : TSNamespace<R>;
event_id: string;
};
} & DefaultNever<C, {}>;
};
/**
@@ -125,21 +126,25 @@ export type ExtensibleEventType = NamespacedValue<string, string> | string;
* @param expected - The expected event type.
* @returns True if the given type matches the expected type.
*/
export function isEventTypeSame(given: ExtensibleEventType | null, expected: ExtensibleEventType | null): boolean {
export function isEventTypeSame(
given: Optional<ExtensibleEventType>,
expected: Optional<ExtensibleEventType>,
): boolean {
if (typeof given === "string") {
if (typeof expected === "string") {
return expected === given;
} else {
return expected!.matches(given);
return (expected as NamespacedValue<string, string>).matches(given as string);
}
} else {
if (typeof expected === "string") {
return given!.matches(expected);
return (given as NamespacedValue<string, string>).matches(expected as string);
} else {
const expectedNs = expected!;
const givenNs = given!;
const expectedNs = expected as NamespacedValue<string, string>;
const givenNs = given as NamespacedValue<string, string>;
return (
expectedNs.matches(givenNs.name) || (isProvided(givenNs.altName) && expectedNs.matches(givenNs.altName))
expectedNs.matches(givenNs.name) ||
(isProvided(givenNs.altName) && expectedNs.matches(givenNs.altName!))
);
}
}

View File

@@ -14,54 +14,102 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
// this is needed to tell TS about global.Olm
import "@matrix-org/olm";
export {};
declare global {
// use `number` as the return type in all cases for globalThis.set{Interval,Timeout},
// use `number` as the return type in all cases for global.set{Interval,Timeout},
// so we don't accidentally use the methods on NodeJS.Timeout - they only exist in a subset of environments.
// The overload for clear{Interval,Timeout} is resolved as expected.
// We use `ReturnType<typeof setTimeout>` in the code to be agnostic of if this definition gets loaded.
function setInterval(handler: TimerHandler, timeout: number, ...args: any[]): number;
function setTimeout(handler: TimerHandler, timeout: number, ...args: any[]): number;
function setInterval(handler: TimerHandler, timeout: number, ...arguments: any[]): number;
function setTimeout(handler: TimerHandler, timeout: number, ...arguments: any[]): number;
namespace NodeJS {
interface Global {
localStorage: Storage;
// marker variable used to detect both the browser & node entrypoints being used at once
__js_sdk_entrypoint: unknown;
}
}
// Chrome-specific getUserMedia constraints
interface MediaTrackConstraints {
mandatory?: {
chromeMediaSource: string;
chromeMediaSourceId: string;
};
interface Window {
webkitAudioContext: typeof AudioContext;
}
interface Crypto {
webkitSubtle?: Window["crypto"]["subtle"];
}
interface MediaDevices {
// This is experimental and types don't know about it yet
// https://github.com/microsoft/TypeScript/issues/33232
getDisplayMedia(constraints: MediaStreamConstraints | DesktopCapturerConstraints): Promise<MediaStream>;
getUserMedia(constraints: MediaStreamConstraints | DesktopCapturerConstraints): Promise<MediaStream>;
}
interface DesktopCapturerConstraints {
audio:
| boolean
| {
mandatory: {
chromeMediaSource: string;
chromeMediaSourceId: string;
};
};
video:
| boolean
| {
mandatory: {
chromeMediaSource: string;
chromeMediaSourceId: string;
};
};
}
interface DummyInterfaceWeShouldntBeUsingThis {}
interface Navigator {
// We check for the webkit-prefixed getUserMedia to detect if we're
// on webkit: we should check if we still need to do this
webkitGetUserMedia?: unknown;
webkitGetUserMedia: DummyInterfaceWeShouldntBeUsingThis;
}
export interface Uint8ArrayToBase64Options {
alphabet?: "base64" | "base64url";
omitPadding?: boolean;
export interface ISettledFulfilled<T> {
status: "fulfilled";
value: T;
}
export interface ISettledRejected {
status: "rejected";
reason: any;
}
interface Uint8Array {
// https://tc39.es/proposal-arraybuffer-base64/spec/#sec-uint8array.prototype.tobase64
toBase64?(options?: Uint8ArrayToBase64Options): string;
interface PromiseConstructor {
allSettled<T>(promises: Promise<T>[]): Promise<Array<ISettledFulfilled<T> | ISettledRejected>>;
}
export interface Uint8ArrayFromBase64Options {
alphabet?: "base64"; // Our fallback code only handles base64.
lastChunkHandling?: "loose"; // Our fallback code doesn't support other handling at this time.
interface RTCRtpTransceiver {
// This has been removed from TS
// (https://github.com/microsoft/TypeScript-DOM-lib-generator/issues/1029),
// but we still need this for MatrixCall::getRidOfRTXCodecs()
setCodecPreferences(codecs: RTCRtpCodecCapability[]): void;
}
interface Uint8ArrayConstructor {
// https://tc39.es/proposal-arraybuffer-base64/spec/#sec-uint8array.frombase64
fromBase64?(base64: string, options?: Uint8ArrayFromBase64Options): Uint8Array<ArrayBuffer>;
interface RequestInit {
/**
* Specifies the priority of the fetch request relative to other requests of the same type.
* Must be one of the following strings:
* high: A high priority fetch request relative to other requests of the same type.
* low: A low priority fetch request relative to other requests of the same type.
* auto: Automatically determine the priority of the fetch request relative to other requests of the same type (default).
*
* @see https://html.spec.whatwg.org/multipage/urls-and-fetching.html#fetch-priority-attribute
* @see https://github.com/microsoft/TypeScript/issues/54472
* @see https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API#browser_compatibility
* Not yet supported in Safari or Firefox
*/
priority?: "high" | "low" | "auto";
}
}

View File

@@ -1,16 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
// Types for JSON and JSON objects, copied from element-web (left in both places as I don't think we
// want this as a part of js-sdk's interface and I don't think it's worth it being its own package)
export type JsonValue = null | string | number | boolean;
export type JsonArray = Array<JsonValue | JsonObject | JsonArray>;
export interface JsonObject {
[key: string]: JsonObject | JsonArray | JsonValue;
}
export type Json = JsonArray | JsonObject;

View File

@@ -15,10 +15,10 @@ limitations under the License.
*/
// Types for MSC3488 - m.location: Extending events with location data
import { type EitherAnd } from "matrix-events-sdk";
import { EitherAnd } from "matrix-events-sdk";
import { UnstableValue } from "../NamespacedValue.ts";
import { type M_TEXT } from "./extensible_events.ts";
import { UnstableValue } from "../NamespacedValue";
import { M_TEXT } from "./extensible_events";
export enum LocationAssetType {
Self = "m.self",

View File

@@ -1,38 +0,0 @@
/*
Copyright 2024 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 type * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
declare module "@matrix-org/matrix-sdk-crypto-wasm" {
interface SecretsBundle {
to_json(): Promise<{
cross_signing: {
master_key: string;
self_signing_key: string;
user_signing_key: string;
};
backup?: {
algorithm: string;
key: string;
backup_version: string;
};
}>;
}
interface Device {
requestVerification(methods?: any[]): [RustSdkCryptoJs.VerificationRequest, RustSdkCryptoJs.ToDeviceRequest];
}
}

View File

@@ -1,245 +0,0 @@
/*
Copyright 2024 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 { type MsgType } from "../@types/event.ts";
/**
* Information on encrypted media attachments.
*
* Used within `m.room.message` events that reference files, such as `m.file` and `m.image`.
*
* @see https://spec.matrix.org/v1.11/client-server-api/#extensions-to-mroommessage-msgtypes
*/
export interface EncryptedFile {
/**
* The URL to the file.
*/
url: string;
/**
* A JSON Web Key object.
*/
key: {
alg: string;
key_ops: string[];
kty: string;
k: string;
ext: boolean;
};
/**
* The 128-bit unique counter block used by AES-CTR, encoded as unpadded base64.
*/
iv: string;
/**
* A map from an algorithm name to a hash of the ciphertext, encoded as unpadded base64.
* Clients should support the SHA-256 hash, which uses the key `sha256`.
*/
hashes: { [alg: string]: string };
/**
* Version of the encrypted attachment's protocol. Must be `v2`.
*/
v: string;
}
interface ThumbnailInfo {
/**
* The mimetype of the image, e.g. image/jpeg.
*/
mimetype?: string;
/**
* The intended display width of the image in pixels.
* This may differ from the intrinsic dimensions of the image file.
*/
w?: number;
/**
* The intended display height of the image in pixels.
* This may differ from the intrinsic dimensions of the image file.
*/
h?: number;
/**
* Size of the image in bytes.
*/
size?: number;
}
interface BaseInfo {
mimetype?: string;
size?: number;
}
/**
* Information on media attachments of msgtype `m.file`
*
* Used within `m.room.message` events that reference files.
*
* @see https://spec.matrix.org/v1.11/client-server-api/#mfile
*/
export interface FileInfo extends BaseInfo {
/**
* Information on the encrypted thumbnail file, as specified in End-to-end encryption.
* Only present if the thumbnail is encrypted.
* @see https://spec.matrix.org/v1.11/client-server-api/#sending-encrypted-attachments
*/
thumbnail_file?: EncryptedFile;
/**
* Metadata about the image referred to in thumbnail_url.
*/
thumbnail_info?: ThumbnailInfo;
/**
* The URL to the thumbnail of the file. Only present if the thumbnail is unencrypted.
*/
thumbnail_url?: string;
}
/**
* Information on media attachments of msgtype `m.image`
*
* Used within `m.room.message` events that reference images.
*
* @see https://spec.matrix.org/v1.11/client-server-api/#mimage
*/
export interface ImageInfo extends FileInfo, ThumbnailInfo {}
/**
* Information on media attachments of msgtype `m.audio`
*
* Used within `m.room.message` events that reference audio files.
*
* @see https://spec.matrix.org/v1.11/client-server-api/#maudio
*/
export interface AudioInfo extends BaseInfo {
/**
* The duration of the audio in milliseconds.
*/
duration?: number;
}
/**
* Information on media attachments of msgtype `m.video`
*
* Used within `m.room.message` events that reference video files.
*
* @see https://spec.matrix.org/v1.11/client-server-api/#mvideo
*/
export interface VideoInfo extends AudioInfo, ImageInfo {
/**
* The duration of the video in milliseconds.
*/
duration?: number;
}
/**
* Union type representing the `content.info` field of all specified media events.
*/
export type MediaEventInfo = FileInfo | ImageInfo | AudioInfo | VideoInfo;
interface BaseContent {
/**
* Required if the file is encrypted. Information on the encrypted file, as specified in End-to-end encryption.
* @see https://spec.matrix.org/v1.11/client-server-api/#sending-encrypted-attachments
*/
file?: EncryptedFile;
/**
* Required if the file is unencrypted. The URL (typically mxc:// URI) to the file.
*/
url?: string;
/**
* If filename is not set or the value of both properties are identical,
* this is the filename of the original upload. Otherwise, this is a
* caption for the file.
*/
body: string;
/**
* The original filename of the uploaded file.
*/
filename?: string;
/**
* The format used in the `formatted_body`.
*/
format?: "org.matrix.custom.html";
/**
* The formatted version of the `body`, when it acts as a caption. This is required if `format` is specified.
*/
formatted_body?: string;
}
/**
* Content format of media events with msgtype `m.file`
*
* @see https://spec.matrix.org/v1.11/client-server-api/#mfile
*/
export interface FileContent extends BaseContent {
/**
* Information about the file referred to in url.
*/
info?: FileInfo;
/**
* One of: [m.file].
*/
msgtype: MsgType.File;
}
/**
* Content format of media events with msgtype `m.image`
*
* @see https://spec.matrix.org/v1.11/client-server-api/#mimage
*/
export interface ImageContent extends BaseContent {
/**
* Metadata about the image referred to in url.
*/
info?: ImageInfo;
/**
* One of: [m.image].
*/
msgtype: MsgType.Image;
}
/**
* Content format of media events with msgtype `m.audio`
*
* @see https://spec.matrix.org/v1.11/client-server-api/#maudio
*/
export interface AudioContent extends BaseContent {
/**
* Metadata for the audio clip referred to in url.
*/
info?: AudioInfo;
/**
* One of: [m.audio].
*/
msgtype: MsgType.Audio;
}
/**
* Content format of media events with msgtype `m.video`
*
* @see https://spec.matrix.org/v1.11/client-server-api/#mvideo
*/
export interface VideoContent extends BaseContent {
/**
* Metadata about the video clip referred to in url.
*/
info?: VideoInfo;
/**
* One of: [m.video].
*/
msgtype: MsgType.Video;
}
/**
* Type representing media event contents for `m.room.message` events listed in the Matrix specification
*/
export type MediaEventContent = FileContent | ImageContent | AudioContent | VideoContent;

View File

@@ -1,57 +0,0 @@
/*
Copyright 2024 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.
*/
/**
* Well-known values (from the spec or MSCs) that are allowed in the
* {@link Membership} type.
*/
export enum KnownMembership {
/**
* The user has been banned from the room, and is no longer allowed to join
* it until they are un-banned from the room (by having their membership
* state set to a value other than ban).
*/
Ban = "ban",
/**
* The user has been invited to join a room, but has not yet joined it.
* They may not participate in the room until they join.
* */
Invite = "invite",
/**
* The user has joined the room (possibly after accepting an invite), and
* may participate in it.
*/
Join = "join",
/**
* The user has knocked on the room, requesting permission to participate.
* They may not participate in the room until they join.
*/
Knock = "knock",
/**
* The user was once joined to the room, but has since left (possibly by
* choice, or possibly by being kicked).
*/
Leave = "leave",
}
/**
* The membership state for a user in a room [1]. A value from
* {@link KnownMembership} should be used where available, but all string values
* are allowed to provide flexibility for upcoming spec changes or proposals.
*
* [1] https://spec.matrix.org/latest/client-server-api/#mroommember
*/
export type Membership = KnownMembership | string;

View File

@@ -14,6 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
export interface IImageInfo {
size?: number;
mimetype?: string;
thumbnail_info?: {
// eslint-disable-line camelcase
w?: number;
h?: number;
size?: number;
mimetype?: string;
};
w?: number;
h?: number;
}
export enum Visibility {
Public = "public",
Private = "private",
@@ -45,6 +59,14 @@ export enum RestrictedAllowType {
RoomMembership = "m.room_membership",
}
export interface IJoinRuleEventContent {
join_rule: JoinRule; // eslint-disable-line camelcase
allow?: {
type: RestrictedAllowType;
room_id: string; // eslint-disable-line camelcase
}[];
}
export enum GuestAccess {
CanJoin = "can_join",
Forbidden = "forbidden",
@@ -60,42 +82,8 @@ export enum HistoryVisibility {
export interface IUsageLimit {
// "hs_disabled" is NOT a specced string, but is used in Synapse
// This is tracked over at https://github.com/matrix-org/synapse/issues/9237
// eslint-disable-next-line camelcase
limit_type: "monthly_active_user" | "hs_disabled" | string;
// eslint-disable-next-line camelcase
admin_contact?: string;
}
/**
* A policy name & url in a specific internationalisation
* @see https://spec.matrix.org/v1.13/identity-service-api/#get_matrixidentityv2terms_response-200_internationalised-policy
*/
export interface InternationalisedPolicy {
name: string;
url: string;
}
/**
* A versioned policy with internationalised variants
* @see https://spec.matrix.org/v1.13/identity-service-api/#get_matrixidentityv2terms_response-200_policy-object
*/
export interface Policy {
/**
* The version for the policy.
* There are no requirements on what this might be and could be “alpha”, semantically versioned, or arbitrary.
*/
version: string;
/**
* The policy information for the specified language.
* @remarks the type has to include a union with string due to limitations in the type system.
*/
[lang: string]: InternationalisedPolicy | string;
}
/**
* Response from the Terms API for Identity servers
* @see https://spec.matrix.org/v1.13/identity-service-api/#get_matrixidentityv2terms
*/
export interface Terms {
policies: {
[policyName: string]: Policy;
};
}

View File

@@ -14,15 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type EitherAnd, UnstableValue } from "matrix-events-sdk";
import { EitherAnd, UnstableValue } from "matrix-events-sdk";
import {
type ExtensibleAnyMessageEventContent,
type REFERENCE_RELATION,
type RelatesToRelationship,
type TSNamespace,
} from "./extensible_events.ts";
import { type EmptyObject } from "./common.ts";
ExtensibleAnyMessageEventContent,
REFERENCE_RELATION,
RelatesToRelationship,
TSNamespace,
} from "./extensible_events";
/**
* Identifier for a disclosed poll.
@@ -110,7 +109,7 @@ export const M_POLL_END = new UnstableValue("m.poll.end", "org.matrix.msc3381.po
/**
* The event definition for an m.poll.end event (in content)
*/
export type PollEndEvent = EitherAnd<{ [M_POLL_END.name]: EmptyObject }, { [M_POLL_END.altName]: EmptyObject }>;
export type PollEndEvent = EitherAnd<{ [M_POLL_END.name]: {} }, { [M_POLL_END.altName]: {} }>;
/**
* The content for an m.poll.end event

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type AuthDict } from "../interactive-auth.ts";
import { AuthDict } from "../interactive-auth";
/**
* The request body of a call to `POST /_matrix/client/v3/register`.
@@ -52,13 +52,21 @@ export interface RegisterRequest {
*/
initial_device_display_name?: string;
/**
* Guest users can also upgrade their account by going through the ordinary register flow,
* but specifying the additional POST parameter guest_access_token containing the guest’s access token.
* They are also required to specify the username parameter to the value of the local part of their username,
* which is otherwise optional.
* @see https://spec.matrix.org/v1.10/client-server-api/#guest-access
* @deprecated missing in the spec
*/
guest_access_token?: string;
/**
* @deprecated missing in the spec
*/
x_show_msisdn?: boolean;
/**
* @deprecated missing in the spec
*/
bind_msisdn?: boolean;
/**
* @deprecated missing in the spec
*/
bind_email?: boolean;
}
/**
@@ -99,4 +107,10 @@ export interface RegisterResponse {
* Omitted if the inhibit_login option is true.
*/
refresh_token?: string;
/**
* The server_name of the homeserver on which the account has been registered.
*
* @deprecated Clients should extract the server_name from user_id (by splitting at the first colon) if they require it.
*/
home_server?: string;
}

View File

@@ -14,17 +14,26 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type IContent, type IEvent } from "../models/event.ts";
import { type Preset, type Visibility } from "./partials.ts";
import { type IEventWithRoomId, type SearchKey } from "./search.ts";
import { type IRoomEventFilter } from "../filter.ts";
import { type Direction } from "../models/event-timeline.ts";
import { type PushRuleAction } from "./PushRules.ts";
import { type MatrixError } from "../matrix.ts";
import { type IRoomEvent } from "../sync-accumulator.ts";
import { type EventType, type RelationType, type RoomType } from "./event.ts";
import { IContent, IEvent } from "../models/event";
import { Preset, Visibility } from "./partials";
import { IEventWithRoomId, SearchKey } from "./search";
import { IRoomEventFilter } from "../filter";
import { Direction } from "../models/event-timeline";
import { PushRuleAction } from "./PushRules";
import { IRoomEvent } from "../sync-accumulator";
import { EventType, RelationType, RoomType } from "./event";
// allow camelcase as these are things that go onto the wire
/* eslint-disable camelcase */
export interface IJoinRoomOpts {
/**
* True to do a room initial sync on the resulting
* room. If false, the <strong>returned Room object will have no current state.
* </strong> Default: true.
*/
syncRoom?: boolean;
/**
* If the caller has a keypair 3pid invite, the signing URL is passed in this parameter.
*/
@@ -32,46 +41,8 @@ export interface IJoinRoomOpts {
/**
* The server names to try and join through in addition to those that are automatically chosen.
* Only the first 3 are actually used in the request, to avoid HTTP 414 Request-URI Too Long responses.
*/
viaServers?: string[];
/**
* Previously, configured whether to accept encrypted history shared by the inviter. This is now always enabled,
* and the setting is only retained to avoid a breaking change to the API. It has no effect.
*
* @deprecated
*/
acceptSharedHistory?: boolean;
}
/** Options object for {@link MatrixClient.invite}. */
export interface InviteOpts {
/**
* The reason for the invite.
*/
reason?: string;
/**
* Previously, configured whether to send encrypted history if the visibility settings allow it.
* This is now always enabled, and the setting is only retained to avoid a breaking change to the API. It has no effect.
*
* @deprecated
*/
shareEncryptedHistory?: boolean;
}
export interface KnockRoomOpts {
/**
* The reason for the knock.
*/
reason?: string;
/**
* The server names to try and knock through in addition to those that are automatically chosen.
* Only the first 3 are actually used in the request, to avoid HTTP 414 Request-URI Too Long responses.
*/
viaServers?: string | string[];
}
export interface IRedactOpts {
@@ -93,65 +64,6 @@ export interface ISendEventResponse {
event_id: string;
}
export type SendDelayedEventRequestOpts = { parent_delay_id: string } | { delay: number; parent_delay_id?: string };
export function isSendDelayedEventRequestOpts(opts: object): opts is SendDelayedEventRequestOpts {
if ("parent_delay_id" in opts && typeof opts.parent_delay_id !== "string") {
// Invalid type, reject
return false;
}
if ("delay" in opts && typeof opts.delay !== "number") {
// Invalid type, reject.
return true;
}
// At least one of these fields must be specified.
return "delay" in opts || "parent_delay_id" in opts;
}
export type SendDelayedEventResponse = {
delay_id: string;
};
export enum UpdateDelayedEventAction {
Cancel = "cancel",
Restart = "restart",
Send = "send",
}
export type UpdateDelayedEventRequestOpts = SendDelayedEventResponse & {
action: UpdateDelayedEventAction;
};
type DelayedPartialTimelineEvent = {
room_id: string;
type: string;
content: IContent;
};
type DelayedPartialStateEvent = DelayedPartialTimelineEvent & {
state_key: string;
};
type DelayedPartialEvent = DelayedPartialTimelineEvent | DelayedPartialStateEvent;
export type DelayedEventInfoItem = DelayedPartialEvent &
SendDelayedEventResponse &
SendDelayedEventRequestOpts & {
running_since: number;
};
export type DelayedEventInfo = {
scheduled?: DelayedEventInfoItem[];
finalised?: {
delayed_event: DelayedEventInfoItem;
outcome: "send" | "cancel";
reason: "error" | "action" | "delay";
error?: MatrixError["data"];
event_id?: string;
origin_server_ts?: number;
}[];
next_batch?: string;
};
export interface IPresenceOpts {
// One of "online", "offline" or "unavailable"
presence: "online" | "offline" | "unavailable";
@@ -238,21 +150,11 @@ export interface ICreateRoomOpts {
}
export interface IRoomDirectoryOptions {
/**
* The remote server to query for the room list.
* Optional. If unspecified, get the local homeserver's public room list.
*/
server?: string;
/**
* Maximum number of entries to return
*/
limit?: number;
/**
* Token to paginate from
*/
since?: string;
/** Filter parameters */
// Filter parameters
filter?: {
// String to search for
generic_search_term?: string;
@@ -279,6 +181,12 @@ export interface IBindThreePidBody {
sid: string;
}
export interface IAddThreePidBody {
client_secret: string;
id_server: string;
sid: string;
}
export interface IRelationsRequestOpts {
from?: string;
to?: string;
@@ -294,12 +202,12 @@ export interface IRelationsResponse {
}
export interface IContextResponse {
end?: string;
start?: string;
state?: IEventWithRoomId[];
events_before?: IEventWithRoomId[];
events_after?: IEventWithRoomId[];
event?: IEventWithRoomId;
end: string;
start: string;
state: IEventWithRoomId[];
events_before: IEventWithRoomId[];
events_after: IEventWithRoomId[];
event: IEventWithRoomId;
}
export interface IEventsResponse {
@@ -338,3 +246,5 @@ export interface IStatusResponse extends IPresenceOpts {
currently_active?: boolean;
last_active_ago?: number;
}
/* eslint-enable camelcase */

View File

@@ -1,16 +0,0 @@
import { UnstableValue } from "../NamespacedValue.ts";
/**
* Event type for a room policy event.
* NOTE: While MSC1763 has not been merged, `m.room.retention` is unfortunately
* already in use in production.
*/
export const ROOM_RETENTION_TYPE = new UnstableValue("m.room.retention", "org.matrix.msc1763.retention");
/**
* The content of a `m.room.retention` state event.
*/
export interface RoomRetentionContent {
max_lifetime?: number;
min_lifetime?: number;
}

View File

@@ -16,10 +16,11 @@ limitations under the License.
// Types relating to the /search API
import { type IRoomEvent, type IStateEvent } from "../sync-accumulator.ts";
import { type IRoomEventFilter } from "../filter.ts";
import { type SearchResult } from "../models/search-result.ts";
import { IRoomEvent, IStateEvent } from "../sync-accumulator";
import { IRoomEventFilter } from "../filter";
import { SearchResult } from "../models/search-result";
/* eslint-disable camelcase */
export interface IEventWithRoomId extends IRoomEvent {
room_id: string;
}
@@ -53,9 +54,9 @@ enum GroupKey {
}
export interface IResultRoomEvents {
count?: number;
highlights?: string[];
results?: ISearchResult[];
count: number;
highlights: string[];
results: ISearchResult[];
state?: { [roomId: string]: IStateEventWithRoomId[] };
groups?: {
[groupKey in GroupKey]: {
@@ -115,3 +116,4 @@ export interface ISearchResults {
pendingRequest?: Promise<ISearchResults>;
abortSignal?: AbortSignal;
}
/* eslint-enable camelcase */

View File

@@ -14,12 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type IPublicRoomsChunkRoom } from "../client.ts";
import { type RoomType } from "./event.ts";
import { type IStrippedState } from "../sync-accumulator.ts";
import { IPublicRoomsChunkRoom } from "../client";
import { RoomType } from "./event";
import { IStrippedState } from "../sync-accumulator";
// Types relating to Rooms of type `m.space` and related APIs
/* eslint-disable camelcase */
export interface IHierarchyRelation extends IStrippedState {
origin_server_ts: number;
content: {
@@ -33,3 +34,4 @@ export interface IHierarchyRoom extends IPublicRoomsChunkRoom {
room_type?: RoomType | string;
children_state: IHierarchyRelation[];
}
/* eslint-enable camelcase */

View File

@@ -1,153 +0,0 @@
/*
Copyright 2024 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 { type RoomType } from "./event.ts";
import { type GuestAccess, type HistoryVisibility, type JoinRule, type RestrictedAllowType } from "./partials.ts";
import { type ImageInfo } from "./media.ts";
import { type PolicyRecommendation } from "../models/invites-ignorer.ts";
export interface RoomCanonicalAliasEventContent {
alias?: string;
alt_aliases?: string[];
}
export interface RoomCreateEventContent {
"creator"?: string;
"m.federate"?: boolean;
"predecessor"?: {
event_id: string;
room_id: string;
};
"room_version"?: string;
"type"?: RoomType;
}
export interface RoomJoinRulesEventContent {
join_rule: JoinRule;
allow?: {
room_id: string;
type: RestrictedAllowType;
}[];
}
export interface RoomMemberEventContent {
avatar_url?: string;
displayname?: string;
is_direct?: boolean;
join_authorised_via_users_server?: string;
membership: "invite" | "join" | "knock" | "leave" | "ban";
reason?: string;
third_party_invite?: {
display_name: string;
signed: {
mxid: string;
token: string;
ts: number;
};
};
}
export interface RoomThirdPartyInviteEventContent {
display_name: string;
key_validity_url: string;
public_key: string;
public_keys: {
key_validity_url?: string;
public_key: string;
}[];
}
export interface RoomPowerLevelsEventContent {
ban?: number;
events?: { [eventType: string]: number };
events_default?: number;
invite?: number;
kick?: number;
notifications?: {
room?: number;
};
redact?: number;
state_default?: number;
users?: { [userId: string]: number };
users_default?: number;
}
export interface RoomNameEventContent {
name: string;
}
export interface RoomTopicEventContent {
topic: string | undefined | null;
}
export interface RoomAvatarEventContent {
url?: string;
// The spec says that an encrypted file can be used for the thumbnail but this isn't true
// https://github.com/matrix-org/matrix-spec/issues/562 so omit those fields
info?: Omit<ImageInfo, "thumbnail_file">;
}
export interface RoomPinnedEventsEventContent {
pinned: string[];
}
export interface RoomEncryptionEventContent {
"algorithm": "m.megolm.v1.aes-sha2";
"io.element.msc4362.encrypt_state_events"?: boolean;
"rotation_period_ms"?: number;
"rotation_period_msgs"?: number;
}
export interface RoomHistoryVisibilityEventContent {
history_visibility: HistoryVisibility;
}
export interface RoomGuestAccessEventContent {
guest_access: GuestAccess;
}
export interface RoomServerAclEventContent {
allow?: string[];
allow_ip_literals?: boolean;
deny?: string[];
}
export interface RoomTombstoneEventContent {
body: string;
replacement_room: string;
}
export interface SpaceChildEventContent {
order?: string;
suggested?: boolean;
via?: string[];
}
export interface SpaceParentEventContent {
canonical?: boolean;
via?: string[];
}
export interface PolicyRuleEventContent {
entity: string;
reason: string;
recommendation: PolicyRecommendation;
}
export interface RoomPolicyContent {
via: string;
public_key: string;
}

View File

@@ -14,10 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type IdServerUnbindResult } from "./partials.ts";
import { IdServerUnbindResult } from "./partials";
// Types relating to Synapse Admin APIs
/* eslint-disable camelcase */
export interface ISynapseAdminWhoisResponse {
user_id: string;
devices: {
@@ -36,3 +37,4 @@ export interface ISynapseAdminWhoisResponse {
export interface ISynapseAdminDeactivateResponse {
id_server_unbind_result: IdServerUnbindResult;
}
/* eslint-enable camelcase */

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { ServerControlledNamespacedValue } from "../NamespacedValue.ts";
import { ServerControlledNamespacedValue } from "../NamespacedValue";
/**
* https://github.com/matrix-org/matrix-doc/pull/3773

View File

@@ -23,7 +23,7 @@ export enum ThreepidMedium {
export interface IThreepid {
medium: ThreepidMedium;
address: string;
validated_at: number;
added_at: number;
validated_at: number; // eslint-disable-line camelcase
added_at: number; // eslint-disable-line camelcase
bound?: boolean;
}

View File

@@ -13,8 +13,11 @@ 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 { NamespacedValue } from "../NamespacedValue.ts";
import { type IMessageRendering } from "./extensible_events.ts";
import { EitherAnd } from "matrix-events-sdk";
import { UnstableValue } from "../NamespacedValue";
import { IMessageRendering } from "./extensible_events";
/**
* Extensible topic event type based on MSC3765
@@ -42,28 +45,19 @@ import { type IMessageRendering } from "./extensible_events.ts";
/**
* The event type for an m.topic event (in content)
*/
export const M_TOPIC = new NamespacedValue("m.topic", null);
export const M_TOPIC = new UnstableValue("m.topic", "org.matrix.msc3765.topic");
/**
* The event content for an m.topic event (in content)
*/
export type MTopicContent = { "m.text": IMessageRendering[] };
/**
* A previous incorrect form of m.topic used by matrix-js-sdk
* TODO remove this after a few releases
* https://github.com/matrix-org/matrix-js-sdk/pull/4984#pullrequestreview-3174251065
*/
export type MalformedMTopicEvent = { "m.topic": IMessageRendering[] };
export type MTopicContent = IMessageRendering[];
/**
* The event definition for an m.topic event (in content)
*/
export type MTopicEvent = { "m.topic": MTopicContent } | MalformedMTopicEvent;
export type MTopicEvent = EitherAnd<{ [M_TOPIC.name]: MTopicContent }, { [M_TOPIC.altName]: MTopicContent }>;
/**
* The event content for an m.room.topic event
*/
export type MRoomTopicEventContent = {
topic: string | null | undefined;
} & Partial<MTopicEvent>;
export type MRoomTopicEventContent = { topic: string } & MTopicEvent;

View File

@@ -1,126 +0,0 @@
/*
Copyright 2026 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 { logger } from "../logger.ts";
/**
* Type guard to check the given value is a Record type with string keys.
* @param value - the value to check
*/
export const isRecord = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === "object" && !Array.isArray(value);
/**
* Type guard to check the given record has a key and its value is a string
* @param metadata - the record to check
* @param key - the key to check
*/
export const hasRequiredStringProperty = <K extends string>(
metadata: Record<string, unknown>,
key: K,
): metadata is Record<string, unknown> & Record<K, string> => {
if (!metadata[key] || !hasOptionalStringProperty(metadata, key)) {
logger.error(`Missing or invalid property: ${key}`);
return false;
}
return true;
};
/**
* Type guard to check that if the given record has a key then its value is a string
* @param metadata - the record to check
* @param key - the key to check
*/
export const hasOptionalStringProperty = <K extends string>(
metadata: Record<string, unknown>,
key: K,
): metadata is Record<string, unknown> & Record<K, string | undefined> => {
if (!!metadata[key] && typeof metadata[key] !== "string") {
logger.error(`Invalid property: ${key}`);
return false;
}
return true;
};
/**
* Type guard to check the given record has a key and its value is a number
* @param metadata - the record to check
* @param key - the key to check
*/
export const hasRequiredNumberProperty = <K extends string>(
metadata: Record<string, unknown>,
key: K,
): metadata is Record<string, unknown> & Record<K, number> => {
if (!metadata[key] || !hasOptionalNumberProperty(metadata, key)) {
logger.error(`Missing or invalid property: ${key}`);
return false;
}
return true;
};
/**
* Type guard to check that if the given record has a key then its value is a number
* @param metadata - the record to check
* @param key - the key to check
*/
export const hasOptionalNumberProperty = <K extends string>(
metadata: Record<string, unknown>,
key: K,
): metadata is Record<string, unknown> & Record<K, number | undefined> => {
if (!!metadata[key] && typeof metadata[key] !== "number") {
logger.error(`Invalid property: ${key}`);
return false;
}
return true;
};
/**
* Type guard to check that if the given record has a key then its value is an array containing only strings
* @param metadata - the record to check
* @param key - the key to check
*/
export const optionalStringArrayProperty = <K extends string>(
metadata: Record<string, unknown>,
key: K,
): metadata is Record<string, unknown> & Record<K, string[] | undefined> => {
if (
!!metadata[key] &&
(!Array.isArray(metadata[key]) || !(<unknown[]>metadata[key]).every((v) => typeof v === "string"))
) {
logger.error(`Invalid property: ${key}`);
return false;
}
return true;
};
/**
* Type guard to check the given record has a key and its value is an array of strings containing at least the given value
* @param metadata - the record to check
* @param key - the key to check
* @param value - the value to require to be present in the array
*/
export const requiredArrayValue = <K extends string, V>(
metadata: Record<string, unknown>,
key: K,
value: V,
): metadata is Record<string, unknown> & Record<K, V[]> => {
const array = metadata[key];
if (!array || !Array.isArray(array) || !array.includes(value)) {
logger.error(`Invalid property: ${key}. ${value} is required.`);
return false;
}
return true;
};

View File

@@ -14,11 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type AuthDict } from "../interactive-auth.ts";
import { IAuthDict, IAuthData } from "../interactive-auth";
/**
* Helper type to represent HTTP request body for a UIA enabled endpoint
*/
export type UIARequest<T> = T & {
auth?: AuthDict;
auth?: IAuthDict;
};
/**
* Helper type to represent HTTP response body for a UIA enabled endpoint
*/
export type UIAResponse<T> = T | IAuthData;

View File

@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Optional } from "matrix-events-sdk/lib/types";
/**
* Represents a simple Matrix namespaced value. This will assume that if a stable prefix
* is provided that the stable prefix should be used when representing the identifier.
@@ -22,12 +24,9 @@ export class NamespacedValue<S extends string, U extends string> {
// Stable is optional, but one of the two parameters is required, hence the weird-looking types.
// Goal is to to have developers explicitly say there is no stable value (if applicable).
public constructor(stable: S, unstable: U);
public constructor(stable: S, unstable: U | null);
public constructor(stable: null, unstable: U);
public constructor(
public readonly stable: S | null,
public readonly unstable: U | null,
) {
public constructor(stable: S, unstable?: U);
public constructor(stable: null | undefined, unstable: U);
public constructor(public readonly stable?: S | null, public readonly unstable?: U) {
if (!this.unstable && !this.stable) {
throw new Error("One of stable or unstable values must be supplied");
}
@@ -60,8 +59,8 @@ export class NamespacedValue<S extends string, U extends string> {
// this desperately wants https://github.com/microsoft/TypeScript/pull/26349 at the top level of the class
// so we can instantiate `NamespacedValue<string, _, _>` as a default type for that namespace.
public findIn<V>(obj: Partial<Record<NonNullable<S | U>, V>>): V | undefined {
let val: V | undefined = undefined;
public findIn<T>(obj: any): Optional<T> {
let val: T | undefined = undefined;
if (this.name) {
val = obj?.[this.name];
}

View File

@@ -17,9 +17,9 @@ limitations under the License.
*/
// eslint-disable-next-line no-restricted-imports
import { type EventEmitter } from "events";
import { EventEmitter } from "events";
import { type ListenerMap, type TypedEventEmitter } from "./models/typed-event-emitter.ts";
import { ListenerMap, TypedEventEmitter } from "./models/typed-event-emitter";
export class ReEmitter {
public constructor(private readonly target: EventEmitter) {}

View File

@@ -14,19 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { ToDeviceMessageId } from "./@types/event.ts";
import { type Logger } from "./logger.ts";
import { type MatrixClient, ClientEvent } from "./client.ts";
import { type MatrixError } from "./http-api/index.ts";
import {
type IndexedToDeviceBatch,
type ToDeviceBatch,
type ToDeviceBatchWithTxnId,
type ToDevicePayload,
} from "./models/ToDeviceMessage.ts";
import { MatrixScheduler } from "./scheduler.ts";
import { SyncState } from "./sync.ts";
import { MapWithDefault } from "./utils.ts";
import { ToDeviceMessageId } from "./@types/event";
import { logger } from "./logger";
import { MatrixClient, ClientEvent } from "./client";
import { MatrixError } from "./http-api";
import { IndexedToDeviceBatch, ToDeviceBatch, ToDeviceBatchWithTxnId, ToDevicePayload } from "./models/ToDeviceMessage";
import { MatrixScheduler } from "./scheduler";
import { SyncState } from "./sync";
import { MapWithDefault } from "./utils";
const MAX_BATCH_SIZE = 20;
@@ -40,10 +35,7 @@ export class ToDeviceMessageQueue {
private retryTimeout: ReturnType<typeof setTimeout> | null = null;
private retryAttempts = 0;
public constructor(
private client: MatrixClient,
private readonly logger: Logger,
) {}
public constructor(private client: MatrixClient) {}
public start(): void {
this.running = true;
@@ -70,14 +62,14 @@ export class ToDeviceMessageQueue {
const msgmap = batchWithTxnId.batch.map(
(msg) => `${msg.userId}/${msg.deviceId} (msgid ${msg.payload[ToDeviceMessageId]})`,
);
this.logger.info(
logger.info(
`Enqueuing batch of to-device messages. type=${batch.eventType} txnid=${batchWithTxnId.txnId}`,
msgmap,
);
}
await this.client.store.saveToDeviceBatches(batches);
void this.sendQueue();
this.sendQueue();
}
public sendQueue = async (): Promise<void> => {
@@ -86,7 +78,7 @@ export class ToDeviceMessageQueue {
if (this.sending || !this.running) return;
this.logger.debug("Attempting to send queued to-device messages");
logger.debug("Attempting to send queued to-device messages");
this.sending = true;
let headBatch: IndexedToDeviceBatch | null;
@@ -102,23 +94,25 @@ export class ToDeviceMessageQueue {
// Make sure we're still running after the async tasks: if not, stop.
if (!this.running) return;
this.logger.debug("All queued to-device messages sent");
logger.debug("All queued to-device messages sent");
} catch (e) {
++this.retryAttempts;
// eslint-disable-next-line @typescript-eslint/naming-convention
// eslint-disable-next-line new-cap
const retryDelay = MatrixScheduler.RETRY_BACKOFF_RATELIMIT(null, this.retryAttempts, <MatrixError>e);
if (retryDelay === -1) {
// the scheduler function doesn't differentiate between fatal errors and just getting
// bored and giving up for now
if (Math.floor((<MatrixError>e).httpStatus! / 100) === 4) {
this.logger.error("Fatal error when sending to-device message - dropping to-device batch!", e);
logger.error("Fatal error when sending to-device message - dropping to-device batch!", e);
await this.client.store.removeToDeviceBatch(headBatch!.id);
} else {
this.logger.info("Automatic retry limit reached for to-device messages.");
logger.info("Automatic retry limit reached for to-device messages.");
}
return;
}
this.logger.info(`Failed to send batch of to-device messages. Will retry in ${retryDelay}ms`, e);
logger.info(`Failed to send batch of to-device messages. Will retry in ${retryDelay}ms`, e);
this.retryTimeout = setTimeout(this.sendQueue, retryDelay);
} finally {
this.sending = false;
@@ -134,7 +128,7 @@ export class ToDeviceMessageQueue {
contentMap.getOrCreate(item.userId).set(item.deviceId, item.payload);
}
this.logger.info(
logger.info(
`Sending batch of ${batch.batch.length} to-device messages with ID ${batch.id} and txnId ${batch.txnId}`,
);
@@ -147,8 +141,8 @@ export class ToDeviceMessageQueue {
*/
private onResumedSync = (state: SyncState | null, oldState: SyncState | null): void => {
if (state === SyncState.Syncing && oldState !== SyncState.Syncing) {
this.logger.info(`Resuming queue after resumed sync`);
void this.sendQueue();
logger.info(`Resuming queue after resumed sync`);
this.sendQueue();
}
};
}

View File

@@ -15,10 +15,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type IClientWellKnown, type IWellKnownConfig, type IServerVersions } from "./client.ts";
import { logger } from "./logger.ts";
import { type MatrixError, Method, timeoutSignal } from "./http-api/index.ts";
import { SUPPORTED_MATRIX_VERSIONS } from "./version-support.ts";
import { SigningKey } from "oidc-client-ts";
import { IClientWellKnown, IWellKnownConfig, IDelegatedAuthConfig, IServerVersions, M_AUTHENTICATION } from "./client";
import { logger } from "./logger";
import { MatrixError, Method, timeoutSignal } from "./http-api";
import { discoverAndValidateAuthenticationConfig } from "./oidc/discovery";
import {
ValidatedIssuerConfig,
ValidatedIssuerMetadata,
validateOIDCIssuerWellKnown,
validateWellKnownAuthentication,
} from "./oidc/validate";
import { OidcError } from "./oidc/error";
// Dev note: Auto discovery is part of the spec.
// See: https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery
@@ -31,7 +40,7 @@ export enum AutoDiscoveryAction {
FAIL_ERROR = "FAIL_ERROR",
}
export enum AutoDiscoveryError {
enum AutoDiscoveryError {
Invalid = "Invalid homeserver discovery response",
GenericFailure = "Failed to get autodiscovery configuration from server",
InvalidHsBaseUrl = "Invalid base_url for m.homeserver",
@@ -41,10 +50,6 @@ export enum AutoDiscoveryError {
InvalidIs = "Invalid identity server discovery response",
MissingWellknown = "No .well-known JSON file found",
InvalidJson = "Invalid JSON",
UnsupportedHomeserverSpecVersion = "The homeserver does not meet the version requirements",
// TODO: Implement when Sydent supports the `/versions` endpoint - https://github.com/matrix-org/sydent/issues/424
//IdentityServerTooOld = "The identity server does not meet the minimum version requirements",
}
interface AutoDiscoveryState {
@@ -53,9 +58,26 @@ interface AutoDiscoveryState {
}
interface WellKnownConfig extends Omit<IWellKnownConfig, "error">, AutoDiscoveryState {}
/**
* @deprecated in favour of OidcClientConfig
*/
interface DelegatedAuthConfig extends IDelegatedAuthConfig, ValidatedIssuerConfig, AutoDiscoveryState {}
/**
* @experimental
*/
export interface OidcClientConfig extends IDelegatedAuthConfig, ValidatedIssuerConfig {
metadata: ValidatedIssuerMetadata;
signingKeys?: SigningKey[];
}
export interface ClientConfig extends Omit<IClientWellKnown, "m.homeserver" | "m.identity_server"> {
"m.homeserver": WellKnownConfig;
"m.identity_server": WellKnownConfig;
/**
* @experimental
*/
"m.authentication"?: (OidcClientConfig & AutoDiscoveryState) | AutoDiscoveryState;
}
/**
@@ -86,10 +108,7 @@ export class AutoDiscovery {
public static readonly ERROR_INVALID_JSON = AutoDiscoveryError.InvalidJson;
public static readonly ERROR_UNSUPPORTED_HOMESERVER_SPEC_VERSION =
AutoDiscoveryError.UnsupportedHomeserverSpecVersion;
public static readonly ALL_ERRORS = Object.keys(AutoDiscoveryError) as AutoDiscoveryError[];
public static readonly ALL_ERRORS = Object.keys(AutoDiscoveryError);
/**
* The auto discovery failed. The client is expected to communicate
@@ -130,7 +149,7 @@ export class AutoDiscovery {
* configuration, which may include error states. Rejects on unexpected
* failure, not when verification fails.
*/
public static async fromDiscoveryConfig(wellknown?: IClientWellKnown): Promise<ClientConfig> {
public static async fromDiscoveryConfig(wellknown: IClientWellKnown): Promise<ClientConfig> {
// Step 1 is to get the config, which is provided to us here.
// We default to an error state to make the first few checks easier to
@@ -180,7 +199,7 @@ export class AutoDiscovery {
// Step 3: Make sure the homeserver URL points to a homeserver.
const hsVersions = await this.fetchWellKnownObject<IServerVersions>(`${hsUrl}/_matrix/client/versions`);
if (!hsVersions || !Array.isArray(hsVersions.raw?.["versions"])) {
if (!hsVersions?.raw?.["versions"]) {
logger.error("Invalid /versions response");
clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HOMESERVER;
@@ -191,27 +210,6 @@ export class AutoDiscovery {
return Promise.resolve(clientConfig);
}
// Step 3.1: Non-spec check to ensure the server will actually work for us. We need to check if
// any of the versions in `SUPPORTED_MATRIX_VERSIONS` are listed in the /versions response.
const hsVersionSet = new Set(hsVersions.raw["versions"]);
let supportedVersionFound = false;
for (const version of SUPPORTED_MATRIX_VERSIONS) {
if (hsVersionSet.has(version)) {
supportedVersionFound = true;
break;
}
}
if (!supportedVersionFound) {
logger.error("Homeserver does not meet version requirements");
clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_UNSUPPORTED_HOMESERVER_SPEC_VERSION;
// Supply the base_url to the caller because they may be ignoring liveliness
// errors, like this one.
clientConfig["m.homeserver"].base_url = hsUrl;
return Promise.resolve(clientConfig);
}
// Step 4: Now that the homeserver looks valid, update our client config.
clientConfig["m.homeserver"] = {
state: AutoDiscovery.SUCCESS,
@@ -286,10 +284,107 @@ export class AutoDiscovery {
}
});
const authConfig = await this.discoverAndValidateAuthenticationConfig(wellknown);
clientConfig[M_AUTHENTICATION.stable!] = authConfig;
// Step 8: Give the config to the caller (finally)
return Promise.resolve(clientConfig);
}
/**
* Validate delegated auth configuration
* @deprecated use discoverAndValidateAuthenticationConfig
* - m.authentication config is present and valid
* - delegated auth issuer openid-configuration is reachable
* - delegated auth issuer openid-configuration is configured correctly for us
* When successful, DelegatedAuthConfig will be returned with endpoints used for delegated auth
* Any errors are caught, and AutoDiscoveryState returned with error
* @param wellKnown - configuration object as returned
* by the .well-known auto-discovery endpoint
* @returns Config or failure result
*/
public static async validateDiscoveryAuthenticationConfig(
wellKnown: IClientWellKnown,
): Promise<DelegatedAuthConfig | AutoDiscoveryState> {
try {
const authentication = M_AUTHENTICATION.findIn<IDelegatedAuthConfig>(wellKnown) || undefined;
const homeserverAuthenticationConfig = validateWellKnownAuthentication(authentication);
const issuerOpenIdConfigUrl = `${this.sanitizeWellKnownUrl(
homeserverAuthenticationConfig.issuer,
)}/.well-known/openid-configuration`;
const issuerWellKnown = await this.fetchWellKnownObject<unknown>(issuerOpenIdConfigUrl);
if (issuerWellKnown.action !== AutoDiscoveryAction.SUCCESS) {
logger.error("Failed to fetch issuer openid configuration");
throw new Error(OidcError.General);
}
const validatedIssuerConfig = validateOIDCIssuerWellKnown(issuerWellKnown.raw);
const delegatedAuthConfig: DelegatedAuthConfig = {
state: AutoDiscoveryAction.SUCCESS,
error: null,
...homeserverAuthenticationConfig,
...validatedIssuerConfig,
};
return delegatedAuthConfig;
} catch (error) {
const errorMessage = (error as Error).message as unknown as OidcError;
const errorType = Object.values(OidcError).includes(errorMessage) ? errorMessage : OidcError.General;
const state =
errorType === OidcError.NotSupported ? AutoDiscoveryAction.IGNORE : AutoDiscoveryAction.FAIL_ERROR;
return {
state,
error: errorType,
};
}
}
/**
* Validate delegated auth configuration
* - m.authentication config is present and valid
* - delegated auth issuer openid-configuration is reachable
* - delegated auth issuer openid-configuration is configured correctly for us
* When successful, validated authentication metadata and optionally signing keys will be returned
* Any errors are caught, and AutoDiscoveryState returned with error
* @param wellKnown - configuration object as returned
* by the .well-known auto-discovery endpoint
* @returns Config or failure result
*/
public static async discoverAndValidateAuthenticationConfig(
wellKnown: IClientWellKnown,
): Promise<(OidcClientConfig & AutoDiscoveryState) | AutoDiscoveryState> {
try {
const authentication = M_AUTHENTICATION.findIn<IDelegatedAuthConfig>(wellKnown) || undefined;
const result = await discoverAndValidateAuthenticationConfig(authentication);
// include this for backwards compatibility
const validatedIssuerConfig = validateOIDCIssuerWellKnown(result.metadata);
const response = {
state: AutoDiscoveryAction.SUCCESS,
error: null,
...validatedIssuerConfig,
...result,
};
return response;
} catch (error) {
const errorMessage = (error as Error).message as unknown as OidcError;
const errorType = Object.values(OidcError).includes(errorMessage) ? errorMessage : OidcError.General;
const state =
errorType === OidcError.NotSupported ? AutoDiscoveryAction.IGNORE : AutoDiscoveryAction.FAIL_ERROR;
return {
state,
error: errorType,
};
}
}
/**
* Attempts to automatically discover client configuration information
* prior to logging in. Such information includes the homeserver URL
@@ -358,7 +453,7 @@ export class AutoDiscovery {
}
// Step 2: Validate and parse the config
return AutoDiscovery.fromDiscoveryConfig(wellknown.raw);
return AutoDiscovery.fromDiscoveryConfig(wellknown.raw!);
}
/**
@@ -414,16 +509,16 @@ export class AutoDiscovery {
}
}
private static fetch(resource: URL | string, options?: RequestInit): ReturnType<typeof globalThis.fetch> {
private static fetch(resource: URL | string, options?: RequestInit): ReturnType<typeof global.fetch> {
if (this.fetchFn) {
return this.fetchFn(resource, options);
}
return globalThis.fetch(resource, options);
return global.fetch(resource, options);
}
private static fetchFn?: typeof globalThis.fetch;
private static fetchFn?: typeof global.fetch;
public static setFetchFn(fetchFn: typeof globalThis.fetch): void {
public static setFetchFn(fetchFn: typeof global.fetch): void {
AutoDiscovery.fetchFn = fetchFn;
}
@@ -462,7 +557,7 @@ export class AutoDiscovery {
};
}
if (response.status !== 200) {
if (!response.ok) {
return {
raw: {},
action: AutoDiscoveryAction.FAIL_PROMPT,

View File

@@ -1,86 +0,0 @@
/*
Copyright 2023 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.
*/
/**
* Base64 encoding and decoding utilities
*/
function toBase64(uint8Array: Uint8Array, options: Uint8ArrayToBase64Options): string {
if (typeof uint8Array.toBase64 === "function") {
// Currently this is only supported in Firefox,
// but we match the options in the hope in the future we can rely on it for all environments.
// https://tc39.es/proposal-arraybuffer-base64/spec/#sec-uint8array.prototype.tobase64
return uint8Array.toBase64(options);
}
let base64 = btoa(uint8Array.reduce((acc, current) => acc + String.fromCharCode(current), ""));
if (options.omitPadding) {
base64 = base64.replace(/={1,2}$/, "");
}
if (options.alphabet === "base64url") {
base64 = base64.replace(/\+/g, "-").replace(/\//g, "_");
}
return base64;
}
/**
* Encode a typed array of uint8 as base64.
* @param uint8Array - The data to encode.
* @returns The base64.
*/
export function encodeBase64(uint8Array: Uint8Array): string {
return toBase64(uint8Array, { alphabet: "base64", omitPadding: false });
}
/**
* Encode a typed array of uint8 as unpadded base64.
* @param uint8Array - The data to encode.
* @returns The unpadded base64.
*/
export function encodeUnpaddedBase64(uint8Array: Uint8Array): string {
return toBase64(uint8Array, { alphabet: "base64", omitPadding: true });
}
/**
* Encode a typed array of uint8 as unpadded base64 using the URL-safe encoding.
* @param uint8Array - The data to encode.
* @returns The unpadded base64.
*/
export function encodeUnpaddedBase64Url(uint8Array: Uint8Array): string {
return toBase64(uint8Array, { alphabet: "base64url", omitPadding: true });
}
function fromBase64(base64: string, options: Uint8ArrayFromBase64Options): Uint8Array<ArrayBuffer> {
if (typeof Uint8Array.fromBase64 === "function") {
// Currently this is only supported in Firefox,
// but we match the options in the hope in the future we can rely on it for all environments.
// https://tc39.es/proposal-arraybuffer-base64/spec/#sec-uint8array.frombase64
return Uint8Array.fromBase64(base64, options);
}
return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
}
/**
* Decode a base64 (or base64url) string to a typed array of uint8.
* @param base64 - The base64 to decode.
* @returns The decoded data.
*/
export function decodeBase64(base64: string): Uint8Array<ArrayBuffer> {
// The function requires us to select an alphabet, but we don't know if base64url was used so we convert.
return fromBase64(base64.replace(/-/g, "+").replace(/_/g, "/"), { alphabet: "base64", lastChunkHandling: "loose" });
}

View File

@@ -14,31 +14,34 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as sdk from "./matrix.ts";
import * as matrixcs from "./matrix";
type BrowserMatrix = typeof sdk;
type BrowserMatrix = typeof matrixcs;
declare global {
/* eslint-disable no-var, camelcase */
var __js_sdk_entrypoint: boolean;
var matrixcs: BrowserMatrix;
/* eslint-enable no-var */
}
if (globalThis.__js_sdk_entrypoint) {
if (global.__js_sdk_entrypoint) {
throw new Error("Multiple matrix-js-sdk entrypoints detected!");
}
globalThis.__js_sdk_entrypoint = true;
global.__js_sdk_entrypoint = true;
// just *accessing* indexedDB throws an exception in firefox with indexeddb disabled.
let indexedDB: IDBFactory | undefined;
try {
indexedDB = globalThis.indexedDB;
} catch {
// Do nothing
}
indexedDB = global.indexedDB;
} catch (e) {}
// if our browser (appears to) support indexeddb, use an indexeddb crypto store.
if (indexedDB) {
sdk.setCryptoStoreFactory(() => new sdk.IndexedDBCryptoStore(indexedDB, "matrix-js-sdk:crypto"));
matrixcs.setCryptoStoreFactory(() => new matrixcs.IndexedDBCryptoStore(indexedDB!, "matrix-js-sdk:crypto"));
}
export * from "./matrix.ts";
globalThis.matrixcs = sdk;
// We export 3 things to make browserify happy as well as downstream projects.
// It's awkward, but required.
export * from "./matrix";
export default matrixcs; // keep export for browserify package deps
global.matrixcs = matrixcs;

View File

@@ -1,102 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 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 { type IHttpOpts, type MatrixHttpApi } from "./http-api/index.ts";
import { type Logger } from "./logger.ts";
import { TypedEventEmitter } from "./models/typed-event-emitter.ts";
import { deepCompare } from "./utils.ts";
// How often we update the server capabilities.
// 6 hours - an arbitrary value, but they should change very infrequently.
const CAPABILITIES_CACHE_MS = 6 * 60 * 60 * 1000;
// How long we want before retrying if we couldn't fetch
const CAPABILITIES_RETRY_MS = 30 * 1000;
/**
* Manages storing and periodically refreshing the server capabilities.
*/
export abstract class CapabilityPoller<ResponseType> extends TypedEventEmitter<
"update",
{ update: (data: ResponseType) => void }
> {
protected cached?: ResponseType;
private retryTimeout?: ReturnType<typeof setTimeout>;
private refreshTimeout?: ReturnType<typeof setInterval>;
public constructor(
protected readonly logger: Logger,
protected readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
private readonly name: string,
) {
super();
}
/**
* Starts periodically fetching the server capabilities.
*/
public start(): void {
this.poll().then();
}
/**
* Stops the service
*/
public stop(): void {
this.clearTimeouts();
}
/**
* Returns the cached capabilities, or undefined if none are cached.
* @returns the current capabilities, if any.
*/
public getCached(): ResponseType | undefined {
return this.cached;
}
public abstract fetch(): Promise<ResponseType>;
private poll = async (): Promise<void> => {
this.logger.debug("Checking capabilites");
try {
const current = this.cached;
await this.fetch();
this.clearTimeouts();
this.refreshTimeout = globalThis.setTimeout(this.poll, CAPABILITIES_CACHE_MS);
this.logger.debug(`Fetched new server ${this.name}`);
if (this.cached && !deepCompare(current, this.cached)) {
this.emit("update", this.cached);
}
} catch (e) {
this.clearTimeouts();
const howLong = Math.floor(CAPABILITIES_RETRY_MS + Math.random() * 5000);
this.retryTimeout = globalThis.setTimeout(this.poll, howLong);
this.logger.warn(`Failed to refresh ${this.name}: retrying in ${howLong}ms`, e);
}
};
private clearTimeouts(): void {
if (this.refreshTimeout) {
clearInterval(this.refreshTimeout);
this.refreshTimeout = undefined;
}
if (this.retryTimeout) {
clearTimeout(this.retryTimeout);
this.retryTimeout = undefined;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -14,12 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import type { IDeviceLists, IToDeviceEvent, ReceivedToDeviceMessage } from "../sync-accumulator.ts";
import { type IClearEvent, type MatrixEvent } from "../models/event.ts";
import { type Room } from "../models/room.ts";
import { type CryptoApi, type DecryptionFailureCode, type ImportRoomKeysOpts } from "../crypto-api/index.ts";
import { type KeyBackupInfo, type KeyBackupSession } from "../crypto-api/keybackup.ts";
import { type IMegolmSessionData } from "../@types/crypto.ts";
import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator";
import { MatrixEvent } from "../models/event";
import { Room } from "../models/room";
import { CryptoApi } from "../crypto-api";
import { CrossSigningInfo, UserTrustLevel } from "../crypto/CrossSigning";
import { IEncryptedEventInfo } from "../crypto/api";
import { IEventDecryptionResult } from "../@types/crypto";
/**
* Common interface for the crypto implementations
@@ -33,8 +34,8 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
* symmetry with setGlobalBlacklistUnverifiedDevices but there is currently
* no room-level equivalent for this setting.
*
* @remarks This has no effect in Rust Crypto; it exists only for the sake of
* the accessors in MatrixClient.
* @remarks this is here, rather than in `CryptoApi`, because I don't think we're
* going to support it in the rust crypto implementation.
*/
globalErrorOnUnknownDevices: boolean;
@@ -43,6 +44,15 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
*/
stop(): void;
/**
* Get the verification level for a given user
*
* TODO: define this better
*
* @param userId - user to be checked
*/
checkUserTrust(userId: string): UserTrustLevel;
/**
* Encrypt an event according to the configuration of the room.
*
@@ -61,61 +71,34 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
* @returns a promise which resolves once we have finished decrypting.
* Rejects with an error if there is a problem decrypting the event.
*/
decryptEvent(event: MatrixEvent): Promise<EventDecryptionResult>;
decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult>;
/**
* Get a backup decryptor capable of decrypting megolm session data encrypted with the given backup information.
* @param backupInfo - The backup information
* @param privKey - The private decryption key.
* Get information about the encryption of an event
*
* @param event - event to be checked
*/
getBackupDecryptor(backupInfo: KeyBackupInfo, privKey: Uint8Array): Promise<BackupDecryptor>;
getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo;
/**
* Import a list of room keys restored from backup
* Get the cross signing information for a given user.
*
* @param keys - a list of session export objects
* @param backupVersion - the version of the backup these keys came from.
* @param opts - options object
* @returns a promise which resolves once the keys have been imported
* The cross-signing API is currently UNSTABLE and may change without notice.
*
* @param userId - the user ID to get the cross-signing info for.
*
* @returns the cross signing information for the user.
*/
importBackedUpRoomKeys(keys: IMegolmSessionData[], backupVersion: string, opts?: ImportRoomKeysOpts): Promise<void>;
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// Room key history sharing (MSC4268)
//
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
getStoredCrossSigningForUser(userId: string): CrossSigningInfo | null;
/**
* Share any shareable E2EE history in the given room with the given recipient,
* as per [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268)
* Check the cross signing trust of the current user
*
* @param opts - Options object.
*
* @deprecated Unneeded for the new crypto
*/
shareRoomHistoryWithUser(roomId: string, userId: string): Promise<void>;
/**
* Having accepted an invite for the given room from the given user, attempt to
* find information about a room key bundle and, if found, download the
* bundle and import the room keys, as per {@link https://github.com/matrix-org/matrix-spec-proposals/pull/4268|MSC4268}.
*
* @param roomId - The room we were invited to, for which we want to check if a room
* key bundle was received.
*
* @param inviter - The user who invited us to the room and is expected to have
* sent the room key bundle.
*
* @returns `true` if the key bundle was successfuly downloaded and imported.
*/
maybeAcceptKeyBundle(roomId: string, inviter: string): Promise<boolean>;
/**
* Mark a room as pending a key bundle under MSC4268. The backend will listen for room key bundle messages, and if
* it sees one matching the room specified, it will automatically import it as long as the message author's ID matches
* the inviter's ID.
*
* @param roomId - The room we were invited to, for which we did not receive a key bundle before accepting the invite.
* @param inviterId - The user who invited us to the room and is expected to send the room key bundle.
*/
markRoomAsPendingKeyBundle(roomId: string, inviterId: string): Promise<void>;
checkOwnCrossSigningTrust(opts?: CheckOwnCrossSigningTrustOpts): Promise<void>;
}
/** The methods which crypto implementations should expose to the Sync api
@@ -133,11 +116,9 @@ export interface SyncCryptoCallbacks {
* messages, rather than the results of any decryption attempts.
*
* @param events - the received to-device messages
* @returns A list of preprocessed to-device messages. This will not map 1:1 to the input list, as some messages may be invalid or
* failed to decrypt, and so will be omitted from the output list.
*
* @returns A list of preprocessed to-device messages.
*/
preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<ReceivedToDeviceMessage[]>;
preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]>;
/**
* Called by the /sync loop when one time key counts and unused fallback key details are received.
@@ -177,15 +158,6 @@ export interface SyncCryptoCallbacks {
* @param syncState - information about the completed sync.
*/
onSyncCompleted(syncState: OnSyncCompletedData): void;
/**
* Mark all tracked users' device lists as dirty.
*
* This method will cause additional `/keys/query` requests on the server, so should be used only
* when the client has desynced tracking device list deltas from the server.
* In MSC4186: Simplified Sliding Sync, this can happen when the server expires the connection.
*/
markAllTrackedUsersAsDirty(): Promise<void>;
}
/**
@@ -204,104 +176,8 @@ export interface OnSyncCompletedData {
}
/**
* The result of a (successful) call to {@link CryptoBackend.decryptEvent}
* Options object for {@link CryptoBackend#checkOwnCrossSigningTrust}.
*/
export interface EventDecryptionResult {
/**
* The plaintext payload for the event (typically containing <tt>type</tt> and <tt>content</tt> fields).
*/
clearEvent: IClearEvent;
/**
* No longer used.
* See {@link MatrixEvent#getForwardingCurve25519KeyChain}.
* @deprecated
*/
forwardingCurve25519KeyChain?: string[];
/**
* Key owned by the sender of this event. See {@link MatrixEvent#getSenderKey}.
*/
senderCurve25519Key?: string;
/**
* ed25519 key claimed by the sender of this event. See {@link MatrixEvent#getClaimedEd25519Key}.
*/
claimedEd25519Key?: string;
/**
* If another user forwarded the key to this message
* (eg via [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268)),
* the ID of that user.
*/
keyForwardedBy?: string;
}
/**
* Responsible for decrypting megolm session data retrieved from a remote backup.
* The result of {@link CryptoBackend#getBackupDecryptor}.
*/
export interface BackupDecryptor {
/**
* Whether keys retrieved from this backup can be trusted.
*
* Depending on the backup algorithm, keys retrieved from the backup can be trusted or not.
* If false, keys retrieved from the backup must be considered unsafe (authenticity cannot be guaranteed).
* It could be by design (deniability) or for some technical reason (eg asymmetric encryption).
*/
readonly sourceTrusted: boolean;
/**
*
* Decrypt megolm session data retrieved from backup.
*
* @param ciphertexts - a Record of sessionId to session data.
*
* @returns An array of decrypted `IMegolmSessionData`
*/
decryptSessions(ciphertexts: Record<string, KeyBackupSession>): Promise<IMegolmSessionData[]>;
/**
* Free any resources held by this decryptor.
*
* Should be called once the decryptor is no longer needed.
*/
free(): void;
}
/**
* Exception thrown when decryption fails
*
* @param code - Reason code for the failure.
*
* @param msg - user-visible message describing the problem
*
* @param details - key/value pairs reported in the logs but not shown
* to the user.
*/
export class DecryptionError extends Error {
public readonly detailedString: string;
public constructor(
public readonly code: DecryptionFailureCode,
msg: string,
details?: Record<string, string | Error>,
) {
super(msg);
this.name = "DecryptionError";
this.detailedString = detailedStringForDecryptionError(this, details);
}
}
function detailedStringForDecryptionError(err: DecryptionError, details?: Record<string, string | Error>): string {
let result = err.name + "[msg: " + err.message;
if (details) {
result +=
", " +
Object.keys(details)
.map((k) => k + ": " + details[k])
.join(", ");
}
result += "]";
return result;
export interface CheckOwnCrossSigningTrustOpts {
allowPrivateKeyRequests?: boolean;
}

View File

@@ -1,42 +0,0 @@
/*
* Copyright 2024 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 { deriveRecoveryKeyFromPassphrase } from "../crypto-api/index.ts";
interface IAuthData {
private_key_salt?: string;
private_key_iterations?: number;
private_key_bits?: number;
}
/**
* Derive a backup key from a passphrase using the salt and iterations from the auth data.
* @param authData - The auth data containing the salt and iterations
* @param passphrase - The passphrase to derive the key from
* @deprecated Deriving a backup key from a passphrase is not part of the matrix spec. Instead, a random key is generated and stored/shared via 4S.
*/
export function keyFromAuthData(authData: IAuthData, passphrase: string): Promise<Uint8Array> {
if (!authData.private_key_salt || !authData.private_key_iterations) {
throw new Error("Salt and/or iterations not found: this backup cannot be restored with a passphrase");
}
return deriveRecoveryKeyFromPassphrase(
passphrase,
authData.private_key_salt,
authData.private_key_iterations,
authData.private_key_bits,
);
}

View File

@@ -14,23 +14,23 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type MBeaconEventContent, type MBeaconInfoContent, type MBeaconInfoEventContent } from "./@types/beacon.ts";
import { MsgType } from "./@types/event.ts";
import { type IMessageRendering, M_TEXT, REFERENCE_RELATION } from "./@types/extensible_events.ts";
import { isProvided } from "./extensible_events_v1/utilities.ts";
import { MBeaconEventContent, MBeaconInfoContent, MBeaconInfoEventContent } from "./@types/beacon";
import { MsgType } from "./@types/event";
import { M_TEXT, REFERENCE_RELATION } from "./@types/extensible_events";
import { isProvided } from "./extensible_events_v1/utilities";
import {
M_ASSET,
LocationAssetType,
M_LOCATION,
M_TIMESTAMP,
type LocationEventWireContent,
type MLocationEventContent,
type MLocationContent,
type MAssetContent,
type LegacyLocationEventContent,
} from "./@types/location.ts";
import { type MRoomTopicEventContent, type MTopicContent, M_TOPIC, type MTopicEvent } from "./@types/topic.ts";
import { type RoomMessageEventContent } from "./@types/events.ts";
LocationEventWireContent,
MLocationEventContent,
MLocationContent,
MAssetContent,
LegacyLocationEventContent,
} from "./@types/location";
import { MRoomTopicEventContent, MTopicContent, M_TOPIC } from "./@types/topic";
import { IContent } from "./models/event";
/**
* Generates the content for a HTML Message event
@@ -38,7 +38,7 @@ import { type RoomMessageEventContent } from "./@types/events.ts";
* @param htmlBody - the HTML representation of the message
* @returns
*/
export function makeHtmlMessage(body: string, htmlBody: string): RoomMessageEventContent {
export function makeHtmlMessage(body: string, htmlBody: string): IContent {
return {
msgtype: MsgType.Text,
format: "org.matrix.custom.html",
@@ -53,7 +53,7 @@ export function makeHtmlMessage(body: string, htmlBody: string): RoomMessageEven
* @param htmlBody - the HTML representation of the notice
* @returns
*/
export function makeHtmlNotice(body: string, htmlBody: string): RoomMessageEventContent {
export function makeHtmlNotice(body: string, htmlBody: string): IContent {
return {
msgtype: MsgType.Notice,
format: "org.matrix.custom.html",
@@ -68,7 +68,7 @@ export function makeHtmlNotice(body: string, htmlBody: string): RoomMessageEvent
* @param htmlBody - the HTML representation of the emote
* @returns
*/
export function makeHtmlEmote(body: string, htmlBody: string): RoomMessageEventContent {
export function makeHtmlEmote(body: string, htmlBody: string): IContent {
return {
msgtype: MsgType.Emote,
format: "org.matrix.custom.html",
@@ -82,7 +82,7 @@ export function makeHtmlEmote(body: string, htmlBody: string): RoomMessageEventC
* @param body - the plaintext body of the emote
* @returns
*/
export function makeTextMessage(body: string): RoomMessageEventContent {
export function makeTextMessage(body: string): IContent {
return {
msgtype: MsgType.Text,
body: body,
@@ -94,7 +94,7 @@ export function makeTextMessage(body: string): RoomMessageEventContent {
* @param body - the plaintext body of the notice
* @returns
*/
export function makeNotice(body: string): RoomMessageEventContent {
export function makeNotice(body: string): IContent {
return {
msgtype: MsgType.Notice,
body: body,
@@ -106,7 +106,7 @@ export function makeNotice(body: string): RoomMessageEventContent {
* @param body - the plaintext body of the emote
* @returns
*/
export function makeEmoteMessage(body: string): RoomMessageEventContent {
export function makeEmoteMessage(body: string): IContent {
return {
msgtype: MsgType.Emote,
body: body,
@@ -185,37 +185,27 @@ export const parseLocationEvent = (wireEventContent: LocationEventWireContent):
/**
* Topic event helpers
*/
export type MakeTopicContent = (topic: string | null | undefined, htmlTopic?: string) => MRoomTopicEventContent;
export type MakeTopicContent = (topic: string, htmlTopic?: string) => MRoomTopicEventContent;
export const makeTopicContent: MakeTopicContent = (topic, htmlTopic) => {
const renderings = [];
// Put HTML first because clients will render the first type in
// the array that they understand
const renderings = [{ body: topic, mimetype: "text/plain" }];
if (isProvided(htmlTopic)) {
renderings.push({ body: htmlTopic, mimetype: "text/html" });
renderings.push({ body: htmlTopic!, mimetype: "text/html" });
}
if (isProvided(topic)) {
renderings.push({ body: topic, mimetype: "text/plain" });
}
return { topic, [M_TOPIC.name]: { "m.text": renderings } };
return { topic, [M_TOPIC.name]: renderings };
};
export type TopicState = {
text?: string;
text: string;
html?: string;
};
export const parseTopicContent = (content: MRoomTopicEventContent): TopicState => {
const mtopicParent = M_TOPIC.findIn<MTopicContent | IMessageRendering[]>(content as MTopicEvent);
const mtopic = Array.isArray(mtopicParent) ? mtopicParent : mtopicParent?.["m.text"];
// TODO remove support for the old malformed m.topic arrays after a few releases (only allow array in m.text)
// https://github.com/matrix-org/matrix-js-sdk/pull/4984#pullrequestreview-3174251065
//const mtopic = M_TOPIC.findIn<MTopicContent>(content)?.["m.text"];
const mtopic = M_TOPIC.findIn<MTopicContent>(content);
if (!Array.isArray(mtopic)) {
return { text: content.topic ?? undefined };
return { text: content.topic };
}
const text =
mtopic?.find((r) => !isProvided(r.mimetype) || r.mimetype === "text/plain")?.body ?? content.topic ?? undefined;
const text = mtopic?.find((r) => !isProvided(r.mimetype) || r.mimetype === "text/plain")?.body ?? content.topic;
const html = mtopic?.find((r) => r.mimetype === "text/html")?.body;
return { text, html };
};

View File

@@ -1,5 +1,5 @@
/*
Copyright 2015 - 2024 The Matrix.org Foundation C.I.C.
Copyright 2015 - 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.
@@ -14,22 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
// Validation based on https://spec.matrix.org/v1.12/appendices/#server-name
// We do not use the validation described in https://spec.matrix.org/v1.12/client-server-api/#security-considerations-5
// as it'd wrongly make all MXCs invalid due to not allowing `[].:` in server names.
const serverNameRegex =
/^(?:(?:\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})|(?:\[[\dA-Fa-f:.]{2,45}])|(?:[A-Za-z\d\-.]{1,255}))(?::\d{1,5})?$/;
function validateServerName(serverName: string): boolean {
const matches = serverNameRegex.exec(serverName);
return matches?.[0] === serverName;
}
// Validation based on https://spec.matrix.org/v1.12/client-server-api/#security-considerations-5
const mediaIdRegex = /^[\w-]+$/;
function validateMediaId(mediaId: string): boolean {
const matches = mediaIdRegex.exec(mediaId);
return matches?.[0] === mediaId;
}
import { encodeParams } from "./utils";
/**
* Get the HTTP URL for an MXC URI.
@@ -43,16 +28,7 @@ function validateMediaId(mediaId: string): boolean {
* directly. Fetching such URLs will leak information about the user to
* anyone they share a room with. If false, will return the emptry string
* for such URLs.
* @param allowRedirects - If true, the caller supports the URL being 307 or
* 308 redirected to another resource upon request. If false, redirects
* are not expected. Implied `true` when `useAuthentication` is `true`.
* @param useAuthentication - If true, the caller supports authenticated
* media and wants an authentication-required URL. Note that server support
* for authenticated media will *not* be checked - it is the caller's responsibility
* to do so before calling this function. Note also that `useAuthentication`
* implies `allowRedirects`. Defaults to false (unauthenticated endpoints).
* @param animated - Whether the desired thumbnail should be animated.
* @returns The complete URL to the content, may be an empty string if the provided mxc is not valid.
* @returns The complete URL to the content.
*/
export function getHttpUriForMxc(
baseUrl: string,
@@ -61,62 +37,43 @@ export function getHttpUriForMxc(
height?: number,
resizeMethod?: string,
allowDirectLinks = false,
allowRedirects?: boolean,
useAuthentication?: boolean,
animated?: boolean,
): string {
if (typeof mxc !== "string" || !mxc) {
return "";
}
if (!mxc.startsWith("mxc://")) {
if (mxc.indexOf("mxc://") !== 0) {
if (allowDirectLinks) {
return mxc;
} else {
return "";
}
}
const [serverName, mediaId, ...rest] = mxc.slice(6).split("/");
if (rest.length > 0 || !validateServerName(serverName) || !validateMediaId(mediaId)) {
return "";
}
if (useAuthentication) {
allowRedirects = true; // per docs (MSC3916 always expects redirects)
// Dev note: MSC3916 removes `allow_redirect` entirely, but
// for explicitness we set it here. This makes it slightly more obvious to
// callers, hopefully.
}
let prefix: string;
const isThumbnailRequest = !!width || !!height || !!resizeMethod;
const verb = isThumbnailRequest ? "thumbnail" : "download";
if (useAuthentication) {
prefix = `/_matrix/client/v1/media/${verb}`;
} else {
prefix = `/_matrix/media/v3/${verb}`;
}
const url = new URL(`${prefix}/${serverName}/${mediaId}`, baseUrl);
let serverAndMediaId = mxc.slice(6); // strips mxc://
let prefix = "/_matrix/media/r0/download/";
const params: Record<string, string> = {};
if (width) {
url.searchParams.set("width", Math.round(width).toString());
params["width"] = Math.round(width).toString();
}
if (height) {
url.searchParams.set("height", Math.round(height).toString());
params["height"] = Math.round(height).toString();
}
if (resizeMethod) {
url.searchParams.set("method", resizeMethod);
params["method"] = resizeMethod;
}
if (animated !== undefined) {
url.searchParams.set("animated", String(animated));
if (Object.keys(params).length > 0) {
// these are thumbnailing params so they probably want the
// thumbnailing API...
prefix = "/_matrix/media/r0/thumbnail/";
}
if (typeof allowRedirects === "boolean") {
// We add this after, so we don't convert everything to a thumbnail request.
url.searchParams.set("allow_redirect", JSON.stringify(allowRedirects));
const fragmentOffset = serverAndMediaId.indexOf("#");
let fragment = "";
if (fragmentOffset >= 0) {
fragment = serverAndMediaId.slice(fragmentOffset);
serverAndMediaId = serverAndMediaId.slice(0, fragmentOffset);
}
return url.href;
const urlParams = Object.keys(params).length === 0 ? "" : "?" + encodeParams(params);
return baseUrl + prefix + serverAndMediaId + urlParams + fragment;
}

View File

@@ -1,152 +0,0 @@
/*
* Copyright 2024 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.
*/
/**
* Cryptography-related events emitted by the {@link matrix.MatrixClient}.
*/
export enum CryptoEvent {
/**
* Fires when the trust status of a user changes.
* The payload is a pair (userId, userTrustLevel). The trust level is one of the values from UserVerificationStatus.
*/
UserTrustStatusChanged = "userTrustStatusChanged",
/**
* Fires when the key backup status changes.
* The payload is a boolean indicating whether the key backup is enabled.
*/
KeyBackupStatus = "crypto.keyBackupStatus",
/**
* Fires when we failed to back up the keys
* The payload is the error code of the error that occurred.
*/
KeyBackupFailed = "crypto.keyBackupFailed",
/**
* Fires when the number of sessions that can be backed up changes.
* The payload is the remaining number of sessions that can be backed up.
*/
KeyBackupSessionsRemaining = "crypto.keyBackupSessionsRemaining",
/**
* Fires when a new valid backup decryption key is in cache.
* This will happen when a secret is received from another session, from secret storage,
* or when a new backup is created from this session.
*
* The payload is the version of the backup for which we have the key for.
*
* This event is only fired by the rust crypto backend.
*/
KeyBackupDecryptionKeyCached = "crypto.keyBackupDecryptionKeyCached",
/**
* Fires when a key verification request is received.
* The payload is a VerificationRequest object representing the request.
*/
VerificationRequestReceived = "crypto.verificationRequestReceived",
/** @deprecated Use {@link DevicesUpdated} instead when using rust crypto */
WillUpdateDevices = "crypto.willUpdateDevices",
/**
* Fires whenever the stored devices for a user have been updated
* The payload is a pair (userIds, initialFetch).
*/
DevicesUpdated = "crypto.devicesUpdated",
/**
* Fires when the user's cross-signing keys have changed or cross-signing
* has been enabled/disabled. The client can use getStoredCrossSigningForUser
* with the user ID of the logged in user to check if cross-signing is
* enabled on the account. If enabled, it can test whether the current key
* is trusted using with checkUserTrust with the user ID of the logged
* in user. The checkOwnCrossSigningTrust function may be used to reconcile
* the trust in the account key.
*
* The cross-signing API is currently UNSTABLE and may change without notice.
* @experimental
*/
KeysChanged = "crossSigning.keysChanged",
/**
* Fires when data is being migrated from legacy crypto to rust crypto.
*
* The payload is a pair `(progress, total)`, where `progress` is the number of steps completed so far, and
* `total` is the total number of steps. When migration is complete, a final instance of the event is emitted, with
* `progress === total === -1`.
*/
LegacyCryptoStoreMigrationProgress = "crypto.legacyCryptoStoreMigrationProgress",
/**
* Fires when a new dehydrated device is created locally.
*
* After the client calls {@link CryptoApi.startDehydration}, this event
* will be fired every time a new dehydrated device is created. It may fire
* before `startDehydration` returns.
*/
DehydratedDeviceCreated = "dehydration.DehydratedDeviceCreated",
/**
* Fires when a new dehydrated device is successfully uploaded to the server.
*
* This should fire shortly after {@link DehydratedDeviceCreated} fires. If
* upload is unsuccessful, this will be reported either by an error thrown
* by {@link CryptoApi.startDehydration} (for errors that happen before
* `startDehydration` returns), or by firing {@link DehydratedDeviceRotationError}
* (for errors that happen during regular rotation of the dehydrated device)
*/
DehydratedDeviceUploaded = "dehydration.DehydratedDeviceUploaded",
/**
* Fires when rehydration has started.
*
* After the client calls {@link CryptoApi.startDehydration}, this event will
* fire if a dehydrated device is found and we attempt to rehydrate it.
*/
RehydrationStarted = "dehydration.RehydrationStarted",
/**
* Fires during rehydration, to inform the application of rehydration progress.
*
* The payload is a pair `[roomKeyCount: number, toDeviceCount: number]`,
* where `roomKeyCount` is the number of room keys that have been received
* so far, and `toDeviceCount` is the number of to-device messages received
* so far (including the messages containing room keys).
*/
RehydrationProgress = "dehydration.RehydrationProgress",
/** Fires when rehydration has completed successfully. */
RehydrationCompleted = "dehydration.RehydrationCompleted",
/** Fires when there was an error in rehydration.
*
* The payload is an error message as a string.
*/
RehydrationError = "dehydration.RehydrationError",
/**
* Fires when a dehydrated device key has been cached in the local database.
*/
DehydrationKeyCached = "dehydration.DehydrationKeyCached",
/**
* Fires when an error occurs during periodic rotation of the dehydrated device.
*
* The payload is an error message as a string.
*/
DehydratedDeviceRotationError = "dehydration.DehydratedDeviceRotationError",
}

View File

@@ -1,42 +0,0 @@
/*
* Copyright 2024 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 { type CryptoEvent } from "./CryptoEvent.ts";
import { type VerificationRequest } from "./verification.ts";
import { type UserVerificationStatus } from "./index.ts";
import { type RustBackupCryptoEventMap } from "../rust-crypto/backup.ts";
import { type EmptyObject } from "../@types/common.ts";
/**
* A map of the {@link CryptoEvent} fired by the {@link CryptoApi} and their payloads.
*/
export type CryptoEventHandlerMap = {
[CryptoEvent.VerificationRequestReceived]: (request: VerificationRequest) => void;
[CryptoEvent.UserTrustStatusChanged]: (userId: string, userTrustLevel: UserVerificationStatus) => void;
[CryptoEvent.KeyBackupDecryptionKeyCached]: (version: string) => void;
[CryptoEvent.KeysChanged]: (data: EmptyObject) => void;
[CryptoEvent.WillUpdateDevices]: (users: string[], initialFetch: boolean) => void;
[CryptoEvent.DevicesUpdated]: (users: string[], initialFetch: boolean) => void;
[CryptoEvent.LegacyCryptoStoreMigrationProgress]: (progress: number, total: number) => void;
[CryptoEvent.DehydratedDeviceCreated]: () => void;
[CryptoEvent.DehydratedDeviceUploaded]: () => void;
[CryptoEvent.RehydrationStarted]: () => void;
[CryptoEvent.RehydrationProgress]: (roomKeyCount: number, toDeviceCount: number) => void;
[CryptoEvent.RehydrationCompleted]: () => void;
[CryptoEvent.RehydrationError]: (msg: string) => void;
[CryptoEvent.DehydrationKeyCached]: () => void;
[CryptoEvent.DehydratedDeviceRotationError]: (msg: string) => void;
} & RustBackupCryptoEventMap;

File diff suppressed because it is too large Load Diff

View File

@@ -1,58 +0,0 @@
/*
* Copyright 2024 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.
*/
const DEFAULT_BIT_SIZE = 256;
/**
* Derive a recovery key from a passphrase and salt using PBKDF2.
* @see https://spec.matrix.org/v1.11/client-server-api/#deriving-keys-from-passphrases
*
* @param passphrase - The passphrase to derive the key from
* @param salt - The salt to use in the derivation
* @param iterations - The number of iterations to use in the derivation
* @param numBits - The number of bits to derive
*/
export async function deriveRecoveryKeyFromPassphrase(
passphrase: string,
salt: string,
iterations: number,
numBits = DEFAULT_BIT_SIZE,
): Promise<Uint8Array<ArrayBuffer>> {
if (!globalThis.crypto.subtle || !TextEncoder) {
throw new Error("Password-based backup is not available on this platform");
}
const key = await globalThis.crypto.subtle.importKey(
"raw",
new TextEncoder().encode(passphrase),
{ name: "PBKDF2" },
false,
["deriveBits"],
);
const keybits = await globalThis.crypto.subtle.deriveBits(
{
name: "PBKDF2",
salt: new TextEncoder().encode(salt),
iterations: iterations,
hash: "SHA-512",
},
key,
numBits,
);
return new Uint8Array(keybits);
}

View File

@@ -14,9 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type ISigned } from "../@types/signed.ts";
import { type AESEncryptedSecretStoragePayload } from "../@types/AESEncryptedSecretStoragePayload.ts";
import { type ImportRoomKeyProgressData } from "./index.ts";
import { ISigned } from "../@types/signed";
export interface Curve25519AuthData {
public_key: string;
@@ -33,89 +31,12 @@ export interface Aes256AuthData {
}
/**
* Information about a new server-side key backup.
*
* The type of the request body for [`POST /_matrix/client/v3/room_keys/version`](https://spec.matrix.org/v1.19/client-server-api/#post_matrixclientv3room_keysversion).
* Extra info of a recovery key
*/
export interface NewKeyBackupInfo {
export interface KeyBackupInfo {
algorithm: string;
auth_data: ISigned & (Curve25519AuthData | Aes256AuthData);
}
/**
* Information about an existing server-side key backup.
*
* Returned by [`GET /_matrix/client/v3/room_keys/version`](https://spec.matrix.org/v1.7/client-server-api/#get_matrixclientv3room_keysversion).
*/
export interface KeyBackupInfo extends NewKeyBackupInfo {
count: number;
etag: string;
version: string; // number contained within
}
/**
* Information on whether a given server-side backup is trusted.
*/
export interface BackupTrustInfo {
/**
* Is this backup trusted?
*
* True if, and only if, there is a valid signature on the backup from a trusted device.
*/
readonly trusted: boolean;
/**
* True if this backup matches the stored decryption key.
*/
readonly matchesDecryptionKey: boolean;
}
/**
* The result of {@link CryptoApi.checkKeyBackupAndEnable}.
*/
export interface KeyBackupCheck {
backupInfo: KeyBackupInfo;
trustInfo: BackupTrustInfo;
}
export interface Curve25519SessionData {
ciphertext: string;
ephemeral: string;
mac: string;
}
export interface KeyBackupSession<T = Curve25519SessionData | AESEncryptedSecretStoragePayload> {
first_message_index: number;
forwarded_count: number;
is_verified: boolean;
session_data: T;
}
export interface KeyBackupRoomSessions {
[sessionId: string]: KeyBackupSession;
}
/**
* Extra parameters for {@link CryptoApi.restoreKeyBackup} and {@link CryptoApi.restoreKeyBackupWithPassphrase}.
*/
export interface KeyBackupRestoreOpts {
/**
* A callback which, if defined, will be called periodically to report ongoing progress of the backup restore process.
* @param progress
*/
progressCallback?: (progress: ImportRoomKeyProgressData) => void;
}
/**
* The result of {@link CryptoApi.restoreKeyBackup}.
*/
export interface KeyBackupRestoreResult {
/**
* The total number of keys that were found in the backup.
*/
total: number;
/**
* The number of keys that were imported.
*/
imported: number;
count?: number;
etag?: string;
version?: string; // number contained within
}

View File

@@ -1,69 +0,0 @@
/*
* Copyright 2024 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 bs58 from "bs58";
// picked arbitrarily but to try & avoid clashing with any bitcoin ones
// (which are also base58 encoded, but bitcoin's involve a lot more hashing)
const OLM_RECOVERY_KEY_PREFIX = [0x8b, 0x01];
const KEY_SIZE = 32;
/**
* Encode a recovery key using the Matrix {@link https://spec.matrix.org/v1.11/appendices/#cryptographic-key-representation | Cryptographic key representation}
* @param key
*/
export function encodeRecoveryKey(key: ArrayLike<number>): string | undefined {
const buf = new Uint8Array(OLM_RECOVERY_KEY_PREFIX.length + key.length + 1);
buf.set(OLM_RECOVERY_KEY_PREFIX, 0);
buf.set(key, OLM_RECOVERY_KEY_PREFIX.length);
let parity = 0;
for (let i = 0; i < buf.length - 1; ++i) {
parity ^= buf[i];
}
buf[buf.length - 1] = parity;
const base58key = bs58.encode(buf);
return base58key.match(/.{1,4}/g)?.join(" ");
}
/**
* Decode a recovery key encoded with the Matrix {@link https://spec.matrix.org/v1.11/appendices/#cryptographic-key-representation | Cryptographic key representation} encoding.
* @param recoveryKey
*/
export function decodeRecoveryKey(recoveryKey: string): Uint8Array<ArrayBuffer> {
const result = bs58.decode(recoveryKey.replace(/ /g, ""));
let parity = 0;
for (const b of result) {
parity ^= b;
}
if (parity !== 0) {
throw new Error("Incorrect parity");
}
for (let i = 0; i < OLM_RECOVERY_KEY_PREFIX.length; ++i) {
if (result[i] !== OLM_RECOVERY_KEY_PREFIX[i]) {
throw new Error("Incorrect prefix");
}
}
if (result.length !== OLM_RECOVERY_KEY_PREFIX.length + KEY_SIZE + 1) {
throw new Error("Incorrect length");
}
return Uint8Array.from(result.slice(OLM_RECOVERY_KEY_PREFIX.length, OLM_RECOVERY_KEY_PREFIX.length + KEY_SIZE));
}

View File

@@ -14,16 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type MatrixEvent } from "../models/event.ts";
import { type TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { MatrixEvent } from "../models/event";
import { TypedEventEmitter } from "../models/typed-event-emitter";
/**
* An incoming, or outgoing, request to verify a user or a device via cross-signing.
*/
export interface VerificationRequest extends TypedEventEmitter<
VerificationRequestEvent,
VerificationRequestEventHandlerMap
> {
export interface VerificationRequest
extends TypedEventEmitter<VerificationRequestEvent, VerificationRequestEventHandlerMap> {
/**
* Unique ID for this verification request.
*
@@ -116,18 +114,29 @@ export interface VerificationRequest extends TypedEventEmitter<
*/
cancel(params?: { reason?: string; code?: string }): Promise<void>;
/**
* Create a {@link Verifier} to do this verification via a particular method.
*
* If a verifier has already been created for this request, returns that verifier.
*
* This does *not* send the `m.key.verification.start` event - to do so, call {@link Crypto.Verifier#verify} on the
* returned verifier.
*
* If no previous events have been sent, pass in `targetDevice` to set who to direct this request to.
*
* @param method - the name of the verification method to use.
* @param targetDevice - details of where to send the request to.
*
* @returns The verifier which will do the actual verification.
*
* @deprecated Use {@link VerificationRequest#startVerification} instead.
*/
beginKeyVerification(method: string, targetDevice?: { userId?: string; deviceId?: string }): Verifier;
/**
* Send an `m.key.verification.start` event to start verification via a particular method.
*
* This is used for SAS (emoji) verification: `method` should be set to `m.sas.v1`. It does not start
* QR-code verification, and passing a QR-code method (such as `m.reciprocate.v1`, `m.qr_code.scan.v1`,
* or `m.qr_code.show.v1`) will be rejected with an "Unsupported verification method" error.
*
* For QR-code verification, use {@link VerificationRequest#generateQRCode} to display a QR code to the
* other device, or {@link VerificationRequest#scanQRCode} to consume a QR code scanned from it. This is
* only possible once the `phase` is {@link VerificationPhase.Ready}; when it is, a client can typically
* offer the user any of three options: show a QR code, scan the other party's QR code, or fall back to
* emoji (SAS) verification via this method.
* This is normally used when starting a verification via emojis (ie, `method` is set to `m.sas.v1`).
*
* @param method - the name of the verification method to use.
*
@@ -138,17 +147,15 @@ export interface VerificationRequest extends TypedEventEmitter<
/**
* Start a QR code verification by providing a scanned QR code for this verification flow.
*
* Call this once the user has scanned the QR code displayed by the other device (for example, the bytes
* produced by that device's {@link VerificationRequest#generateQRCode}). Validates the QR code, and if it
* is ok, sends an `m.key.verification.start` event with `method` set to `m.reciprocate.v1`, to tell the
* other side the scan was successful.
* Validates the QR code, and if it is ok, sends an `m.key.verification.start` event with `method` set to
* `m.reciprocate.v1`, to tell the other side the scan was successful.
*
* See also {@link VerificationRequest#startVerification} which can be used to start other verification methods.
*
* @param qrCodeData - the decoded QR code.
* @returns A verifier; call `.verify()` on it to wait for the other side to complete the verification flow.
*/
scanQRCode(qrCodeData: Uint8ClampedArray): Promise<Verifier>;
scanQRCode(qrCodeData: Uint8Array): Promise<Verifier>;
/**
* The verifier which is doing the actual verification, once the method has been established.
@@ -156,21 +163,22 @@ export interface VerificationRequest extends TypedEventEmitter<
*/
get verifier(): Verifier | undefined;
/**
* Get the data for a QR code allowing the other device to verify this one, if it supports it.
*
* Only set after a .ready if the other party can scan a QR code, otherwise undefined.
*
* @deprecated Not supported in Rust Crypto. Use {@link VerificationRequest#generateQRCode} instead.
*/
getQRCodeBytes(): Buffer | undefined;
/**
* Generate the data for a QR code allowing the other device to verify this one, if it supports it.
*
* Returns the QR code data only when all of the following hold; otherwise it returns `undefined`:
* - `phase` is {@link VerificationPhase.Ready};
* - the other party advertises support for scanning a QR code (ie, `otherPartySupportsMethod("m.qr_code.scan.v1")`
* is true); and
* - this device has its cross-signing keys available locally. A frequent cause of an unexpected `undefined`
* is that cross-signing has not been set up or the keys have not yet been fetched, so the QR code cannot be
* constructed even though the phase and method checks pass.
*
* On success, display the returned bytes as a QR code for the other device to scan; if the other side scans it
* and confirms, there is nothing further to do on this side.
* Only returns data once `phase` is {@link VerificationPhase.Ready} and the other party can scan a QR code;
* otherwise returns `undefined`.
*/
generateQRCode(): Promise<Uint8ClampedArray | undefined>;
generateQRCode(): Promise<Buffer | undefined>;
/**
* If this request has been cancelled, the cancellation code (e.g `m.user`) which is responsible for cancelling
@@ -216,25 +224,13 @@ export enum VerificationPhase {
/** An `m.key.verification.ready` event has been sent or received, indicating the verification request is accepted. */
Ready,
/**
* The verification is in flight.
*
* This means that an `m.key.verification.start` event has been sent or received, choosing a verification method;
* however the verification has not yet completed or been cancelled.
*/
/** An `m.key.verification.start` event has been sent or received, choosing a verification method */
Started,
/**
* An `m.key.verification.cancel` event has been sent or received at any time before the `done` event, cancelling
* the verification request
*/
/** An `m.key.verification.cancel` event has been sent or received at any time before the `done` event, cancelling the verification request */
Cancelled,
/**
* The verification request is complete.
*
* Normally this means that `m.key.verification.done` events have been sent and received.
*/
/** An `m.key.verification.done` event has been **sent**, completing the verification request. */
Done,
}
@@ -273,7 +269,7 @@ export interface Verifier extends TypedEventEmitter<VerifierEvent, VerifierEvent
* Cancel a verification.
*
* We will send an `m.key.verification.cancel` if the verification is still in flight. The verification promise
* will reject, and a {@link crypto-api.VerifierEvent.Cancel | VerifierEvent.Cancel} will be emitted.
* will reject, and a {@link Crypto.VerifierEvent#Cancel} will be emitted.
*
* @param e - the reason for the cancellation.
*/

View File

@@ -14,67 +14,66 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type Logger } from "../../logger.ts";
import { type CrossSigningKeyInfo } from "../../crypto-api/index.ts";
import { type AESEncryptedSecretStoragePayload } from "../../@types/AESEncryptedSecretStoragePayload.ts";
import { type ISignatures } from "../../@types/signed.ts";
import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "../index";
import { RoomKeyRequestState } from "../OutgoingRoomKeyRequestManager";
import { ICrossSigningKey } from "../../client";
import { IOlmDevice } from "../algorithms/megolm";
import { TrackingStatus } from "../DeviceList";
import { IRoomEncryption } from "../RoomList";
import { IDevice } from "../deviceinfo";
import { ICrossSigningInfo } from "../CrossSigning";
import { PrefixedLogger } from "../../logger";
import { InboundGroupSessionData } from "../OlmDevice";
import { MatrixEvent } from "../../models/event";
import { DehydrationManager } from "../dehydration";
import { IEncryptedPayload } from "../aes";
/**
* Internal module. Definitions for storage for the crypto module
*/
export interface SecretStorePrivateKeys {
"m.megolm_backup.v1": AESEncryptedSecretStoragePayload;
"dehydration": {
keyInfo: DehydrationManager["keyInfo"];
key: IEncryptedPayload;
deviceDisplayName: string;
time: number;
} | null;
"m.megolm_backup.v1": IEncryptedPayload;
}
/**
* Abstraction of things that can store data required for end-to-end encryption
*/
export interface CryptoStore {
/**
* Returns true if this CryptoStore has ever been initialised (ie, it might contain data).
*
* Unlike the rest of the methods in this interface, can be called before {@link CryptoStore#startup}.
*
* @internal
*/
containsData(): Promise<boolean>;
/**
* Initialise this crypto store.
*
* Typically, this involves provisioning storage, and migrating any existing data to the current version of the
* storage schema where appropriate.
*
* Must be called before any of the rest of the methods in this interface.
*/
startup(): Promise<CryptoStore>;
deleteAllData(): Promise<void>;
/**
* Get data on how much of the libolm to Rust Crypto migration has been done.
*
* @internal
*/
getMigrationState(): Promise<MigrationState>;
/**
* Set data on how much of the libolm to Rust Crypto migration has been done.
*
* @internal
*/
setMigrationState(migrationState: MigrationState): Promise<void>;
getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest>;
getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<OutgoingRoomKeyRequest | null>;
getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise<OutgoingRoomKeyRequest | null>;
getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise<OutgoingRoomKeyRequest[]>;
getOutgoingRoomKeyRequestsByTarget(
userId: string,
deviceId: string,
wantedStates: number[],
): Promise<OutgoingRoomKeyRequest[]>;
updateOutgoingRoomKeyRequest(
requestId: string,
expectedState: number,
updates: Partial<OutgoingRoomKeyRequest>,
): Promise<OutgoingRoomKeyRequest | null>;
deleteOutgoingRoomKeyRequest(requestId: string, expectedState: number): Promise<OutgoingRoomKeyRequest | null>;
// Olm Account
getAccount(txn: unknown, func: (accountPickle: string | null) => void): void;
storeAccount(txn: unknown, accountPickle: string): void;
getCrossSigningKeys(txn: unknown, func: (keys: Record<string, CrossSigningKeyInfo> | null) => void): void;
getCrossSigningKeys(txn: unknown, func: (keys: Record<string, ICrossSigningKey> | null) => void): void;
getSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
txn: unknown,
func: (key: SecretStorePrivateKeys[K] | null) => void,
type: K,
): void;
storeCrossSigningKeys(txn: unknown, keys: Record<string, ICrossSigningKey>): void;
storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
txn: unknown,
type: K,
@@ -94,25 +93,11 @@ export interface CryptoStore {
txn: unknown,
func: (sessions: { [sessionId: string]: ISessionInfo }) => void,
): void;
getAllEndToEndSessions(txn: unknown, func: (session: ISessionInfo | null) => void): void;
storeEndToEndSession(deviceKey: string, sessionId: string, sessionInfo: ISessionInfo, txn: unknown): void;
/**
* Get a batch of end-to-end sessions from the database.
*
* @returns A batch of Olm Sessions, or `null` if no sessions are left.
* @internal
*/
getEndToEndSessionsBatch(): Promise<ISessionInfo[] | null>;
/**
* Delete a batch of end-to-end sessions from the database.
*
* Any sessions in the list which are not found are silently ignored.
*
* @internal
*/
deleteEndToEndSessionsBatch(sessions: { deviceKey?: string; sessionId?: string }[]): Promise<void>;
storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void>;
getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null>;
filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]>;
// Inbound Group Sessions
getEndToEndInboundGroupSession(
@@ -121,60 +106,55 @@ export interface CryptoStore {
txn: unknown,
func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void,
): void;
getAllEndToEndInboundGroupSessions(txn: unknown, func: (session: ISession | null) => void): void;
addEndToEndInboundGroupSession(
senderCurve25519Key: string,
sessionId: string,
sessionData: InboundGroupSessionData,
txn: unknown,
): void;
storeEndToEndInboundGroupSession(
senderCurve25519Key: string,
sessionId: string,
sessionData: InboundGroupSessionData,
txn: unknown,
): void;
/**
* Count the number of Megolm sessions in the database.
*
* @internal
*/
countEndToEndInboundGroupSessions(): Promise<number>;
/**
* Get a batch of Megolm sessions from the database.
*
* @returns A batch of Megolm Sessions, or `null` if no sessions are left.
* @internal
*/
getEndToEndInboundGroupSessionsBatch(): Promise<SessionExtended[] | null>;
/**
* Delete a batch of Megolm sessions from the database.
*
* Any sessions in the list which are not found are silently ignored.
*
* @internal
*/
deleteEndToEndInboundGroupSessionsBatch(sessions: { senderKey: string; sessionId: string }[]): Promise<void>;
storeEndToEndInboundGroupSessionWithheld(
senderCurve25519Key: string,
sessionId: string,
sessionData: IWithheld,
txn: unknown,
): void;
// Device Data
getEndToEndDeviceData(txn: unknown, func: (deviceData: IDeviceData | null) => void): void;
storeEndToEndDeviceData(deviceData: IDeviceData, txn: unknown): void;
storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: unknown): void;
getEndToEndRooms(txn: unknown, func: (rooms: Record<string, IRoomEncryption>) => void): void;
getSessionsNeedingBackup(limit: number): Promise<ISession[]>;
countSessionsNeedingBackup(txn?: unknown): Promise<number>;
unmarkSessionsNeedingBackup(sessions: ISession[], txn?: unknown): Promise<void>;
markSessionsNeedingBackup(sessions: ISession[], txn?: unknown): Promise<void>;
addSharedHistoryInboundGroupSession(roomId: string, senderKey: string, sessionId: string, txn?: unknown): void;
getSharedHistoryInboundGroupSessions(
roomId: string,
txn?: unknown,
): Promise<[senderKey: string, sessionId: string][]>;
addParkedSharedHistory(roomId: string, data: ParkedSharedHistory, txn?: unknown): void;
takeParkedSharedHistory(roomId: string, txn?: unknown): Promise<ParkedSharedHistory[]>;
// Session key backups
doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn: unknown) => T, log?: Logger): Promise<T>;
doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn: unknown) => T, log?: PrefixedLogger): Promise<T>;
}
export type Mode = "readonly" | "readwrite";
/** Data on a Megolm session */
export interface ISession {
senderKey: string;
sessionId: string;
sessionData?: InboundGroupSessionData;
}
/** Extended data on a Megolm session */
export interface SessionExtended extends ISession {
needsBackup: boolean;
}
/** Data on an Olm session */
export interface ISessionInfo {
deviceKey?: string;
sessionId?: string;
@@ -195,7 +175,14 @@ export interface IDeviceData {
syncToken?: string;
}
export interface IProblem {
type: string;
fixed: boolean;
time: number;
}
export interface IWithheld {
// eslint-disable-next-line camelcase
room_id: string;
code: string;
reason: string;
@@ -229,153 +216,11 @@ export interface OutgoingRoomKeyRequest {
state: RoomKeyRequestState;
}
/**
* Keys for the `account` object store to store the migration state.
* Values are defined in `MigrationState`.
* @internal
*/
export const ACCOUNT_OBJECT_KEY_MIGRATION_STATE = "migrationState";
/**
* A record of which steps have been completed in the libolm to Rust Crypto migration.
*
* Used by {@link CryptoStore#getMigrationState} and {@link CryptoStore#setMigrationState}.
*
* @internal
*/
export enum MigrationState {
/** No migration steps have yet been completed. */
NOT_STARTED,
/** We have migrated the account data, cross-signing keys, etc. */
INITIAL_DATA_MIGRATED,
/** INITIAL_DATA_MIGRATED, and in addition, we have migrated all the Olm sessions. */
OLM_SESSIONS_MIGRATED,
/** OLM_SESSIONS_MIGRATED, and in addition, we have migrated all the Megolm sessions. */
MEGOLM_SESSIONS_MIGRATED,
/** MEGOLM_SESSIONS_MIGRATED, and in addition, we have migrated all the room settings. */
ROOM_SETTINGS_MIGRATED,
/** ROOM_SETTINGS_MIGRATED, and in addition, we have done the first own keys query in order to
* load the public part of the keys that have been migrated */
INITIAL_OWN_KEY_QUERY_DONE,
}
/**
* The size of batches to be returned by {@link CryptoStore#getEndToEndSessionsBatch} and
* {@link CryptoStore#getEndToEndInboundGroupSessionsBatch}.
*/
export const SESSION_BATCH_SIZE = 50;
export interface InboundGroupSessionData {
room_id: string;
/** pickled Olm.InboundGroupSession */
session: string;
keysClaimed?: Record<string, string>;
/** Devices involved in forwarding this session to us (normally empty). */
export interface ParkedSharedHistory {
senderId: string;
senderKey: string;
sessionId: string;
sessionKey: string;
keysClaimed: ReturnType<MatrixEvent["getKeysClaimed"]>; // XXX: Less type dependence on MatrixEvent
forwardingCurve25519KeyChain: string[];
/** whether this session is untrusted. */
untrusted?: boolean;
/** whether this session exists during the room being set to shared history. */
sharedHistory?: boolean;
}
export interface ICrossSigningInfo {
keys: Record<string, CrossSigningKeyInfo>;
firstUse: boolean;
crossSigningVerifiedBefore: boolean;
}
export interface IRoomEncryption {
algorithm: string;
rotation_period_ms?: number;
rotation_period_msgs?: number;
}
export enum TrackingStatus {
NotTracked,
PendingDownload,
DownloadInProgress,
UpToDate,
}
/**
* possible states for a room key request
*
* The state machine looks like:
* ```
*
* | (cancellation sent)
* | .-------------------------------------------------.
* | | |
* V V (cancellation requested) |
* UNSENT -----------------------------+ |
* | | |
* | | |
* | (send successful) | CANCELLATION_PENDING_AND_WILL_RESEND
* V | Λ
* SENT | |
* |-------------------------------- | --------------'
* | | (cancellation requested with intent
* | | to resend the original request)
* | |
* | (cancellation requested) |
* V |
* CANCELLATION_PENDING |
* | |
* | (cancellation sent) |
* V |
* (deleted) <---------------------------+
* ```
*/
export enum RoomKeyRequestState {
/** request not yet sent */
Unsent,
/** request sent, awaiting reply */
Sent,
/** reply received, cancellation not yet sent */
CancellationPending,
/**
* Cancellation not yet sent and will transition to UNSENT instead of
* being deleted once the cancellation has been sent.
*/
CancellationPendingAndWillResend,
}
interface IRoomKey {
room_id: string;
algorithm: string;
}
/**
* The parameters of a room key request. The details of the request may
* vary with the crypto algorithm, but the management and storage layers for
* outgoing requests expect it to have 'room_id' and 'session_id' properties.
*/
export interface IRoomKeyRequestBody extends IRoomKey {
session_id: string;
sender_key: string;
}
export interface IRoomKeyRequestRecipient {
userId: string;
deviceId: string;
}
interface IDevice {
keys: Record<string, string>;
algorithms: string[];
verified: DeviceVerification;
known: boolean;
unsigned?: Record<string, any>;
signatures?: ISignatures;
}
/** State of the verification of the device. */
export enum DeviceVerification {
Blocked = -1,
Unverified = 0,
Verified = 1,
}

View File

@@ -14,24 +14,25 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type Logger, logger } from "../../logger.ts";
import { logger, PrefixedLogger } from "../../logger";
import { deepCompare } from "../../utils";
import {
type CryptoStore,
type IDeviceData,
type ISession,
type SessionExtended,
type ISessionInfo,
type IWithheld,
MigrationState,
type Mode,
type SecretStorePrivateKeys,
SESSION_BATCH_SIZE,
ACCOUNT_OBJECT_KEY_MIGRATION_STATE,
type InboundGroupSessionData,
type IRoomEncryption,
} from "./base.ts";
import { IndexedDBCryptoStore } from "./indexeddb-crypto-store.ts";
import { type CrossSigningKeyInfo } from "../../crypto-api/index.ts";
CryptoStore,
IDeviceData,
IProblem,
ISession,
ISessionInfo,
IWithheld,
Mode,
OutgoingRoomKeyRequest,
ParkedSharedHistory,
SecretStorePrivateKeys,
} from "./base";
import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "../index";
import { ICrossSigningKey } from "../../client";
import { IOlmDevice } from "../algorithms/megolm";
import { IRoomEncryption } from "../RoomList";
import { InboundGroupSessionData } from "../OlmDevice";
const PROFILE_TRANSACTIONS = false;
@@ -39,8 +40,6 @@ const PROFILE_TRANSACTIONS = false;
* Implementation of a CryptoStore which is backed by an existing
* IndexedDB connection. Generally you want IndexedDBCryptoStore
* which connects to the database and defers to one of these.
*
* @internal
*/
export class Backend implements CryptoStore {
private nextTxnId = 0;
@@ -57,49 +56,306 @@ export class Backend implements CryptoStore {
};
}
public async containsData(): Promise<boolean> {
throw Error("Not implemented for Backend");
}
public async startup(): Promise<CryptoStore> {
// No work to do, as the startup is done by the caller (e.g IndexedDBCryptoStore)
// by passing us a ready IDBDatabase instance
return this;
}
public async deleteAllData(): Promise<void> {
throw Error("This is not implemented, call IDBFactory::deleteDatabase(dbName) instead.");
}
/**
* Get data on how much of the libolm to Rust Crypto migration has been done.
* Look for an existing outgoing room key request, and if none is found,
* add a new one
*
* Implementation of {@link CryptoStore.getMigrationState}.
*
* @returns resolves to
* {@link OutgoingRoomKeyRequest}: either the
* same instance as passed in, or the existing one.
*/
public async getMigrationState(): Promise<MigrationState> {
let migrationState = MigrationState.NOT_STARTED;
await this.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
const objectStore = txn.objectStore(IndexedDBCryptoStore.STORE_ACCOUNT);
const getReq = objectStore.get(ACCOUNT_OBJECT_KEY_MIGRATION_STATE);
getReq.onsuccess = (): void => {
migrationState = getReq.result ?? MigrationState.NOT_STARTED;
};
public getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest> {
const requestBody = request.requestBody;
return new Promise((resolve, reject) => {
const txn = this.db.transaction("outgoingRoomKeyRequests", "readwrite");
txn.onerror = reject;
// first see if we already have an entry for this request.
this._getOutgoingRoomKeyRequest(txn, requestBody, (existing) => {
if (existing) {
// this entry matches the request - return it.
logger.log(
`already have key request outstanding for ` +
`${requestBody.room_id} / ${requestBody.session_id}: ` +
`not sending another`,
);
resolve(existing);
return;
}
// we got to the end of the list without finding a match
// - add the new request.
logger.log(`enqueueing key request for ${requestBody.room_id} / ` + requestBody.session_id);
txn.oncomplete = (): void => {
resolve(request);
};
const store = txn.objectStore("outgoingRoomKeyRequests");
store.add(request);
});
});
return migrationState;
}
/**
* Set data on how much of the libolm to Rust Crypto migration has been done.
* Look for an existing room key request
*
* Implementation of {@link CryptoStore.setMigrationState}.
* @param requestBody - existing request to look for
*
* @returns resolves to the matching
* {@link OutgoingRoomKeyRequest}, or null if
* not found
*/
public async setMigrationState(migrationState: MigrationState): Promise<void> {
await this.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
const objectStore = txn.objectStore(IndexedDBCryptoStore.STORE_ACCOUNT);
objectStore.put(migrationState, ACCOUNT_OBJECT_KEY_MIGRATION_STATE);
public getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<OutgoingRoomKeyRequest | null> {
return new Promise((resolve, reject) => {
const txn = this.db.transaction("outgoingRoomKeyRequests", "readonly");
txn.onerror = reject;
this._getOutgoingRoomKeyRequest(txn, requestBody, (existing) => {
resolve(existing);
});
});
}
/**
* look for an existing room key request in the db
*
* @internal
* @param txn - database transaction
* @param requestBody - existing request to look for
* @param callback - function to call with the results of the
* search. Either passed a matching
* {@link OutgoingRoomKeyRequest}, or null if
* not found.
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
private _getOutgoingRoomKeyRequest(
txn: IDBTransaction,
requestBody: IRoomKeyRequestBody,
callback: (req: OutgoingRoomKeyRequest | null) => void,
): void {
const store = txn.objectStore("outgoingRoomKeyRequests");
const idx = store.index("session");
const cursorReq = idx.openCursor([requestBody.room_id, requestBody.session_id]);
cursorReq.onsuccess = (): void => {
const cursor = cursorReq.result;
if (!cursor) {
// no match found
callback(null);
return;
}
const existing = cursor.value;
if (deepCompare(existing.requestBody, requestBody)) {
// got a match
callback(existing);
return;
}
// look at the next entry in the index
cursor.continue();
};
}
/**
* Look for room key requests by state
*
* @param wantedStates - list of acceptable states
*
* @returns resolves to the a
* {@link OutgoingRoomKeyRequest}, or null if
* there are no pending requests in those states. If there are multiple
* requests in those states, an arbitrary one is chosen.
*/
public getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise<OutgoingRoomKeyRequest | null> {
if (wantedStates.length === 0) {
return Promise.resolve(null);
}
// this is a bit tortuous because we need to make sure we do the lookup
// in a single transaction, to avoid having a race with the insertion
// code.
// index into the wantedStates array
let stateIndex = 0;
let result: OutgoingRoomKeyRequest;
function onsuccess(this: IDBRequest<IDBCursorWithValue | null>): void {
const cursor = this.result;
if (cursor) {
// got a match
result = cursor.value;
return;
}
// try the next state in the list
stateIndex++;
if (stateIndex >= wantedStates.length) {
// no matches
return;
}
const wantedState = wantedStates[stateIndex];
const cursorReq = (this.source as IDBIndex).openCursor(wantedState);
cursorReq.onsuccess = onsuccess;
}
const txn = this.db.transaction("outgoingRoomKeyRequests", "readonly");
const store = txn.objectStore("outgoingRoomKeyRequests");
const wantedState = wantedStates[stateIndex];
const cursorReq = store.index("state").openCursor(wantedState);
cursorReq.onsuccess = onsuccess;
return promiseifyTxn(txn).then(() => result);
}
/**
*
* @returns All elements in a given state
*/
public getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise<OutgoingRoomKeyRequest[]> {
return new Promise((resolve, reject) => {
const txn = this.db.transaction("outgoingRoomKeyRequests", "readonly");
const store = txn.objectStore("outgoingRoomKeyRequests");
const index = store.index("state");
const request = index.getAll(wantedState);
request.onsuccess = (): void => resolve(request.result);
request.onerror = (): void => reject(request.error);
});
}
public getOutgoingRoomKeyRequestsByTarget(
userId: string,
deviceId: string,
wantedStates: number[],
): Promise<OutgoingRoomKeyRequest[]> {
let stateIndex = 0;
const results: OutgoingRoomKeyRequest[] = [];
function onsuccess(this: IDBRequest<IDBCursorWithValue | null>): void {
const cursor = this.result;
if (cursor) {
const keyReq = cursor.value;
if (
keyReq.recipients.some(
(recipient: IRoomKeyRequestRecipient) =>
recipient.userId === userId && recipient.deviceId === deviceId,
)
) {
results.push(keyReq);
}
cursor.continue();
} else {
// try the next state in the list
stateIndex++;
if (stateIndex >= wantedStates.length) {
// no matches
return;
}
const wantedState = wantedStates[stateIndex];
const cursorReq = (this.source as IDBIndex).openCursor(wantedState);
cursorReq.onsuccess = onsuccess;
}
}
const txn = this.db.transaction("outgoingRoomKeyRequests", "readonly");
const store = txn.objectStore("outgoingRoomKeyRequests");
const wantedState = wantedStates[stateIndex];
const cursorReq = store.index("state").openCursor(wantedState);
cursorReq.onsuccess = onsuccess;
return promiseifyTxn(txn).then(() => results);
}
/**
* Look for an existing room key request by id and state, and update it if
* found
*
* @param requestId - ID of request to update
* @param expectedState - state we expect to find the request in
* @param updates - name/value map of updates to apply
*
* @returns resolves to
* {@link OutgoingRoomKeyRequest}
* updated request, or null if no matching row was found
*/
public updateOutgoingRoomKeyRequest(
requestId: string,
expectedState: number,
updates: Partial<OutgoingRoomKeyRequest>,
): Promise<OutgoingRoomKeyRequest | null> {
let result: OutgoingRoomKeyRequest | null = null;
function onsuccess(this: IDBRequest<IDBCursorWithValue | null>): void {
const cursor = this.result;
if (!cursor) {
return;
}
const data = cursor.value;
if (data.state != expectedState) {
logger.warn(
`Cannot update room key request from ${expectedState} ` +
`as it was already updated to ${data.state}`,
);
return;
}
Object.assign(data, updates);
cursor.update(data);
result = data;
}
const txn = this.db.transaction("outgoingRoomKeyRequests", "readwrite");
const cursorReq = txn.objectStore("outgoingRoomKeyRequests").openCursor(requestId);
cursorReq.onsuccess = onsuccess;
return promiseifyTxn(txn).then(() => result);
}
/**
* Look for an existing room key request by id and state, and delete it if
* found
*
* @param requestId - ID of request to update
* @param expectedState - state we expect to find the request in
*
* @returns resolves once the operation is completed
*/
public deleteOutgoingRoomKeyRequest(
requestId: string,
expectedState: number,
): Promise<OutgoingRoomKeyRequest | null> {
const txn = this.db.transaction("outgoingRoomKeyRequests", "readwrite");
const cursorReq = txn.objectStore("outgoingRoomKeyRequests").openCursor(requestId);
cursorReq.onsuccess = (): void => {
const cursor = cursorReq.result;
if (!cursor) {
return;
}
const data = cursor.value;
if (data.state != expectedState) {
logger.warn(`Cannot delete room key request in state ${data.state} ` + `(expected ${expectedState})`);
return;
}
cursor.delete();
};
return promiseifyTxn<OutgoingRoomKeyRequest | null>(txn);
}
// Olm Account
public getAccount(txn: IDBTransaction, func: (accountPickle: string | null) => void): void {
@@ -121,7 +377,7 @@ export class Backend implements CryptoStore {
public getCrossSigningKeys(
txn: IDBTransaction,
func: (keys: Record<string, CrossSigningKeyInfo> | null) => void,
func: (keys: Record<string, ICrossSigningKey> | null) => void,
): void {
const objectStore = txn.objectStore("account");
const getReq = objectStore.get("crossSigningKeys");
@@ -150,6 +406,11 @@ export class Backend implements CryptoStore {
};
}
public storeCrossSigningKeys(txn: IDBTransaction, keys: Record<string, ICrossSigningKey>): void {
const objectStore = txn.objectStore("account");
objectStore.put(keys, "crossSigningKeys");
}
public storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
txn: IDBTransaction,
type: K,
@@ -224,6 +485,24 @@ export class Backend implements CryptoStore {
};
}
public getAllEndToEndSessions(txn: IDBTransaction, func: (session: ISessionInfo | null) => void): void {
const objectStore = txn.objectStore("sessions");
const getReq = objectStore.openCursor();
getReq.onsuccess = function (): void {
try {
const cursor = getReq.result;
if (cursor) {
func(cursor.value);
cursor.continue();
} else {
func(null);
}
} catch (e) {
abortWithException(txn, <Error>e);
}
};
}
public storeEndToEndSession(
deviceKey: string,
sessionId: string,
@@ -239,60 +518,74 @@ export class Backend implements CryptoStore {
});
}
/**
* Fetch a batch of Olm sessions from the database.
*
* Implementation of {@link CryptoStore.getEndToEndSessionsBatch}.
*/
public async getEndToEndSessionsBatch(): Promise<null | ISessionInfo[]> {
const result: ISessionInfo[] = [];
await this.doTxn("readonly", [IndexedDBCryptoStore.STORE_SESSIONS], (txn) => {
const objectStore = txn.objectStore(IndexedDBCryptoStore.STORE_SESSIONS);
const getReq = objectStore.openCursor();
getReq.onsuccess = function (): void {
try {
const cursor = getReq.result;
if (cursor) {
result.push(cursor.value);
if (result.length < SESSION_BATCH_SIZE) {
cursor.continue();
}
}
} catch (e) {
abortWithException(txn, <Error>e);
}
};
public async storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void> {
const txn = this.db.transaction("session_problems", "readwrite");
const objectStore = txn.objectStore("session_problems");
objectStore.put({
deviceKey,
type,
fixed,
time: Date.now(),
});
await promiseifyTxn(txn);
}
if (result.length === 0) {
// No sessions left.
return null;
}
public async getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null> {
let result: IProblem | null = null;
const txn = this.db.transaction("session_problems", "readwrite");
const objectStore = txn.objectStore("session_problems");
const index = objectStore.index("deviceKey");
const req = index.getAll(deviceKey);
req.onsuccess = (): void => {
const problems = req.result;
if (!problems.length) {
result = null;
return;
}
problems.sort((a, b) => {
return a.time - b.time;
});
const lastProblem = problems[problems.length - 1];
for (const problem of problems) {
if (problem.time > timestamp) {
result = Object.assign({}, problem, { fixed: lastProblem.fixed });
return;
}
}
if (lastProblem.fixed) {
result = null;
} else {
result = lastProblem;
}
};
await promiseifyTxn(txn);
return result;
}
/**
* Delete a batch of Olm sessions from the database.
*
* Implementation of {@link CryptoStore.deleteEndToEndSessionsBatch}.
*
* @internal
*/
public async deleteEndToEndSessionsBatch(sessions: { deviceKey: string; sessionId: string }[]): Promise<void> {
await this.doTxn("readwrite", [IndexedDBCryptoStore.STORE_SESSIONS], async (txn) => {
try {
const objectStore = txn.objectStore(IndexedDBCryptoStore.STORE_SESSIONS);
for (const { deviceKey, sessionId } of sessions) {
const req = objectStore.delete([deviceKey, sessionId]);
await new Promise((resolve) => {
req.onsuccess = resolve;
});
}
} catch (e) {
abortWithException(txn, <Error>e);
}
});
// FIXME: we should probably prune this when devices get deleted
public async filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]> {
const txn = this.db.transaction("notified_error_devices", "readwrite");
const objectStore = txn.objectStore("notified_error_devices");
const ret: IOlmDevice[] = [];
await Promise.all(
devices.map((device) => {
return new Promise<void>((resolve) => {
const { userId, deviceInfo } = device;
const getReq = objectStore.get([userId, deviceInfo.deviceId]);
getReq.onsuccess = function (): void {
if (!getReq.result) {
objectStore.put({ userId, deviceId: deviceInfo.deviceId });
ret.push(device);
}
resolve();
};
});
}),
);
return ret;
}
// Inbound group sessions
@@ -340,6 +633,57 @@ export class Backend implements CryptoStore {
};
}
public getAllEndToEndInboundGroupSessions(txn: IDBTransaction, func: (session: ISession | null) => void): void {
const objectStore = txn.objectStore("inbound_group_sessions");
const getReq = objectStore.openCursor();
getReq.onsuccess = function (): void {
const cursor = getReq.result;
if (cursor) {
try {
func({
senderKey: cursor.value.senderCurve25519Key,
sessionId: cursor.value.sessionId,
sessionData: cursor.value.session,
});
} catch (e) {
abortWithException(txn, <Error>e);
}
cursor.continue();
} else {
try {
func(null);
} catch (e) {
abortWithException(txn, <Error>e);
}
}
};
}
public addEndToEndInboundGroupSession(
senderCurve25519Key: string,
sessionId: string,
sessionData: InboundGroupSessionData,
txn: IDBTransaction,
): void {
const objectStore = txn.objectStore("inbound_group_sessions");
const addReq = objectStore.add({
senderCurve25519Key,
sessionId,
session: sessionData,
});
addReq.onerror = (ev): void => {
if (addReq.error?.name === "ConstraintError") {
// This stops the error from triggering the txn's onerror
ev.stopPropagation();
// ...and this stops it from aborting the transaction
ev.preventDefault();
logger.log("Ignoring duplicate inbound group session: " + senderCurve25519Key + " / " + sessionId);
} else {
abortWithException(txn, new Error("Failed to add inbound group session: " + addReq.error));
}
};
}
public storeEndToEndInboundGroupSession(
senderCurve25519Key: string,
sessionId: string,
@@ -354,94 +698,17 @@ export class Backend implements CryptoStore {
});
}
/**
* Count the number of Megolm sessions in the database.
*
* Implementation of {@link CryptoStore.countEndToEndInboundGroupSessions}.
*
* @internal
*/
public async countEndToEndInboundGroupSessions(): Promise<number> {
let result = 0;
await this.doTxn("readonly", [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], (txn) => {
const sessionStore = txn.objectStore(IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS);
const countReq = sessionStore.count();
countReq.onsuccess = (): void => {
result = countReq.result;
};
});
return result;
}
/**
* Fetch a batch of Megolm sessions from the database.
*
* Implementation of {@link CryptoStore.getEndToEndInboundGroupSessionsBatch}.
*/
public async getEndToEndInboundGroupSessionsBatch(): Promise<null | SessionExtended[]> {
const result: SessionExtended[] = [];
await this.doTxn(
"readonly",
[IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, IndexedDBCryptoStore.STORE_BACKUP],
(txn) => {
const sessionStore = txn.objectStore(IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS);
const backupStore = txn.objectStore(IndexedDBCryptoStore.STORE_BACKUP);
const getReq = sessionStore.openCursor();
getReq.onsuccess = function (): void {
try {
const cursor = getReq.result;
if (cursor) {
const backupGetReq = backupStore.get(cursor.key);
backupGetReq.onsuccess = (): void => {
result.push({
senderKey: cursor.value.senderCurve25519Key,
sessionId: cursor.value.sessionId,
sessionData: cursor.value.session,
needsBackup: backupGetReq.result !== undefined,
});
if (result.length < SESSION_BATCH_SIZE) {
cursor.continue();
}
};
}
} catch (e) {
abortWithException(txn, <Error>e);
}
};
},
);
if (result.length === 0) {
// No sessions left.
return null;
}
return result;
}
/**
* Delete a batch of Megolm sessions from the database.
*
* Implementation of {@link CryptoStore.deleteEndToEndInboundGroupSessionsBatch}.
*
* @internal
*/
public async deleteEndToEndInboundGroupSessionsBatch(
sessions: { senderKey: string; sessionId: string }[],
): Promise<void> {
await this.doTxn("readwrite", [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], async (txn) => {
try {
const objectStore = txn.objectStore(IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS);
for (const { senderKey, sessionId } of sessions) {
const req = objectStore.delete([senderKey, sessionId]);
await new Promise((resolve) => {
req.onsuccess = resolve;
});
}
} catch (e) {
abortWithException(txn, <Error>e);
}
public storeEndToEndInboundGroupSessionWithheld(
senderCurve25519Key: string,
sessionId: string,
sessionData: IWithheld,
txn: IDBTransaction,
): void {
const objectStore = txn.objectStore("inbound_group_sessions_withheld");
objectStore.put({
senderCurve25519Key,
sessionId,
session: sessionData,
});
}
@@ -457,6 +724,16 @@ export class Backend implements CryptoStore {
};
}
public storeEndToEndDeviceData(deviceData: IDeviceData, txn: IDBTransaction): void {
const objectStore = txn.objectStore("device_data");
objectStore.put(deviceData, "-");
}
public storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: IDBTransaction): void {
const objectStore = txn.objectStore("rooms");
objectStore.put(roomInfo, roomId);
}
public getEndToEndRooms(txn: IDBTransaction, func: (rooms: Record<string, IRoomEncryption>) => void): void {
const rooms: Parameters<Parameters<Backend["getEndToEndRooms"]>[1]>[0] = {};
const objectStore = txn.objectStore("rooms");
@@ -476,6 +753,67 @@ export class Backend implements CryptoStore {
};
}
// session backups
public getSessionsNeedingBackup(limit: number): Promise<ISession[]> {
return new Promise((resolve, reject) => {
const sessions: ISession[] = [];
const txn = this.db.transaction(["sessions_needing_backup", "inbound_group_sessions"], "readonly");
txn.onerror = reject;
txn.oncomplete = function (): void {
resolve(sessions);
};
const objectStore = txn.objectStore("sessions_needing_backup");
const sessionStore = txn.objectStore("inbound_group_sessions");
const getReq = objectStore.openCursor();
getReq.onsuccess = function (): void {
const cursor = getReq.result;
if (cursor) {
const sessionGetReq = sessionStore.get(cursor.key);
sessionGetReq.onsuccess = function (): void {
sessions.push({
senderKey: sessionGetReq.result.senderCurve25519Key,
sessionId: sessionGetReq.result.sessionId,
sessionData: sessionGetReq.result.session,
});
};
if (!limit || sessions.length < limit) {
cursor.continue();
}
}
};
});
}
public countSessionsNeedingBackup(txn?: IDBTransaction): Promise<number> {
if (!txn) {
txn = this.db.transaction("sessions_needing_backup", "readonly");
}
const objectStore = txn.objectStore("sessions_needing_backup");
return new Promise((resolve, reject) => {
const req = objectStore.count();
req.onerror = reject;
req.onsuccess = (): void => resolve(req.result);
});
}
public async unmarkSessionsNeedingBackup(sessions: ISession[], txn?: IDBTransaction): Promise<void> {
if (!txn) {
txn = this.db.transaction("sessions_needing_backup", "readwrite");
}
const objectStore = txn.objectStore("sessions_needing_backup");
await Promise.all(
sessions.map((session) => {
return new Promise((resolve, reject) => {
const req = objectStore.delete([session.senderKey, session.sessionId]);
req.onsuccess = resolve;
req.onerror = reject;
});
}),
);
}
public async markSessionsNeedingBackup(sessions: ISession[], txn?: IDBTransaction): Promise<void> {
if (!txn) {
txn = this.db.transaction("sessions_needing_backup", "readwrite");
@@ -495,11 +833,80 @@ export class Backend implements CryptoStore {
);
}
public addSharedHistoryInboundGroupSession(
roomId: string,
senderKey: string,
sessionId: string,
txn?: IDBTransaction,
): void {
if (!txn) {
txn = this.db.transaction("shared_history_inbound_group_sessions", "readwrite");
}
const objectStore = txn.objectStore("shared_history_inbound_group_sessions");
const req = objectStore.get([roomId]);
req.onsuccess = (): void => {
const { sessions } = req.result || { sessions: [] };
sessions.push([senderKey, sessionId]);
objectStore.put({ roomId, sessions });
};
}
public getSharedHistoryInboundGroupSessions(
roomId: string,
txn?: IDBTransaction,
): Promise<[senderKey: string, sessionId: string][]> {
if (!txn) {
txn = this.db.transaction("shared_history_inbound_group_sessions", "readonly");
}
const objectStore = txn.objectStore("shared_history_inbound_group_sessions");
const req = objectStore.get([roomId]);
return new Promise((resolve, reject) => {
req.onsuccess = (): void => {
const { sessions } = req.result || { sessions: [] };
resolve(sessions);
};
req.onerror = reject;
});
}
public addParkedSharedHistory(roomId: string, parkedData: ParkedSharedHistory, txn?: IDBTransaction): void {
if (!txn) {
txn = this.db.transaction("parked_shared_history", "readwrite");
}
const objectStore = txn.objectStore("parked_shared_history");
const req = objectStore.get([roomId]);
req.onsuccess = (): void => {
const { parked } = req.result || { parked: [] };
parked.push(parkedData);
objectStore.put({ roomId, parked });
};
}
public takeParkedSharedHistory(roomId: string, txn?: IDBTransaction): Promise<ParkedSharedHistory[]> {
if (!txn) {
txn = this.db.transaction("parked_shared_history", "readwrite");
}
const cursorReq = txn.objectStore("parked_shared_history").openCursor(roomId);
return new Promise((resolve, reject) => {
cursorReq.onsuccess = (): void => {
const cursor = cursorReq.result;
if (!cursor) {
resolve([]);
return;
}
const data = cursor.value;
cursor.delete();
resolve(data);
};
cursorReq.onerror = reject;
});
}
public doTxn<T>(
mode: Mode,
stores: string | string[],
func: (txn: IDBTransaction) => T,
log: Logger = logger,
log: PrefixedLogger = logger,
): Promise<T> {
let startTime: number;
let description: string;
@@ -590,7 +997,7 @@ const DB_MIGRATIONS: DbMigration[] = [
export const VERSION = DB_MIGRATIONS.length;
export function upgradeDatabase(db: IDBDatabase, oldVersion: number): void {
logger.log(`Upgrading IndexedDBCryptoStore from version ${oldVersion} to ${VERSION}`);
logger.log(`Upgrading IndexedDBCryptoStore from version ${oldVersion}` + ` to ${VERSION}`);
DB_MIGRATIONS.forEach((migration, index) => {
if (oldVersion <= index) migration(db);
});
@@ -607,7 +1014,7 @@ function createDatabase(db: IDBDatabase): void {
}
interface IWrappedIDBTransaction extends IDBTransaction {
_mx_abortexception: Error;
_mx_abortexception: Error; // eslint-disable-line camelcase
}
/*
@@ -621,7 +1028,7 @@ function abortWithException(txn: IDBTransaction, e: Error): void {
(txn as IWrappedIDBTransaction)._mx_abortexception = e;
try {
txn.abort();
} catch {
} catch (e) {
// sometimes we won't be able to abort the transaction
// (ie. if it's aborted or completed)
}
@@ -632,7 +1039,6 @@ function promiseifyTxn<T>(txn: IDBTransaction): Promise<T | null> {
txn.oncomplete = (): void => {
if ((txn as IWrappedIDBTransaction)._mx_abortexception !== undefined) {
reject((txn as IWrappedIDBTransaction)._mx_abortexception);
return;
}
resolve(null);
};

View File

@@ -14,28 +14,31 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger, type Logger } from "../../logger.ts";
import { LocalStorageCryptoStore } from "./localStorage-crypto-store.ts";
import { MemoryCryptoStore } from "./memory-crypto-store.ts";
import * as IndexedDBCryptoStoreBackend from "./indexeddb-crypto-store-backend.ts";
import { InvalidCryptoStoreError, InvalidCryptoStoreState } from "../../errors.ts";
import * as IndexedDBHelpers from "../../indexeddb-helpers.ts";
import { logger, PrefixedLogger } from "../../logger";
import { LocalStorageCryptoStore } from "./localStorage-crypto-store";
import { MemoryCryptoStore } from "./memory-crypto-store";
import * as IndexedDBCryptoStoreBackend from "./indexeddb-crypto-store-backend";
import { InvalidCryptoStoreError, InvalidCryptoStoreState } from "../../errors";
import * as IndexedDBHelpers from "../../indexeddb-helpers";
import {
type CryptoStore,
type ISession,
type SessionExtended,
type ISessionInfo,
type IWithheld,
MigrationState,
type Mode,
type SecretStorePrivateKeys,
ACCOUNT_OBJECT_KEY_MIGRATION_STATE,
type InboundGroupSessionData,
type IRoomEncryption,
} from "./base.ts";
import { type CrossSigningKeyInfo } from "../../crypto-api/index.ts";
CryptoStore,
IDeviceData,
IProblem,
ISession,
ISessionInfo,
IWithheld,
Mode,
OutgoingRoomKeyRequest,
ParkedSharedHistory,
SecretStorePrivateKeys,
} from "./base";
import { IRoomKeyRequestBody } from "../index";
import { ICrossSigningKey } from "../../client";
import { IOlmDevice } from "../algorithms/megolm";
import { IRoomEncryption } from "../RoomList";
import { InboundGroupSessionData } from "../OlmDevice";
/*
/**
* Internal module. indexeddb storage for e2e.
*/
@@ -58,52 +61,6 @@ export class IndexedDBCryptoStore implements CryptoStore {
return IndexedDBHelpers.exists(indexedDB, dbName);
}
/**
* Utility to check if a legacy crypto store exists and has not been migrated.
* Returns true if the store exists and has not been migrated, false otherwise.
*/
public static existsAndIsNotMigrated(indexedDb: IDBFactory, dbName: string): Promise<boolean> {
return new Promise<boolean>((resolve, reject) => {
let exists = true;
const openDBRequest = indexedDb.open(dbName);
openDBRequest.onupgradeneeded = (): void => {
// Since we did not provide an explicit version when opening, this event
// should only fire if the DB did not exist before at any version.
exists = false;
};
openDBRequest.onblocked = (): void => reject(openDBRequest.error);
openDBRequest.onsuccess = (): void => {
const db = openDBRequest.result;
if (!exists) {
db.close();
// The DB did not exist before, but has been created as part of this
// existence check. Delete it now to restore previous state. Delete can
// actually take a while to complete in some browsers, so don't wait for
// it. This won't block future open calls that a store might issue next to
// properly set up the DB.
indexedDb.deleteDatabase(dbName);
resolve(false);
} else {
const tx = db.transaction([IndexedDBCryptoStore.STORE_ACCOUNT], "readonly");
const objectStore = tx.objectStore(IndexedDBCryptoStore.STORE_ACCOUNT);
const getReq = objectStore.get(ACCOUNT_OBJECT_KEY_MIGRATION_STATE);
getReq.onsuccess = (): void => {
const migrationState = getReq.result ?? MigrationState.NOT_STARTED;
resolve(migrationState === MigrationState.NOT_STARTED);
};
getReq.onerror = (): void => {
reject(getReq.error);
};
db.close();
}
};
openDBRequest.onerror = (): void => reject(openDBRequest.error);
});
}
private backendPromise?: Promise<CryptoStore>;
private backend?: CryptoStore;
@@ -113,21 +70,7 @@ export class IndexedDBCryptoStore implements CryptoStore {
* @param indexedDB - global indexedDB instance
* @param dbName - name of db to connect to
*/
public constructor(
private readonly indexedDB: IDBFactory,
private readonly dbName: string,
) {}
/**
* Returns true if this CryptoStore has ever been initialised (ie, it might contain data).
*
* Implementation of {@link CryptoStore.containsData}.
*
* @internal
*/
public async containsData(): Promise<boolean> {
return IndexedDBCryptoStore.exists(this.indexedDB, this.dbName);
}
public constructor(private readonly indexedDB: IDBFactory, private readonly dbName: string) {}
/**
* Ensure the database exists and is up-to-date, or fall back to
@@ -199,15 +142,14 @@ export class IndexedDBCryptoStore implements CryptoStore {
// in this db so we should use it or nothing at all.
throw new InvalidCryptoStoreError(InvalidCryptoStoreState.TooNew);
}
logger.warn(`unable to connect to indexeddb ${this.dbName}: falling back to localStorage store: ${e}`);
logger.warn(
`unable to connect to indexeddb ${this.dbName}` + `: falling back to localStorage store: ${e}`,
);
try {
if (!(globalThis.localStorage instanceof Storage)) {
throw new Error("localStorage is not available");
}
return new LocalStorageCryptoStore(globalThis.localStorage);
return new LocalStorageCryptoStore(global.localStorage);
} catch (e) {
logger.warn(`Unable to open localStorage: falling back to in-memory store: ${e}`);
logger.warn(`unable to open localStorage: falling back to in-memory store: ${e}`);
return new MemoryCryptoStore();
}
})
@@ -256,25 +198,107 @@ export class IndexedDBCryptoStore implements CryptoStore {
}
/**
* Get data on how much of the libolm to Rust Crypto migration has been done.
* Look for an existing outgoing room key request, and if none is found,
* add a new one
*
* Implementation of {@link CryptoStore.getMigrationState}.
*
* @internal
* @returns resolves to
* {@link OutgoingRoomKeyRequest}: either the
* same instance as passed in, or the existing one.
*/
public getMigrationState(): Promise<MigrationState> {
return this.backend!.getMigrationState();
public getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest> {
return this.backend!.getOrAddOutgoingRoomKeyRequest(request);
}
/**
* Set data on how much of the libolm to Rust Crypto migration has been done.
* Look for an existing room key request
*
* Implementation of {@link CryptoStore.setMigrationState}.
* @param requestBody - existing request to look for
*
* @internal
* @returns resolves to the matching
* {@link OutgoingRoomKeyRequest}, or null if
* not found
*/
public setMigrationState(migrationState: MigrationState): Promise<void> {
return this.backend!.setMigrationState(migrationState);
public getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<OutgoingRoomKeyRequest | null> {
return this.backend!.getOutgoingRoomKeyRequest(requestBody);
}
/**
* Look for room key requests by state
*
* @param wantedStates - list of acceptable states
*
* @returns resolves to the a
* {@link OutgoingRoomKeyRequest}, or null if
* there are no pending requests in those states. If there are multiple
* requests in those states, an arbitrary one is chosen.
*/
public getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise<OutgoingRoomKeyRequest | null> {
return this.backend!.getOutgoingRoomKeyRequestByState(wantedStates);
}
/**
* Look for room key requests by state –
* unlike above, return a list of all entries in one state.
*
* @returns Returns an array of requests in the given state
*/
public getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise<OutgoingRoomKeyRequest[]> {
return this.backend!.getAllOutgoingRoomKeyRequestsByState(wantedState);
}
/**
* Look for room key requests by target device and state
*
* @param userId - Target user ID
* @param deviceId - Target device ID
* @param wantedStates - list of acceptable states
*
* @returns resolves to a list of all the
* {@link OutgoingRoomKeyRequest}
*/
public getOutgoingRoomKeyRequestsByTarget(
userId: string,
deviceId: string,
wantedStates: number[],
): Promise<OutgoingRoomKeyRequest[]> {
return this.backend!.getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates);
}
/**
* Look for an existing room key request by id and state, and update it if
* found
*
* @param requestId - ID of request to update
* @param expectedState - state we expect to find the request in
* @param updates - name/value map of updates to apply
*
* @returns resolves to
* {@link OutgoingRoomKeyRequest}
* updated request, or null if no matching row was found
*/
public updateOutgoingRoomKeyRequest(
requestId: string,
expectedState: number,
updates: Partial<OutgoingRoomKeyRequest>,
): Promise<OutgoingRoomKeyRequest | null> {
return this.backend!.updateOutgoingRoomKeyRequest(requestId, expectedState, updates);
}
/**
* Look for an existing room key request by id and state, and delete it if
* found
*
* @param requestId - ID of request to update
* @param expectedState - state we expect to find the request in
*
* @returns resolves once the operation is completed
*/
public deleteOutgoingRoomKeyRequest(
requestId: string,
expectedState: number,
): Promise<OutgoingRoomKeyRequest | null> {
return this.backend!.deleteOutgoingRoomKeyRequest(requestId, expectedState);
}
// Olm Account
@@ -311,7 +335,7 @@ export class IndexedDBCryptoStore implements CryptoStore {
*/
public getCrossSigningKeys(
txn: IDBTransaction,
func: (keys: Record<string, CrossSigningKeyInfo> | null) => void,
func: (keys: Record<string, ICrossSigningKey> | null) => void,
): void {
this.backend!.getCrossSigningKeys(txn, func);
}
@@ -329,6 +353,16 @@ export class IndexedDBCryptoStore implements CryptoStore {
this.backend!.getSecretStorePrivateKey(txn, func, type);
}
/**
* Write the cross-signing keys back to the store
*
* @param txn - An active transaction. See doTxn().
* @param keys - keys object as getCrossSigningKeys()
*/
public storeCrossSigningKeys(txn: IDBTransaction, keys: Record<string, ICrossSigningKey>): void {
this.backend!.storeCrossSigningKeys(txn, keys);
}
/**
* Write the cross-signing private keys back to the store
*
@@ -395,6 +429,17 @@ export class IndexedDBCryptoStore implements CryptoStore {
this.backend!.getEndToEndSessions(deviceKey, txn, func);
}
/**
* Retrieve all end-to-end sessions
* @param txn - An active transaction. See doTxn().
* @param func - Called one for each session with
* an object with, deviceKey, lastReceivedMessageTs, sessionId
* and session keys.
*/
public getAllEndToEndSessions(txn: IDBTransaction, func: (session: ISessionInfo | null) => void): void {
this.backend!.getAllEndToEndSessions(txn, func);
}
/**
* Store a session between the logged-in user and another device
* @param deviceKey - The public key of the other device.
@@ -411,37 +456,16 @@ export class IndexedDBCryptoStore implements CryptoStore {
this.backend!.storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn);
}
/**
* Count the number of Megolm sessions in the database.
*
* Implementation of {@link CryptoStore.countEndToEndInboundGroupSessions}.
*
* @internal
*/
public countEndToEndInboundGroupSessions(): Promise<number> {
return this.backend!.countEndToEndInboundGroupSessions();
public storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void> {
return this.backend!.storeEndToEndSessionProblem(deviceKey, type, fixed);
}
/**
* Fetch a batch of Olm sessions from the database.
*
* Implementation of {@link CryptoStore.getEndToEndSessionsBatch}.
*
* @internal
*/
public getEndToEndSessionsBatch(): Promise<null | ISessionInfo[]> {
return this.backend!.getEndToEndSessionsBatch();
public getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null> {
return this.backend!.getEndToEndSessionProblem(deviceKey, timestamp);
}
/**
* Delete a batch of Olm sessions from the database.
*
* Implementation of {@link CryptoStore.deleteEndToEndSessionsBatch}.
*
* @internal
*/
public deleteEndToEndSessionsBatch(sessions: { deviceKey: string; sessionId: string }[]): Promise<void> {
return this.backend!.deleteEndToEndSessionsBatch(sessions);
public filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]> {
return this.backend!.filterOutNotifiedErrorDevices(devices);
}
// Inbound group sessions
@@ -464,6 +488,35 @@ export class IndexedDBCryptoStore implements CryptoStore {
this.backend!.getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func);
}
/**
* Fetches all inbound group sessions in the store
* @param txn - An active transaction. See doTxn().
* @param func - Called once for each group session
* in the store with an object having keys `{senderKey, sessionId, sessionData}`,
* then once with null to indicate the end of the list.
*/
public getAllEndToEndInboundGroupSessions(txn: IDBTransaction, func: (session: ISession | null) => void): void {
this.backend!.getAllEndToEndInboundGroupSessions(txn, func);
}
/**
* Adds an end-to-end inbound group session to the store.
* If there already exists an inbound group session with the same
* senderCurve25519Key and sessionID, the session will not be added.
* @param senderCurve25519Key - The sender's curve 25519 key
* @param sessionId - The ID of the session
* @param sessionData - The session data structure
* @param txn - An active transaction. See doTxn().
*/
public addEndToEndInboundGroupSession(
senderCurve25519Key: string,
sessionId: string,
sessionData: InboundGroupSessionData,
txn: IDBTransaction,
): void {
this.backend!.addEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn);
}
/**
* Writes an end-to-end inbound group session to the store.
* If there already exists an inbound group session with the same
@@ -482,28 +535,51 @@ export class IndexedDBCryptoStore implements CryptoStore {
this.backend!.storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn);
}
public storeEndToEndInboundGroupSessionWithheld(
senderCurve25519Key: string,
sessionId: string,
sessionData: IWithheld,
txn: IDBTransaction,
): void {
this.backend!.storeEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId, sessionData, txn);
}
// End-to-end device tracking
/**
* Fetch a batch of Megolm sessions from the database.
* Store the state of all tracked devices
* This contains devices for each user, a tracking state for each user
* and a sync token matching the point in time the snapshot represents.
* These all need to be written out in full each time such that the snapshot
* is always consistent, so they are stored in one object.
*
* Implementation of {@link CryptoStore.getEndToEndInboundGroupSessionsBatch}.
*
* @internal
* @param txn - An active transaction. See doTxn().
*/
public getEndToEndInboundGroupSessionsBatch(): Promise<SessionExtended[] | null> {
return this.backend!.getEndToEndInboundGroupSessionsBatch();
public storeEndToEndDeviceData(deviceData: IDeviceData, txn: IDBTransaction): void {
this.backend!.storeEndToEndDeviceData(deviceData, txn);
}
/**
* Delete a batch of Megolm sessions from the database.
* Get the state of all tracked devices
*
* Implementation of {@link CryptoStore.deleteEndToEndInboundGroupSessionsBatch}.
*
* @internal
* @param txn - An active transaction. See doTxn().
* @param func - Function called with the
* device data
*/
public deleteEndToEndInboundGroupSessionsBatch(
sessions: { senderKey: string; sessionId: string }[],
): Promise<void> {
return this.backend!.deleteEndToEndInboundGroupSessionsBatch(sessions);
public getEndToEndDeviceData(txn: IDBTransaction, func: (deviceData: IDeviceData | null) => void): void {
this.backend!.getEndToEndDeviceData(txn, func);
}
// End to End Rooms
/**
* Store the end-to-end state for a room.
* @param roomId - The room's ID.
* @param roomInfo - The end-to-end info for the room.
* @param txn - An active transaction. See doTxn().
*/
public storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: IDBTransaction): void {
this.backend!.storeEndToEndRoom(roomId, roomInfo, txn);
}
/**
@@ -515,6 +591,37 @@ export class IndexedDBCryptoStore implements CryptoStore {
this.backend!.getEndToEndRooms(txn, func);
}
// session backups
/**
* Get the inbound group sessions that need to be backed up.
* @param limit - The maximum number of sessions to retrieve. 0
* for no limit.
* @returns resolves to an array of inbound group sessions
*/
public getSessionsNeedingBackup(limit: number): Promise<ISession[]> {
return this.backend!.getSessionsNeedingBackup(limit);
}
/**
* Count the inbound group sessions that need to be backed up.
* @param txn - An active transaction. See doTxn(). (optional)
* @returns resolves to the number of sessions
*/
public countSessionsNeedingBackup(txn?: IDBTransaction): Promise<number> {
return this.backend!.countSessionsNeedingBackup(txn);
}
/**
* Unmark sessions as needing to be backed up.
* @param sessions - The sessions that need to be backed up.
* @param txn - An active transaction. See doTxn(). (optional)
* @returns resolves when the sessions are unmarked
*/
public unmarkSessionsNeedingBackup(sessions: ISession[], txn?: IDBTransaction): Promise<void> {
return this.backend!.unmarkSessionsNeedingBackup(sessions, txn);
}
/**
* Mark sessions as needing to be backed up.
* @param sessions - The sessions that need to be backed up.
@@ -525,6 +632,49 @@ export class IndexedDBCryptoStore implements CryptoStore {
return this.backend!.markSessionsNeedingBackup(sessions, txn);
}
/**
* Add a shared-history group session for a room.
* @param roomId - The room that the key belongs to
* @param senderKey - The sender's curve 25519 key
* @param sessionId - The ID of the session
* @param txn - An active transaction. See doTxn(). (optional)
*/
public addSharedHistoryInboundGroupSession(
roomId: string,
senderKey: string,
sessionId: string,
txn?: IDBTransaction,
): void {
this.backend!.addSharedHistoryInboundGroupSession(roomId, senderKey, sessionId, txn);
}
/**
* Get the shared-history group session for a room.
* @param roomId - The room that the key belongs to
* @param txn - An active transaction. See doTxn(). (optional)
* @returns Promise which resolves to an array of [senderKey, sessionId]
*/
public getSharedHistoryInboundGroupSessions(
roomId: string,
txn?: IDBTransaction,
): Promise<[senderKey: string, sessionId: string][]> {
return this.backend!.getSharedHistoryInboundGroupSessions(roomId, txn);
}
/**
* Park a shared-history group session for a room we may be invited to later.
*/
public addParkedSharedHistory(roomId: string, parkedData: ParkedSharedHistory, txn?: IDBTransaction): void {
this.backend!.addParkedSharedHistory(roomId, parkedData, txn);
}
/**
* Pop out all shared-history group sessions for a room.
*/
public takeParkedSharedHistory(roomId: string, txn?: IDBTransaction): Promise<ParkedSharedHistory[]> {
return this.backend!.takeParkedSharedHistory(roomId, txn);
}
/**
* Perform a transaction on the crypto store. Any store methods
* that require a transaction (txn) object to be passed in may
@@ -547,7 +697,12 @@ export class IndexedDBCryptoStore implements CryptoStore {
* reject with that exception. On synchronous backends, the
* exception will propagate to the caller of the getFoo method.
*/
public doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn: IDBTransaction) => T, log?: Logger): Promise<T> {
public doTxn<T>(
mode: Mode,
stores: Iterable<string>,
func: (txn: IDBTransaction) => T,
log?: PrefixedLogger,
): Promise<T> {
return this.backend!.doTxn<T>(mode, stores, func as (txn: unknown) => T, log);
}
}

View File

@@ -14,22 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger } from "../../logger.ts";
import { MemoryCryptoStore } from "./memory-crypto-store.ts";
import {
type CryptoStore,
type ISession,
type SessionExtended,
type ISessionInfo,
type IWithheld,
MigrationState,
type Mode,
type SecretStorePrivateKeys,
SESSION_BATCH_SIZE,
type InboundGroupSessionData,
type IRoomEncryption,
} from "./base.ts";
import { type CrossSigningKeyInfo } from "../../crypto-api/index.ts";
import { logger } from "../../logger";
import { MemoryCryptoStore } from "./memory-crypto-store";
import { IDeviceData, IProblem, ISession, ISessionInfo, IWithheld, Mode, SecretStorePrivateKeys } from "./base";
import { IOlmDevice } from "../algorithms/megolm";
import { IRoomEncryption } from "../RoomList";
import { ICrossSigningKey } from "../../client";
import { InboundGroupSessionData } from "../OlmDevice";
import { safeSet } from "../../utils";
/**
* Internal module. Partial localStorage backed storage for e2e.
@@ -40,9 +32,10 @@ import { type CrossSigningKeyInfo } from "../../crypto-api/index.ts";
*/
const E2E_PREFIX = "crypto.";
const KEY_END_TO_END_MIGRATION_STATE = E2E_PREFIX + "migration";
const KEY_END_TO_END_ACCOUNT = E2E_PREFIX + "account";
const KEY_CROSS_SIGNING_KEYS = E2E_PREFIX + "cross_signing_keys";
const KEY_NOTIFIED_ERROR_DEVICES = E2E_PREFIX + "notified_error_devices";
const KEY_DEVICE_DATA = E2E_PREFIX + "device_data";
const KEY_INBOUND_SESSION_PREFIX = E2E_PREFIX + "inboundgroupsessions/";
const KEY_INBOUND_SESSION_WITHHELD_PREFIX = E2E_PREFIX + "inboundgroupsessions.withheld/";
const KEY_ROOMS_PREFIX = E2E_PREFIX + "rooms/";
@@ -52,6 +45,10 @@ function keyEndToEndSessions(deviceKey: string): string {
return E2E_PREFIX + "sessions/" + deviceKey;
}
function keyEndToEndSessionProblems(deviceKey: string): string {
return E2E_PREFIX + "session.problems/" + deviceKey;
}
function keyEndToEndInboundGroupSession(senderKey: string, sessionId: string): string {
return KEY_INBOUND_SESSION_PREFIX + senderKey + "/" + sessionId;
}
@@ -64,7 +61,7 @@ function keyEndToEndRoomsPrefix(roomId: string): string {
return KEY_ROOMS_PREFIX + roomId;
}
export class LocalStorageCryptoStore extends MemoryCryptoStore implements CryptoStore {
export class LocalStorageCryptoStore extends MemoryCryptoStore {
public static exists(store: Storage): boolean {
const length = store.length;
for (let i = 0; i < length; i++) {
@@ -79,53 +76,17 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
super();
}
/**
* Returns true if this CryptoStore has ever been initialised (ie, it might contain data).
*
* Implementation of {@link CryptoStore.containsData}.
*
* @internal
*/
public async containsData(): Promise<boolean> {
return LocalStorageCryptoStore.exists(this.store);
}
/**
* Get data on how much of the libolm to Rust Crypto migration has been done.
*
* Implementation of {@link CryptoStore.getMigrationState}.
*
* @internal
*/
public async getMigrationState(): Promise<MigrationState> {
return getJsonItem(this.store, KEY_END_TO_END_MIGRATION_STATE) ?? MigrationState.NOT_STARTED;
}
/**
* Set data on how much of the libolm to Rust Crypto migration has been done.
*
* Implementation of {@link CryptoStore.setMigrationState}.
*
* @internal
*/
public async setMigrationState(migrationState: MigrationState): Promise<void> {
setJsonItem(this.store, KEY_END_TO_END_MIGRATION_STATE, migrationState);
}
// Olm Sessions
public countEndToEndSessions(txn: unknown, func: (count: number) => void): void {
let count = 0;
for (let i = 0; i < this.store.length; ++i) {
const key = this.store.key(i);
if (key?.startsWith(keyEndToEndSessions(""))) {
const sessions = getJsonItem(this.store, key);
count += Object.keys(sessions ?? {}).length;
}
if (this.store.key(i)?.startsWith(keyEndToEndSessions(""))) ++count;
}
func(count);
}
// eslint-disable-next-line @typescript-eslint/naming-convention
private _getEndToEndSessions(deviceKey: string): Record<string, ISessionInfo> {
const sessions = getJsonItem(this.store, keyEndToEndSessions(deviceKey));
const fixedSessions: Record<string, ISessionInfo> = {};
@@ -151,7 +112,7 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
func: (session: ISessionInfo) => void,
): void {
const sessions = this._getEndToEndSessions(deviceKey);
func(sessions[sessionId] ?? {});
func(sessions[sessionId] || {});
}
public getEndToEndSessions(
@@ -159,7 +120,18 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
txn: unknown,
func: (sessions: { [sessionId: string]: ISessionInfo }) => void,
): void {
func(this._getEndToEndSessions(deviceKey) ?? {});
func(this._getEndToEndSessions(deviceKey) || {});
}
public getAllEndToEndSessions(txn: unknown, func: (session: ISessionInfo) => void): void {
for (let i = 0; i < this.store.length; ++i) {
if (this.store.key(i)?.startsWith(keyEndToEndSessions(""))) {
const deviceKey = this.store.key(i)!.split("/")[1];
for (const sess of Object.values(this._getEndToEndSessions(deviceKey))) {
func(sess);
}
}
}
}
public storeEndToEndSession(deviceKey: string, sessionId: string, sessionInfo: ISessionInfo, txn: unknown): void {
@@ -168,54 +140,56 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
setJsonItem(this.store, keyEndToEndSessions(deviceKey), sessions);
}
/**
* Fetch a batch of Olm sessions from the database.
*
* Implementation of {@link CryptoStore.getEndToEndSessionsBatch}.
*
* @internal
*/
public async getEndToEndSessionsBatch(): Promise<null | ISessionInfo[]> {
const result: ISessionInfo[] = [];
for (let i = 0; i < this.store.length; ++i) {
if (this.store.key(i)?.startsWith(keyEndToEndSessions(""))) {
const deviceKey = this.store.key(i)!.split("/")[1];
for (const session of Object.values(this._getEndToEndSessions(deviceKey))) {
result.push(session);
if (result.length >= SESSION_BATCH_SIZE) {
return result;
}
}
}
}
if (result.length === 0) {
// No sessions left.
return null;
}
// There are fewer sessions than the batch size; return the final batch of sessions.
return result;
public async storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void> {
const key = keyEndToEndSessionProblems(deviceKey);
const problems = getJsonItem<IProblem[]>(this.store, key) || [];
problems.push({ type, fixed, time: Date.now() });
problems.sort((a, b) => {
return a.time - b.time;
});
setJsonItem(this.store, key, problems);
}
/**
* Delete a batch of Olm sessions from the database.
*
* Implementation of {@link CryptoStore.deleteEndToEndSessionsBatch}.
*
* @internal
*/
public async deleteEndToEndSessionsBatch(sessions: { deviceKey: string; sessionId: string }[]): Promise<void> {
for (const { deviceKey, sessionId } of sessions) {
const deviceSessions = this._getEndToEndSessions(deviceKey) || {};
delete deviceSessions[sessionId];
if (Object.keys(deviceSessions).length === 0) {
// No more sessions for this device.
this.store.removeItem(keyEndToEndSessions(deviceKey));
} else {
setJsonItem(this.store, keyEndToEndSessions(deviceKey), deviceSessions);
public async getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null> {
const key = keyEndToEndSessionProblems(deviceKey);
const problems = getJsonItem<IProblem[]>(this.store, key) || [];
if (!problems.length) {
return null;
}
const lastProblem = problems[problems.length - 1];
for (const problem of problems) {
if (problem.time > timestamp) {
return Object.assign({}, problem, { fixed: lastProblem.fixed });
}
}
if (lastProblem.fixed) {
return null;
} else {
return lastProblem;
}
}
public async filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]> {
const notifiedErrorDevices =
getJsonItem<MemoryCryptoStore["notifiedErrorDevices"]>(this.store, KEY_NOTIFIED_ERROR_DEVICES) || {};
const ret: IOlmDevice[] = [];
for (const device of devices) {
const { userId, deviceInfo } = device;
if (userId in notifiedErrorDevices) {
if (!(deviceInfo.deviceId in notifiedErrorDevices[userId])) {
ret.push(device);
safeSet(notifiedErrorDevices[userId], deviceInfo.deviceId, true);
}
} else {
ret.push(device);
safeSet(notifiedErrorDevices, userId, { [deviceInfo.deviceId]: true });
}
}
setJsonItem(this.store, KEY_NOTIFIED_ERROR_DEVICES, notifiedErrorDevices);
return ret;
}
// Inbound Group Sessions
@@ -232,6 +206,37 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
);
}
public getAllEndToEndInboundGroupSessions(txn: unknown, func: (session: ISession | null) => void): void {
for (let i = 0; i < this.store.length; ++i) {
const key = this.store.key(i);
if (key?.startsWith(KEY_INBOUND_SESSION_PREFIX)) {
// we can't use split, as the components we are trying to split out
// might themselves contain '/' characters. We rely on the
// senderKey being a (32-byte) curve25519 key, base64-encoded
// (hence 43 characters long).
func({
senderKey: key.slice(KEY_INBOUND_SESSION_PREFIX.length, KEY_INBOUND_SESSION_PREFIX.length + 43),
sessionId: key.slice(KEY_INBOUND_SESSION_PREFIX.length + 44),
sessionData: getJsonItem(this.store, key)!,
});
}
}
func(null);
}
public addEndToEndInboundGroupSession(
senderCurve25519Key: string,
sessionId: string,
sessionData: InboundGroupSessionData,
txn: unknown,
): void {
const existing = getJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId));
if (!existing) {
this.storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn);
}
}
public storeEndToEndInboundGroupSession(
senderCurve25519Key: string,
sessionId: string,
@@ -241,80 +246,25 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
setJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId), sessionData);
}
/**
* Count the number of Megolm sessions in the database.
*
* Implementation of {@link CryptoStore.countEndToEndInboundGroupSessions}.
*
* @internal
*/
public async countEndToEndInboundGroupSessions(): Promise<number> {
let count = 0;
for (let i = 0; i < this.store.length; ++i) {
const key = this.store.key(i);
if (key?.startsWith(KEY_INBOUND_SESSION_PREFIX)) {
count += 1;
}
}
return count;
public storeEndToEndInboundGroupSessionWithheld(
senderCurve25519Key: string,
sessionId: string,
sessionData: IWithheld,
txn: unknown,
): void {
setJsonItem(this.store, keyEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId), sessionData);
}
/**
* Fetch a batch of Megolm sessions from the database.
*
* Implementation of {@link CryptoStore.getEndToEndInboundGroupSessionsBatch}.
*
* @internal
*/
public async getEndToEndInboundGroupSessionsBatch(): Promise<SessionExtended[] | null> {
const sessionsNeedingBackup = getJsonItem<string[]>(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {};
const result: SessionExtended[] = [];
for (let i = 0; i < this.store.length; ++i) {
const key = this.store.key(i);
if (key?.startsWith(KEY_INBOUND_SESSION_PREFIX)) {
const key2 = key.slice(KEY_INBOUND_SESSION_PREFIX.length);
// we can't use split, as the components we are trying to split out
// might themselves contain '/' characters. We rely on the
// senderKey being a (32-byte) curve25519 key, base64-encoded
// (hence 43 characters long).
result.push({
senderKey: key2.slice(0, 43),
sessionId: key2.slice(44),
sessionData: getJsonItem(this.store, key)!,
needsBackup: key2 in sessionsNeedingBackup,
});
if (result.length >= SESSION_BATCH_SIZE) {
return result;
}
}
}
if (result.length === 0) {
// No sessions left.
return null;
}
// There are fewer sessions than the batch size; return the final batch of sessions.
return result;
public getEndToEndDeviceData(txn: unknown, func: (deviceData: IDeviceData | null) => void): void {
func(getJsonItem(this.store, KEY_DEVICE_DATA));
}
/**
* Delete a batch of Megolm sessions from the database.
*
* Implementation of {@link CryptoStore.deleteEndToEndInboundGroupSessionsBatch}.
*
* @internal
*/
public async deleteEndToEndInboundGroupSessionsBatch(
sessions: { senderKey: string; sessionId: string }[],
): Promise<void> {
for (const { senderKey, sessionId } of sessions) {
const k = keyEndToEndInboundGroupSession(senderKey, sessionId);
this.store.removeItem(k);
}
public storeEndToEndDeviceData(deviceData: IDeviceData, txn: unknown): void {
setJsonItem(this.store, KEY_DEVICE_DATA, deviceData);
}
public storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: unknown): void {
setJsonItem(this.store, keyEndToEndRoomsPrefix(roomId), roomInfo);
}
public getEndToEndRooms(txn: unknown, func: (rooms: Record<string, IRoomEncryption>) => void): void {
@@ -331,6 +281,47 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
func(result);
}
public getSessionsNeedingBackup(limit: number): Promise<ISession[]> {
const sessionsNeedingBackup = getJsonItem<string[]>(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {};
const sessions: ISession[] = [];
for (const session in sessionsNeedingBackup) {
if (Object.prototype.hasOwnProperty.call(sessionsNeedingBackup, session)) {
// see getAllEndToEndInboundGroupSessions for the magic number explanations
const senderKey = session.slice(0, 43);
const sessionId = session.slice(44);
this.getEndToEndInboundGroupSession(senderKey, sessionId, null, (sessionData) => {
sessions.push({
senderKey: senderKey,
sessionId: sessionId,
sessionData: sessionData!,
});
});
if (limit && sessions.length >= limit) {
break;
}
}
}
return Promise.resolve(sessions);
}
public countSessionsNeedingBackup(): Promise<number> {
const sessionsNeedingBackup = getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {};
return Promise.resolve(Object.keys(sessionsNeedingBackup).length);
}
public unmarkSessionsNeedingBackup(sessions: ISession[]): Promise<void> {
const sessionsNeedingBackup =
getJsonItem<{
[senderKeySessionId: string]: string;
}>(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {};
for (const session of sessions) {
delete sessionsNeedingBackup[session.senderKey + "/" + session.sessionId];
}
setJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP, sessionsNeedingBackup);
return Promise.resolve();
}
public markSessionsNeedingBackup(sessions: ISession[]): Promise<void> {
const sessionsNeedingBackup =
getJsonItem<{
@@ -364,8 +355,8 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
setJsonItem(this.store, KEY_END_TO_END_ACCOUNT, accountPickle);
}
public getCrossSigningKeys(txn: unknown, func: (keys: Record<string, CrossSigningKeyInfo> | null) => void): void {
const keys = getJsonItem<Record<string, CrossSigningKeyInfo>>(this.store, KEY_CROSS_SIGNING_KEYS);
public getCrossSigningKeys(txn: unknown, func: (keys: Record<string, ICrossSigningKey> | null) => void): void {
const keys = getJsonItem<Record<string, ICrossSigningKey>>(this.store, KEY_CROSS_SIGNING_KEYS);
func(keys);
}
@@ -378,6 +369,10 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
func(key);
}
public storeCrossSigningKeys(txn: unknown, keys: Record<string, ICrossSigningKey>): void {
setJsonItem(this.store, KEY_CROSS_SIGNING_KEYS, keys);
}
public storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
txn: unknown,
type: K,

View File

@@ -14,61 +14,47 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { safeSet } from "../../utils.ts";
import { logger } from "../../logger";
import { safeSet, deepCompare, promiseTry } from "../../utils";
import {
type CryptoStore,
type ISession,
type SessionExtended,
type ISessionInfo,
type IWithheld,
MigrationState,
type Mode,
type SecretStorePrivateKeys,
SESSION_BATCH_SIZE,
type InboundGroupSessionData,
type IRoomEncryption,
} from "./base.ts";
import { type CrossSigningKeyInfo } from "../../crypto-api/index.ts";
function encodeSessionKey(senderCurve25519Key: string, sessionId: string): string {
return encodeURIComponent(senderCurve25519Key) + "/" + encodeURIComponent(sessionId);
}
function decodeSessionKey(key: string): { senderKey: string; sessionId: string } {
const keyParts = key.split("/");
const senderKey = decodeURIComponent(keyParts[0]);
const sessionId = decodeURIComponent(keyParts[1]);
return { senderKey, sessionId };
}
CryptoStore,
IDeviceData,
IProblem,
ISession,
ISessionInfo,
IWithheld,
Mode,
OutgoingRoomKeyRequest,
ParkedSharedHistory,
SecretStorePrivateKeys,
} from "./base";
import { IRoomKeyRequestBody } from "../index";
import { ICrossSigningKey } from "../../client";
import { IOlmDevice } from "../algorithms/megolm";
import { IRoomEncryption } from "../RoomList";
import { InboundGroupSessionData } from "../OlmDevice";
/**
* Internal module. in-memory storage for e2e.
*/
export class MemoryCryptoStore implements CryptoStore {
private migrationState: MigrationState = MigrationState.NOT_STARTED;
private outgoingRoomKeyRequests: OutgoingRoomKeyRequest[] = [];
private account: string | null = null;
private crossSigningKeys: Record<string, CrossSigningKeyInfo> | null = null;
private crossSigningKeys: Record<string, ICrossSigningKey> | null = null;
private privateKeys: Partial<SecretStorePrivateKeys> = {};
private sessions: { [deviceKey: string]: { [sessionId: string]: ISessionInfo } } = {};
private sessionProblems: { [deviceKey: string]: IProblem[] } = {};
private notifiedErrorDevices: { [userId: string]: { [deviceId: string]: boolean } } = {};
private inboundGroupSessions: { [sessionKey: string]: InboundGroupSessionData } = {};
private inboundGroupSessionsWithheld: Record<string, IWithheld> = {};
// Opaque device data object
private deviceData: IDeviceData | null = null;
private rooms: { [roomId: string]: IRoomEncryption } = {};
private sessionsNeedingBackup: { [sessionKey: string]: boolean } = {};
/**
* Returns true if this CryptoStore has ever been initialised (ie, it might contain data).
*
* Implementation of {@link CryptoStore.containsData}.
*
* @internal
*/
public async containsData(): Promise<boolean> {
// If it contains anything, it should contain an account.
return this.account !== null;
}
private sharedHistoryInboundGroupSessions: { [roomId: string]: [senderKey: string, sessionId: string][] } = {};
private parkedSharedHistory = new Map<string, ParkedSharedHistory[]>(); // keyed by room ID
/**
* Ensure the database exists and is up-to-date.
@@ -92,25 +78,186 @@ export class MemoryCryptoStore implements CryptoStore {
}
/**
* Get data on how much of the libolm to Rust Crypto migration has been done.
* Look for an existing outgoing room key request, and if none is found,
* add a new one
*
* Implementation of {@link CryptoStore.getMigrationState}.
*
* @internal
* @returns resolves to
* {@link OutgoingRoomKeyRequest}: either the
* same instance as passed in, or the existing one.
*/
public async getMigrationState(): Promise<MigrationState> {
return this.migrationState;
public getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest> {
const requestBody = request.requestBody;
return promiseTry(() => {
// first see if we already have an entry for this request.
const existing = this._getOutgoingRoomKeyRequest(requestBody);
if (existing) {
// this entry matches the request - return it.
logger.log(
`already have key request outstanding for ` +
`${requestBody.room_id} / ${requestBody.session_id}: ` +
`not sending another`,
);
return existing;
}
// we got to the end of the list without finding a match
// - add the new request.
logger.log(`enqueueing key request for ${requestBody.room_id} / ` + requestBody.session_id);
this.outgoingRoomKeyRequests.push(request);
return request;
});
}
/**
* Set data on how much of the libolm to Rust Crypto migration has been done.
* Look for an existing room key request
*
* Implementation of {@link CryptoStore.setMigrationState}.
* @param requestBody - existing request to look for
*
* @returns resolves to the matching
* {@link OutgoingRoomKeyRequest}, or null if
* not found
*/
public getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<OutgoingRoomKeyRequest | null> {
return Promise.resolve(this._getOutgoingRoomKeyRequest(requestBody));
}
/**
* Looks for existing room key request, and returns the result synchronously.
*
* @internal
*
* @param requestBody - existing request to look for
*
* @returns
* the matching request, or null if not found
*/
public async setMigrationState(migrationState: MigrationState): Promise<void> {
this.migrationState = migrationState;
// eslint-disable-next-line @typescript-eslint/naming-convention
private _getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): OutgoingRoomKeyRequest | null {
for (const existing of this.outgoingRoomKeyRequests) {
if (deepCompare(existing.requestBody, requestBody)) {
return existing;
}
}
return null;
}
/**
* Look for room key requests by state
*
* @param wantedStates - list of acceptable states
*
* @returns resolves to the a
* {@link OutgoingRoomKeyRequest}, or null if
* there are no pending requests in those states
*/
public getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise<OutgoingRoomKeyRequest | null> {
for (const req of this.outgoingRoomKeyRequests) {
for (const state of wantedStates) {
if (req.state === state) {
return Promise.resolve(req);
}
}
}
return Promise.resolve(null);
}
/**
*
* @returns All OutgoingRoomKeyRequests in state
*/
public getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise<OutgoingRoomKeyRequest[]> {
return Promise.resolve(this.outgoingRoomKeyRequests.filter((r) => r.state == wantedState));
}
public getOutgoingRoomKeyRequestsByTarget(
userId: string,
deviceId: string,
wantedStates: number[],
): Promise<OutgoingRoomKeyRequest[]> {
const results: OutgoingRoomKeyRequest[] = [];
for (const req of this.outgoingRoomKeyRequests) {
for (const state of wantedStates) {
if (
req.state === state &&
req.recipients.some((recipient) => recipient.userId === userId && recipient.deviceId === deviceId)
) {
results.push(req);
}
}
}
return Promise.resolve(results);
}
/**
* Look for an existing room key request by id and state, and update it if
* found
*
* @param requestId - ID of request to update
* @param expectedState - state we expect to find the request in
* @param updates - name/value map of updates to apply
*
* @returns resolves to
* {@link OutgoingRoomKeyRequest}
* updated request, or null if no matching row was found
*/
public updateOutgoingRoomKeyRequest(
requestId: string,
expectedState: number,
updates: Partial<OutgoingRoomKeyRequest>,
): Promise<OutgoingRoomKeyRequest | null> {
for (const req of this.outgoingRoomKeyRequests) {
if (req.requestId !== requestId) {
continue;
}
if (req.state !== expectedState) {
logger.warn(
`Cannot update room key request from ${expectedState} ` +
`as it was already updated to ${req.state}`,
);
return Promise.resolve(null);
}
Object.assign(req, updates);
return Promise.resolve(req);
}
return Promise.resolve(null);
}
/**
* Look for an existing room key request by id and state, and delete it if
* found
*
* @param requestId - ID of request to update
* @param expectedState - state we expect to find the request in
*
* @returns resolves once the operation is completed
*/
public deleteOutgoingRoomKeyRequest(
requestId: string,
expectedState: number,
): Promise<OutgoingRoomKeyRequest | null> {
for (let i = 0; i < this.outgoingRoomKeyRequests.length; i++) {
const req = this.outgoingRoomKeyRequests[i];
if (req.requestId !== requestId) {
continue;
}
if (req.state != expectedState) {
logger.warn(`Cannot delete room key request in state ${req.state} ` + `(expected ${expectedState})`);
return Promise.resolve(null);
}
this.outgoingRoomKeyRequests.splice(i, 1);
return Promise.resolve(req);
}
return Promise.resolve(null);
}
// Olm Account
@@ -123,7 +270,7 @@ export class MemoryCryptoStore implements CryptoStore {
this.account = accountPickle;
}
public getCrossSigningKeys(txn: unknown, func: (keys: Record<string, CrossSigningKeyInfo> | null) => void): void {
public getCrossSigningKeys(txn: unknown, func: (keys: Record<string, ICrossSigningKey> | null) => void): void {
func(this.crossSigningKeys);
}
@@ -132,10 +279,14 @@ export class MemoryCryptoStore implements CryptoStore {
func: (key: SecretStorePrivateKeys[K] | null) => void,
type: K,
): void {
const result = this.privateKeys[type];
const result = this.privateKeys[type] as SecretStorePrivateKeys[K] | undefined;
func(result || null);
}
public storeCrossSigningKeys(txn: unknown, keys: Record<string, ICrossSigningKey>): void {
this.crossSigningKeys = keys;
}
public storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
txn: unknown,
type: K,
@@ -147,11 +298,7 @@ export class MemoryCryptoStore implements CryptoStore {
// Olm Sessions
public countEndToEndSessions(txn: unknown, func: (count: number) => void): void {
let count = 0;
for (const deviceSessions of Object.values(this.sessions)) {
count += Object.keys(deviceSessions).length;
}
func(count);
func(Object.keys(this.sessions).length);
}
public getEndToEndSession(
@@ -172,6 +319,18 @@ export class MemoryCryptoStore implements CryptoStore {
func(this.sessions[deviceKey] || {});
}
public getAllEndToEndSessions(txn: unknown, func: (session: ISessionInfo) => void): void {
Object.entries(this.sessions).forEach(([deviceKey, deviceSessions]) => {
Object.entries(deviceSessions).forEach(([sessionId, session]) => {
func({
...session,
deviceKey,
sessionId,
});
});
});
}
public storeEndToEndSession(deviceKey: string, sessionId: string, sessionInfo: ISessionInfo, txn: unknown): void {
let deviceSessions = this.sessions[deviceKey];
if (deviceSessions === undefined) {
@@ -181,49 +340,50 @@ export class MemoryCryptoStore implements CryptoStore {
safeSet(deviceSessions, sessionId, sessionInfo);
}
/**
* Fetch a batch of Olm sessions from the database.
*
* Implementation of {@link CryptoStore.getEndToEndSessionsBatch}.
*
* @internal
*/
public async getEndToEndSessionsBatch(): Promise<null | ISessionInfo[]> {
const result: ISessionInfo[] = [];
for (const deviceSessions of Object.values(this.sessions)) {
for (const session of Object.values(deviceSessions)) {
result.push(session);
if (result.length >= SESSION_BATCH_SIZE) {
return result;
}
}
}
if (result.length === 0) {
// No sessions left.
return null;
}
// There are fewer sessions than the batch size; return the final batch of sessions.
return result;
public async storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void> {
const problems = (this.sessionProblems[deviceKey] = this.sessionProblems[deviceKey] || []);
problems.push({ type, fixed, time: Date.now() });
problems.sort((a, b) => {
return a.time - b.time;
});
}
/**
* Delete a batch of Olm sessions from the database.
*
* Implementation of {@link CryptoStore.deleteEndToEndSessionsBatch}.
*
* @internal
*/
public async deleteEndToEndSessionsBatch(sessions: { deviceKey: string; sessionId: string }[]): Promise<void> {
for (const { deviceKey, sessionId } of sessions) {
const deviceSessions = this.sessions[deviceKey] || {};
delete deviceSessions[sessionId];
if (Object.keys(deviceSessions).length === 0) {
// No more sessions for this device.
delete this.sessions[deviceKey];
public async getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null> {
const problems = this.sessionProblems[deviceKey] || [];
if (!problems.length) {
return null;
}
const lastProblem = problems[problems.length - 1];
for (const problem of problems) {
if (problem.time > timestamp) {
return Object.assign({}, problem, { fixed: lastProblem.fixed });
}
}
if (lastProblem.fixed) {
return null;
} else {
return lastProblem;
}
}
public async filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]> {
const notifiedErrorDevices = this.notifiedErrorDevices;
const ret: IOlmDevice[] = [];
for (const device of devices) {
const { userId, deviceInfo } = device;
if (userId in notifiedErrorDevices) {
if (!(deviceInfo.deviceId in notifiedErrorDevices[userId])) {
ret.push(device);
safeSet(notifiedErrorDevices[userId], deviceInfo.deviceId, true);
}
} else {
ret.push(device);
safeSet(notifiedErrorDevices, userId, { [deviceInfo.deviceId]: true });
}
}
return ret;
}
// Inbound Group Sessions
@@ -234,90 +394,136 @@ export class MemoryCryptoStore implements CryptoStore {
txn: unknown,
func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void,
): void {
const k = encodeSessionKey(senderCurve25519Key, sessionId);
const k = senderCurve25519Key + "/" + sessionId;
func(this.inboundGroupSessions[k] || null, this.inboundGroupSessionsWithheld[k] || null);
}
public getAllEndToEndInboundGroupSessions(txn: unknown, func: (session: ISession | null) => void): void {
for (const key of Object.keys(this.inboundGroupSessions)) {
// we can't use split, as the components we are trying to split out
// might themselves contain '/' characters. We rely on the
// senderKey being a (32-byte) curve25519 key, base64-encoded
// (hence 43 characters long).
func({
senderKey: key.slice(0, 43),
sessionId: key.slice(44),
sessionData: this.inboundGroupSessions[key],
});
}
func(null);
}
public addEndToEndInboundGroupSession(
senderCurve25519Key: string,
sessionId: string,
sessionData: InboundGroupSessionData,
txn: unknown,
): void {
const k = senderCurve25519Key + "/" + sessionId;
if (this.inboundGroupSessions[k] === undefined) {
this.inboundGroupSessions[k] = sessionData;
}
}
public storeEndToEndInboundGroupSession(
senderCurve25519Key: string,
sessionId: string,
sessionData: InboundGroupSessionData,
txn: unknown,
): void {
const k = encodeSessionKey(senderCurve25519Key, sessionId);
this.inboundGroupSessions[k] = sessionData;
this.inboundGroupSessions[senderCurve25519Key + "/" + sessionId] = sessionData;
}
/**
* Count the number of Megolm sessions in the database.
*
* Implementation of {@link CryptoStore.countEndToEndInboundGroupSessions}.
*
* @internal
*/
public async countEndToEndInboundGroupSessions(): Promise<number> {
return Object.keys(this.inboundGroupSessions).length;
public storeEndToEndInboundGroupSessionWithheld(
senderCurve25519Key: string,
sessionId: string,
sessionData: IWithheld,
txn: unknown,
): void {
const k = senderCurve25519Key + "/" + sessionId;
this.inboundGroupSessionsWithheld[k] = sessionData;
}
/**
* Fetch a batch of Megolm sessions from the database.
*
* Implementation of {@link CryptoStore.getEndToEndInboundGroupSessionsBatch}.
*
* @internal
*/
public async getEndToEndInboundGroupSessionsBatch(): Promise<null | SessionExtended[]> {
const result: SessionExtended[] = [];
for (const [key, session] of Object.entries(this.inboundGroupSessions)) {
result.push({
...decodeSessionKey(key),
sessionData: session,
needsBackup: key in this.sessionsNeedingBackup,
});
if (result.length >= SESSION_BATCH_SIZE) {
return result;
}
}
// Device Data
if (result.length === 0) {
// No sessions left.
return null;
}
// There are fewer sessions than the batch size; return the final batch of sessions.
return result;
public getEndToEndDeviceData(txn: unknown, func: (deviceData: IDeviceData | null) => void): void {
func(this.deviceData);
}
/**
* Delete a batch of Megolm sessions from the database.
*
* Implementation of {@link CryptoStore.deleteEndToEndInboundGroupSessionsBatch}.
*
* @internal
*/
public async deleteEndToEndInboundGroupSessionsBatch(
sessions: { senderKey: string; sessionId: string }[],
): Promise<void> {
for (const { senderKey, sessionId } of sessions) {
const k = encodeSessionKey(senderKey, sessionId);
delete this.inboundGroupSessions[k];
}
public storeEndToEndDeviceData(deviceData: IDeviceData, txn: unknown): void {
this.deviceData = deviceData;
}
// E2E rooms
public storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: unknown): void {
this.rooms[roomId] = roomInfo;
}
public getEndToEndRooms(txn: unknown, func: (rooms: Record<string, IRoomEncryption>) => void): void {
func(this.rooms);
}
public getSessionsNeedingBackup(limit: number): Promise<ISession[]> {
const sessions: ISession[] = [];
for (const session in this.sessionsNeedingBackup) {
if (this.inboundGroupSessions[session]) {
sessions.push({
senderKey: session.slice(0, 43),
sessionId: session.slice(44),
sessionData: this.inboundGroupSessions[session],
});
if (limit && session.length >= limit) {
break;
}
}
}
return Promise.resolve(sessions);
}
public countSessionsNeedingBackup(): Promise<number> {
return Promise.resolve(Object.keys(this.sessionsNeedingBackup).length);
}
public unmarkSessionsNeedingBackup(sessions: ISession[]): Promise<void> {
for (const session of sessions) {
const sessionKey = session.senderKey + "/" + session.sessionId;
delete this.sessionsNeedingBackup[sessionKey];
}
return Promise.resolve();
}
public markSessionsNeedingBackup(sessions: ISession[]): Promise<void> {
for (const session of sessions) {
const sessionKey = encodeSessionKey(session.senderKey, session.sessionId);
const sessionKey = session.senderKey + "/" + session.sessionId;
this.sessionsNeedingBackup[sessionKey] = true;
}
return Promise.resolve();
}
public addSharedHistoryInboundGroupSession(roomId: string, senderKey: string, sessionId: string): void {
const sessions = this.sharedHistoryInboundGroupSessions[roomId] || [];
sessions.push([senderKey, sessionId]);
this.sharedHistoryInboundGroupSessions[roomId] = sessions;
}
public getSharedHistoryInboundGroupSessions(roomId: string): Promise<[senderKey: string, sessionId: string][]> {
return Promise.resolve(this.sharedHistoryInboundGroupSessions[roomId] || []);
}
public addParkedSharedHistory(roomId: string, parkedData: ParkedSharedHistory): void {
const parked = this.parkedSharedHistory.get(roomId) ?? [];
parked.push(parkedData);
this.parkedSharedHistory.set(roomId, parked);
}
public takeParkedSharedHistory(roomId: string): Promise<ParkedSharedHistory[]> {
const parked = this.parkedSharedHistory.get(roomId) ?? [];
this.parkedSharedHistory.delete(roomId);
return Promise.resolve(parked);
}
// Session key backups
public doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn?: unknown) => T): Promise<T> {

View File

@@ -1,34 +0,0 @@
/*
Copyright 2024 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.
*/
/**
* Computes a SHA-256 hash of a string (after utf-8 encoding) and returns it as an ArrayBuffer.
*
* @param plaintext The string to hash
* @returns An Uint8Array containing the SHA-256 hash of the input string
* @throws If the subtle crypto API is not available, for example if the code is running
* in a web page with an insecure context (eg. served over plain HTTP).
*/
export async function sha256(plaintext: string): Promise<Uint8Array> {
if (!globalThis.crypto.subtle) {
throw new Error("Crypto.subtle is not available: insecure context?");
}
const utf8 = new TextEncoder().encode(plaintext);
const digest = await globalThis.crypto.subtle.digest("SHA-256", utf8);
return new Uint8Array(digest);
}

View File

@@ -15,51 +15,29 @@ limitations under the License.
*/
import {
type WidgetApi,
WidgetApi,
WidgetApiToWidgetAction,
WidgetApiResponseError,
MatrixCapabilities,
type IWidgetApiRequest,
type IWidgetApiAcknowledgeResponseData,
type ISendEventToWidgetActionRequest,
type ISendToDeviceToWidgetActionRequest,
type ISendEventFromWidgetResponseData,
type IWidgetApiRequestData,
type WidgetApiAction,
type IWidgetApiResponse,
type IWidgetApiResponseData,
type IUpdateStateToWidgetActionRequest,
UnstableApiVersion,
IWidgetApiRequest,
IWidgetApiAcknowledgeResponseData,
ISendEventToWidgetActionRequest,
ISendToDeviceToWidgetActionRequest,
ISendEventFromWidgetResponseData,
} from "matrix-widget-api";
import { type Transport } from "./matrixrtc/index.ts";
import { MatrixEvent, type IEvent, EventStatus } from "./models/event.ts";
import {
type ISendEventResponse,
type SendDelayedEventRequestOpts,
type SendDelayedEventResponse,
UpdateDelayedEventAction,
isSendDelayedEventRequestOpts,
} from "./@types/requests.ts";
import { EventType, type StateEvents } from "./@types/event.ts";
import { logger } from "./logger.ts";
import {
MatrixClient,
ClientEvent,
type IMatrixClientCreateOpts,
type IStartClientOpts,
type SendToDeviceContentMap,
type IOpenIDToken,
UNSTABLE_MSC4140_DELAYED_EVENTS,
} from "./client.ts";
import { SyncApi, SyncState } from "./sync.ts";
import { SlidingSyncSdk } from "./sliding-sync-sdk.ts";
import { ConnectionError, MatrixError } from "./http-api/errors.ts";
import { User } from "./models/user.ts";
import { type Room } from "./models/room.ts";
import { type ToDeviceBatch, type ToDevicePayload } from "./models/ToDeviceMessage.ts";
import { MapWithDefault, type QueryDict, recursiveMapToObject } from "./utils.ts";
import { type EmptyObject, TypedEventEmitter, UnsupportedDelayedEventsEndpointError } from "./matrix.ts";
import { MatrixEvent, IEvent, IContent, EventStatus } from "./models/event";
import { ISendEventResponse } from "./@types/requests";
import { EventType } from "./@types/event";
import { logger } from "./logger";
import { MatrixClient, ClientEvent, IMatrixClientCreateOpts, IStartClientOpts, SendToDeviceContentMap } from "./client";
import { SyncApi, SyncState } from "./sync";
import { SlidingSyncSdk } from "./sliding-sync-sdk";
import { User } from "./models/user";
import { Room } from "./models/room";
import { ToDeviceBatch, ToDevicePayload } from "./models/ToDeviceMessage";
import { DeviceInfo } from "./crypto/deviceinfo";
import { IOlmDevice } from "./crypto/algorithms/megolm";
import { MapWithDefault, recursiveMapToObject } from "./utils";
interface IStateEventRequest {
eventType: string;
@@ -110,48 +88,8 @@ export interface ICapabilities {
* @defaultValue false
*/
turnServers?: boolean;
/**
* Whether this client needs to be able to send delayed events.
* @experimental Part of MSC4140 & MSC4157
* @defaultValue false
*/
sendDelayedEvents?: boolean;
/**
* Whether this client needs to be able to update delayed events.
* @experimental Part of MSC4140 & MSC4157
* @defaultValue false
*/
updateDelayedEvents?: boolean;
/**
* Whether this client needs to be able to send sticky events.
* @experimental Part of MSC4354 & MSC4407
* @defaultValue false
*/
sendSticky?: boolean;
/**
* Whether this client needs to be able to receive sticky events.
* @experimental Part of MSC4354 & MSC4407
* @defaultValue false
*/
receiveSticky?: boolean;
/**
* Whether this client needs to be able to discover the RTC transports the host can reach.
* @experimental Part of MSC4515
* @defaultValue false
*/
rtcTransports?: boolean;
}
export enum RoomWidgetClientEvent {
PendingEventsChanged = "PendingEvent.pendingEventsChanged",
}
export type EventHandlerMap = { [RoomWidgetClientEvent.PendingEventsChanged]: () => void };
/**
* A MatrixClient that routes its requests through the widget API instead of the
* real CS API.
@@ -160,83 +98,19 @@ export type EventHandlerMap = { [RoomWidgetClientEvent.PendingEventsChanged]: ()
export class RoomWidgetClient extends MatrixClient {
private room?: Room;
private readonly widgetApiReady: Promise<void>;
private readonly roomStateSynced: Promise<void>;
private lifecycle?: AbortController;
private syncState: SyncState | null = null;
private pendingSendingEventsTxId: { type: string; id: string | undefined; txId: string }[] = [];
private eventEmitter = new TypedEventEmitter<keyof EventHandlerMap, EventHandlerMap>();
/**
*
* @param widgetApi - The widget api to use for communication.
* @param capabilities - The capabilities the widget client will request.
* @param roomId - The room id the widget is associated with.
* @param opts - The configuration options for this client.
* @param sendContentLoaded - Whether to send a content loaded widget action immediately after initial setup.
* Set to `false` if the widget uses `waitForIFrameLoad=true` (in this case the client does not expect a content loaded action at all),
* or if the the widget wants to send the `ContentLoaded` action at a later point in time after the initial setup.
*/
public constructor(
private readonly widgetApi: WidgetApi,
private readonly capabilities: ICapabilities,
private readonly roomId: string,
opts: IMatrixClientCreateOpts,
sendContentLoaded: boolean,
) {
super(opts);
const transportSend = this.widgetApi.transport.send.bind(this.widgetApi.transport);
this.widgetApi.transport.send = async <
T extends IWidgetApiRequestData,
R extends IWidgetApiResponseData = IWidgetApiAcknowledgeResponseData,
>(
action: WidgetApiAction,
data: T,
): Promise<R> => {
try {
return await transportSend(action, data);
} catch (error) {
processAndThrow(error);
}
};
const transportSendComplete = this.widgetApi.transport.sendComplete.bind(this.widgetApi.transport);
this.widgetApi.transport.sendComplete = async <T extends IWidgetApiRequestData, R extends IWidgetApiResponse>(
action: WidgetApiAction,
data: T,
): Promise<R> => {
try {
return await transportSendComplete(action, data);
} catch (error) {
processAndThrow(error);
}
};
this.widgetApiReady = new Promise<void>((resolve) => this.widgetApi.once("ready", resolve));
this.roomStateSynced = capabilities.receiveState?.length
? new Promise<void>((resolve) =>
this.widgetApi.once(`action:${WidgetApiToWidgetAction.UpdateState}`, resolve),
)
: Promise.resolve();
this.requestInitialCapabilities(capabilities, roomId);
widgetApi.on(`action:${WidgetApiToWidgetAction.SendEvent}`, this.onEvent);
widgetApi.on(`action:${WidgetApiToWidgetAction.SendToDevice}`, this.onToDevice);
widgetApi.on(`action:${WidgetApiToWidgetAction.UpdateState}`, this.onStateUpdate);
// Open communication with the host
widgetApi.start();
// Send a content loaded event now we've started the widget API
// Note that element-web currently does not use waitForIFrameLoad=false and so
// does *not* (yes, that is the right way around) wait for this event. Let's
// start sending this, then once this has rolled out, we can change element-web to
// use waitForIFrameLoad=false and have a widget API that's less racy.
if (sendContentLoaded) widgetApi.sendContentLoaded();
}
private requestInitialCapabilities(capabilities: ICapabilities, roomId: string): void {
// Request capabilities for the functionality this client needs to support
if (
capabilities.sendEvent?.length ||
@@ -248,61 +122,39 @@ export class RoomWidgetClient extends MatrixClient {
capabilities.sendState?.length ||
capabilities.receiveState?.length
) {
this.widgetApi.requestCapabilityForRoomTimeline(roomId);
widgetApi.requestCapabilityForRoomTimeline(roomId);
}
capabilities.sendEvent?.forEach((eventType) => this.widgetApi.requestCapabilityToSendEvent(eventType));
capabilities.receiveEvent?.forEach((eventType) => this.widgetApi.requestCapabilityToReceiveEvent(eventType));
capabilities.sendEvent?.forEach((eventType) => widgetApi.requestCapabilityToSendEvent(eventType));
capabilities.receiveEvent?.forEach((eventType) => widgetApi.requestCapabilityToReceiveEvent(eventType));
if (capabilities.sendMessage === true) {
this.widgetApi.requestCapabilityToSendMessage();
widgetApi.requestCapabilityToSendMessage();
} else if (Array.isArray(capabilities.sendMessage)) {
capabilities.sendMessage.forEach((msgType) => this.widgetApi.requestCapabilityToSendMessage(msgType));
capabilities.sendMessage.forEach((msgType) => widgetApi.requestCapabilityToSendMessage(msgType));
}
if (capabilities.receiveMessage === true) {
this.widgetApi.requestCapabilityToReceiveMessage();
widgetApi.requestCapabilityToReceiveMessage();
} else if (Array.isArray(capabilities.receiveMessage)) {
capabilities.receiveMessage.forEach((msgType) => this.widgetApi.requestCapabilityToReceiveMessage(msgType));
capabilities.receiveMessage.forEach((msgType) => widgetApi.requestCapabilityToReceiveMessage(msgType));
}
capabilities.sendState?.forEach(({ eventType, stateKey }) =>
this.widgetApi.requestCapabilityToSendState(eventType, stateKey),
widgetApi.requestCapabilityToSendState(eventType, stateKey),
);
capabilities.receiveState?.forEach(({ eventType, stateKey }) =>
this.widgetApi.requestCapabilityToReceiveState(eventType, stateKey),
widgetApi.requestCapabilityToReceiveState(eventType, stateKey),
);
capabilities.sendToDevice?.forEach((eventType) => this.widgetApi.requestCapabilityToSendToDevice(eventType));
capabilities.receiveToDevice?.forEach((eventType) =>
this.widgetApi.requestCapabilityToReceiveToDevice(eventType),
);
if (
capabilities.sendDelayedEvents &&
(capabilities.sendEvent?.length ||
capabilities.sendMessage === true ||
(Array.isArray(capabilities.sendMessage) && capabilities.sendMessage.length) ||
capabilities.sendState?.length)
) {
this.widgetApi.requestCapability(MatrixCapabilities.MSC4157SendDelayedEvent);
}
if (capabilities.updateDelayedEvents) {
this.widgetApi.requestCapability(MatrixCapabilities.MSC4157UpdateDelayedEvent);
}
if (capabilities.sendSticky) {
this.widgetApi.requestCapability(MatrixCapabilities.MSC4407SendStickyEvent);
}
if (capabilities.receiveSticky) {
this.widgetApi.requestCapability(MatrixCapabilities.MSC4407ReceiveStickyEvent);
}
capabilities.sendToDevice?.forEach((eventType) => widgetApi.requestCapabilityToSendToDevice(eventType));
capabilities.receiveToDevice?.forEach((eventType) => widgetApi.requestCapabilityToReceiveToDevice(eventType));
if (capabilities.turnServers) {
this.widgetApi.requestCapability(MatrixCapabilities.MSC3846TurnServers);
}
if (capabilities.rtcTransports) {
this.widgetApi.requestCapability(MatrixCapabilities.MSC4515RtcTransports);
widgetApi.requestCapability(MatrixCapabilities.MSC3846TurnServers);
}
widgetApi.on(`action:${WidgetApiToWidgetAction.SendEvent}`, this.onEvent);
widgetApi.on(`action:${WidgetApiToWidgetAction.SendToDevice}`, this.onToDevice);
// Open communication with the host
widgetApi.start();
}
public async supportUpdateState(): Promise<boolean> {
return (await this.widgetApi.getClientVersions()).includes(UnstableApiVersion.MSC2762_UPDATE_STATE);
}
private readonly syncApiResolver = Promise.withResolvers<void>();
public async startClient(opts: IStartClientOpts = {}): Promise<void> {
this.lifecycle = new AbortController();
@@ -321,49 +173,29 @@ export class RoomWidgetClient extends MatrixClient {
} else {
this.syncApi = new SyncApi(this, opts, this.buildSyncApiOptions());
}
this.syncApiResolver.resolve();
this.room = this.syncApi.createRoom(this.roomId);
this.store.storeRoom(this.room);
await this.widgetApiReady;
// sync room state:
if (await this.supportUpdateState()) {
// This will resolve once the client driver has sent us all the allowed room state.
await this.roomStateSynced;
} else {
// Backfill the requested events
// We only get the most recent event for every type + state key combo,
// so it doesn't really matter what order we inject them in
await Promise.all(
this.capabilities.receiveState?.map(async ({ eventType, stateKey }) => {
const rawEvents = await this.widgetApi.readStateEvents(eventType, undefined, stateKey, [
this.roomId,
]);
const events = rawEvents.map((rawEvent) => new MatrixEvent(rawEvent as Partial<IEvent>));
// Backfill the requested events
// We only get the most recent event for every type + state key combo,
// so it doesn't really matter what order we inject them in
await Promise.all(
this.capabilities.receiveState?.map(async ({ eventType, stateKey }) => {
const rawEvents = await this.widgetApi.readStateEvents(eventType, undefined, stateKey, [this.roomId]);
const events = rawEvents.map((rawEvent) => new MatrixEvent(rawEvent as Partial<IEvent>));
if (this.syncApi instanceof SyncApi) {
// Passing events as `stateAfterEventList` will update the state.
await this.syncApi.injectRoomEvents(this.room!, undefined, events);
} else {
await this.syncApi!.injectRoomEvents(this.room!, events); // Sliding Sync
}
events.forEach((event) => {
this.emit(ClientEvent.Event, event);
logger.info(`Backfilled event ${event.getId()} ${event.getType()} ${event.getStateKey()}`);
});
}) ?? [],
);
}
this.cachedWellKnown.start(
opts.clientWellKnownPollPeriod !== undefined ? 1000 * opts.clientWellKnownPollPeriod : undefined,
await this.syncApi!.injectRoomEvents(this.room!, [], events);
events.forEach((event) => {
this.emit(ClientEvent.Event, event);
logger.info(`Backfilled event ${event.getId()} ${event.getType()} ${event.getStateKey()}`);
});
}) ?? [],
);
this.setSyncState(SyncState.Syncing);
logger.info("Finished initial sync");
this.matrixRTC.start();
logger.info("Finished backfilling events");
// Watch for TURN servers, if requested
if (this.capabilities.turnServers) this.watchTurnServers();
@@ -372,7 +204,6 @@ export class RoomWidgetClient extends MatrixClient {
public stopClient(): void {
this.widgetApi.off(`action:${WidgetApiToWidgetAction.SendEvent}`, this.onEvent);
this.widgetApi.off(`action:${WidgetApiToWidgetAction.SendToDevice}`, this.onToDevice);
this.widgetApi.off(`action:${WidgetApiToWidgetAction.UpdateState}`, this.onStateUpdate);
super.stopClient();
this.lifecycle!.abort(); // Signal to other async tasks that the client has stopped
@@ -383,87 +214,17 @@ export class RoomWidgetClient extends MatrixClient {
throw new Error(`Unknown room: ${roomIdOrAlias}`);
}
protected async encryptAndSendEvent(
room: Room,
event: MatrixEvent,
queryDict?: QueryDict,
): Promise<ISendEventResponse>;
protected async encryptAndSendEvent(
room: Room,
event: MatrixEvent,
delayOpts: SendDelayedEventRequestOpts,
queryDict?: QueryDict,
): Promise<ISendEventResponse>;
protected async encryptAndSendEvent(
room: Room,
event: MatrixEvent,
delayOptsOrQuery?: SendDelayedEventRequestOpts | QueryDict,
queryDict?: QueryDict,
): Promise<ISendEventResponse | SendDelayedEventResponse> {
let queryOpts = queryDict;
let delayOpts: SendDelayedEventRequestOpts | undefined;
if (delayOptsOrQuery && isSendDelayedEventRequestOpts(delayOptsOrQuery)) {
delayOpts = delayOptsOrQuery;
} else if (!queryOpts) {
queryOpts = delayOptsOrQuery;
}
const stickyDurationMs = queryOpts?.["org.matrix.msc4354.sticky_duration_ms"];
if (stickyDurationMs !== undefined && typeof stickyDurationMs !== "number") {
throw new Error("Sticky duration must be a number when defined");
}
// This is save since we just checked that above
// We need the additional as assertion for the EW linter to be happy.
// It is not capable of implying the type based on the throw if `stickyDurationMs !== undefined && typeof stickyDurationMs !== "number"`
// above
const stickyDurationMsAsNumber: number | undefined = stickyDurationMs;
// We need to extend the content with the redacts parameter
// The js sdk uses event.redacts but the widget api uses event.content.redacts
// This will be converted back to event.redacts in the widget driver.
const content = event.event.redacts
? { ...event.getContent(), redacts: event.event.redacts }
: event.getContent();
// Delayed event special case.
if (delayOpts) {
// TODO: updatePendingEvent for delayed events?
const response = await this.widgetApi
.sendRoomEvent(
event.getType(),
content,
room.roomId,
"delay" in delayOpts ? delayOpts.delay : undefined,
"parent_delay_id" in delayOpts ? delayOpts.parent_delay_id : undefined,
stickyDurationMsAsNumber,
)
.catch(timeoutToConnectionError);
return this.validateSendDelayedEventResponse(response);
}
const txId = event.getTxnId();
// Add the txnId to the pending list (still with unknown evID)
if (txId) this.pendingSendingEventsTxId.push({ type: event.getType(), id: undefined, txId });
protected async encryptAndSendEvent(room: Room, event: MatrixEvent): Promise<ISendEventResponse> {
let response: ISendEventFromWidgetResponseData;
try {
response = await this.widgetApi
.sendRoomEvent(event.getType(), content, room.roomId, undefined, undefined, stickyDurationMsAsNumber)
.catch(timeoutToConnectionError);
response = await this.widgetApi.sendRoomEvent(event.getType(), event.getContent(), room.roomId);
} catch (e) {
this.updatePendingEventStatus(room, event, EventStatus.NOT_SENT);
throw e;
}
// This also checks for an event id on the response
room.updatePendingEvent(event, EventStatus.SENT, response.event_id);
// Update the pending events list with the eventId
this.pendingSendingEventsTxId.forEach((p) => {
if (p.txId === txId) p.id = response.event_id;
});
this.eventEmitter.emit(RoomWidgetClientEvent.PendingEventsChanged);
return { event_id: response.event_id! };
return { event_id: response.event_id };
}
public async sendStateEvent(
@@ -472,179 +233,14 @@ export class RoomWidgetClient extends MatrixClient {
content: any,
stateKey = "",
): Promise<ISendEventResponse> {
const response = await this.widgetApi
.sendStateEvent(eventType, stateKey, content, roomId)
.catch(timeoutToConnectionError);
if (response.event_id === undefined) {
throw new Error("'event_id' absent from response to an event request");
}
return { event_id: response.event_id };
return await this.widgetApi.sendStateEvent(eventType, stateKey, content, roomId);
}
/**
* @experimental This currently relies on an unstable MSC (MSC4140).
*/
public async _unstable_sendDelayedStateEvent<K extends keyof StateEvents>(
roomId: string,
delayOpts: SendDelayedEventRequestOpts,
eventType: K,
content: StateEvents[K],
stateKey = "",
): Promise<SendDelayedEventResponse> {
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
throw new UnsupportedDelayedEventsEndpointError(
"Server does not support the delayed events API",
"sendDelayedStateEvent",
);
}
const response = await this.widgetApi
.sendStateEvent(
eventType,
stateKey,
content,
roomId,
"delay" in delayOpts ? delayOpts.delay : undefined,
"parent_delay_id" in delayOpts ? delayOpts.parent_delay_id : undefined,
)
.catch(timeoutToConnectionError);
return this.validateSendDelayedEventResponse(response);
}
private validateSendDelayedEventResponse(response: ISendEventFromWidgetResponseData): SendDelayedEventResponse {
if (response.delay_id === undefined) {
throw new Error("'delay_id' absent from response to a delayed event request");
}
return { delay_id: response.delay_id };
}
/**
* @experimental This currently relies on an unstable MSC (MSC4140).
* @deprecated Instead use one of:
* - {@link _unstable_cancelScheduledDelayedEvent}
* - {@link _unstable_restartScheduledDelayedEvent}
* - {@link _unstable_sendScheduledDelayedEvent}
*/
public async _unstable_updateDelayedEvent(delayId: string, action: UpdateDelayedEventAction): Promise<EmptyObject> {
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
throw new UnsupportedDelayedEventsEndpointError(
"Server does not support the delayed events API",
"updateDelayedEvent",
);
}
switch (action) {
case UpdateDelayedEventAction.Cancel:
await this.widgetApi.cancelScheduledDelayedEvent(delayId).catch(timeoutToConnectionError);
break;
case UpdateDelayedEventAction.Restart:
await this.widgetApi.restartScheduledDelayedEvent(delayId).catch(timeoutToConnectionError);
break;
case UpdateDelayedEventAction.Send:
await this.widgetApi.sendScheduledDelayedEvent(delayId).catch(timeoutToConnectionError);
break;
}
public async sendToDevice(eventType: string, contentMap: SendToDeviceContentMap): Promise<{}> {
await this.widgetApi.sendToDevice(eventType, false, recursiveMapToObject(contentMap));
return {};
}
/**
* @experimental This currently relies on an unstable MSC (MSC4140).
*/
public async _unstable_cancelScheduledDelayedEvent(delayId: string): Promise<EmptyObject> {
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
throw new UnsupportedDelayedEventsEndpointError(
"Server does not support the delayed events API",
"cancelScheduledDelayedEvent",
);
}
await this.widgetApi.cancelScheduledDelayedEvent(delayId).catch(timeoutToConnectionError);
return {};
}
/**
* @experimental This currently relies on an unstable MSC (MSC4140).
*/
public async _unstable_restartScheduledDelayedEvent(delayId: string): Promise<EmptyObject> {
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
throw new UnsupportedDelayedEventsEndpointError(
"Server does not support the delayed events API",
"restartScheduledDelayedEvent",
);
}
await this.widgetApi.restartScheduledDelayedEvent(delayId).catch(timeoutToConnectionError);
return {};
}
/**
* @experimental This currently relies on an unstable MSC (MSC4140).
*/
public async _unstable_sendScheduledDelayedEvent(delayId: string): Promise<EmptyObject> {
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
throw new UnsupportedDelayedEventsEndpointError(
"Server does not support the delayed events API",
"sendScheduledDelayedEvent",
);
}
await this.widgetApi.sendScheduledDelayedEvent(delayId).catch(timeoutToConnectionError);
return {};
}
/**
* by {@link MatrixClient.encryptAndSendToDevice}.
*/
public async encryptAndSendToDevice(
eventType: string,
devices: { userId: string; deviceId: string }[],
payload: ToDevicePayload,
): Promise<void> {
// map: user Id → device Id → payload
const contentMap: MapWithDefault<string, Map<string, ToDevicePayload>> = new MapWithDefault(() => new Map());
for (const { userId, deviceId } of devices) {
contentMap.getOrCreate(userId).set(deviceId, payload);
}
await this.widgetApi
.sendToDevice(eventType, true, recursiveMapToObject(contentMap))
.catch(timeoutToConnectionError);
}
public async sendToDevice(eventType: string, contentMap: SendToDeviceContentMap): Promise<EmptyObject> {
await this.widgetApi
.sendToDevice(eventType, false, recursiveMapToObject(contentMap))
.catch(timeoutToConnectionError);
return {};
}
public async getOpenIdToken(): Promise<IOpenIDToken> {
const token = await this.widgetApi.requestOpenIDConnectToken().catch(timeoutToConnectionError);
// the IOpenIDCredentials from the widget-api and IOpenIDToken form the matrix-js-sdk are compatible.
// we still recreate the token to make this transparent and catch'able by the linter in case the types change in the future.
return <IOpenIDToken>{
access_token: token.access_token,
expires_in: token.expires_in,
matrix_server_name: token.matrix_server_name,
token_type: token.token_type,
};
}
/**
* Returns a set of configured RTC transports supported by the homeserver.
*
* Overrides the homeserver-side {@link MatrixClient._unstable_getRTCTransports} (MSC4143):
* a widget cannot make authenticated homeserver calls itself, so we ask the host over the
* widget API instead (MSC4515). Requires the `rtcTransports` capability and a host that
* advertises the `org.matrix.msc4515` API version (otherwise the request throws).
*/
public override async _unstable_getRTCTransports(): Promise<Transport[]> {
const { rtc_transports: rtcTransports } = await this.widgetApi
.getRtcTransports()
.catch(timeoutToConnectionError);
return rtcTransports;
}
public async queueToDevice({ eventType, batch }: ToDeviceBatch): Promise<void> {
// map: user Id → device Id → payload
const contentMap: MapWithDefault<string, Map<string, ToDevicePayload>> = new MapWithDefault(() => new Map());
@@ -652,32 +248,20 @@ export class RoomWidgetClient extends MatrixClient {
contentMap.getOrCreate(userId).set(deviceId, payload);
}
await this.widgetApi
.sendToDevice(eventType, false, recursiveMapToObject(contentMap))
.catch(timeoutToConnectionError);
await this.widgetApi.sendToDevice(eventType, false, recursiveMapToObject(contentMap));
}
/**
* Send an event to a specific list of devices via the widget API. Optionally encrypts the event.
*
* If you are using a full MatrixClient you would be calling {@link MatrixClient.getCrypto().encryptToDeviceMessages()} followed
* by {@link MatrixClient.queueToDevice}.
*
* However, this is combined into a single step when running as an embedded widget client. So, we expose this method for those
* that need it.
*
* @param eventType - Type of the event to send.
* @param encrypted - Whether the event should be encrypted.
* @param contentMap - The content to send. Map from user_id to device_id to content object.
*/
public async sendToDeviceViaWidgetApi(
eventType: string,
encrypted: boolean,
contentMap: SendToDeviceContentMap,
): Promise<void> {
await this.widgetApi
.sendToDevice(eventType, encrypted, recursiveMapToObject(contentMap))
.catch(timeoutToConnectionError);
public async encryptAndSendToDevices(userDeviceInfoArr: IOlmDevice<DeviceInfo>[], payload: object): Promise<void> {
// map: user Id → device Id → payload
const contentMap: MapWithDefault<string, Map<string, object>> = new MapWithDefault(() => new Map());
for (const {
userId,
deviceInfo: { deviceId },
} of userDeviceInfoArr) {
contentMap.getOrCreate(userId).set(deviceId, payload);
}
await this.widgetApi.sendToDevice((payload as { type: string }).type, true, recursiveMapToObject(contentMap));
}
// Overridden since we get TURN servers automatically over the widget API,
@@ -698,85 +282,20 @@ export class RoomWidgetClient extends MatrixClient {
}
private async ack(ev: CustomEvent<IWidgetApiRequest>): Promise<void> {
this.widgetApi.transport.reply<IWidgetApiAcknowledgeResponseData>(ev.detail, {});
await this.widgetApi.transport.reply<IWidgetApiAcknowledgeResponseData>(ev.detail, {});
}
private updateTxId = async (event: MatrixEvent): Promise<void> => {
// We update the txId for remote echos that originate from this client.
// This happens with the help of `pendingSendingEventsTxId` where we store all events that are currently sending
// with their widget txId and once ready the final evId.
if (
// This could theoretically be an event send by this device
// In that case we need to update the txId of the event because the embedded client/widget
// knows this event with a different transaction Id than what was used by the host client.
event.getSender() === this.getUserId() &&
// We optimize by not blocking events from types that we have not send
// with this client.
this.pendingSendingEventsTxId.some((p) => event.getType() === p.type)
) {
// Compare by event Id if we have a matching pending event where we know the txId.
let matchingTxId = this.pendingSendingEventsTxId.find((p) => p.id === event.getId())?.txId;
// Block any further processing of this event until we have received the sending response.
// -> until we know the event id.
// -> until we have not pending events anymore.
while (!matchingTxId && this.pendingSendingEventsTxId.length > 0) {
// Recheck whenever the PendingEventsChanged
await new Promise<void>((resolve) =>
this.eventEmitter.once(RoomWidgetClientEvent.PendingEventsChanged, () => resolve()),
);
matchingTxId = this.pendingSendingEventsTxId.find((p) => p.id === event.getId())?.txId;
}
// We found the correct txId: we update the event and delete the entry of the pending events.
if (matchingTxId) {
event.setTxnId(matchingTxId);
event.setUnsigned({ ...event.getUnsigned(), transaction_id: matchingTxId });
}
this.pendingSendingEventsTxId = this.pendingSendingEventsTxId.filter((p) => p.id !== event.getId());
// Emit once there are no pending events anymore to release all other events that got
// awaited in the `while (!matchingTxId && this.pendingSendingEventsTxId.length > 0)` loop
// but are not send by this client.
if (this.pendingSendingEventsTxId.length === 0) {
this.eventEmitter.emit(RoomWidgetClientEvent.PendingEventsChanged);
}
}
};
private onEvent = async (ev: CustomEvent<ISendEventToWidgetActionRequest>): Promise<void> => {
ev.preventDefault();
// Verify the room ID matches, since it's possible for the client to
// send us events from other rooms if this widget is always on screen
if (ev.detail.data.room_id === this.roomId) {
const event = new MatrixEvent(ev.detail.data);
// Only inject once we have update the txId
await this.updateTxId(event);
await this.syncApiResolver.promise;
if (this.syncApi instanceof SyncApi) {
if (await this.supportUpdateState()) {
await this.syncApi.injectRoomEvents(this.room!, undefined, [], [event]);
} else {
// Passing undefined for `stateAfterEventList` will make `injectRoomEvents` run in legacy mode
// -> state events in `timelineEventList` will update the state.
await this.syncApi.injectRoomEvents(this.room!, [], undefined, [event]);
}
} else {
// Sliding Sync
if (await this.supportUpdateState()) {
await this.syncApi!.injectRoomEvents(this.room!, [], [event]);
} else {
logger.error(
"slididng sync cannot be used in widget mode if the client widget driver does not support the version: 'org.matrix.msc2762_update_state'",
);
}
}
const event = new MatrixEvent(ev.detail.data as Partial<IEvent>);
await this.syncApi!.injectRoomEvents(this.room!, [], [event]);
this.emit(ClientEvent.Event, event);
if (event.unstableStickyInfo !== undefined) this.room!._unstable_addStickyEvents([event]);
this.setSyncState(SyncState.Syncing);
logger.info(`Received event ${event.getId()} ${event.getType()}`);
logger.info(`Received event ${event.getId()} ${event.getType()} ${event.getStateKey()}`);
} else {
const { event_id: eventId, room_id: roomId } = ev.detail.data;
logger.info(`Received event ${eventId} for a different room ${roomId}; discarding`);
@@ -791,7 +310,7 @@ export class RoomWidgetClient extends MatrixClient {
const event = new MatrixEvent({
type: ev.detail.data.type,
sender: ev.detail.data.sender,
content: ev.detail.data.content,
content: ev.detail.data.content as IContent,
});
// Mark the event as encrypted if it was, using fake contents and keys since those are unknown to us
if (ev.detail.data.encrypted) event.makeEncrypted(EventType.RoomMessageEncrypted, {}, "", "");
@@ -801,37 +320,6 @@ export class RoomWidgetClient extends MatrixClient {
await this.ack(ev);
};
private onStateUpdate = async (ev: CustomEvent<IUpdateStateToWidgetActionRequest>): Promise<void> => {
ev.preventDefault();
if (!(await this.supportUpdateState())) {
logger.warn(
"received update_state widget action but the widget driver did not claim to support 'org.matrix.msc2762_update_state'",
);
}
await this.syncApiResolver.promise;
for (const rawEvent of ev.detail.data.state) {
// Verify the room ID matches, since it's possible for the client to
// send us state updates from other rooms if this widget is always
// on screen
if (rawEvent.room_id === this.roomId) {
const event = new MatrixEvent(rawEvent);
if (this.syncApi instanceof SyncApi) {
await this.syncApi.injectRoomEvents(this.room!, undefined, [event]);
} else {
// Sliding Sync
await this.syncApi!.injectRoomEvents(this.room!, [event]);
}
logger.debug(`Updated state entry ${event.getType()} ${event.getStateKey()} to ${event.getId()}`);
} else {
const { event_id: eventId, room_id: roomId } = ev.detail.data;
logger.info(`Received state entry ${eventId} for a different room ${roomId}; discarding`);
}
}
await this.ack(ev);
};
private async watchTurnServers(): Promise<void> {
const servers = this.widgetApi.getTurnServers();
const onClientStopped = (): void => {
@@ -858,24 +346,3 @@ export class RoomWidgetClient extends MatrixClient {
}
}
}
function processAndThrow(error: unknown): never {
if (error instanceof WidgetApiResponseError && error.data.matrix_api_error) {
throw MatrixError.fromWidgetApiErrorData(error.data.matrix_api_error);
} else {
throw error;
}
}
/**
* This converts an "Request timed out" error from the PostmessageTransport into a ConnectionError.
* It either throws the original error or a new ConnectionError.
**/
function timeoutToConnectionError(error: unknown): never {
// TODO: this should not check on error.message but instead it should be a specific type
// error instanceof WidgetTimeoutError
if (error instanceof Error && error.message === "Request timed out") {
throw new ConnectionError("widget api timeout");
}
throw error;
}

View File

@@ -14,6 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
export enum InvalidStoreState {
ToggledLazyLoading,
}
export class InvalidStoreError extends Error {
public static TOGGLED_LAZY_LOADING = InvalidStoreState.ToggledLazyLoading;
public constructor(public readonly reason: InvalidStoreState, public readonly value: any) {
const message =
`Store is invalid because ${reason}, ` +
`please stop the client, delete all data and start the client again`;
super(message);
this.name = "InvalidStoreError";
}
}
export enum InvalidCryptoStoreState {
TooNew = "TOO_NEW",
}
@@ -31,57 +47,7 @@ export class InvalidCryptoStoreError extends Error {
}
export class KeySignatureUploadError extends Error {
public constructor(
message: string,
public readonly value: any,
) {
public constructor(message: string, public readonly value: any) {
super(message);
}
}
/**
* It is invalid to call most methods once {@link MatrixClient#stopClient} has been called.
*
* This error will be thrown if you attempt to do so.
*
* {@link MatrixClient#stopClient} itself is an exception to this: it may safely be called multiple times on the same
* instance.
*/
export class ClientStoppedError extends Error {
public constructor() {
super("MatrixClient has been stopped");
}
}
/**
* This error is thrown when the Homeserver does not support the delayed events endpoints.
*/
export class UnsupportedDelayedEventsEndpointError extends Error {
public constructor(
message: string,
public clientEndpoint:
| "sendDelayedEvent"
| "updateDelayedEvent"
| "cancelScheduledDelayedEvent"
| "restartScheduledDelayedEvent"
| "sendScheduledDelayedEvent"
| "sendDelayedStateEvent"
| "getDelayedEvents",
) {
super(message);
this.name = "UnsupportedDelayedEventsEndpointError";
}
}
/**
* This error is thrown when the Homeserver does not support the sticky events endpoints.
*/
export class UnsupportedStickyEventsEndpointError extends Error {
public constructor(
message: string,
public clientEndpoint: "sendStickyEvent" | "sendStickyStateEvent",
) {
super(message);
this.name = "UnsupportedStickyEventsEndpointError";
}
}

View File

@@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type MatrixClient } from "./client.ts";
import { type IEvent, MatrixEvent, MatrixEventEvent } from "./models/event.ts";
import { RelationType } from "./@types/event.ts";
import { MatrixClient } from "./client";
import { IEvent, MatrixEvent, MatrixEventEvent } from "./models/event";
import { RelationType } from "./@types/event";
export type EventMapper = (obj: Partial<IEvent>) => MatrixEvent;
@@ -25,6 +25,8 @@ export interface MapperOpts {
preventReEmit?: boolean;
// decrypt event proactively
decrypt?: boolean;
// the event is a to_device event
toDevice?: boolean;
}
export function eventMapperFor(client: MatrixClient, options: MapperOpts): EventMapper {
@@ -32,6 +34,10 @@ export function eventMapperFor(client: MatrixClient, options: MapperOpts): Event
const decrypt = options.decrypt !== false;
function mapper(plainOldJsObject: Partial<IEvent>): MatrixEvent {
if (options.toDevice) {
delete plainOldJsObject.room_id;
}
const room = client.getRoom(plainOldJsObject.room_id);
let event: MatrixEvent | undefined;
@@ -68,6 +74,9 @@ export function eventMapperFor(client: MatrixClient, options: MapperOpts): Event
event.setThread(thread);
}
// TODO: once we get rid of the old libolm-backed crypto, we can restrict this to room events (rather than
// to-device events), because the rust implementation decrypts to-device messages at a higher level.
// Generally we probably want to use a different eventMapper implementation for to-device events because
if (event.isEncrypted()) {
if (!preventReEmit) {
client.reEmitter.reEmit(event, [MatrixEventEvent.Decrypted]);

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type ExtensibleEventType, type IPartialEvent } from "../@types/extensible_events.ts";
import { ExtensibleEventType, IPartialEvent } from "../@types/extensible_events";
/**
* Represents an Extensible Event in Matrix.

View File

@@ -14,19 +14,21 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { ExtensibleEvent } from "./ExtensibleEvent.ts";
import { Optional } from "matrix-events-sdk";
import { ExtensibleEvent } from "./ExtensibleEvent";
import {
type ExtensibleEventType,
type IMessageRendering,
type IPartialEvent,
ExtensibleEventType,
IMessageRendering,
IPartialEvent,
isEventTypeSame,
M_HTML,
M_MESSAGE,
type ExtensibleAnyMessageEventContent,
ExtensibleAnyMessageEventContent,
M_TEXT,
} from "../@types/extensible_events.ts";
import { isOptionalAString, isProvided } from "./utilities.ts";
import { InvalidEventError } from "./InvalidEventError.ts";
} from "../@types/extensible_events";
import { isOptionalAString, isProvided } from "./utilities";
import { InvalidEventError } from "./InvalidEventError";
/**
* Represents a message event. Message events are the simplest form of event with
@@ -44,7 +46,7 @@ export class MessageEvent extends ExtensibleEvent<ExtensibleAnyMessageEventConte
/**
* The default HTML for the event, if provided.
*/
public readonly html?: string;
public readonly html: Optional<string>;
/**
* All the different renderings of the message. Note that this is the same
@@ -80,7 +82,7 @@ export class MessageEvent extends ExtensibleEvent<ExtensibleAnyMessageEventConte
this.renderings = mmessage;
} else if (isOptionalAString(mtext)) {
this.text = mtext;
this.html = mhtml ?? undefined;
this.html = mhtml;
this.renderings = [{ body: mtext, mimetype: "text/plain" }];
if (this.html) {
this.renderings.push({ body: this.html, mimetype: "text/html" });

View File

@@ -15,16 +15,16 @@ limitations under the License.
*/
import {
type ExtensibleEventType,
type IPartialEvent,
ExtensibleEventType,
IPartialEvent,
isEventTypeSame,
M_TEXT,
REFERENCE_RELATION,
} from "../@types/extensible_events.ts";
import { M_POLL_END, type PollEndEventContent } from "../@types/polls.ts";
import { ExtensibleEvent } from "./ExtensibleEvent.ts";
import { InvalidEventError } from "./InvalidEventError.ts";
import { MessageEvent } from "./MessageEvent.ts";
} from "../@types/extensible_events";
import { M_POLL_END, PollEndEventContent } from "../@types/polls";
import { ExtensibleEvent } from "./ExtensibleEvent";
import { InvalidEventError } from "./InvalidEventError";
import { MessageEvent } from "./MessageEvent";
/**
* Represents a poll end/closure event.

View File

@@ -14,16 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { ExtensibleEvent } from "./ExtensibleEvent.ts";
import { M_POLL_RESPONSE, type PollResponseEventContent, type PollResponseSubtype } from "../@types/polls.ts";
import {
type ExtensibleEventType,
type IPartialEvent,
isEventTypeSame,
REFERENCE_RELATION,
} from "../@types/extensible_events.ts";
import { InvalidEventError } from "./InvalidEventError.ts";
import { type PollStartEvent } from "./PollStartEvent.ts";
import { ExtensibleEvent } from "./ExtensibleEvent";
import { M_POLL_RESPONSE, PollResponseEventContent, PollResponseSubtype } from "../@types/polls";
import { ExtensibleEventType, IPartialEvent, isEventTypeSame, REFERENCE_RELATION } from "../@types/extensible_events";
import { InvalidEventError } from "./InvalidEventError";
import { PollStartEvent } from "./PollStartEvent";
/**
* Represents a poll response event.

View File

@@ -16,19 +16,19 @@ limitations under the License.
import { NamespacedValue } from "matrix-events-sdk";
import { MessageEvent } from "./MessageEvent.ts";
import { type ExtensibleEventType, type IPartialEvent, isEventTypeSame, M_TEXT } from "../@types/extensible_events.ts";
import { MessageEvent } from "./MessageEvent";
import { ExtensibleEventType, IPartialEvent, isEventTypeSame, M_TEXT } from "../@types/extensible_events";
import {
type KnownPollKind,
KnownPollKind,
M_POLL_KIND_DISCLOSED,
M_POLL_KIND_UNDISCLOSED,
M_POLL_START,
type PollStartEventContent,
type PollStartSubtype,
type PollAnswer,
} from "../@types/polls.ts";
import { InvalidEventError } from "./InvalidEventError.ts";
import { ExtensibleEvent } from "./ExtensibleEvent.ts";
PollStartEventContent,
PollStartSubtype,
PollAnswer,
} from "../@types/polls";
import { InvalidEventError } from "./InvalidEventError";
import { ExtensibleEvent } from "./ExtensibleEvent";
/**
* Represents a poll answer. Note that this is represented as a subtype and is

View File

@@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type Optional } from "matrix-events-sdk";
import { Optional } from "matrix-events-sdk";
/**
* Determines if the given optional was provided a value.
* @param s - The optional to test.
* @returns True if the value is defined.
*/
export function isProvided<T>(s: Optional<T>): s is T {
export function isProvided<T>(s: Optional<T>): boolean {
return s !== null && s !== undefined;
}

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type IServerVersions } from "./client.ts";
import { IServerVersions } from "./client";
export enum ServerSupport {
Stable,
@@ -55,9 +55,11 @@ const featureSupportResolver: Record<string, FeatureSupportCondition> = {
[Feature.RelationBasedRedactions]: {
unstablePrefixes: ["org.matrix.msc3912"],
},
[Feature.AccountDataDeletion]: {
unstablePrefixes: ["org.matrix.msc3391"],
},
[Feature.RelationsRecursion]: {
unstablePrefixes: ["org.matrix.msc3981"],
matrixVersion: "v1.10",
},
[Feature.IntentionalMentions]: {
unstablePrefixes: ["org.matrix.msc3952_intentional_mentions"],

View File

@@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type RelationType } from "./@types/event.ts";
import { type MatrixEvent } from "./models/event.ts";
import { FILTER_RELATED_BY_REL_TYPES, FILTER_RELATED_BY_SENDERS, THREAD_RELATION_TYPE } from "./models/thread.ts";
import { RelationType } from "./@types/event";
import { MatrixEvent } from "./models/event";
import { FILTER_RELATED_BY_REL_TYPES, FILTER_RELATED_BY_SENDERS, THREAD_RELATION_TYPE } from "./models/thread";
/**
* Checks if a value matches a given field value, which may be a * terminated
@@ -34,6 +34,7 @@ function matchesWildcard(actualValue: string, filterValue: string): boolean {
}
}
/* eslint-disable camelcase */
export interface IFilterComponent {
"types"?: string[];
"not_types"?: string[];
@@ -50,6 +51,7 @@ export interface IFilterComponent {
"io.element.relation_senders"?: Array<RelationType | string>;
"io.element.relation_types"?: string[];
}
/* eslint-enable camelcase */
/**
* FilterComponent is a section of a Filter definition which defines the
@@ -60,10 +62,7 @@ export interface IFilterComponent {
* 'Filters' are referred to as 'FilterCollections'.
*/
export class FilterComponent {
public constructor(
private filterJson: IFilterComponent,
public readonly userId?: string | undefined | null,
) {}
public constructor(private filterJson: IFilterComponent, public readonly userId?: string | undefined | null) {}
/**
* Checks with the filter component matches the given event
@@ -97,19 +96,17 @@ export class FilterComponent {
* Converts the filter component into the form expected over the wire
*/
public toJSON(): object {
return Object.fromEntries(
Object.entries({
types: this.filterJson.types,
not_types: this.filterJson.not_types,
rooms: this.filterJson.rooms,
not_rooms: this.filterJson.not_rooms,
senders: this.filterJson.senders,
not_senders: this.filterJson.not_senders,
contains_url: this.filterJson.contains_url,
[FILTER_RELATED_BY_SENDERS.name]: this.filterJson[FILTER_RELATED_BY_SENDERS.name],
[FILTER_RELATED_BY_REL_TYPES.name]: this.filterJson[FILTER_RELATED_BY_REL_TYPES.name],
}).filter(([_key, value]) => value),
);
return {
types: this.filterJson.types || null,
not_types: this.filterJson.not_types || [],
rooms: this.filterJson.rooms || null,
not_rooms: this.filterJson.not_rooms || [],
senders: this.filterJson.senders || null,
not_senders: this.filterJson.not_senders || [],
contains_url: this.filterJson.contains_url || null,
[FILTER_RELATED_BY_SENDERS.name]: this.filterJson[FILTER_RELATED_BY_SENDERS.name] || [],
[FILTER_RELATED_BY_REL_TYPES.name]: this.filterJson[FILTER_RELATED_BY_REL_TYPES.name] || [],
};
}
/**
@@ -142,7 +139,6 @@ export class FilterComponent {
},
} as const;
// oxlint-disable-next-line guard-for-in
for (const name in literalKeys) {
const matchFunc = literalKeys[<keyof typeof literalKeys>name];
const notName = "not_" + name;
@@ -194,7 +190,6 @@ export class FilterComponent {
* @returns events which matched the filter component
*/
public filter(events: MatrixEvent[]): MatrixEvent[] {
// oxlint-disable-next-line typescript/unbound-method
return events.filter(this.check, this);
}

View File

@@ -14,13 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type EventType, type RelationType } from "./@types/event.ts";
import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync.ts";
import { FilterComponent, type IFilterComponent } from "./filter-component.ts";
import { type MatrixEvent } from "./models/event.ts";
import { NamespacedValue } from "./NamespacedValue.ts";
const profileFieldsFilterName = new NamespacedValue("profile_fields", "org.matrix.msc4429.profile_fields");
import { EventType, RelationType } from "./@types/event";
import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync";
import { FilterComponent, IFilterComponent } from "./filter-component";
import { MatrixEvent } from "./models/event";
/**
*/
@@ -36,14 +33,13 @@ function setProp(obj: Record<string, any>, keyNesting: string, val: any): void {
currentObj[nestedKeys[nestedKeys.length - 1]] = val;
}
/* eslint-disable camelcase */
export interface IFilterDefinition {
"event_fields"?: string[];
"event_format"?: "client" | "federation";
"presence"?: IFilterComponent;
"account_data"?: IFilterComponent;
"room"?: IRoomFilter;
"profile_fields"?: ProfileFieldsFilter;
"org.matrix.msc4429.profile_fields"?: ProfileFieldsFilter;
event_fields?: string[];
event_format?: "client" | "federation";
presence?: IFilterComponent;
account_data?: IFilterComponent;
room?: IRoomFilter;
}
export interface IRoomEventFilter extends IFilterComponent {
@@ -71,13 +67,7 @@ interface IRoomFilter {
timeline?: IRoomEventFilter;
account_data?: IRoomEventFilter;
}
/**
* Filter section used for requesting a set of extended profile fields that will be sent down the sync stream.
*/
interface ProfileFieldsFilter {
ids: string[];
}
/* eslint-enable camelcase */
export class Filter {
public static LAZY_LOADING_MESSAGES_FILTER = {
@@ -102,10 +92,7 @@ export class Filter {
* @param userId - The user ID for this filter.
* @param filterId - The filter ID if known.
*/
public constructor(
public readonly userId: string | undefined | null,
public filterId?: string,
) {}
public constructor(public readonly userId: string | undefined | null, public filterId?: string) {}
/**
* Get the ID of this filter on your homeserver (if known)
@@ -252,17 +239,4 @@ export class Filter {
public setIncludeLeaveRooms(includeLeave: boolean): void {
setProp(this.definition, "room.include_leave", includeLeave);
}
/**
* Set the list of fields to be included in the profile information sent down the sync stream.
* @param ids The field IDs to sync.
* @param stable Whether to use the stable or unstable versions of this filter.
* @experimental
*/
public setUnstableMSC4429SyncUserProfiles(ids: string[], stable: boolean): void {
const field = stable
? profileFieldsFilterName.name
: (profileFieldsFilterName.unstable ?? profileFieldsFilterName.name);
this.definition[field] = { ids };
}
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 - 2024 The Matrix.org Foundation C.I.C.
Copyright 2022 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.
@@ -14,12 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type IMatrixApiError as IWidgetMatrixError } from "matrix-widget-api";
import { type IUsageLimit } from "../@types/partials.ts";
import { type MatrixEvent } from "../models/event.ts";
import { NamespacedValue } from "../NamespacedValue.ts";
import { hasRequiredStringProperty, isRecord } from "../@types/type-guards.ts";
import { IUsageLimit } from "../@types/partials";
import { MatrixEvent } from "../models/event";
interface IErrorJson extends Partial<IUsageLimit> {
[key: string]: any; // extensible
@@ -32,75 +28,16 @@ interface IErrorJson extends Partial<IUsageLimit> {
* specific to HTTP responses.
* @param msg - The error message to include.
* @param httpStatus - The HTTP response status code.
* @param httpHeaders - The HTTP response headers.
*/
export class HTTPError extends Error {
public constructor(
msg: string,
public readonly httpStatus?: number,
public readonly httpHeaders?: Headers,
) {
public constructor(msg: string, public readonly httpStatus?: number) {
super(msg);
}
/**
* Check if this error was due to rate-limiting on the server side (and should therefore be retried after a delay).
*
* If this returns `true`, {@link getRetryAfterMs} can be called to retrieve the server-side
* recommendation for the retry period.
*
* @returns Whether this error is due to rate-limiting.
*/
public isRateLimitError(): boolean {
return this.httpStatus === 429;
}
/**
* @returns The recommended delay in milliseconds to wait before retrying
* the request that triggered this error, or null if no delay is recommended.
* @throws Error if the recommended delay is an invalid value.
* @see {@link safeGetRetryAfterMs} for a version of this check that doesn't throw.
*/
public getRetryAfterMs(): number | null {
const retryAfter = this.httpHeaders?.get("Retry-After");
if (retryAfter != null) {
if (/^\d+$/.test(retryAfter)) {
const ms = Number.parseInt(retryAfter) * 1000;
if (!Number.isFinite(ms)) {
throw new Error("Retry-After header integer value is too large");
}
return ms;
}
const date = new Date(retryAfter);
if (date.toUTCString() !== retryAfter) {
throw new Error("Retry-After header value is not a valid HTTP-date or non-negative decimal integer");
}
return date.getTime() - Date.now();
}
return null;
}
}
/**
* Check if the given (JSON-parsed) response body looks like a Matrix error
* response as specified in https://spec.matrix.org/v1.19/client-server-api/#standard-error-response
*
* @param response - the parsed response body to check
* @returns whether the response is a valid {@link MatrixError}
*/
export function isMatrixErrorResponse(response: unknown): response is MatrixError {
return (
isRecord(response) &&
hasRequiredStringProperty(response, "error") &&
hasRequiredStringProperty(response, "errcode")
);
}
export class MatrixError extends HTTPError {
// The Matrix 'errcode' value, e.g. "M_FORBIDDEN".
public readonly errcode?: string;
// The Matrix 'error' value.
public readonly error?: string;
// The raw Matrix error JSON used to construct this object.
public data: IErrorJson;
@@ -109,14 +46,12 @@ export class MatrixError extends HTTPError {
* information specific to the standard Matrix error response.
* @param errorJson - The Matrix error JSON returned from the homeserver.
* @param httpStatus - The numeric HTTP status code given
* @param httpHeaders - The HTTP response headers given
*/
public constructor(
errorJson: IErrorJson = {},
httpStatus?: number,
public readonly httpStatus?: number,
public url?: string,
public event?: MatrixEvent,
httpHeaders?: Headers,
) {
let message = errorJson.error || "Unknown message";
if (httpStatus) {
@@ -125,81 +60,11 @@ export class MatrixError extends HTTPError {
if (url) {
message = `${message} (${url})`;
}
super(`MatrixError: ${message}`, httpStatus, httpHeaders);
super(`MatrixError: ${message}`, httpStatus);
this.errcode = errorJson.errcode;
this.error = errorJson.error;
this.name = errorJson.errcode || "Unknown error code";
this.data = errorJson;
}
public isRateLimitError(): boolean {
return (
this.errcode === "M_LIMIT_EXCEEDED" ||
((this.errcode === "M_UNKNOWN" || this.errcode === undefined) && super.isRateLimitError())
);
}
public getRetryAfterMs(): number | null {
const headerValue = super.getRetryAfterMs();
if (headerValue !== null) {
return headerValue;
}
// Note: retry_after_ms is deprecated as of spec version v1.10
if (this.errcode === "M_LIMIT_EXCEEDED" && "retry_after_ms" in this.data) {
if (!Number.isInteger(this.data.retry_after_ms)) {
throw new Error("retry_after_ms is not an integer");
}
return this.data.retry_after_ms;
}
return null;
}
/**
* @returns this error expressed as a JSON payload
* for use by Widget API error responses.
*/
public asWidgetApiErrorData(): IWidgetMatrixError {
const headers: Record<string, string> = {};
if (this.httpHeaders) {
for (const [name, value] of this.httpHeaders) {
headers[name] = value;
}
}
return {
http_status: this.httpStatus ?? 400,
http_headers: headers,
url: this.url ?? "",
response: {
errcode: this.errcode ?? "M_UNKNOWN",
error: this.data.error ?? "Unknown message",
...this.data,
},
};
}
/**
* @returns a new {@link MatrixError} from a JSON payload
* received from Widget API error responses.
*/
public static fromWidgetApiErrorData(data: IWidgetMatrixError): MatrixError {
return new MatrixError(data.response, data.http_status, data.url, undefined, new Headers(data.http_headers));
}
}
/**
* @returns The recommended delay in milliseconds to wait before retrying the request.
* @param error - The error to check for a retry delay.
* @param defaultMs - The delay to use if the error was not due to rate-limiting or if no valid delay is recommended.
*/
export function safeGetRetryAfterMs(error: unknown, defaultMs: number): number {
if (!(error instanceof HTTPError) || !error.isRateLimitError()) {
return defaultMs;
}
try {
return error.getRetryAfterMs() ?? defaultMs;
} catch {
return defaultMs;
}
}
/**
@@ -217,61 +82,3 @@ export class ConnectionError extends Error {
return "ConnectionError";
}
}
/**
* Construct a TokenRefreshError. This indicates that a request failed due to the token being expired,
* and attempting to refresh said token also failed but in a way which was not indicative of token invalidation.
* Assumed to be a temporary failure.
*/
export class TokenRefreshError extends Error {
public constructor(cause?: Error) {
super(cause?.message ?? "");
}
public get name(): string {
return "TokenRefreshError";
}
}
/**
* Construct a TokenRefreshError. This indicates that a request failed due to the token being expired,
* and attempting to refresh said token failed in a way indicative of token invalidation.
*/
export class TokenRefreshLogoutError extends Error {
public constructor(cause?: Error) {
super(cause?.message ?? "");
}
public get name(): string {
return "TokenRefreshLogoutError";
}
}
export const MatrixSafetyErrorCode = new NamespacedValue(null, "ORG.MATRIX.MSC4387_SAFETY");
/***
* This error is thrown when the homeserver refuses to handle an action due to a
* safety concern.
* @see https://github.com/matrix-org/matrix-spec-proposals/pull/4387
*/
export class MatrixSafetyError extends MatrixError {
/**
* The kinds of harms detected by the server.
* @see https://github.com/matrix-org/matrix-spec-proposals/pull/4387 for a list of spec defined harms.
*/
public readonly harms: Set<string>;
/**
* The date at which a request can be reattempted.
*/
public readonly expiry?: Date;
public constructor(...props: ConstructorParameters<typeof MatrixError>) {
super(...props);
const body = props[0];
this.harms = new Set(body && "harms" in body && Array.isArray(body.harms) ? body.harms : []);
this.message = `${super.message} (${[...this.harms].join(", ")})`;
if (body && "expiry" in body && typeof body.expiry === "number") {
this.expiry = new Date(body.expiry);
}
}
}

View File

@@ -18,37 +18,35 @@ limitations under the License.
* This is an internal module. See {@link MatrixHttpApi} for the public class.
*/
import { checkObjectHasKeys, deepCopy, encodeParams } from "../utils.ts";
import { type TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { Method } from "./method.ts";
import { ConnectionError, MatrixError, TokenRefreshError } from "./errors.ts";
import {
type BaseRequestOpts,
HttpApiEvent,
type HttpApiEventHandlerMap,
type IHttpOpts,
type IRequestOpts,
type Body,
} from "./interface.ts";
import { anySignal, parseErrorResponse, timeoutSignal } from "./utils.ts";
import { type QueryDict } from "../utils.ts";
import { TokenRefresher, TokenRefreshOutcome } from "./refresh.ts";
import { checkObjectHasKeys, encodeParams } from "../utils";
import { TypedEventEmitter } from "../models/typed-event-emitter";
import { Method } from "./method";
import { ConnectionError, MatrixError } from "./errors";
import { HttpApiEvent, HttpApiEventHandlerMap, IHttpOpts, IRequestOpts, Body } from "./interface";
import { anySignal, parseErrorResponse, timeoutSignal } from "./utils";
import { QueryDict } from "../utils";
import { logger } from "../logger";
interface TypedResponse<T> extends Response {
json(): Promise<T>;
}
export type ResponseType<T, O extends IHttpOpts> = O extends undefined
? T
: O extends { onlyData: true }
? T
: TypedResponse<T>;
export class FetchHttpApi<O extends IHttpOpts> {
private abortController = new AbortController();
private readonly tokenRefresher: TokenRefresher;
public constructor(
private eventEmitter: TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>,
public readonly opts: O,
) {
checkObjectHasKeys(opts, ["baseUrl", "prefix"]);
if (!opts.onlyData) {
throw new Error("Constructing FetchHttpApi without `onlyData=true` is no longer supported.");
}
opts.onlyData = !!opts.onlyData;
opts.useAuthorizationHeader = opts.useAuthorizationHeader ?? true;
this.tokenRefresher = new TokenRefresher(opts);
}
public abort(): void {
@@ -56,11 +54,11 @@ export class FetchHttpApi<O extends IHttpOpts> {
this.abortController = new AbortController();
}
public fetch(resource: URL | string, options?: RequestInit): ReturnType<typeof globalThis.fetch> {
public fetch(resource: URL | string, options?: RequestInit): ReturnType<typeof global.fetch> {
if (this.opts.fetchFn) {
return this.opts.fetchFn(resource, options);
}
return globalThis.fetch(resource, options);
return global.fetch(resource, options);
}
/**
@@ -71,13 +69,13 @@ export class FetchHttpApi<O extends IHttpOpts> {
this.opts.idBaseUrl = url;
}
public idServerRequest<T extends object = Record<string, unknown>>(
public idServerRequest<T extends {} = Record<string, unknown>>(
method: Method,
path: string,
params: Record<string, string | string[]> | undefined,
prefix: string,
accessToken?: string,
): Promise<T> {
): Promise<ResponseType<T, O>> {
if (!this.opts.idBaseUrl) {
throw new Error("No identity server base URL set");
}
@@ -114,82 +112,59 @@ export class FetchHttpApi<O extends IHttpOpts> {
*
* @param body - The HTTP JSON body.
*
* @param paramOpts - additional options.
* When `paramOpts.doNotAttemptTokenRefresh` is true, token refresh will not be attempted
* when an expired token is encountered. Used to only attempt token refresh once.
* @param opts - additional options. If a number is specified,
* this is treated as `opts.localTimeoutMs`.
*
* @returns The parsed response.
* @throws Error if a problem occurred. This includes network problems and Matrix-specific error JSON.
* @returns Promise which resolves to
* ```
* {
* data: {Object},
* headers: {Object},
* code: {Number},
* }
* ```
* If `onlyData` is set, this will resolve to the `data` object only.
* @returns Rejects with an error if a problem occurred.
* This includes network problems and Matrix-specific error JSON.
*/
public authedRequest<T>(
method: Method,
path: string,
queryParams: QueryDict = {},
queryParams?: QueryDict,
body?: Body,
paramOpts: IRequestOpts = {},
): Promise<T> {
return this.doAuthedRequest<T>(1, method, path, queryParams, body, paramOpts);
}
opts: IRequestOpts = {},
): Promise<ResponseType<T, O>> {
if (!queryParams) queryParams = {};
// Wrapper around public method authedRequest to allow for tracking retry attempt counts
private async doAuthedRequest<T>(
attempt: number,
method: Method,
path: string,
queryParams: QueryDict,
body?: Body,
paramOpts: IRequestOpts = {},
): Promise<T> {
// avoid mutating paramOpts so they can be used on retry
const opts = deepCopy(paramOpts);
// we have to manually copy the abortSignal over as it is not a plain object
opts.abortSignal = paramOpts.abortSignal;
// Take a snapshot of the current token state before we start the request so we can reference it if we error
const requestSnapshot = await this.tokenRefresher.prepareForRequest();
if (requestSnapshot.accessToken) {
if (this.opts.accessToken) {
if (this.opts.useAuthorizationHeader) {
if (!opts.headers) {
opts.headers = {};
}
if (!opts.headers.Authorization) {
opts.headers.Authorization = `Bearer ${requestSnapshot.accessToken}`;
opts.headers.Authorization = "Bearer " + this.opts.accessToken;
}
if (queryParams.access_token) {
delete queryParams.access_token;
}
} else if (!queryParams.access_token) {
queryParams.access_token = requestSnapshot.accessToken;
queryParams.access_token = this.opts.accessToken;
}
}
try {
const response = await this.request<T>(method, path, queryParams, body, opts);
return response;
} catch (error) {
if (!(error instanceof MatrixError)) {
throw error;
const requestPromise = this.request<T>(method, path, queryParams, body, opts);
requestPromise.catch((err: MatrixError) => {
if (err.errcode == "M_UNKNOWN_TOKEN" && !opts?.inhibitLogoutEmit) {
this.eventEmitter.emit(HttpApiEvent.SessionLoggedOut, err);
} else if (err.errcode == "M_CONSENT_NOT_GIVEN") {
this.eventEmitter.emit(HttpApiEvent.NoConsent, err.message, err.data.consent_uri);
}
});
if (error.errcode === "M_UNKNOWN_TOKEN") {
const outcome = await this.tokenRefresher.handleUnknownToken(requestSnapshot, attempt);
if (outcome === TokenRefreshOutcome.Success) {
// if we got a new token retry the request
return this.doAuthedRequest(attempt + 1, method, path, queryParams, body, paramOpts);
}
if (outcome === TokenRefreshOutcome.Failure) {
throw new TokenRefreshError(error);
}
if (!opts?.inhibitLogoutEmit) {
this.eventEmitter.emit(HttpApiEvent.SessionLoggedOut, error);
}
} else if (error.errcode == "M_CONSENT_NOT_GIVEN") {
this.eventEmitter.emit(HttpApiEvent.NoConsent, error.message, error.data.consent_uri);
}
throw error;
}
// return the original promise, otherwise tests break due to it having to
// go around the event loop one more time to process the result of the request
return requestPromise;
}
/**
@@ -205,16 +180,26 @@ export class FetchHttpApi<O extends IHttpOpts> {
*
* @param opts - additional options
*
* @returns The parsed response.
* @throws Error if a problem occurred. This includes network problems and Matrix-specific error JSON.
* @returns Promise which resolves to
* ```
* {
* data: {Object},
* headers: {Object},
* code: {Number},
* }
* ```
* If `onlyData</code> is set, this will resolve to the <code>data`
* object only.
* @returns Rejects with an error if a problem
* occurred. This includes network problems and Matrix-specific error JSON.
*/
public request<T = unknown>(
public request<T>(
method: Method,
path: string,
queryParams?: QueryDict,
body?: Body,
opts?: IRequestOpts,
): Promise<T> {
): Promise<ResponseType<T, O>> {
const fullUri = this.getUrl(path, queryParams, opts?.prefix, opts?.baseUrl);
return this.requestOtherUrl<T>(method, fullUri, body, opts);
}
@@ -228,27 +213,30 @@ export class FetchHttpApi<O extends IHttpOpts> {
*
* @param opts - additional options
*
* @returns The parsed response.
* @throws Error if a problem occurred. This includes network problems and Matrix-specific error JSON.
* @returns Promise which resolves to data unless `onlyData` is specified as false,
* where the resolved value will be a fetch Response object.
* @returns Rejects with an error if a problem
* occurred. This includes network problems and Matrix-specific error JSON.
*/
public async requestOtherUrl<T>(
method: Method,
url: URL | string,
body?: Body,
opts: BaseRequestOpts = {},
): Promise<T> {
if (opts.json !== undefined && opts.rawResponseBody !== undefined) {
throw new Error("Invalid call to `FetchHttpApi` sets both `opts.json` and `opts.rawResponseBody`");
}
opts: Pick<IRequestOpts, "headers" | "json" | "localTimeoutMs" | "keepAlive" | "abortSignal" | "priority"> = {},
): Promise<ResponseType<T, O>> {
const urlForLogs = this.sanitizeUrlForLogs(url);
this.opts.logger?.debug(`FetchHttpApi: --> ${method} ${urlForLogs}`);
logger.debug(`FetchHttpApi: --> ${method} ${urlForLogs}`);
const headers = Object.assign({}, opts.headers || {});
const json = opts.json ?? true;
// We can't use getPrototypeOf here as objects made in other contexts e.g. over postMessage won't have same ref
const jsonBody = json && body?.constructor?.name === Object.name;
if (json) {
if (jsonBody && !headers["Content-Type"]) {
headers["Content-Type"] = "application/json";
}
const jsonResponse = !opts.rawResponseBody && opts.json !== false;
if (jsonResponse) {
if (!headers["Accept"]) {
headers["Accept"] = "application/json";
}
@@ -264,28 +252,15 @@ export class FetchHttpApi<O extends IHttpOpts> {
signals.push(opts.abortSignal);
}
// If the body is an object, encode it as JSON and set the `Content-Type` header,
// unless that has been explicitly inhibited by setting `opts.json: false`.
// We can't use getPrototypeOf here as objects made in other contexts e.g. over postMessage won't have same ref
let data: BodyInit;
if (opts.json !== false && body?.constructor?.name === Object.name) {
if (jsonBody) {
data = JSON.stringify(body);
if (!headers["Content-Type"]) {
headers["Content-Type"] = "application/json";
}
} else {
data = body as BodyInit;
}
const { signal, cleanup } = anySignal(signals);
// Set cache mode based on presence of Authorization header.
// Browsers/proxies do not cache responses to requests with Authorization headers.
// So specifying "no-cache" is redundant, and actually prevents caching
// of preflight requests in CORS scenarios. As such, we only set "no-cache"
// when there is no Authorization header.
const cacheMode = "Authorization" in headers ? undefined : "no-cache";
let res: Response;
const start = Date.now();
try {
@@ -298,17 +273,15 @@ export class FetchHttpApi<O extends IHttpOpts> {
redirect: "follow",
referrer: "",
referrerPolicy: "no-referrer",
cache: cacheMode,
cache: "no-cache",
credentials: "omit", // we send credentials via headers
keepalive: keepAlive,
priority: opts.priority,
});
this.opts.logger?.debug(
`FetchHttpApi: <-- ${method} ${urlForLogs} [${Date.now() - start}ms ${res.status}]`,
);
logger.debug(`FetchHttpApi: <-- ${method} ${urlForLogs} [${Date.now() - start}ms ${res.status}]`);
} catch (e) {
this.opts.logger?.debug(`FetchHttpApi: <-- ${method} ${urlForLogs} [${Date.now() - start}ms ${e}]`);
logger.debug(`FetchHttpApi: <-- ${method} ${urlForLogs} [${Date.now() - start}ms ${e}]`);
if ((<Error>e).name === "AbortError") {
throw e;
}
@@ -321,13 +294,10 @@ export class FetchHttpApi<O extends IHttpOpts> {
throw parseErrorResponse(res, await res.text());
}
if (opts.rawResponseBody) {
return (await res.blob()) as T;
} else if (jsonResponse) {
return await res.json();
} else {
return (await res.text()) as T;
if (this.opts.onlyData) {
return json ? res.json() : res.text();
}
return res as ResponseType<T, O>;
}
private sanitizeUrlForLogs(url: URL | string): string {
@@ -347,7 +317,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
const sanitizedQsUrlPiece = sanitizedQsString ? `?${sanitizedQsString}` : "";
return asUrl.origin + asUrl.pathname + sanitizedQsUrlPiece;
} catch {
} catch (error) {
// defensive coding for malformed url
return "??";
}
@@ -366,12 +336,9 @@ export class FetchHttpApi<O extends IHttpOpts> {
? baseUrlWithFallback.slice(0, -1)
: baseUrlWithFallback;
const url = new URL(baseUrlWithoutTrailingSlash + (prefix ?? this.opts.prefix) + path);
// If there are any params, encode and append them to the URL.
if (this.opts.extraParams || queryParams) {
const mergedParams = { ...this.opts.extraParams, ...queryParams };
encodeParams(mergedParams, url.searchParams);
if (queryParams) {
encodeParams(queryParams, url.searchParams);
}
return url;
}
}

View File

@@ -14,20 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { FetchHttpApi } from "./fetch.ts";
import { type FileType, type IHttpOpts, type Upload, type UploadOpts, type UploadResponse } from "./interface.ts";
import { MediaPrefix } from "./prefix.ts";
import { type QueryDict, removeElement } from "../utils.ts";
import * as callbacks from "../realtime-callbacks.ts";
import { Method } from "./method.ts";
import { ConnectionError } from "./errors.ts";
import { parseErrorResponse } from "./utils.ts";
import { FetchHttpApi } from "./fetch";
import { FileType, IContentUri, IHttpOpts, Upload, UploadOpts, UploadResponse } from "./interface";
import { MediaPrefix } from "./prefix";
import { defer, QueryDict, removeElement } from "../utils";
import * as callbacks from "../realtime-callbacks";
import { Method } from "./method";
import { ConnectionError } from "./errors";
import { parseErrorResponse } from "./utils";
export * from "./interface.ts";
export * from "./prefix.ts";
export * from "./errors.ts";
export * from "./method.ts";
export * from "./utils.ts";
export * from "./interface";
export * from "./prefix";
export * from "./errors";
export * from "./method";
export * from "./utils";
export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
private uploads: Upload[] = [];
@@ -41,16 +41,16 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
*
* @param opts - options object
*
* @returns Promise which resolves to response object, or rejects with an error (usually a MatrixError).
* @throws May throw a `MatrixSafetyError` if content is deemed unsafe.
* @see MatrixSafetyError
* @returns Promise which resolves to response object, as
* determined by this.opts.onlyData, opts.rawResponse, and
* opts.onlyContentUri. Rejects with an error (usually a MatrixError).
*/
public uploadContent(file: FileType, opts: UploadOpts = {}): Promise<UploadResponse> {
const includeFilename = opts.includeFilename ?? true;
const abortController = opts.abortController ?? new AbortController();
// If the file doesn't have a mime type, use a default since the HS errors if we don't supply one.
const contentType = (opts.type ?? (file as File).type) || "application/octet-stream";
const contentType = opts.type ?? (file as File).type ?? "application/octet-stream";
const fileName = opts.name ?? (file as File).name;
const upload = {
@@ -58,14 +58,14 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
total: 0,
abortController,
} as Upload;
const uploadResolvers = Promise.withResolvers<UploadResponse>();
const deferred = defer<UploadResponse>();
if (globalThis.XMLHttpRequest) {
const xhr = new globalThis.XMLHttpRequest();
if (global.XMLHttpRequest) {
const xhr = new global.XMLHttpRequest();
const timeoutFn = function (): void {
xhr.abort();
uploadResolvers.reject(new Error("Timeout"));
deferred.reject(new Error("Timeout"));
};
// set an initial timeout of 30s; we'll advance it each time we get a progress notification
@@ -73,7 +73,7 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
xhr.onreadystatechange = function (): void {
switch (xhr.readyState) {
case globalThis.XMLHttpRequest.DONE:
case global.XMLHttpRequest.DONE:
callbacks.clearTimeout(timeoutTimer);
try {
if (xhr.status === 0) {
@@ -84,16 +84,16 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
}
if (xhr.status >= 400) {
uploadResolvers.reject(parseErrorResponse(xhr, xhr.responseText));
deferred.reject(parseErrorResponse(xhr, xhr.responseText));
} else {
uploadResolvers.resolve(JSON.parse(xhr.responseText));
deferred.resolve(JSON.parse(xhr.responseText));
}
} catch (err) {
if ((<Error>err).name === "AbortError") {
uploadResolvers.reject(err);
deferred.reject(err);
return;
}
uploadResolvers.reject(new ConnectionError("request failed", <Error>err));
deferred.reject(new ConnectionError("request failed", <Error>err));
}
break;
}
@@ -110,7 +110,7 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
});
};
const url = this.getUrl("/upload", undefined, MediaPrefix.V3);
const url = this.getUrl("/upload", undefined, MediaPrefix.R0);
if (includeFilename && fileName) {
url.searchParams.set("filename", encodeURIComponent(fileName));
@@ -139,19 +139,23 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
const headers: Record<string, string> = { "Content-Type": contentType };
this.authedRequest<UploadResponse>(Method.Post, "/upload", queryParams, file, {
prefix: MediaPrefix.V3,
prefix: MediaPrefix.R0,
headers,
abortSignal: abortController.signal,
}).then(uploadResolvers.resolve, uploadResolvers.reject);
})
.then((response) => {
return this.opts.onlyData ? <UploadResponse>response : response.json();
})
.then(deferred.resolve, deferred.reject);
}
// remove the upload from the list on completion
upload.promise = uploadResolvers.promise.finally(() => {
upload.promise = deferred.promise.finally(() => {
removeElement(this.uploads, (elem) => elem === upload);
});
abortController.signal.addEventListener("abort", () => {
removeElement(this.uploads, (elem) => elem === upload);
uploadResolvers.reject(new DOMException("Aborted", "AbortError"));
deferred.reject(new DOMException("Aborted", "AbortError"));
});
this.uploads.push(upload);
return upload.promise;
@@ -169,4 +173,19 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
public getCurrentUploads(): Upload[] {
return this.uploads;
}
/**
* Get the content repository url with query parameters.
* @returns An object with a 'base', 'path' and 'params' for base URL,
* path and query parameters respectively.
*/
public getContentUri(): IContentUri {
return {
base: this.opts.baseUrl,
path: MediaPrefix.R0 + "/upload",
params: {
access_token: this.opts.accessToken!,
},
};
}
}

View File

@@ -14,75 +14,36 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type MatrixError } from "./errors.ts";
import { type Logger } from "../logger.ts";
import { type QueryDict } from "../utils.ts";
import { MatrixError } from "./errors";
export type Body = Record<string, any> | BodyInit;
/**
* Unencrypted access and (optional) refresh token
*/
export type AccessTokens = {
/**
* The new access token to use for authenticated requests
*/
accessToken: string;
/**
* The new refresh token to use for refreshing tokens, optional
*/
refreshToken?: string;
/**
* Approximate date when the access token will expire, optional
*/
expiry?: Date;
};
/**
* Function that performs token refresh using the given refreshToken.
* Returns a promise that resolves to the refreshed access and (optional) refresh tokens.
*
* Can be passed to HttpApi instance as {@link IHttpOpts.tokenRefreshFunction} during client creation {@link ICreateClientOpts}
*/
export type TokenRefreshFunction = (refreshToken: string) => Promise<AccessTokens>;
/** Options object for `FetchHttpApi` and {@link MatrixHttpApi}. */
export interface IHttpOpts {
fetchFn?: typeof globalThis.fetch;
fetchFn?: typeof global.fetch;
baseUrl: string;
idBaseUrl?: string;
prefix: string;
extraParams?: QueryDict;
extraParams?: Record<string, string>;
accessToken?: string;
/**
* Used in conjunction with tokenRefreshFunction to attempt token refresh
*/
refreshToken?: string;
/**
* Function to attempt token refresh when a possibly expired token is encountered
* Optional, only called when a refreshToken is present
*/
tokenRefreshFunction?: TokenRefreshFunction;
/**
* Whether to use the HTTP Authorization header over the `access_token` query parameter
* @deprecated as of v1.11 in https://spec.matrix.org/v1.17/client-server-api/#using-access-tokens
*/
useAuthorizationHeader?: boolean; // defaults to true
/** For historical reasons, must be set to `true`. Will eventually be removed. */
onlyData?: boolean;
localTimeoutMs?: number;
/** Optional logger instance. If provided, requests and responses will be logged. */
logger?: Logger;
}
/** Options object for `FetchHttpApi.requestOtherUrl`. */
export interface BaseRequestOpts extends Pick<RequestInit, "priority"> {
export interface IRequestOpts extends Pick<RequestInit, "priority"> {
/**
* The alternative base url to use.
* If not specified, uses this.opts.baseUrl
*/
baseUrl?: string;
/**
* The full prefix to use e.g.
* "/_matrix/client/v2_alpha". If not specified, uses this.opts.prefix.
*/
prefix?: string;
/**
* map of additional request headers
*/
@@ -94,46 +55,7 @@ export interface BaseRequestOpts extends Pick<RequestInit, "priority"> {
*/
localTimeoutMs?: number;
keepAlive?: boolean; // defaults to false
/**
* By default, we will:
*
* * If the `body` is an object, JSON-encode it and set `Content-Type: application/json` in the
* request headers (unless overridden by {@link headers}).
*
* * Set `Accept: application/json` in the request headers (again, unless overridden by {@link headers}).
*
* * Parse the response as JSON and return the parsed response.
*
* Setting this to `false` inhibits all three behaviors, and the response is instead parsed as a UTF-8 string. It
* defaults to `true`, unless {@link rawResponseBody} is set.
*
* @deprecated Instead of setting this to `false`, set {@link rawResponseBody} to `true`.
*/
json?: boolean;
/**
* Setting this to `true` does two things:
*
* * Inhibits the automatic addition of `Accept: application/json` in the request headers.
*
* * Causes the raw response to be returned as a {@link https://developer.mozilla.org/en-US/docs/Web/API/Blob|Blob}
* instead of parsing it as JSON.
*/
rawResponseBody?: boolean;
}
export interface IRequestOpts extends BaseRequestOpts {
/**
* The alternative base url to use.
* If not specified, uses this.opts.baseUrl
*/
baseUrl?: string;
/**
* The full prefix to use e.g.
* "/_matrix/client/v2_alpha". If not specified, uses this.opts.prefix.
*/
prefix?: string;
json?: boolean; // defaults to true
// Set to true to prevent the request function from emitting a Session.logged_out event.
// This is intended for use on endpoints where M_UNKNOWN_TOKEN is a valid/notable error response,
@@ -141,6 +63,15 @@ export interface IRequestOpts extends BaseRequestOpts {
inhibitLogoutEmit?: boolean;
}
export interface IContentUri {
base: string;
path: string;
params: {
// eslint-disable-next-line camelcase
access_token: string;
};
}
export enum HttpApiEvent {
SessionLoggedOut = "Session.logged_out",
NoConsent = "no_consent",
@@ -211,6 +142,7 @@ export interface Upload {
}
export interface UploadResponse {
// eslint-disable-next-line camelcase
content_uri: string;
}

View File

@@ -19,7 +19,4 @@ export enum Method {
Put = "PUT",
Post = "POST",
Delete = "DELETE",
Options = "OPTIONS",
Head = "HEAD",
Patch = "PATCH",
}

View File

@@ -16,7 +16,11 @@ limitations under the License.
export enum ClientPrefix {
/**
* A constant representing the URI path for Client-Server API endpoints versioned at v1.
* A constant representing the URI path for release 0 of the Client-Server HTTP API.
*/
R0 = "/_matrix/client/r0",
/**
* A constant representing the URI path for the legacy release v1 of the Client-Server HTTP API.
*/
V1 = "/_matrix/client/v1",
/**
@@ -38,11 +42,7 @@ export enum IdentityPrefix {
export enum MediaPrefix {
/**
* A constant representing the URI path for Client-Server API Media endpoints versioned at v1.
* URI path for the media repo API
*/
V1 = "/_matrix/media/v1",
/**
* A constant representing the URI path for Client-Server API Media endpoints versioned at v3.
*/
V3 = "/_matrix/media/v3",
R0 = "/_matrix/media/r0",
}

View File

@@ -1,166 +0,0 @@
/*
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.
*/
import { MatrixError, TokenRefreshLogoutError } from "./errors.ts";
import { type IHttpOpts } from "./interface.ts";
import { sleep } from "../utils.ts";
/**
* This is an internal module. See {@link MatrixHttpApi} for the public class.
*/
export const enum TokenRefreshOutcome {
Success = "success",
Failure = "failure",
Logout = "logout",
}
interface Snapshot {
accessToken: string;
refreshToken?: string;
expiry?: Date;
}
// If the token expires in less than this time amount of time, we will eagerly refresh it before making the intended request.
const REFRESH_IF_TOKEN_EXPIRES_WITHIN_MS = 500;
// If we get an unknown token error and the token expires in less than this time amount of time, we will refresh it before making the intended request.
// Otherwise, we will error as the token should not have expired yet and we need to avoid retrying indefinitely.
const REFRESH_ON_ERROR_IF_TOKEN_EXPIRES_WITHIN_MS = 60 * 1000;
type Opts = Pick<IHttpOpts, "tokenRefreshFunction" | "logger" | "refreshToken" | "accessToken">;
/**
* This class is responsible for managing the access token and refresh token for authenticated requests.
* It will automatically refresh the access token when it is about to expire, and will handle unknown token errors.
*/
export class TokenRefresher {
public constructor(private readonly opts: Opts) {}
/**
* Promise used to block authenticated requests during a token refresh to avoid repeated expected errors.
* @private
*/
private tokenRefreshPromise?: Promise<TokenRefreshOutcome>;
private latestTokenRefreshExpiry?: Date;
/**
* This function is called before every request to ensure that the access token is valid.
* @returns a snapshot containing the access token and other properties which must be passed to the handleUnknownToken
* handler if an M_UNKNOWN_TOKEN error is encountered.
*/
public async prepareForRequest(): Promise<Snapshot> {
// Ensure our token is refreshed before we build the headers/params
await this.refreshIfNeeded();
return {
accessToken: this.opts.accessToken!,
refreshToken: this.opts.refreshToken,
expiry: this.latestTokenRefreshExpiry,
};
}
private async refreshIfNeeded(): Promise<unknown> {
if (this.tokenRefreshPromise) {
return this.tokenRefreshPromise;
}
// If we don't know the token expiry, we can't eagerly refresh
if (!this.latestTokenRefreshExpiry) return;
const expiresIn = this.latestTokenRefreshExpiry.getTime() - Date.now();
if (expiresIn <= REFRESH_IF_TOKEN_EXPIRES_WITHIN_MS) {
await this._handleUnknownToken();
}
}
/**
* This function is called when an M_UNKNOWN_TOKEN error is encountered.
* It will attempt to refresh the access token if it is unknown, and will return a TokenRefreshOutcome.
* @param snapshot - the snapshot returned by prepareForRequest
* @param attempt - the number of attempts made for this request so far
* @returns a TokenRefreshOutcome indicating the result of the refresh attempt
*/
public async handleUnknownToken(snapshot: Snapshot, attempt: number): Promise<TokenRefreshOutcome> {
return this._handleUnknownToken(snapshot, attempt);
}
private async _handleUnknownToken(): Promise<TokenRefreshOutcome>;
private async _handleUnknownToken(snapshot: Snapshot, attempt: number): Promise<TokenRefreshOutcome>;
private async _handleUnknownToken(snapshot?: Snapshot, attempt?: number): Promise<TokenRefreshOutcome> {
if (snapshot?.expiry) {
// If our token is unknown, but it should not have expired yet, then we should not refresh
const expiresIn = snapshot.expiry.getTime() - Date.now();
// If it still has plenty of time left on the clock, we assume something else must be wrong and
// do not refresh. Otherwise if it's expired, or will soon, we try refreshing.
if (expiresIn >= REFRESH_ON_ERROR_IF_TOKEN_EXPIRES_WITHIN_MS) {
return TokenRefreshOutcome.Logout;
}
}
if (!snapshot || snapshot?.accessToken === this.opts.accessToken) {
// If we have a snapshot, but the access token is the same as the current one then a refresh
// did not happen behind us but one may be ongoing anyway
this.tokenRefreshPromise ??= this.doTokenRefresh(attempt);
try {
return await this.tokenRefreshPromise;
} finally {
this.tokenRefreshPromise = undefined;
}
}
// We may end up here if the token was refreshed in the background due to another request
return TokenRefreshOutcome.Success;
}
/**
* Attempt to refresh access tokens.
* On success, sets new access and refresh tokens in opts.
* @returns Promise that resolves to a boolean - true when token was refreshed successfully
*/
private async doTokenRefresh(attempt?: number): Promise<TokenRefreshOutcome> {
if (!this.opts.refreshToken || !this.opts.tokenRefreshFunction) {
this.opts.logger?.error("Unable to refresh token - no refresh token or refresh function");
return TokenRefreshOutcome.Logout;
}
if (attempt && attempt > 1) {
// Exponential backoff to ensure we don't trash the server, up to 2^5 seconds
await sleep(1000 * Math.min(32, 2 ** attempt));
}
try {
this.opts.logger?.debug("Attempting to refresh token");
const { accessToken, refreshToken, expiry } = await this.opts.tokenRefreshFunction(this.opts.refreshToken);
this.opts.accessToken = accessToken;
this.opts.refreshToken = refreshToken;
this.latestTokenRefreshExpiry = expiry;
this.opts.logger?.debug("... token refresh complete, new token expiry:", expiry);
// successfully got new tokens
return TokenRefreshOutcome.Success;
} catch (error) {
// If we get a TokenError or MatrixError, we should log out, otherwise assume transient
if (error instanceof TokenRefreshLogoutError || error instanceof MatrixError) {
this.opts.logger?.error("Failed to refresh token", error);
return TokenRefreshOutcome.Logout;
}
this.opts.logger?.warn("Failed to refresh token", error);
return TokenRefreshOutcome.Failure;
}
}
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 - 2024 The Matrix.org Foundation C.I.C.
Copyright 2022 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.
@@ -14,18 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { parse as parseContentType, type ContentType } from "content-type";
import { parse as parseContentType, ParsedMediaType } from "content-type";
import { logger } from "../logger.ts";
import { sleep } from "../utils.ts";
import {
ConnectionError,
HTTPError,
MatrixError,
MatrixSafetyError,
MatrixSafetyErrorCode,
safeGetRetryAfterMs,
} from "./errors.ts";
import { logger } from "../logger";
import { sleep } from "../utils";
import { ConnectionError, HTTPError, MatrixError } from "./errors";
// Ponyfill for https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout
export function timeoutSignal(ms: number): AbortSignal {
@@ -39,7 +32,7 @@ export function timeoutSignal(ms: number): AbortSignal {
export function anySignal(signals: AbortSignal[]): {
signal: AbortSignal;
cleanup(this: void): void;
cleanup(): void;
} {
const controller = new AbortController();
@@ -79,48 +72,24 @@ export function anySignal(signals: AbortSignal[]): {
* @returns
*/
export function parseErrorResponse(response: XMLHttpRequest | Response, body?: string): Error {
const httpHeaders = isXhr(response)
? new Headers(
response
.getAllResponseHeaders()
.trim()
.split(/[\r\n]+/)
.map((header): [string, string] => {
const colonIdx = header.indexOf(":");
return [header.substring(0, colonIdx), header.substring(colonIdx + 1)];
}),
)
: response.headers;
let contentType: ContentType | null;
let contentType: ParsedMediaType | null;
try {
contentType = getResponseContentType(httpHeaders);
contentType = getResponseContentType(response);
} catch (e) {
return <Error>e;
}
if (contentType?.type === "application/json" && body) {
const errorBody = JSON.parse(body);
if (errorBody.errcode && MatrixSafetyErrorCode.matches(errorBody.errcode)) {
return new MatrixSafetyError(
errorBody,
response.status,
isXhr(response) ? response.responseURL : response.url,
undefined,
httpHeaders,
);
}
return new MatrixError(
errorBody,
JSON.parse(body),
response.status,
isXhr(response) ? response.responseURL : response.url,
undefined,
httpHeaders,
);
}
if (contentType?.type === "text/plain") {
return new HTTPError(`Server returned ${response.status} error: ${body}`, response.status, httpHeaders);
return new HTTPError(`Server returned ${response.status} error: ${body}`, response.status);
}
return new HTTPError(`Server returned ${response.status} error`, response.status, httpHeaders);
return new HTTPError(`Server returned ${response.status} error`, response.status);
}
function isXhr(response: XMLHttpRequest | Response): response is XMLHttpRequest {
@@ -128,7 +97,7 @@ function isXhr(response: XMLHttpRequest | Response): response is XMLHttpRequest
}
/**
* extract the Content-Type header from response headers, and
* extract the Content-Type header from the response object, and
* parse it to a `{type, parameters}` object.
*
* returns null if no content-type header could be found.
@@ -136,10 +105,21 @@ function isXhr(response: XMLHttpRequest | Response): response is XMLHttpRequest
* @param response - response object
* @returns parsed content-type header, or null if not found
*/
function getResponseContentType(headers: Headers): ContentType | null {
const contentType = headers.get("Content-Type");
if (contentType === null) return null;
return parseContentType(contentType);
function getResponseContentType(response: XMLHttpRequest | Response): ParsedMediaType | null {
let contentType: string | null;
if (isXhr(response)) {
contentType = response.getResponseHeader("Content-Type");
} else {
contentType = response.headers.get("Content-Type");
}
if (!contentType) return null;
try {
return parseContentType(contentType);
} catch (e) {
throw new Error(`Error parsing Content-Type '${contentType}': ${e}`);
}
}
/**
@@ -171,42 +151,3 @@ export async function retryNetworkOperation<T>(maxAttempts: number, callback: ()
}
throw lastConnectionError;
}
/**
* Calculate the backoff time for a request retry attempt.
* This produces wait times of 2, 4, 8, and 16 seconds (30s total) after which we give up. If the
* failure was due to a rate limited request, the time specified in the error is returned.
*
* Returns -1 if the error is not retryable, or if we reach the maximum number of attempts.
*
* @param err - The error thrown by the http call
* @param attempts - The number of attempts made so far, including the one that just failed.
* @param retryConnectionError - Whether to retry on {@link ConnectionError} (CORS, connection is down, etc.)
*/
export function calculateRetryBackoff(err: any, attempts: number, retryConnectionError: boolean): number {
if (attempts > 4) {
return -1; // give up
}
if (err instanceof ConnectionError && !retryConnectionError) {
return -1;
}
if (err.httpStatus && Math.floor(err.httpStatus / 100) === 4 && err.httpStatus !== 429) {
// client error; no amount of retrying will save you now (except for rate limiting which is handled below)
return -1;
}
if (err.name === "AbortError") {
// this is a client timeout, that is already very high 60s/80s
// we don't want to retry, as it could do it for very long
return -1;
}
// If we are trying to send an event (or similar) that is too large in any way, then retrying won't help
if (err.name === "M_TOO_LARGE") {
return -1;
}
return safeGetRetryAfterMs(err, 1000 * Math.pow(2, attempts));
}

View File

@@ -14,12 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as matrixcs from "./matrix.ts";
import * as matrixcs from "./matrix";
if (globalThis.__js_sdk_entrypoint) {
if (global.__js_sdk_entrypoint) {
throw new Error("Multiple matrix-js-sdk entrypoints detected!");
}
globalThis.__js_sdk_entrypoint = true;
global.__js_sdk_entrypoint = true;
export * from "./matrix.ts";
export * from "./matrix";
export default matrixcs;

View File

@@ -21,4 +21,4 @@ limitations under the License.
*/
/** The {@link IndexedDBStoreWorker} class. */
export { IndexedDBStoreWorker } from "./store/indexeddb-store-worker.ts";
export { IndexedDBStoreWorker } from "./store/indexeddb-store-worker";

View File

@@ -16,10 +16,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger } from "./logger.ts";
import { type MatrixClient } from "./client.ts";
import { MatrixError } from "./http-api/index.ts";
import { type UserIdentifier } from "./@types/auth.ts";
import { logger } from "./logger";
import { MatrixClient } from "./client";
import { defer, IDeferred } from "./utils";
import { MatrixError } from "./http-api";
import { UIAResponse } from "./@types/uia";
import { UserIdentifier } from "./@types/auth";
const EMAIL_STAGE_TYPE = "m.login.email.identity";
const MSISDN_STAGE_TYPE = "m.login.msisdn";
@@ -85,11 +87,6 @@ export enum AuthType {
// use the stable "m.login.registration_token" type.
// The authentication flow is the same in both cases.
UnstableRegistrationToken = "org.matrix.msc3231.login.registration_token",
/**
* m.oauth stage introduced by MSC4312:
* https://spec.matrix.org/v1.17/client-server-api/#oauth-authentication
*/
OAuth = "m.oauth",
}
/**
@@ -124,6 +121,10 @@ interface ThreepidCreds {
type EmailIdentityDict = {
type: AuthType.Email;
threepid_creds: ThreepidCreds;
/**
* @deprecated in favour of `threepid_creds` - kept for backwards compatibility
*/
threepidCreds?: ThreepidCreds;
session: string;
};
@@ -137,17 +138,19 @@ export type AuthDict =
| RecaptchaDict
| EmailIdentityDict
| { type: Exclude<string, AuthType>; [key: string]: any }
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
| {};
/**
* Backwards compatible export
* @deprecated in favour of AuthDict
*/
export type IAuthDict = AuthDict;
export class NoAuthFlowFoundError extends Error {
public name = "NoAuthFlowFoundError";
public constructor(
m: string,
public readonly required_stages: string[],
public readonly flows: UIAFlow[],
) {
// eslint-disable-next-line @typescript-eslint/naming-convention, camelcase
public constructor(m: string, public readonly required_stages: string[], public readonly flows: UIAFlow[]) {
super(m);
}
}
@@ -161,7 +164,7 @@ export class NoAuthFlowFoundError extends Error {
*
* The generic parameter `T` is the type of the response of the endpoint, once it is eventually successful.
*/
export type UIAuthCallback<T> = (makeRequest: (authData: AuthDict | null) => Promise<T>) => Promise<T>;
export type UIAuthCallback<T> = (makeRequest: (authData: IAuthDict | null) => Promise<UIAResponse<T>>) => Promise<T>;
interface IOpts<T> {
/**
@@ -204,7 +207,7 @@ interface IOpts<T> {
* The busyChanged callback should be used instead of the background flag.
* Should return a promise which resolves to the successful response or rejects with a MatrixError.
*/
doRequest(this: void, auth: AuthDict | null, background: boolean): Promise<T>;
doRequest(auth: AuthDict | null, background: boolean): Promise<T>;
/**
* Called when the status of the UI auth changes,
* ie. when the state of an auth stage changes of when the auth flow moves to a new stage.
@@ -218,7 +221,7 @@ interface IOpts<T> {
* m.login.email.identity:
* * emailSid: string, the sid of the active email auth session
*/
stateUpdated(this: void, nextStage: AuthType | string, status: IStageStatus): void;
stateUpdated(nextStage: AuthType | string, status: IStageStatus): void;
/**
* A function that takes the email address (string), clientSecret (string), attempt number (int) and
@@ -226,20 +229,14 @@ interface IOpts<T> {
* function.
* If the resulting promise rejects, the rejection will propagate through to the attemptAuth promise.
*/
requestEmailToken(
this: void,
email: string,
secret: string,
attempt: number,
session: string,
): Promise<{ sid: string }>;
requestEmailToken(email: string, secret: string, attempt: number, session: string): Promise<{ sid: string }>;
/**
* Called whenever the interactive auth logic becomes busy submitting information provided by the user or finishes.
* After this has been called with true the UI should indicate that a request is in progress
* until it is called again with false.
*/
busyChanged?(this: void, busy: boolean): void;
startAuthStage?(this: void, nextStage: string): Promise<void>; // LEGACY
busyChanged?(busy: boolean): void;
startAuthStage?(nextStage: string): Promise<void>; // LEGACY
}
/**
@@ -271,7 +268,7 @@ export class InteractiveAuth<T> {
private data: IAuthData & MatrixError["data"];
private emailSid?: string;
private requestingEmailToken = false;
private attemptAuthDeferred: PromiseWithResolvers<T> | null = null;
private attemptAuthDeferred: IDeferred<T> | null = null;
private chosenFlow: UIAFlow | null = null;
private currentStage: string | null = null;
@@ -307,7 +304,7 @@ export class InteractiveAuth<T> {
public async attemptAuth(): Promise<T> {
// This promise will be quite long-lived and will resolve when the
// request is authenticated and completes successfully.
this.attemptAuthDeferred = Promise.withResolvers();
this.attemptAuthDeferred = defer();
// pluck the promise out now, as doRequest may clear before we return
const promise = this.attemptAuthDeferred.promise;
@@ -339,7 +336,7 @@ export class InteractiveAuth<T> {
// another just to check what the status is
if (this.submitPromise) return;
let authDict: AuthDict = {};
let authDict: IAuthDict = {};
if (this.currentStage == EMAIL_STAGE_TYPE) {
// The email can be validated out-of-band, but we need to provide the
// creds so the HS can go & check it.
@@ -348,13 +345,17 @@ export class InteractiveAuth<T> {
sid: this.emailSid,
client_secret: this.clientSecret,
};
const isUrl = this.matrixClient.getIdentityServerUrl();
if (isUrl) {
creds.id_server = new URL(isUrl).host;
if (await this.matrixClient.doesServerRequireIdServerParam()) {
const idServerParsedUrl = new URL(this.matrixClient.getIdentityServerUrl()!);
creds.id_server = idServerParsedUrl.host;
}
authDict = {
type: EMAIL_STAGE_TYPE,
// TODO: Remove `threepid_creds` once servers support proper UIA
// See https://github.com/matrix-org/synapse/issues/5665
// See https://github.com/matrix-org/matrix-doc/issues/2220
threepid_creds: creds,
threepidCreds: creds,
};
}
}
@@ -407,7 +408,7 @@ export class InteractiveAuth<T> {
* in the attemptAuth promise being rejected. This can be set to true
* for requests that just poll to see if auth has been completed elsewhere.
*/
public async submitAuthDict(authData: AuthDict, background = false): Promise<void> {
public async submitAuthDict(authData: IAuthDict, background = false): Promise<void> {
if (!this.attemptAuthDeferred) {
throw new Error("submitAuthDict() called before attemptAuth()");
}
@@ -419,24 +420,21 @@ export class InteractiveAuth<T> {
// if we're currently trying a request, wait for it to finish
// as otherwise we can get multiple 200 responses which can mean
// things like multiple logins for register requests.
// (but discard any exceptions as we only care when its done,
// not whether it worked or not)
while (this.submitPromise) {
try {
await this.submitPromise;
} catch {
// discard any exceptions as we only care when its done,
// not whether it worked or not
}
} catch (e) {}
}
// use the sessionid from the last request, if one is present.
let auth: AuthDict;
let auth: IAuthDict;
if ((this.data as IAuthData)?.session) {
auth = Object.assign(
{
session: (this.data as IAuthData).session,
},
authData,
);
auth = {
session: (this.data as IAuthData).session,
};
Object.assign(auth, authData);
} else {
auth = authData;
}
@@ -515,7 +513,7 @@ export class InteractiveAuth<T> {
* This can be set to true for requests that just poll to see if auth has
* been completed elsewhere.
*/
private async doRequest(auth: AuthDict | null, background = false): Promise<void> {
private async doRequest(auth: IAuthDict | null, background = false): Promise<void> {
try {
const result = await this.requestCallback(auth, background);
this.attemptAuthDeferred!.resolve(result);

View File

@@ -1,6 +1,6 @@
/*
Copyright 2018 André Jaenisch
Copyright 2019-2025 The Matrix.org Foundation C.I.C.
Copyright 2019, 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.
@@ -15,73 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import loglevel from "loglevel";
/** Backwards-compatibility hack to expose `log` to applications that might still be relying on it. */
interface LoggerWithLogMethod extends Logger {
/**
* Output debug message to the logger.
*
* @param msg - Data to log.
*
* @deprecated prefer {@link Logger.debug}.
*/
log(...msg: any[]): void;
}
/** Logger interface used within the js-sdk codebase */
export interface Logger extends BaseLogger {
/**
* Create a child logger.
*
* This child will use the `methodFactory` of the parent, so any log extensions applied to the parent
* at the time of calling `getChild` will be applied to the child as well.
* It will NOT apply changes to the parent's `methodFactory` after the child was created.
* Those changes need to be applied to the child manually.
*
* @param namespace - name to add to the current logger to generate the child. Some implementations of `Logger`
* use this as a prefix; others use a different mechanism.
*/
getChild(namespace: string): Logger;
}
/** The basic interface for a logger which doesn't support children */
export interface BaseLogger {
/**
* Output trace message to the logger, with stack trace.
*
* @param msg - Data to log.
*/
trace(this: void, ...msg: any[]): void;
/**
* Output debug message to the logger.
*
* @param msg - Data to log.
*/
debug(this: void, ...msg: any[]): void;
/**
* Output info message to the logger.
*
* @param msg - Data to log.
*/
info(this: void, ...msg: any[]): void;
/**
* Output warn message to the logger.
*
* @param msg - Data to log.
*/
warn(this: void, ...msg: any[]): void;
/**
* Output error message to the logger.
*
* @param msg - Data to log.
*/
error(this: void, ...msg: any[]): void;
}
import log, { Logger } from "loglevel";
// This is to demonstrate, that you can use any namespace you want.
// Namespaces allow you to turn on/off the logging for specific parts of the
@@ -96,11 +30,13 @@ const DEFAULT_NAMESPACE = "matrix";
// to avoid the issue, we override the methodFactory of loglevel that binds to the
// console methods at initialization time by a factory that looks up the console methods
// when logging so we always get the current value of console methods.
loglevel.methodFactory = function (methodName, logLevel, loggerName) {
log.methodFactory = function (methodName, logLevel, loggerName) {
return function (this: PrefixedLogger, ...args): void {
/* eslint-disable @typescript-eslint/no-invalid-this */
if (this.prefix) {
args.unshift(this.prefix);
}
/* eslint-enable @typescript-eslint/no-invalid-this */
const supportedByConsole =
methodName === "error" ||
methodName === "warn" ||
@@ -117,161 +53,34 @@ loglevel.methodFactory = function (methodName, logLevel, loggerName) {
};
};
/**
* Implementation of {@link Logger} based on `loglevel`.
*/
interface PrefixedLogger extends loglevel.Logger, LoggerWithLogMethod {
prefix?: string;
}
/**
* Internal utility function: gets a {@link Logger} based on `loglevel`.
*
* Child loggers produced by {@link Logger.getChild} add the name of the child logger as a prefix on each log line.
*
* @param prefix Prefix to add to each logged line. If undefined, no prefix will be added.
*/
function getPrefixedLogger(prefix?: string): PrefixedLogger {
const loggerName = DEFAULT_NAMESPACE + (prefix === undefined ? "" : `-${prefix}`);
const prefixLogger = loglevel.getLogger(loggerName) as PrefixedLogger;
if (prefixLogger.getChild === undefined) {
// This is a new loglevel Logger which has not been turned into a PrefixedLogger yet.
prefixLogger.prefix = prefix;
prefixLogger.getChild = (childPrefix): Logger => {
// create the new child logger
const childLogger = getPrefixedLogger((prefix ?? "") + childPrefix);
// Assign the methodFactory from the parent logger.
// This is useful if we add extensions to the parent logger that modifies
// its methodFactory. (An example extension is: storing each log to a rageshake db)
childLogger.methodFactory = prefixLogger.methodFactory;
// Rebuild the child logger with the new methodFactory.
childLogger.rebuild();
return childLogger;
};
prefixLogger.setLevel(loglevel.levels.DEBUG, false);
}
return prefixLogger;
}
/**
* Drop-in replacement for `console` using {@link https://www.npmjs.com/package/loglevel|loglevel}.
* Can be tailored down to specific use cases if needed.
*
* @deprecated avoid the use of this unless you are the constructor of `MatrixClient`: you should be using the logger
* associated with `MatrixClient`.
*/
export const logger = getPrefixedLogger() as LoggerWithLogMethod;
export const logger = log.getLogger(DEFAULT_NAMESPACE) as PrefixedLogger;
logger.setLevel(log.levels.DEBUG, false);
/**
* A "span" for grouping related log lines together.
*
* The current implementation just adds the name at the start of each log line.
*
* This offers a lighter-weight alternative to 'child' loggers returned by {@link Logger#getChild}. In particular,
* it's not possible to apply individual filters to the LogSpan such as setting the verbosity level. On the other hand,
* no reference to the LogSpan is retained in the logging framework, so it is safe to make lots of them over the course
* of an application's life and just drop references to them when the job is done.
*/
export class LogSpan implements BaseLogger {
private readonly name;
public constructor(
private readonly parent: BaseLogger,
name: string,
) {
this.name = name + ":";
}
public trace(...msg: any[]): void {
this.parent.trace(this.name, ...msg);
}
public debug(...msg: any[]): void {
this.parent.debug(this.name, ...msg);
}
public info(...msg: any[]): void {
this.parent.info(this.name, ...msg);
}
public warn(...msg: any[]): void {
this.parent.warn(this.name, ...msg);
}
public error(...msg: any[]): void {
this.parent.error(this.name, ...msg);
}
export interface PrefixedLogger extends Logger {
withPrefix: (prefix: string) => PrefixedLogger;
prefix: string;
}
/**
* A simplification of the `Debugger` type exposed by the `debug` library. We reimplement the bits we need here
* to avoid a dependency on `debug`.
*/
interface Debugger {
(formatter: any, ...args: any[]): void;
extend: (namespace: string, delimiter?: string) => Debugger;
function extendLogger(logger: Logger): void {
(<PrefixedLogger>logger).withPrefix = function (prefix: string): PrefixedLogger {
const existingPrefix = this.prefix || "";
return getPrefixedLogger(existingPrefix + prefix);
};
}
/**
* A `Logger` instance, suitable for use in {@link ICreateClientOpts.logger}, which will write to the `debug` library.
*
* @example
* ```js
* import debug from "debug";
*
* const client = createClient({
* baseUrl: homeserverUrl,
* userId: userId,
* accessToken: "akjgkrgjs",
* deviceId: "xzcvb",
* logger: new DebugLogger(debug(`matrix-js-sdk:${userId}`)),
* });
* ```
*/
export class DebugLogger implements Logger {
public constructor(private debugInstance: Debugger) {}
extendLogger(logger);
public trace(...msg: any[]): void {
this.debugWithPrefix("[TRACE]", ...msg);
}
public debug(...msg: any[]): void {
this.debugWithPrefix("[DEBUG]", ...msg);
}
public info(...msg: any[]): void {
this.debugWithPrefix("[INFO]", ...msg);
}
public warn(...msg: any[]): void {
this.debugWithPrefix("[WARN]", ...msg);
}
public error(...msg: any[]): void {
this.debugWithPrefix("[ERROR]", ...msg);
}
public getChild(namespace: string): DebugLogger {
return new DebugLogger(this.debugInstance.extend(namespace));
}
private debugWithPrefix(prefix: string, ...msg: any[]): void {
let formatter: string;
// Convert the first argument to a string, so that we can safely add a prefix. This is much the same logic that
// `debug()` uses.
if (msg.length === 0) {
formatter = "";
} else if (msg[0] instanceof Error) {
const err = msg.shift();
formatter = err.stack || err.message;
} else if (typeof msg[0] == "string") {
formatter = msg.shift();
} else {
formatter = "%O";
}
this.debugInstance(prefix + " " + formatter, ...msg);
function getPrefixedLogger(prefix: string): PrefixedLogger {
const prefixLogger = log.getLogger(`${DEFAULT_NAMESPACE}-${prefix}`) as PrefixedLogger;
if (prefixLogger.prefix !== prefix) {
// Only do this setup work the first time through, as loggers are saved by name.
extendLogger(prefixLogger);
prefixLogger.prefix = prefix;
prefixLogger.setLevel(log.levels.DEBUG, false);
}
return prefixLogger;
}

View File

@@ -14,104 +14,93 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type WidgetApi } from "matrix-widget-api";
import { WidgetApi } from "matrix-widget-api";
import { MemoryCryptoStore } from "./crypto/store/memory-crypto-store.ts";
import { MemoryStore } from "./store/memory.ts";
import { MatrixScheduler } from "./scheduler.ts";
import { MatrixClient, type ICreateClientOpts } from "./client.ts";
import { RoomWidgetClient, type ICapabilities } from "./embedded.ts";
import { type CryptoStore } from "./crypto/store/base.ts";
import { MemoryCryptoStore } from "./crypto/store/memory-crypto-store";
import { MemoryStore } from "./store/memory";
import { MatrixScheduler } from "./scheduler";
import { MatrixClient, ICreateClientOpts } from "./client";
import { RoomWidgetClient, ICapabilities } from "./embedded";
import { CryptoStore } from "./crypto/store/base";
export * from "./client.ts";
export * from "./serverCapabilities.ts";
export * from "./embedded.ts";
export * from "./http-api/index.ts";
export * from "./autodiscovery.ts";
export * from "./sync-accumulator.ts";
export * from "./errors.ts";
export * from "./base64.ts";
export * from "./models/beacon.ts";
export * from "./models/event.ts";
export * from "./models/room.ts";
export * from "./models/event-timeline.ts";
export * from "./models/event-timeline-set.ts";
export * from "./models/poll.ts";
export * from "./models/room-member.ts";
export * from "./models/room-state.ts";
export * from "./models/thread.ts";
export * from "./models/typed-event-emitter.ts";
export * from "./models/user.ts";
export * from "./models/device.ts";
export * from "./models/search-result.ts";
export * from "./oauth/index.ts";
export * from "./scheduler.ts";
export * from "./filter.ts";
export * from "./timeline-window.ts";
export * from "./interactive-auth.ts";
export * from "./version-support.ts";
export * from "./service-types.ts";
export * from "./store/memory.ts";
export * from "./store/indexeddb.ts";
export * from "./crypto/store/memory-crypto-store.ts";
export * from "./crypto/store/localStorage-crypto-store.ts";
export * from "./crypto/store/indexeddb-crypto-store.ts";
export type { OutgoingRoomKeyRequest } from "./crypto/store/base.ts";
export * from "./content-repo.ts";
export type * from "./@types/common.ts";
export type * from "./@types/uia.ts";
export * from "./@types/event.ts";
export * from "./@types/PushRules.ts";
export * from "./@types/partials.ts";
export * from "./@types/requests.ts";
export * from "./@types/search.ts";
export * from "./@types/beacon.ts";
export * from "./@types/topic.ts";
export * from "./@types/location.ts";
export * from "./@types/threepids.ts";
export * from "./@types/auth.ts";
export * from "./@types/polls.ts";
export * from "./@types/retention.ts";
export type * from "./@types/local_notifications.ts";
export type * from "./@types/registration.ts";
export * from "./@types/read_receipts.ts";
export type * from "./@types/crypto.ts";
export * from "./@types/extensible_events.ts";
export type * from "./@types/IIdentityServerProvider.ts";
export * from "./@types/membership.ts";
export * from "./models/room-summary.ts";
export * from "./models/event-status.ts";
export * from "./models/profile-keys.ts";
export * from "./models/related-relations.ts";
export { type StickyMatrixEvent, RoomStickyEventsEvent } from "./models/room-sticky-events.ts";
export type { RoomSummary } from "./client.ts";
export * as ContentHelpers from "./content-helpers.ts";
export * as SecretStorage from "./secret-storage.ts";
export { createNewMatrixCall, CallEvent } from "./webrtc/call.ts";
export type { MatrixCall } from "./webrtc/call.ts";
export * from "./client";
export * from "./embedded";
export * from "./http-api";
export * from "./autodiscovery";
export * from "./sync-accumulator";
export * from "./errors";
export * from "./models/beacon";
export * from "./models/event";
export * from "./models/room";
export * from "./models/event-timeline";
export * from "./models/event-timeline-set";
export * from "./models/poll";
export * from "./models/room-member";
export * from "./models/room-state";
export * from "./models/thread";
export * from "./models/typed-event-emitter";
export * from "./models/user";
export * from "./models/device";
export * from "./scheduler";
export * from "./filter";
export * from "./timeline-window";
export * from "./interactive-auth";
export * from "./service-types";
export * from "./store/memory";
export * from "./store/indexeddb";
export * from "./crypto/store/memory-crypto-store";
export * from "./crypto/store/indexeddb-crypto-store";
export type { OutgoingRoomKeyRequest } from "./crypto/store/base";
export * from "./content-repo";
export * from "./@types/event";
export * from "./@types/PushRules";
export * from "./@types/partials";
export * from "./@types/requests";
export * from "./@types/search";
export * from "./models/room-summary";
export * as ContentHelpers from "./content-helpers";
export * as SecretStorage from "./secret-storage";
export type { ICryptoCallbacks } from "./crypto"; // used to be located here
export { createNewMatrixCall } from "./webrtc/call";
export type { MatrixCall } from "./webrtc/call";
export {
GroupCall,
GroupCallEvent,
GroupCallIntent,
GroupCallState,
GroupCallType,
GroupCallStatsReportEvent,
} from "./webrtc/groupCall.ts";
} from "./webrtc/groupCall";
export type { GroupCall } from "./webrtc/groupCall";
export { CryptoEvent } from "./crypto";
export { SlidingSyncEvent } from "./sliding-sync";
export { MediaHandlerEvent } from "./webrtc/mediaHandler";
export { CallEvent } from "./webrtc/call";
export { CallFeedEvent } from "./webrtc/callFeed";
export { StatsReport } from "./webrtc/stats/statsReport";
export { RelationsEvent } from "./models/relations";
export { LocalStorageErrors } from "./store/local-storage-events-emitter";
export { SyncState, SetPresence } from "./sync.ts";
export type { ISyncStateData as SyncStateData } from "./sync.ts";
export { SlidingSyncEvent } from "./sliding-sync.ts";
export { MediaHandlerEvent } from "./webrtc/mediaHandler.ts";
export { CallFeedEvent } from "./webrtc/callFeed.ts";
export { StatsReport } from "./webrtc/stats/statsReport.ts";
export { Relations, RelationsEvent } from "./models/relations.ts";
export { TypedEventEmitter } from "./models/typed-event-emitter.ts";
export { LocalStorageErrors, localStorageErrorsEventsEmitter } from "./store/local-storage-events-emitter.ts";
export { IdentityProviderBrand, SSOAction } from "./@types/auth.ts";
export type { ISSOFlow as SSOFlow, LoginFlow } from "./@types/auth.ts";
export type { IHierarchyRelation as HierarchyRelation, IHierarchyRoom as HierarchyRoom } from "./@types/spaces.ts";
export { LocationAssetType } from "./@types/location.ts";
export { DebugLogger } from "./logger.ts";
/**
* Types supporting cryptography.
*
* The most important is {@link Crypto.CryptoApi}, an instance of which can be retrieved via
* {@link MatrixClient.getCrypto}.
*/
export * as Crypto from "./crypto-api";
/**
* Backwards compatibility re-export
* @internal
* @deprecated use {@link Crypto.CryptoApi}
*/
export type { CryptoApi } from "./crypto-api";
/**
* Backwards compatibility re-export
* @internal
* @deprecated use {@link Crypto.DeviceVerificationStatus}
*/
export { DeviceVerificationStatus } from "./crypto-api";
let cryptoStoreFactory = (): CryptoStore => new MemoryCryptoStore();
@@ -128,7 +117,7 @@ function amendClientOpts(opts: ICreateClientOpts): ICreateClientOpts {
opts.store =
opts.store ??
new MemoryStore({
localStorage: globalThis.localStorage,
localStorage: global.localStorage,
});
opts.scheduler = opts.scheduler ?? new MatrixScheduler();
opts.cryptoStore = opts.cryptoStore ?? cryptoStoreFactory();
@@ -150,26 +139,11 @@ export function createClient(opts: ICreateClientOpts): MatrixClient {
return new MatrixClient(amendClientOpts(opts));
}
/**
* Construct a Matrix Client that works in a widget.
* This client has a subset of features compared to a full client.
* It uses the widget-api to communicate with matrix. (widget \<-\> client \<-\> homeserver)
* @returns A new matrix client with a subset of features.
* @param opts - The configuration options for this client. These configuration
* options will be passed directly to {@link MatrixClient}.
* @param widgetApi - The widget api to use for communication.
* @param capabilities - The capabilities the widget client will request.
* @param roomId - The room id the widget is associated with.
* @param sendContentLoaded - Whether to send a content loaded widget action immediately after initial setup.
* Set to `false` if the widget uses `waitForIFrameLoad=true` (in this case the client does not expect a content loaded action at all),
* or if the the widget wants to send the `ContentLoaded` action at a later point in time after the initial setup.
*/
export function createRoomWidgetClient(
widgetApi: WidgetApi,
capabilities: ICapabilities,
roomId: string,
opts: ICreateClientOpts,
sendContentLoaded = true,
): MatrixClient {
return new RoomWidgetClient(widgetApi, capabilities, roomId, amendClientOpts(opts), sendContentLoaded);
return new RoomWidgetClient(widgetApi, capabilities, roomId, amendClientOpts(opts));
}

View File

@@ -1,438 +0,0 @@
/*
Copyright 2023-2026 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 { deepCompare } from "../utils.ts";
import { type RTCCallIntent, type Transport, type SlotDescription } from "./types.ts";
import { type MatrixEvent } from "../models/event.ts";
import { type Logger, logger } from "../logger.ts";
import { computeSlotId, slotIdToDescription } from "./utils.ts";
import {
checkRtcMembershipData,
computeRtcIdentityRaw,
type RtcMembershipData,
checkSessionsMembershipData,
type SessionMembershipData,
MatrixRTCMembershipParseError,
} from "./membershipData/index.ts";
import { EventType } from "../@types/event.ts";
/**
* The default duration in milliseconds that a membership is considered valid for.
* Ordinarily the client responsible for the session will update the membership before it expires.
* We use this duration as the fallback case where stale sessions are present for some reason.
*/
export const DEFAULT_EXPIRE_DURATION = 1000 * 60 * 60 * 4;
/**
* Describes the source event type that provided the membership data.
*/
enum MembershipKind {
/**
* The modern MSC4143 format event.
*/
RTC = "rtc",
/**
* The legacy call event type.
*/
Session = "session",
}
type MembershipData =
| { kind: MembershipKind.RTC; data: RtcMembershipData }
| { kind: MembershipKind.Session; data: SessionMembershipData };
type LimitedEvent = Pick<MatrixEvent, "getId" | "getSender" | "getTs" | "getType" | "getContent">;
// TODO: Rename to RtcMembership once we removed the legacy SessionMembership is removed, to avoid confusion.
export class CallMembership {
/**
* Parse the membershipdata from a call membership event.
* @param matrixEvent The Matrix event to read.
* @returns MembershipData in either MembershipKind.RTC or MembershipKind.Session format.
* @throws If the content is neither format.
*/
public static membershipDataFromMatrixEvent(matrixEvent: LimitedEvent): MembershipData {
const sender = matrixEvent.getSender();
const evType = matrixEvent.getType();
const data = matrixEvent.getContent();
if (sender === undefined) throw new Error("matrixEvent is missing sender field");
try {
// Event types are strictly checked here.
if (evType === EventType.RTCMembership && checkRtcMembershipData(data, sender)) {
return { kind: MembershipKind.RTC, data };
} else if (evType === EventType.GroupCallMemberPrefix && checkSessionsMembershipData(data)) {
return { kind: MembershipKind.Session, data };
} else {
throw Error(`'${evType} is not a known call membership type`);
}
} catch (ex) {
if (ex instanceof MatrixRTCMembershipParseError) {
logger.debug("CallMembership.MatrixRTCMembershipParseError provided invalid data", data);
}
throw ex;
}
}
/**
* Parse the contents of a MatrixEvent and create a CallMembership instance.
* @param matrixEvent The Matrix event to read.
*/
public static async parseFromEvent(matrixEvent: LimitedEvent): Promise<CallMembership> {
const membershipData: MembershipData = this.membershipDataFromMatrixEvent(matrixEvent);
const rtcBackendIdentity =
membershipData.kind === MembershipKind.RTC
? await computeRtcIdentityRaw(
membershipData.data.member.user_id,
membershipData.data.member.device_id,
membershipData.data.member.id,
)
: `${matrixEvent.getSender()}:${membershipData.data.device_id}`;
return new CallMembership(matrixEvent, membershipData, rtcBackendIdentity);
}
public static equal(a?: CallMembership, b?: CallMembership): boolean {
return deepCompare(a?.membershipData, b?.membershipData);
}
private logger: Logger;
/** The parsed data from the Matrix event.
* To access checked eventId and sender from the matrixEvent.
* Class construction will fail if these values cannot get obtained. */
private readonly matrixEventData: { eventId: string; sender: string };
/**
* Use `parseFromEvent`.
* Constructor should only be used by tests.
* @private
* @param matrixEvent
* @param membershipData
* @param rtcBackendIdentity
*/
public constructor(
/** The Matrix event that this membership is based on */
private readonly matrixEvent: LimitedEvent,
private readonly membershipData: MembershipData,
public readonly rtcBackendIdentity: string,
) {
const eventId = matrixEvent.getId();
const sender = matrixEvent.getSender();
if (eventId === undefined) throw new Error("parentEvent is missing eventId field");
if (sender === undefined) throw new Error("parentEvent is missing sender field");
this.logger = logger.getChild(`[CallMembership ${sender}:${this.deviceId}]`);
this.matrixEventData = { eventId, sender };
}
/** @deprecated use userId instead */
public get sender(): string {
return this.userId;
}
public get userId(): string {
const { kind, data } = this.membershipData;
switch (kind) {
case MembershipKind.RTC:
return data.member.user_id;
case MembershipKind.Session:
default:
return this.matrixEventData.sender;
}
}
public get eventId(): string {
return this.matrixEventData.eventId;
}
/**
* The ID of the MatrixRTC slot that this membership belongs to (format `{application}#{id}`).
* This is computed in case SessionMembershipData is used.
*/
public get slotId(): string {
const { kind, data } = this.membershipData;
if (data.application === "m.call") {
switch (kind) {
case MembershipKind.RTC:
return data.slot_id;
case MembershipKind.Session:
default: {
const [application, id] = [data.application, data.call_id];
// INFO_SLOT_ID_LEGACY_CASE (search for all occurances of this INFO to get the full picture)
// The spec got changed to use `"ROOM"` instead of `""` empyt string for the implicit default call.
// State events still are sent with `""` however. To find other events that should end up in the same call,
// we use the slotId.
// Since the CallMembership is the public representation of a rtc.member event, we just pretend it is a
// "ROOM" slotId/call_id.
// This makes all the remote members work with just this simple trick.
//
// We of course now need to be careful when sending legacy events (state events)
// They get a slotDescription containing "ROOM" since this is what we use starting at the time this comment
// is commited.
//
// See the Other INFO_SLOT_ID_LEGACY_CASE comments to see where we revert back to "" just before sending the event.
let compatibilityAdaptedId: string;
if (id === "") {
compatibilityAdaptedId = "ROOM";
this.logger?.info("use slotId compat hack emptyString -> ROOM");
} else {
compatibilityAdaptedId = id;
}
return computeSlotId({
application,
id: compatibilityAdaptedId,
});
}
}
}
this.logger?.info("NOT using slotId compat hack emptyString -> ROOM");
// This is what the function should look like for any other application that did not
// go through a `""`=> `"ROOM"` rename
switch (kind) {
case MembershipKind.RTC:
return data.slot_id;
case MembershipKind.Session:
default:
return computeSlotId({ application: data.application, id: data.call_id });
}
}
public get deviceId(): string {
const { kind, data } = this.membershipData;
switch (kind) {
case MembershipKind.RTC:
return data.member.device_id;
case MembershipKind.Session:
default:
return data.device_id;
}
}
public get callIntent(): RTCCallIntent | undefined {
const intent = this.applicationData["m.call.intent"];
if (typeof intent === "string") {
return intent;
}
this.logger.warn("RTC membership has invalid m.call.intent");
return undefined;
}
/**
* Parsed `slot_id` (format `{application}#{id}`) into its components (application and id).
*/
public get slotDescription(): SlotDescription {
const { kind, data } = this.membershipData;
if (kind === MembershipKind.RTC) {
const id = data.slot_id.slice(`${data.application.type}#`.length);
return { application: data.application.type, id };
}
return slotIdToDescription(this.slotId);
}
/**
* The application `type`.
* @deprecated Use @see applicationData
*/
public get application(): string {
return this.applicationData.type;
}
/**
* Information about the application being used for the RTC session.
* May contain extra keys specific to the application.
*/
public get applicationData(): { type: string; [key: string]: unknown } {
const { kind, data } = this.membershipData;
switch (kind) {
case MembershipKind.RTC:
return data.application;
case MembershipKind.Session:
default:
// SessionData does not have application data as such. We return specific
// properties in use by other getters in this class, for compatibility.
return { "type": data.application, "m.call.intent": data["m.call.intent"] };
}
}
/** @deprecated scope is not used and will be removed in future versions. replaced by application specific types.*/
public get scope(): SessionMembershipData["scope"] | undefined {
const { kind, data } = this.membershipData;
switch (kind) {
case MembershipKind.RTC:
return undefined;
case MembershipKind.Session:
default:
return data.scope;
}
}
/**
* This computes the membership ID for the membership.
* For the sticky event based rtcSessionData this is trivial it is `member.id`.
* This is not supposed to be used to identity on an rtc backend. This is just a nouance for
* a generated (sha256) anonymised identity. Only send `rtcBackendIdentity` to any rtc backend service.
*
* For the legacy sessionMemberEvents it is a bit more complex. Here we sometimes do not have this data
* in the event content and we expected the SFU and the client to use `${this.matrixEventData.sender}:${data.device_id}`.
*
* So if there is no membershipID we use the hard coded jwt id default (`${this.matrixEventData.sender}:${data.device_id}`)
* value (used until version 0.16.0)
*
* It is also possible for a session event to set a custom membershipID. in that case this will be used.
*/
public get memberId(): string {
// the createdTs behaves equivalent to the membershipID.
// we only need the field for the legacy member events where we needed to update them
// synapse ignores sending state events if they have the same content.
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
return data.member.id;
case "session":
return (
// best case we have a client already publishing the right custom membershipId
data.membershipID ??
// alternativly we use the hard coded jwt id defuatl value (used until version 0.16.0)
`${this.matrixEventData.sender}:${data.device_id}`
);
default:
throw Error("Not possible to get memberID without knowing the membership event kind");
}
}
/**
* @deprecated renamed to `memberId`
*/
public get membershipID(): string {
return this.memberId;
}
public createdTs(): number {
const { kind, data } = this.membershipData;
switch (kind) {
case MembershipKind.RTC:
// TODO we need to read the referenced (relation) event if available to get the real created_ts
return this.matrixEvent.getTs();
case MembershipKind.Session:
default:
return data.created_ts ?? this.matrixEvent.getTs();
}
}
/**
* Gets the absolute expiry timestamp of the membership.
* @returns The absolute expiry time of the membership as a unix timestamp in milliseconds or undefined if not applicable
*/
public getAbsoluteExpiry(): number | undefined {
const { kind, data } = this.membershipData;
switch (kind) {
case MembershipKind.RTC:
return undefined;
case MembershipKind.Session:
default:
// TODO: calculate this from the MatrixRTCSession join configuration directly
return this.createdTs() + (data.expires ?? DEFAULT_EXPIRE_DURATION);
}
}
/**
* @returns The number of milliseconds until the membership expires or undefined if applicable
* @deprecated Not used by RTC events.
*/
public getMsUntilExpiry(): number | undefined {
const { kind } = this.membershipData;
if (kind === MembershipKind.Session) {
const absExpiry = this.getAbsoluteExpiry();
if (absExpiry) {
// Assume that local clock is sufficiently in sync with other clocks in the distributed system.
// We used to try and adjust for the local clock being skewed, but there are cases where this is not accurate.
// The current implementation allows for the local clock to be -infinity to +MatrixRTCSession.MEMBERSHIP_EXPIRY_TIME/2
return absExpiry - Date.now();
}
}
return undefined;
}
/**
* @returns true if the membership has expired, otherwise false
*/
public isExpired(): boolean {
const { kind } = this.membershipData;
switch (kind) {
case MembershipKind.RTC:
return false;
case MembershipKind.Session:
default:
return this.getMsUntilExpiry()! <= 0;
}
}
/**
* ## RTC Membership
* Gets the primary transport to use for this RTC membership (m.rtc.member).
* This will return the primary transport that is used by this call membership to publish their media.
* Directly relates to the `transports.published` field.
*
* ## Legacy session membership
* In case of a legacy session membership (m.call.member) this will return the selected transport where
* media is published. How this selection happens depends on the `focus_active` field of the session membership.
* If the `focus_selection` is `oldest_membership` this will return the transport of the oldest membership
* in the room (based on the `created_ts` field of the session membership).
* If the `focus_selection` is `multi_sfu` it will return the first transport of the `foci_preferred` list.
* (`multi_sfu` is equivalent to how `m.rtc.member` `transports.published` work).
* @param oldestMembership For backwards compatibility with session membership (legacy). Unused in case of RTC membership.
* Always required to make the consumer not care if it deals with RTC or session memberships.
* @returns The transport this membership uses to publish media or undefined if no transport is available.
*/
public getTransport(oldestMembership: CallMembership): Transport | undefined {
const { kind, data } = this.membershipData;
switch (kind) {
case MembershipKind.RTC:
return data.transports.published[0];
case MembershipKind.Session:
switch (data.focus_active.focus_selection) {
case "oldest_membership":
if (CallMembership.equal(this, oldestMembership)) return data.foci_preferred[0];
if (oldestMembership !== undefined) return oldestMembership.getTransport(oldestMembership);
break;
case "multi_sfu":
return data.foci_preferred[0];
default:
// `focus_selection` not understood.
return undefined;
}
break;
default:
return undefined;
}
}
/**
* The value of the `transports.published` field for RTC memberships (m.rtc.member).
* Or the value of the `foci_preferred` field for legacy session memberships (m.call.member).
*/
public get transports(): Transport[] {
const { kind, data } = this.membershipData;
switch (kind) {
case MembershipKind.RTC:
return data.transports.published;
case MembershipKind.Session:
default:
return data.foci_preferred;
}
}
}

View File

@@ -1,54 +0,0 @@
import { type EncryptionConfig } from "./MatrixRTCSession.ts";
import { type CallMembership } from "./CallMembership.ts";
import { type EncryptionKeyMapKey } from "./types.ts";
/**
* The string used for the keys in the the encryption key map.
* `@bob:examle.org:DEVICEID(UUIDRANDOM_MEMBERID_RANDOMUUID)`
*/
export function getEncryptionKeyMapKey(membership: CallMembershipIdentityParts): EncryptionKeyMapKey {
return `${membership.userId}:${membership.deviceId}(${membership.memberId})`;
}
/**
* This interface is for testing and for making it possible to interchange the encryption manager.
* @internal
*/
export interface IEncryptionManager {
/**
* Joins the encryption manager with the provided configuration.
*
* @param joinConfig - The configuration for joining encryption, or undefined
* if no specific configuration is provided.
*/
join(joinConfig: EncryptionConfig | undefined): void;
/**
* Leaves the encryption manager, cleaning up any associated resources.
*/
leave(): void;
/**
* Called from the MatrixRTCSession when the memberships in this session updated.
*
* @param oldMemberships - The previous state of call memberships before the update.
*/
onMembershipsUpdate(oldMemberships: CallMembership[]): void;
/**
* Retrieves the encryption keys currently managed by the encryption manager.
*
* @returns A map of participant IDs to their encryption keys.
*/
getEncryptionKeys(): ReadonlyMap<
EncryptionKeyMapKey,
ReadonlyArray<{
key: Uint8Array<ArrayBuffer>;
keyIndex: number;
membership: CallMembershipIdentityParts;
rtcBackendIdentity: string;
}>
>;
}
export type CallMembershipIdentityParts = Pick<CallMembership, "userId" | "deviceId" | "memberId">;

View File

@@ -1,63 +0,0 @@
/*
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.
*/
import { type CallMembershipIdentityParts } from "./EncryptionManager.ts";
import { type ParticipantDeviceInfo } from "./types.ts";
export enum KeyTransportEvents {
ReceivedKeys = "received_keys",
NotSupportedError = "not_supported_error",
}
export type KeyTransportEventsHandlerMap = {
[KeyTransportEvents.ReceivedKeys]: KeyTransportEventListener;
[KeyTransportEvents.NotSupportedError]: () => void;
};
export type KeyTransportEventListener = (
membership: CallMembershipIdentityParts,
keyBase64Encoded: string,
index: number,
timestamp: number,
) => void;
/**
* Generic interface for the transport used to share room keys.
* Keys can be shared using different transports, e.g. to-device messages or room messages.
*/
export interface IKeyTransport {
/**
* Sends the current user media key to the given members.
* @param keyBase64Encoded
* @param index
* @param members - The participants that should get they key
*/
sendKey(keyBase64Encoded: string, index: number, members: ParticipantDeviceInfo[]): Promise<void>;
/** Subscribe to keys from this transport. */
on(event: KeyTransportEvents.ReceivedKeys, listener: KeyTransportEventListener): this;
/** Unsubscribe from keys from this transport. */
off(event: KeyTransportEvents.ReceivedKeys, listener: KeyTransportEventListener): this;
/** Once start is called the underlying transport will subscribe to its transport system.
* Before start is called this transport will not emit any events.
*/
start(): void;
/** Once stop is called the underlying transport will unsubscribe from its transport system.
* After stop is called this transport will not emit any events.
*/
stop(): void;
}

View File

@@ -1,120 +0,0 @@
/*
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.
*/
import type { CallMembership } from "./CallMembership.ts";
import type { RTCCallIntent, Status, Transport } from "./types.ts";
import { type TypedEventEmitter } from "../models/typed-event-emitter.ts";
export enum MembershipManagerEvent {
StatusChanged = "StatusChanged",
/**
* Emitted when the membership manager has not heard back from the server for the duration
* of the delayed event and hence failed to restart the delayed event.
* This means that the user is probably not joined anymore and the leave event was distributed to other session members.
*/
ProbablyLeft = "ProbablyLeft",
/**
* Once the membershipManger has aquired the a delay id (after sending the state event)
* It will emit and share the delay id.
*/
DelayIdChanged = "DelayIdChanged",
}
export type MembershipManagerEventHandlerMap = {
[MembershipManagerEvent.StatusChanged]: (prefStatus: Status, newStatus: Status) => void;
[MembershipManagerEvent.ProbablyLeft]: (probablyLeft: boolean) => void;
[MembershipManagerEvent.DelayIdChanged]: (delayId: string | undefined) => void;
};
/**
* This interface defines what a MembershipManager uses and exposes.
* This interface is what we use to write tests and allows changing the actual implementation
* without breaking tests because of some internal method renaming.
*
* @internal
*/
export interface IMembershipManager extends TypedEventEmitter<
MembershipManagerEvent,
MembershipManagerEventHandlerMap
> {
/**
* If we are trying to join, or have successfully joined the session.
* It does not reflect if the room state is already configured to represent us being joined.
* It only means that the Manager should be trying to connect or to disconnect running.
* The Manager is still running right after isJoined becomes false to send the disconnect events.
* @returns true if we intend to be participating in the MatrixRTC session
* @deprecated This name is confusing and replaced by `isActivated()`. (Returns the same as `isActivated()`)
*/
isJoined(): boolean;
/**
* If the manager is activated. This means it tries to do its job to join the call, resend state events...
* It does not imply that the room state is already configured to represent being joined.
* It means that the Manager tries to connect or is connected. ("the manager is still active")
* Once `leave()` is called the manager is not activated anymore but still running until `leave()` resolves.
* @returns `true` if we intend to be participating in the MatrixRTC session
*/
isActivated(): boolean;
/**
* Get the actual connection status of the manager.
*/
get status(): Status;
/**
* The Current own state event if the manger is connected.
* `undefined` if not connected.
*/
get ownMembership(): CallMembership | undefined;
/**
* If the membership manager has reason to believe that the hs sent a leave event
* and as a consequence the current user is perceived as left for other session participants.
*/
get probablyLeft(): boolean;
/**
* If the membership manager has reason to believe that the hs sent a leave event
* and as a consequence the current user is perceived as left for other session participants.
*/
get delayId(): string | undefined;
/**
* Start sending all necessary events to make this user participate in the RTC session.
* @param fociPreferred the list of preferred foci to use in the joined RTC membership event.
* If multiSfuFocus is set, this is only needed if this client wants to publish to multiple transports simultaneously.
* @param multiSfuFocus the active focus to use in the joined RTC membership event. Setting this implies the
* membership manager will operate in a multi-SFU connection mode. If `undefined`, an `oldest_membership`
* transport selection will be used instead.
* @throws can throw if it exceeds a configured maximum retry.
*/
join(fociPreferred: Transport[], multiSfuFocus?: Transport, onError?: (error: unknown) => void): void;
/**
* Send all necessary events to make this user leave the RTC session.
* @param timeout the maximum duration in ms until the promise is forced to resolve.
* @returns It resolves with true in case the leave was sent successfully.
* It resolves with false in case we hit the timeout before sending successfully.
*/
leave(timeout?: number): Promise<boolean>;
/**
* Call this if the MatrixRTC session members have changed.
*/
onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void>;
/**
* Update the intent of a membership on the call (e.g. user is now providing a video feed)
* @param callIntent The new intent to set.
*/
updateCallIntent(callIntent: RTCCallIntent): Promise<void>;
}

View File

@@ -1,46 +0,0 @@
/*
Copyright 2025 New Vector Ltd
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 { type Transport } from "./types.ts";
export interface LivekitTransportConfig extends Transport {
type: "livekit";
livekit_service_url: string;
}
export const isLivekitTransportConfig = (object: any): object is LivekitTransportConfig =>
object.type === "livekit" && "livekit_service_url" in object;
export interface LivekitTransport extends LivekitTransportConfig {
livekit_alias: string;
}
export const isLivekitTransport = (object: any): object is LivekitTransport =>
isLivekitTransportConfig(object) && "livekit_alias" in object;
/**
* @deprecated this is just needed for the old focus active / focus fields of a call membership.
* Not needed for new implementations.
*/
export interface LivekitFocusSelection extends Transport {
type: "livekit";
focus_selection: "oldest_membership" | "multi_sfu";
}
/**
* @deprecated see LivekitFocusSelection
*/
export const isLivekitFocusSelection = (object: any): object is LivekitFocusSelection =>
object.type === "livekit" && "focus_selection" in object;

File diff suppressed because it is too large Load Diff

View File

@@ -1,189 +0,0 @@
/*
Copyright 2023-2026 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 { type Logger } from "../logger.ts";
import { type MatrixClient, ClientEvent } from "../client.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { type Room } from "../models/room.ts";
import { RoomStateEvent } from "../models/room-state.ts";
import { type MatrixEvent } from "../models/event.ts";
import { MatrixRTCSession } from "./MatrixRTCSession.ts";
import { EventType } from "../@types/event.ts";
import { type RtcSlotEventContent, type SlotDescription } from "./types.ts";
import { computeSlotId } from "./utils.ts";
export enum MatrixRTCSessionManagerEvents {
// A member has joined the MatrixRTC session, creating an active session in a room where there wasn't previously
SessionStarted = "session_started",
// All participants have left a given MatrixRTC session.
SessionEnded = "session_ended",
}
type EventHandlerMap = {
[MatrixRTCSessionManagerEvents.SessionStarted]: (roomId: string, session: MatrixRTCSession) => void;
[MatrixRTCSessionManagerEvents.SessionEnded]: (roomId: string, session: MatrixRTCSession) => void;
};
/**
* Holds all active MatrixRTC session objects and creates new ones as events arrive.
* One `MatrixRTCSessionManager` is required for each MatrixRTC sessionDescription (application, session id) that the client wants to support.
* If no application type is specified in the constructor, the default is "m.call".
*
* This interface is UNSTABLE and may change without warning.
*/
export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionManagerEvents, EventHandlerMap> {
// All the room-scoped sessions we know about. This will include any where the app
// has queried for the MatrixRTC sessions in a room, whether it's ever had any members
// or not). We keep a (lazily created) session object for every room to ensure that there
// is only ever one single room session object for any given room for the lifetime of the
// client: that way there can never be any code holding onto a stale object that is no
// longer the correct session object for the room.
private roomSessions = new Map<string, MatrixRTCSession>();
private readonly logger: Logger;
public constructor(
rootLogger: Logger,
private client: MatrixClient,
private readonly slotDescription: SlotDescription = { application: "m.call", id: "ROOM" }, // Default to the Matrix Call application
) {
super();
this.logger = rootLogger.getChild(`[MatrixRTCSessionManager ${computeSlotId(slotDescription)}]`);
}
public start(): void {
// We shouldn't need to null-check here, but matrix-client.spec.ts mocks getRooms
// returning nothing, and breaks tests if you change it to return an empty array :'(
for (const room of this.client.getRooms() ?? []) {
const session = MatrixRTCSession.sessionForSlot(this.client, room, this.slotDescription);
if (session.memberships.length > 0) {
this.roomSessions.set(room.roomId, session);
}
}
this.client.on(ClientEvent.Room, this.onRoom);
this.client.on(ClientEvent.Event, this.onEvent);
this.client.on(RoomStateEvent.Events, this.onRoomState);
}
public stop(): void {
for (const sess of this.roomSessions.values()) {
void sess.stop();
}
this.roomSessions.clear();
this.client.off(ClientEvent.Room, this.onRoom);
this.client.off(ClientEvent.Event, this.onEvent);
this.client.off(RoomStateEvent.Events, this.onRoomState);
}
/**
* Gets the main MatrixRTC session for a room, or undefined if there is
* no current session
*/
public getActiveRoomSession(room: Room): MatrixRTCSession | undefined {
return this.roomSessions.get(room.roomId)!;
}
/**
* Reads the current slot state event's content for the given room's session.
*
* @returns The slot event's content, or `undefined` if no slot event exists for the session.
*/
public getRtcSlot(room: Room): RtcSlotEventContent | undefined {
return this.getRoomSession(room).getRtcSlot();
}
/**
* Whether the given room's slot is closed.
*
* @returns `true` if the slot is closed, `false` if the slot is open or `undefined`
* if no slot exists.
*/
public isSlotClosed(room: Room): boolean | undefined {
return this.getRoomSession(room).isSlotClosed();
}
/**
* Gets the main MatrixRTC session for a room, returning an empty session
* if no members are currently participating
*/
public getRoomSession(room: Room): MatrixRTCSession {
if (!this.roomSessions.has(room.roomId)) {
this.roomSessions.set(
room.roomId,
MatrixRTCSession.sessionForSlot(this.client, room, this.slotDescription),
);
}
return this.roomSessions.get(room.roomId)!;
}
private onRoom = (room: Room): void => {
void this.refreshRoom(room);
};
private readonly onEvent = (event: MatrixEvent): void => {
if (!event.unstableStickyExpiresAt) return; // Not sticky, not interested.
if (event.getType() !== EventType.RTCMembership) return;
const room = this.client.getRoom(event.getRoomId());
if (!room) return;
void this.refreshRoom(room);
};
private readonly onRoomState = (event: MatrixEvent): void => {
if (event.getType() !== EventType.GroupCallMemberPrefix && event.getType() !== EventType.RTCSlot) {
return;
}
const room = this.client.getRoom(event.getRoomId());
if (!room) {
this.logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
return;
}
void this.refreshRoom(room);
};
private async refreshRoom(room: Room): Promise<void> {
const isNewSession = !this.roomSessions.has(room.roomId);
const session = this.getRoomSession(room);
const wasActiveAndKnown = session.memberships.length > 0 && !isNewSession;
// This needs to be here and the event listener cannot be setup in the MatrixRTCSession,
// because we need the update to happen between:
// wasActiveAndKnown = session.memberships.length > 0 and
// nowActive = session.memberships.length
// Alternatively we would need to setup some event emission when the RTC session ended.
// TODO we want to add the emission en session end. This makes the responsibility of the session manager more clear.
await session._onRTCSessionMemberUpdate().catch((error) => {
this.logger.error(`Error updating RTC session members for ${room.roomId}: ${error}`);
});
const nowActive = session.memberships.length > 0;
if (wasActiveAndKnown && !nowActive) {
this.logger.trace(`Session ended for ${room.roomId} (${session.memberships.length} members)`);
this.emit(MatrixRTCSessionManagerEvents.SessionEnded, room.roomId, this.roomSessions.get(room.roomId)!);
} else if (!wasActiveAndKnown && nowActive) {
this.logger.trace(`Session started for ${room.roomId} (${session.memberships.length} members)`);
this.emit(MatrixRTCSessionManagerEvents.SessionStarted, room.roomId, this.roomSessions.get(room.roomId)!);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,138 +0,0 @@
import { type Logger, logger as rootLogger } from "../logger.ts";
import { type EmptyObject } from "../matrix.ts";
import { sleep } from "../utils.ts";
import { MembershipActionType } from "./MembershipManager.ts";
/** @internal */
export interface Action {
/**
* When this action should be executed
*/
ts: number;
/**
* The state of the different loops
* can also be thought of as the type of the action
*/
type: MembershipActionType;
}
/** @internal */
export type ActionUpdate =
| {
/** Replace all existing scheduled actions with this new array */
replace: Action[];
}
| {
/** Add these actions to the existing scheduled actions */
insert: Action[];
}
| EmptyObject;
/**
* This scheduler tracks the state of the current membership participation
* and runs one central timer that wakes up a handler callback with the correct action + state
* whenever necessary.
*
* It can also be awakened whenever a new action is added which is
* earlier then the current "next awake".
* @internal
*/
export class ActionScheduler {
private logger: Logger;
/**
* This is tracking the state of the scheduler loop.
* Only used to prevent starting the loop twice.
*/
public running = false;
public constructor(
/** This is the callback called for each scheduled action (`this.addAction()`) */
private membershipLoopHandler: (type: MembershipActionType) => Promise<ActionUpdate>,
parentLogger?: Logger,
) {
this.logger = (parentLogger ?? rootLogger).getChild(`[NewMembershipActionScheduler]`);
}
// function for the wakeup mechanism (in case we add an action externally and need to leave the current sleep)
private wakeup: (update: ActionUpdate) => void = (update: ActionUpdate): void => {
this.logger.error("Cannot call wakeup before calling `startWithJoin()`");
};
private _actions: Action[] = [];
public get actions(): Action[] {
return this._actions;
}
/**
* This starts the main loop of the membership manager that handles event sending, delayed event sending and delayed event restarting.
* @param initialActions The initial actions the manager will start with. It should be enough to pass: DelayedLeaveActionType.Initial
* @returns Promise that resolves once all actions have run and no more are scheduled.
* @throws This throws an error if one of the actions throws.
* In most other error cases the manager will try to handle any server errors by itself.
*/
public async startWithJoin(): Promise<void> {
if (this.running) {
this.logger.error("Cannot call startWithJoin() on NewMembershipActionScheduler while already running");
return;
}
this.running = true;
this._actions = [{ ts: Date.now(), type: MembershipActionType.SendDelayedEvent }];
try {
while (this._actions.length > 0) {
// Sort so next (smallest ts) action is at the beginning
this._actions.sort((a, b) => a.ts - b.ts);
const nextAction = this._actions[0];
let wakeupUpdate: ActionUpdate | undefined = undefined;
// while we await for the next action, wakeup has to resolve the wakeupPromise
const wakeupPromise = new Promise<void>((resolve) => {
this.wakeup = (update: ActionUpdate): void => {
wakeupUpdate = update;
resolve();
};
});
if (nextAction.ts > Date.now()) await Promise.race([wakeupPromise, sleep(nextAction.ts - Date.now())]);
let handlerResult: ActionUpdate = {};
if (!wakeupUpdate) {
this.logger.debug(
`Current MembershipManager processing: ${nextAction.type}\nQueue:`,
this._actions,
`\nDate.now: "${Date.now()}`,
);
try {
// `this.wakeup` can also be called and sets the `wakeupUpdate` object while we are in the handler.
handlerResult = await this.membershipLoopHandler(nextAction.type);
} catch (e) {
// Preserve the original error as `cause`.
throw new Error(`The MembershipManager shut down because of the end condition: ${e}`, {
cause: e,
});
}
}
// remove the processed action only after we are done processing
this._actions.splice(0, 1);
// The wakeupUpdate always wins since that is a direct external update.
const actionUpdate = wakeupUpdate ?? handlerResult;
if ("replace" in actionUpdate) {
this._actions = actionUpdate.replace;
} else if ("insert" in actionUpdate) {
this._actions.push(...actionUpdate.insert);
}
}
} finally {
// Set the rtc session running state since we cannot recover from here and the consumer user of the
// MatrixRTCSession class needs to manually rejoin.
this.running = false;
}
this.logger.debug("Leave MembershipManager ActionScheduler loop (no more actions)");
}
public initiateJoin(): void {
this.wakeup?.({ replace: [{ ts: Date.now(), type: MembershipActionType.SendDelayedEvent }] });
}
public initiateLeave(): void {
this.wakeup?.({ replace: [{ ts: Date.now(), type: MembershipActionType.SendScheduledDelayedLeaveEvent }] });
}
}

View File

@@ -1,459 +0,0 @@
/*
Copyright 2025-2026 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 {
type CallMembershipIdentityParts,
getEncryptionKeyMapKey,
type IEncryptionManager,
} from "./EncryptionManager.ts";
import { type EncryptionConfig, type MembershipConfig } from "./MatrixRTCSession.ts";
import type { CallMembership } from "./CallMembership.ts";
import { decodeBase64, encodeBase64 } from "../base64.ts";
import { type IKeyTransport, type KeyTransportEventListener, KeyTransportEvents } from "./IKeyTransport.ts";
import { type Logger } from "../logger.ts";
import { sleep } from "../utils.ts";
import {
type EncryptionKeyMapKey,
type InboundEncryptionSession,
type OutboundEncryptionSession,
type ParticipantDeviceInfo,
} from "./types.ts";
import { OutdatedKeyFilter } from "./utils.ts";
import { computeRtcIdentityRaw } from "./membershipData/rtc.ts";
/**
* RTCEncryptionManager is used to manage the encryption keys for a call.
*
* It is responsible for distributing the keys to the other participants and rotating the keys if needed.
*
* This manager when used with to-device transport will share the existing key only to new joiners, and rotate
* if there is a leaver.
*
* XXX In the future we want to distribute a ratcheted key not the current one for new joiners.
*/
export class RTCEncryptionManager implements IEncryptionManager {
// This is a stop-gap solution for now. The preferred way to handle this case would be instead
// to create a NoOpEncryptionManager that does nothing and use it for the session.
// This will be done when removing the legacy EncryptionManager.
private manageMediaKeys = false;
private useHashedRtcBackendIdentity = false;
private ownRtcBackendIdentityCache: string | undefined;
/**
* Store the key rings for each participant.
* The encryption manager stores the keys because the application layer might not be ready yet to handle the keys.
* The keys are stored and can be retrieved later when the application layer is ready {@link RTCEncryptionManager#getEncryptionKeys}.
*/
private readonly participantKeyRings = new Map<
EncryptionKeyMapKey,
Array<{
key: Uint8Array<ArrayBuffer>;
keyIndex: number;
membership: CallMembershipIdentityParts;
rtcBackendIdentity: string;
}>
>();
// The current per-sender media key for this device
private outboundSession: OutboundEncryptionSession | null = null;
/**
* Ensures that there is only one distribute operation at a time for that call.
*/
private currentKeyDistributionPromise: Promise<void> | null = null;
/**
* The time to wait before using the outbound session after it has been distributed.
* This is to ensure that the key is delivered to all participants before it is used.
* When creating the first key, this is set to 0 so that the key can be used immediately.
*/
private useKeyDelay = 5000;
/**
* We want to avoid rolling out a new outbound key when the previous one was created less than `keyRotationGracePeriodMs` milliseconds ago.
* This is to avoid expensive key rotations when users quickly join the call in a row.
*
* This must be higher than `useKeyDelay` to have an effect.
* If it is lower, the current key will always be older than the grace period.
* @private
*/
private keyRotationGracePeriodMs = 10_000;
/**
* If a new key distribution is being requested while one is going on, we will set this flag to true.
* This will ensure that a new round is started after the current one.
* @private
*/
private needToEnsureKeyAgain = false;
/**
* There is a possibility that keys arrive in the wrong order.
* For example, after a quick join/leave/join, there will be 2 keys of index 0 distributed, and
* if they are received in the wrong order, the stream won't be decryptable.
* For that reason we keep a small buffer of keys for a limited time to disambiguate.
* @private
*/
private keyBuffer = new OutdatedKeyFilter();
private logger: Logger | undefined = undefined;
private readonly rtcIdentityProvider: (userId: string, deviceId: string, memberId: string) => Promise<string>;
/**
*
* @param ownMembership - our own membership info
* @param getMemberships - function to get current memberships
* @param transport - key transport (room or to-device)
* @param statistics - statistics collector
* @param onEncryptionKeysChanged - callback to notify the media layer of new keys
* @param parentLogger - optional parent logger
* @param rtcBackendIdProvider - A function to compute the rtc backend identity, exposed for testing purposes
*/
public constructor(
private readonly ownMembership: CallMembershipIdentityParts,
private getMemberships: () => CallMembership[],
private transport: IKeyTransport,
// Callback to notify the media layer of new keys
private onEncryptionKeysChanged: (
keyBin: Uint8Array<ArrayBuffer>,
encryptionKeyIndex: number,
membership: CallMembershipIdentityParts,
rtcBackendIdentity: string,
) => void,
parentLogger?: Logger,
rtcBackendIdProvider?: (userId: string, deviceId: string, memberId: string) => Promise<string>,
) {
this.logger = parentLogger?.getChild(`[EncryptionManager]`);
this.rtcIdentityProvider = rtcBackendIdProvider ?? computeRtcIdentityRaw;
}
private async getOwnRtcBackendIdentity(): Promise<string> {
if (this.ownRtcBackendIdentityCache) return this.ownRtcBackendIdentityCache;
if (this.useHashedRtcBackendIdentity) {
const { userId, deviceId, memberId } = this.ownMembership;
this.logger?.info(
// If we see this log multiple times, we need to reconsider the precompute call of getOwnRtcBackendIdentity
`Computing RTC backend identity for ${userId}:${deviceId}:${memberId} (SHOULD ONLY BE CALLED ONCE)`,
);
this.ownRtcBackendIdentityCache = await this.rtcIdentityProvider(userId, deviceId, memberId);
} else {
this.ownRtcBackendIdentityCache = `${this.ownMembership.userId}:${this.ownMembership.deviceId}`;
}
return this.ownRtcBackendIdentityCache;
}
public getEncryptionKeys(): ReadonlyMap<
EncryptionKeyMapKey,
ReadonlyArray<{
key: Uint8Array<ArrayBuffer>;
keyIndex: number;
membership: CallMembershipIdentityParts;
rtcBackendIdentity: string;
}>
> {
return new Map(this.participantKeyRings);
}
private keysWithoutMatchingRTCMembership: Array<{
key: Uint8Array<ArrayBuffer>;
keyIndex: number;
membership: CallMembershipIdentityParts;
}> = [];
private checkKeysWithoutMatchingRTCMembership(): void {
const keyInfoTemp = this.keysWithoutMatchingRTCMembership;
this.keysWithoutMatchingRTCMembership = [];
keyInfoTemp.forEach((keyInfo) => {
this.addKeyToParticipant(keyInfo.key, keyInfo.keyIndex, keyInfo.membership);
});
}
private addKeyToParticipant(
key: Uint8Array<ArrayBuffer>,
keyIndex: number,
membership: CallMembershipIdentityParts,
): void {
const knownRtcMembership = this.getMemberships();
const fullMembership = knownRtcMembership.find(
(member) => member.userId === membership.userId && member.deviceId === membership.deviceId,
);
if (!fullMembership) {
this.logger?.info(
`No matching RTC membership for key from ${membership.userId}:${membership.deviceId}, delaying key addition`,
);
this.keysWithoutMatchingRTCMembership.push({ key, keyIndex, membership });
return;
}
this.addKeyToParticipantWithBackendIdentity(key, keyIndex, membership, fullMembership.rtcBackendIdentity);
}
private addKeyToParticipantWithBackendIdentity(
key: Uint8Array<ArrayBuffer>,
keyIndex: number,
membership: CallMembershipIdentityParts,
rtcBackendIdentity: string,
): void {
const mapKey = getEncryptionKeyMapKey(membership);
if (!this.participantKeyRings.has(mapKey)) {
this.participantKeyRings.set(mapKey, []);
}
this.participantKeyRings.get(mapKey)!.push({ key, keyIndex, membership, rtcBackendIdentity });
this.onEncryptionKeysChanged(key, keyIndex, membership, rtcBackendIdentity);
}
public join(joinConfig: (EncryptionConfig & MembershipConfig) | undefined): void {
this.manageMediaKeys = joinConfig?.manageMediaKeys ?? true; // default to true
this.useHashedRtcBackendIdentity = joinConfig?.unstableSendStickyEvents ?? false;
this.useKeyDelay = joinConfig?.useKeyDelay ?? 1000;
this.keyRotationGracePeriodMs = joinConfig?.keyRotationGracePeriodMs ?? 10_000;
this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
void this.getOwnRtcBackendIdentity(); // precompute own identity
this.logger?.info(`Joining room`);
this.transport.start();
}
public leave(): void {
this.transport.off(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
this.transport.stop();
this.participantKeyRings.clear();
}
/**
* Will ensure that a new key is distributed and used to encrypt our media.
* If there is already a key distribution in progress, it will schedule a new distribution round just after the current one is completed.
* If this function is called repeatedly while a distribution is in progress,
* the calls will be coalesced to a single new distribution (that will start just after the current one has completed).
*/
private ensureKeyDistribution(): void {
// `manageMediaKeys` is a stop-gap solution for now. The preferred way to handle this case would be instead
// to create a NoOpEncryptionManager that does nothing and use it for the session.
// This will be done when removing the legacy EncryptionManager.
if (!this.manageMediaKeys) return;
if (this.currentKeyDistributionPromise == null) {
this.logger?.debug(`No active rollout, start a new one`);
// start a rollout
this.currentKeyDistributionPromise = this.rolloutOutboundKey().then(() => {
this.logger?.debug(`Rollout completed`);
this.currentKeyDistributionPromise = null;
if (this.needToEnsureKeyAgain) {
this.logger?.debug(`New Rollout needed`);
this.needToEnsureKeyAgain = false;
// rollout a new one
this.ensureKeyDistribution();
}
});
} else {
// There is a rollout in progress, but a key rotation is requested (could be caused by a ownMembership change)
// Remember that a new rotation is needed after the current one.
this.logger?.debug(`Rollout in progress, a new rollout will be started after the current one`);
this.needToEnsureKeyAgain = true;
}
}
public onNewKeyReceived: KeyTransportEventListener = (membership, keyBase64Encoded, index, timestamp) => {
// `manageMediaKeys` is a stop-gap solution for now. The preferred way to handle this case would be instead
// to create a NoOpEncryptionManager that does nothing and use it for the session.
// This will be done when removing the legacy EncryptionManager.
if (!this.manageMediaKeys) {
this.logger?.warn(
`Received key over transport ${membership.userId}:${membership.deviceId} at index ${index} but media keys are disabled`,
);
return;
}
this.logger?.debug(`Received key over transport ${membership.userId}:${membership.deviceId} at index ${index}`);
// We received a new key, notify the video layer of this new key so that it can decrypt the frames properly.
const keyBin = decodeBase64(keyBase64Encoded);
const candidateInboundSession: InboundEncryptionSession = {
key: keyBin,
membership,
keyIndex: index,
creationTS: timestamp,
};
const outdated = this.keyBuffer.isOutdated(membership, candidateInboundSession);
if (!outdated) {
this.addKeyToParticipant(
candidateInboundSession.key,
candidateInboundSession.keyIndex,
candidateInboundSession.membership,
);
} else {
this.logger?.info(
`Received an out of order key for ${membership.userId}:${membership.deviceId}, dropping it`,
);
}
};
/**
* Called when the ownMembership of the call changes.
* This encryption manager is very basic, it will rotate the key everytime this is called.
* @param oldMemberships - This parameter is not used here, but it is kept for compatibility with the interface.
*/
public onMembershipsUpdate(oldMemberships: CallMembership[] = []): void {
this.logger?.trace(`onMembershipsUpdate`);
// Ensure the key is distributed. This will be no-op if the key is already being distributed to everyone.
// If there is an ongoing distribution, it will be completed before a new one is started.
this.ensureKeyDistribution();
// ensure key emission to the rtc backend
this.checkKeysWithoutMatchingRTCMembership();
}
private async rolloutOutboundKey(): Promise<void> {
const isFirstKey = this.outboundSession == null;
if (isFirstKey) {
// create the first key
const firstKey = {
key: this.generateRandomKey(),
creationTS: Date.now(),
sharedWith: [],
keyId: 0,
};
this.outboundSession = firstKey;
this.addKeyToParticipantWithBackendIdentity(
firstKey.key,
firstKey.keyId,
this.ownMembership,
await this.getOwnRtcBackendIdentity(),
);
}
// get current memberships
const toShareWith: ParticipantDeviceInfo[] = this.getMemberships()
.filter((membership) => {
return membership.sender != undefined;
})
.map((membership) => {
return {
userId: membership.sender,
deviceId: membership.deviceId,
membershipTs: membership.createdTs(),
};
});
let alreadySharedWith = this.outboundSession?.sharedWith ?? [];
// Some users might have rotate their ownMembership event (formally called fingerprint) meaning they might have
// clear their key. Reset the `alreadySharedWith` flag for them.
alreadySharedWith = alreadySharedWith.filter(
(x) =>
// If there was a member with same userId and deviceId but different membershipTs, we need to clear it
!toShareWith.some(
(o) => x.userId == o.userId && x.deviceId == o.deviceId && x.membershipTs != o.membershipTs,
),
);
const anyLeft = alreadySharedWith.filter(
(x) =>
!toShareWith.some(
(o) => x.userId == o.userId && x.deviceId == o.deviceId && x.membershipTs == o.membershipTs,
),
);
const anyJoined = toShareWith.filter(
(x) =>
!alreadySharedWith.some(
(o) => x.userId == o.userId && x.deviceId == o.deviceId && x.membershipTs == o.membershipTs,
),
);
let toDistributeTo: ParticipantDeviceInfo[] = [];
let outboundKey: OutboundEncryptionSession;
let hasKeyChanged = false;
if (anyLeft.length > 0) {
// We need to rotate the key
const newOutboundKey = this.createNewOutboundSession();
hasKeyChanged = true;
toDistributeTo = toShareWith;
outboundKey = newOutboundKey;
} else if (anyJoined.length > 0) {
const now = Date.now();
const keyAge = now - this.outboundSession!.creationTS;
// If the current key is recently created (less than `keyRotationGracePeriodMs`), we can keep it and just distribute it to the new joiners.
if (keyAge < this.keyRotationGracePeriodMs) {
// keep the same key
// XXX In the future we want to distribute a ratcheted key, not the current one
this.logger?.debug(`New joiners detected, but the key is recent enough (age:${keyAge}), keeping it`);
toDistributeTo = anyJoined;
outboundKey = this.outboundSession!;
} else {
// We need to rotate the key
this.logger?.debug(`New joiners detected, rotating the key`);
const newOutboundKey = this.createNewOutboundSession();
hasKeyChanged = true;
toDistributeTo = toShareWith;
outboundKey = newOutboundKey;
}
} else {
// no changes
return;
}
try {
this.logger?.trace(`Sending key...`);
await this.transport.sendKey(encodeBase64(outboundKey.key), outboundKey.keyId, toDistributeTo);
outboundKey.sharedWith.push(...toDistributeTo);
this.logger?.trace(
`key index:${outboundKey.keyId} sent to ${outboundKey.sharedWith.map((m) => `${m.userId}:${m.deviceId}`).join(",")}`,
);
if (hasKeyChanged) {
// Delay a bit before using this key
// It is recommended not to start using a key immediately but instead wait for a short time to make sure it is delivered.
this.logger?.trace(`Delay Rollout for key:${outboundKey.keyId}...`);
await sleep(this.useKeyDelay);
this.logger?.trace(`...Delayed rollout of index:${outboundKey.keyId} `);
this.addKeyToParticipantWithBackendIdentity(
outboundKey.key,
outboundKey.keyId,
this.ownMembership,
await this.getOwnRtcBackendIdentity(),
);
}
} catch (err) {
this.logger?.error(`Failed to rollout key`, err);
}
}
private createNewOutboundSession(): OutboundEncryptionSession {
const newOutboundKey: OutboundEncryptionSession = {
key: this.generateRandomKey(),
creationTS: Date.now(),
sharedWith: [],
keyId: this.nextKeyIndex(),
};
this.logger?.info(`creating new outbound key index:${newOutboundKey.keyId}`);
// Set this new key as the current one
this.outboundSession = newOutboundKey;
return newOutboundKey;
}
private nextKeyIndex(): number {
if (this.outboundSession) {
return (this.outboundSession.keyId + 1) % 256;
}
return 0;
}
private generateRandomKey(): Uint8Array<ArrayBuffer> {
const key = new Uint8Array(16);
globalThis.crypto.getRandomValues(key);
return key;
}
}

View File

@@ -1,197 +0,0 @@
/*
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.
*/
import { type WidgetApiResponseError } from "matrix-widget-api";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { type IKeyTransport, KeyTransportEvents, type KeyTransportEventsHandlerMap } from "./IKeyTransport.ts";
import { type Logger, logger as rootLogger } from "../logger.ts";
import { type EncryptionKeysToDeviceEventContent, type ParticipantDeviceInfo, type Statistics } from "./types.ts";
import { ClientEvent, type MatrixClient } from "../client.ts";
import type { MatrixEvent } from "../models/event.ts";
import { EventType } from "../@types/event.ts";
import { type CallMembershipIdentityParts } from "./EncryptionManager.ts";
export class NotSupportedError extends Error {
public constructor(message?: string) {
super(message);
}
public get name(): string {
return "NotSupportedError";
}
}
/**
* ToDeviceKeyTransport is used to send MatrixRTC keys to other devices using the
* to-device CS-API.
*/
export class ToDeviceKeyTransport
extends TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>
implements IKeyTransport
{
private logger: Logger = rootLogger;
public setParentLogger(parentLogger: Logger): void {
this.logger = parentLogger.getChild(`[ToDeviceKeyTransport]`);
}
public constructor(
private membership: CallMembershipIdentityParts,
private roomId: string,
private client: Pick<MatrixClient, "encryptAndSendToDevice" | "on" | "off">,
private statistics: Statistics,
parentLogger?: Logger,
) {
super();
this.setParentLogger(parentLogger ?? rootLogger);
}
public start(): void {
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
}
public stop(): void {
this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
}
public async sendKey(keyBase64Encoded: string, index: number, members: ParticipantDeviceInfo[]): Promise<void> {
const content: EncryptionKeysToDeviceEventContent = {
keys: {
index: index,
key: keyBase64Encoded,
},
room_id: this.roomId,
member: {
claimed_device_id: this.membership.deviceId,
id: this.membership.memberId,
},
session: {
call_id: "",
application: "m.call",
scope: "m.room",
},
sent_ts: Date.now(),
};
const targets = members
.map((member) => {
return {
userId: member.userId,
deviceId: member.deviceId,
};
})
// filter out me
.filter(
(member) => !(member.userId == this.membership.userId && member.deviceId == this.membership.deviceId),
);
if (targets.length > 0) {
await this.client
.encryptAndSendToDevice(EventType.CallEncryptionKeysPrefix, targets, content)
.catch((error: WidgetApiResponseError) => {
const msg: string = error.message;
// This is not ideal. We would want to have a custom error type for unsupported actions.
// This is not part of the widget API spec. Since as of now there are only two implementations:
// Rust SDK + JS-SDK, and the JS-SDK does support to-device sending, we can assume that
// this is a widget driver issue error message.
if (
(msg.includes("unknown variant") && msg.includes("send_to_device")) ||
msg.includes("not supported")
) {
throw new NotSupportedError("The widget driver does not support to-device encryption");
}
});
this.statistics.counters.roomEventEncryptionKeysSent += 1;
} else {
this.logger.warn("No targets found for sending key");
}
}
private receiveCallKeyEvent(fromUser: string, content: EncryptionKeysToDeviceEventContent): void {
// The event has already been validated at this point.
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
// What is this, and why is it needed?
// Also to device events do not have an origin server ts
const now = Date.now();
const age = now - (typeof content.sent_ts === "number" ? content.sent_ts : now);
this.statistics.totals.roomEventEncryptionKeysReceivedTotalAge += age;
const hardcodedMemberIdAlternative = `${fromUser}:${content.member.claimed_device_id}`;
this.emit(
KeyTransportEvents.ReceivedKeys,
// TODO userId this is claimed information, deviceId is claimed information
{
userId: fromUser,
deviceId: content.member.claimed_device_id,
memberId: content.member.id ?? hardcodedMemberIdAlternative,
},
content.keys.key,
content.keys.index,
now,
);
}
private onToDeviceEvent = (event: MatrixEvent): void => {
if (event.getType() !== EventType.CallEncryptionKeysPrefix) {
// Ignore this is not a call encryption event
return;
}
// TODO: Not possible to check if the event is encrypted or not
// see https://github.com/matrix-org/matrix-rust-sdk/issues/4883
// if (evnt.getWireType() != EventType.RoomMessageEncrypted) {
// // WARN: The call keys were sent in clear. Ignore them
// logger.warn(`Call encryption keys sent in clear from: ${event.getSender()}`);
// return;
// }
const content = this.getValidEventContent(event);
if (!content) return;
if (!event.getSender()) return;
this.receiveCallKeyEvent(event.getSender()!, content);
};
private getValidEventContent(event: MatrixEvent): EncryptionKeysToDeviceEventContent | undefined {
const content = event.getContent();
const roomId = content.room_id;
if (!roomId) {
// Invalid event
this.logger.warn("Malformed Event: invalid call encryption keys event, no roomId");
return;
}
if (roomId !== this.roomId) {
this.logger.warn("Malformed Event: Mismatch roomId");
return;
}
if (!content.keys || !content.keys.key || typeof content.keys.index !== "number") {
this.logger.warn("Malformed Event: Missing keys field");
return;
}
if (!content.member || !content.member.claimed_device_id) {
this.logger.warn("Malformed Event: Missing claimed_device_id");
return;
}
// TODO check for session related fields once the to-device encryption uses the new format.
return content as EncryptionKeysToDeviceEventContent;
}
}

View File

@@ -1,24 +0,0 @@
/*
Copyright 2024 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 * from "./CallMembership.ts";
export * from "./LivekitTransport.ts";
export * from "./MatrixRTCSession.ts";
export * from "./MatrixRTCSessionManager.ts";
export type * from "./types.ts";
export { type SessionMembershipData, type RtcMembershipData } from "./membershipData/index.ts";
export { Status, parseCallNotificationContent, isMyMembership, RTC_SLOT_ENCRYPTION_PER_MEMBER } from "./types.ts";
export { MembershipManagerEvent } from "./IMembershipManager.ts";

View File

@@ -1,27 +0,0 @@
/*
Copyright 2026 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.
*/
/**
* Thrown when an event is not valid for use with MatrixRTC.
*/
export class MatrixRTCMembershipParseError extends AggregateError {
public constructor(
public readonly type: string,
errors: string[],
) {
super(errors, `Does not match ${type}:\n${errors.join("\n")}`);
}
}

View File

@@ -1,19 +0,0 @@
/*
Copyright 2026 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 { type SessionMembershipData, checkSessionsMembershipData } from "./session.ts";
export { type RtcMembershipData, computeRtcIdentityRaw, checkRtcMembershipData } from "./rtc.ts";
export { MatrixRTCMembershipParseError } from "./common.ts";

View File

@@ -1,168 +0,0 @@
/*
Copyright 2026 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 { MXID_PATTERN } from "../../models/room-member.ts";
import type { IContent } from "../../models/event.ts";
import type { RelationType } from "../../types.ts";
import { type RtcSlotApplicationContent, type Transport } from "../types.ts";
import { MatrixRTCMembershipParseError } from "./common.ts";
import { sha256 } from "../../digest.ts";
import { encodeUnpaddedBase64 } from "../../base64.ts";
import { slotIdToDescription } from "../utils.ts";
/**
* Represents the current form of MSC4143, which uses sticky events to store membership.
*/
export interface RtcMembershipData {
"slot_id": string;
"member": {
user_id: string;
device_id: string;
id: string;
};
"m.relates_to"?: {
event_id: string;
rel_type: RelationType.Reference;
};
"application": RtcSlotApplicationContent;
"transports": {
published: Transport[];
can_subscribe: string[];
};
"versions": string[];
"msc4354_sticky_key"?: string;
"sticky_key"?: string;
}
/**
* Validates that `data` matches the format expected by MSC4143.
* @param data The event content.
* @param sender The sender of the event.
* @returns true if `data` is valid RtcMembershipData
* @throws {MatrixRTCMembershipParseError} if the content is not valid
*/
export const checkRtcMembershipData = (data: IContent, sender: string): data is RtcMembershipData => {
const errors: string[] = [];
const prefix = " - ";
const expectedSlotPrefix = `${data?.application?.type}#`;
// required fields
if (typeof data.slot_id !== "string") {
errors.push(prefix + "slot_id must be string");
} else if (!data.slot_id.startsWith(expectedSlotPrefix)) {
errors.push(prefix + `slot_id must start with ${expectedSlotPrefix}`);
} else {
try {
slotIdToDescription(data.slot_id);
} catch (ex) {
errors.push(prefix + `slot_id was badly formed${ex instanceof Error ? `: ${ex.message}` : ""}`);
}
}
if (typeof data.member !== "object" || data.member === null) {
errors.push(prefix + "member must be an object");
} else {
if (typeof data.member.user_id !== "string") {
errors.push(prefix + "member.user_id must be string");
} else if (!MXID_PATTERN.test(data.member.user_id)) {
errors.push(prefix + "member.user_id must be a valid mxid");
}
// This is not what the spec enforces but there currently are no rules what power levels are required to
// send a m.rtc.member event for a other user. So we add this check for simplicity and to avoid possible attacks until there
// is a proper definition when this is allowed.
else if (data.member.user_id !== sender) {
errors.push(prefix + "member.user_id must match the sender");
}
if (typeof data.member.device_id !== "string") {
errors.push(prefix + "member.device_id must be string");
}
if (typeof data.member.id !== "string") errors.push(prefix + "member.id must be string");
}
if (typeof data.application !== "object" || data.application === null) {
errors.push(prefix + "application must be an object");
} else {
if (typeof data.application.type !== "string") {
errors.push(prefix + "application.type must be a string");
} else {
if (data.application.type.includes("#")) errors.push(prefix + 'application.type must not include "#"');
}
}
if (typeof data.transports !== "object" || data.transports === null || !Array.isArray(data.transports.published)) {
errors.push(prefix + "transports.published must be an array");
} else {
// validate that each transport has at least a string 'type'
for (const t of data.transports.published) {
if (typeof t !== "object" || t === null || typeof t.type !== "string") {
errors.push(prefix + "transports.published entries must be objects with a string type");
break;
}
}
}
if (
typeof data.transports !== "object" ||
data.transports === null ||
!Array.isArray(data.transports.can_subscribe) ||
!data.transports.can_subscribe.every((t: unknown) => typeof t === "string")
) {
errors.push(prefix + "transports.can_subscribe must be an array of strings");
}
if (data.versions === undefined || !Array.isArray(data.versions)) {
errors.push(prefix + "versions must be an array");
} else if (!data.versions.every((v) => typeof v === "string")) {
errors.push(prefix + "versions must be an array of strings");
}
// optional fields
if ((data.sticky_key ?? data.msc4354_sticky_key) === undefined) {
errors.push(prefix + "sticky_key or msc4354_sticky_key must be a defined");
}
if (data.sticky_key !== undefined && typeof data.sticky_key !== "string") {
errors.push(prefix + "sticky_key must be a string");
}
if (data.msc4354_sticky_key !== undefined && typeof data.msc4354_sticky_key !== "string") {
errors.push(prefix + "msc4354_sticky_key must be a string");
}
if (
data.sticky_key !== undefined &&
data.msc4354_sticky_key !== undefined &&
data.sticky_key !== data.msc4354_sticky_key
) {
errors.push(prefix + "sticky_key and msc4354_sticky_key must be equal if both are defined");
}
if (data["m.relates_to"] !== undefined) {
const rel = data["m.relates_to"] as RtcMembershipData["m.relates_to"];
if (typeof rel !== "object" || rel === null) {
errors.push(prefix + "m.relates_to must be an object if provided");
} else {
if (typeof rel.event_id !== "string") errors.push(prefix + "m.relates_to.event_id must be a string");
if (rel.rel_type !== "m.reference") errors.push(prefix + "m.relates_to.rel_type must be m.reference");
}
}
if (errors.length) {
throw new MatrixRTCMembershipParseError("RtcMembership", errors);
}
return true;
};
export async function computeRtcIdentityRaw(userId: string, deviceId: string, memberId: string): Promise<string> {
// canonical JSON serialization (Matrix canonical JSON for arrays)
const jsonStr = JSON.stringify([userId, deviceId, memberId]);
const hashBuffer = await sha256(jsonStr);
const hashedString = encodeUnpaddedBase64(hashBuffer);
return hashedString;
}

View File

@@ -1,146 +0,0 @@
/*
Copyright 2026 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 { type IContent } from "../../matrix.ts";
import { type RTCCallIntent, type Transport } from "../types.ts";
import { MatrixRTCMembershipParseError } from "./common.ts";
/**
* (MatrixRTC) session membership data.
* This represents the *OLD* form of MSC4143, which uses state events to store membership.
* Represents the `session` in the memberships section of an m.call.member event as it is on the wire.
**/
export type SessionMembershipData = {
/**
* The RTC application defines the type of the RTC session.
*/
"application": string;
/**
* The id of this session.
* A session can never span over multiple rooms so this id is to distinguish between
* multiple session in one room. A room wide session that is not associated with a user,
* and therefore immune to creation race conflicts, uses the `call_id: ""`.
*/
"call_id": string;
/**
* The Matrix device ID of this session. A single user can have multiple sessions on different devices.
*/
"device_id": string;
/**
* The focus selection system this user/membership is using.
* NOTE: This is still included for legacy reasons, but not consumed by the SDK.
*/
"focus_active": {
type: "livekit" | string;
focus_selection: "oldest_membership" | "multi_sfu" | string;
};
/**
* A list of possible foci this user knows about. One of them might be used based on the focus_active
* selection system.
*/
"foci_preferred": Transport[];
/**
* Optional field that contains the creation of the session. If it is undefined the creation
* is the `origin_server_ts` of the event itself. For updates to the event this property tracks
* the `origin_server_ts` of the initial join event.
* - If it is undefined it can be interpreted as a "Join".
* - If it is defined it can be interpreted as an "Update"
*/
"created_ts"?: number;
// Application specific data
/**
* If the `application` = `"m.call"` this defines if it is a room or user owned call.
* There can always be one room scoped call but multiple user owned calls (breakout sessions)
*/
"scope"?: "m.room" | "m.user";
/**
* Optionally we allow to define a delta to the `created_ts` that defines when the event is expired/invalid.
* This should be set to multiple hours. The only reason it exist is to deal with failed delayed events.
* (for example caused by a homeserver crashes)
**/
"expires"?: number;
/**
* The intent of the call from the perspective of this user. This may be an audio call, video call or
* something else.
*/
"m.call.intent"?: RTCCallIntent;
/**
* The id used on the media backend.
* (With livekit this is the participant identity on the LK SFU)
* This can be a UUID but right now it is `${this.matrixEventData.sender}:${data.device_id}`.
*
* It is compleatly valid to not set this field. Other clients will treat `undefined` as `${this.matrixEventData.sender}:${data.device_id}`
*/
"membershipID"?: string;
};
/**
* Validates that `data` matches the format expected by the legacy form of MSC4143.
* @param data The event content.
* @returns true if `data` is valid SessionMembershipData
* @throws {MatrixRTCMembershipParseError} if the content is not valid
*/
export const checkSessionsMembershipData = (data: IContent): data is SessionMembershipData => {
const prefix = " - ";
const errors: string[] = [];
if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string");
if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string");
if (typeof data.application !== "string") errors.push(prefix + "application must be a string");
if (data.focus_active === undefined) {
errors.push(prefix + "focus_active has an invalid type");
}
if (typeof data.focus_active?.type !== "string") {
errors.push(prefix + "focus_active.type must be a string");
}
if (
data.foci_preferred !== undefined &&
!(
Array.isArray(data.foci_preferred) &&
data.foci_preferred.every(
(f: Transport) => typeof f === "object" && f !== null && typeof f.type === "string",
)
)
) {
errors.push(prefix + "foci_preferred must be an array of transport objects");
}
// optional parameters
if (data.created_ts !== undefined && typeof data.created_ts !== "number") {
errors.push(prefix + "created_ts must be number");
}
// application specific data (we first need to check if they exist)
if (data.scope !== undefined && typeof data.scope !== "string") errors.push(prefix + "scope must be string");
if (data["m.call.intent"] !== undefined && typeof data["m.call.intent"] !== "string") {
errors.push(prefix + "m.call.intent must be a string");
}
if (errors.length) {
throw new MatrixRTCMembershipParseError("SessionMembership", errors);
}
return true;
};

View File

@@ -1,244 +0,0 @@
/*
Copyright 2023-2026 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 type { IContent, IMentions } from "../matrix.ts";
import type { RelationEvent } from "../types.ts";
import type { CallMembership } from "./CallMembership.ts";
import { type CallMembershipIdentityParts } from "./EncryptionManager.ts";
export type EncryptionKeyMapKey = string;
export interface EncryptionKeyEntry {
index: number;
key: string;
}
/**
* The mxID, deviceId and membership timestamp of a RTC session participant.
*/
export type ParticipantDeviceInfo = {
userId: string;
deviceId: string;
membershipTs: number;
};
/**
* A type representing the information needed to decrypt video streams.
*/
export type InboundEncryptionSession = {
key: Uint8Array<ArrayBuffer>;
membership: CallMembershipIdentityParts;
keyIndex: number;
creationTS: number;
};
/**
* The information about the key used to encrypt video streams.
*/
export type OutboundEncryptionSession = {
key: Uint8Array<ArrayBuffer>;
creationTS: number;
// The devices that this key is shared with.
sharedWith: Array<ParticipantDeviceInfo>;
// This is an index acting as the id of the key
keyId: number;
};
export interface EncryptionKeysEventContent {
keys: EncryptionKeyEntry[];
device_id: string;
call_id: string;
sent_ts?: number;
}
/**
* THe content of a to-device event that contains encryption keys.
*/
export interface EncryptionKeysToDeviceEventContent {
keys: { index: number; key: string };
member: {
id: string;
// TODO Remove that it is claimed, need to get the sealed sender from decryption info
// Or add some validation on it based on the encryption info
claimed_device_id: string;
};
room_id: string;
session: {
application: string;
call_id: string;
scope: string;
};
// Why is this needed?
sent_ts?: number;
}
/**
* @deprecated Use `RTCNotificationType` instead.
*/
export type CallNotifyType = "ring" | "notify";
/**
* @deprecated Use `IRTCNotificationContent` instead.
*/
export interface ICallNotifyContent {
"application": string;
"m.mentions": IMentions;
"notify_type": CallNotifyType;
"call_id": string;
}
export type RTCNotificationType = "ring" | "notification";
/**
* Represents the intention of the call from the perspective of the sending user.
* May be any string, although `"audio"` and `"video"` are commonly accepted values.
*/
export type RTCCallIntent = "audio" | "video" | string;
/**
* This will check if the content has all the expected fields to be a valid IRTCNotificationContent.
* It will also cap the lifetime to 90000ms (1.5 min) if a higher value is provided.
* @param content
* @throws if the content is invalid
* @returns a parsed IRTCNotificationContent
*/
export function parseCallNotificationContent(content: IContent): IRTCNotificationContent {
if (content["m.mentions"] && typeof content["m.mentions"] !== "object") {
throw new Error("malformed m.mentions");
}
if (typeof content["notification_type"] !== "string") {
throw new Error("Missing or invalid notification_type");
}
if (typeof content["sender_ts"] !== "number") {
throw new Error("Missing or invalid sender_ts");
}
if (typeof content["lifetime"] !== "number") {
throw new Error("Missing or invalid lifetime");
}
if (content["relation"] && content["relation"]["rel_type"] !== "m.reference") {
throw new Error("Invalid relation");
}
if (content["m.call.intent"] && typeof content["m.call.intent"] !== "string") {
throw new Error("Invalid m.call.intent");
}
const cappedLifetime = content["lifetime"] >= 90000 ? 90000 : content["lifetime"];
return { ...content, lifetime: cappedLifetime } as IRTCNotificationContent;
}
/**
* Interface for `org.matrix.msc4075.rtc.notification` events.
* Don't cast event content to this directly. Use `parseCallNotificationContent` instead to validate the content first.
*/
export interface IRTCNotificationContent extends RelationEvent {
"m.mentions"?: IMentions;
"notification_type": RTCNotificationType;
/**
* The initial intent of the calling user.
*/
"m.call.intent"?: RTCCallIntent;
"sender_ts": number;
"lifetime": number;
}
/**
* MSC4310 decline event content for `org.matrix.msc4310.rtc.decline`.
* Sent as a standard m.reference relation to an `org.matrix.msc4075.rtc.notification` event.
*/
export interface IRTCDeclineContent extends RelationEvent {}
export enum Status {
Disconnected = "Disconnected",
Connecting = "Connecting",
Connected = "Connected",
Disconnecting = "Disconnecting",
Unknown = "Unknown",
}
/**
* A type collecting call encryption statistics for a session.
*/
export type Statistics = {
counters: {
/**
* The number of times we have sent a room event containing encryption keys.
*/
roomEventEncryptionKeysSent: number;
/**
* The number of times we have received a room event containing encryption keys.
*/
roomEventEncryptionKeysReceived: number;
};
totals: {
/**
* The total age (in milliseconds) of all room events containing encryption keys that we have received.
* We track the total age so that we can later calculate the average age of all keys received.
*/
roomEventEncryptionKeysReceivedTotalAge: number;
};
};
export const isMyMembership = (m: CallMembership, userId: string, deviceId: string): boolean =>
m.sender === userId && m.deviceId === deviceId;
/**
* A RTC transport is a JSON object that describes how to connect to a RTC member.
*/
export interface Transport {
type: string;
[key: string]: unknown;
}
/**
* Event content for `org.matrix.msc4143.rtc.slot` state events.
*/
export interface RtcSlotEventContent<T extends string = string> {
status: "open" | "closed";
application?: RtcSlotApplicationContent<T>;
encryption?: RtcSlotEncryptionContent;
}
/**
* Content of the `application` object within `org.matrix.msc4143.rtc.slot` events.
*/
export interface RtcSlotApplicationContent<T extends string = string> {
type: T;
// Other application specific keys.
[key: string]: unknown;
}
export const RTC_SLOT_ENCRYPTION_PER_MEMBER = "org.matrix.msc4143.per_member";
/**
* Content of the `encryption` object within `org.matrix.msc4143.rtc.slot` events.
*/
export interface RtcSlotEncryptionContent {
type: typeof RTC_SLOT_ENCRYPTION_PER_MEMBER;
// Other encryption-mechanism specific keys.
[key: string]: unknown;
}
/**
* The session description is used to identify a session. Used in the state event.
*/
export interface SlotDescription {
/**
* The application type. e.g. "m.call".
*/
application: string;
/**
* The application-specific slot ID. e.g. "ROOM".
*/
id: string;
}

View File

@@ -1,69 +0,0 @@
/*
Copyright 2025-2026 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 { getEncryptionKeyMapKey, type CallMembershipIdentityParts } from "./EncryptionManager.ts";
import type { InboundEncryptionSession, EncryptionKeyMapKey, SlotDescription } from "./types.ts";
/**
* Detects when a key for a given index is outdated.
*/
export class OutdatedKeyFilter {
// Map of participantId -> keyIndex -> timestamp
private tsBuffer: Map<EncryptionKeyMapKey, Map<number, number>> = new Map();
/**
* Check if there is a recent key with the same keyId (index) and then use the creationTS to decide what to
* do with the key. If the key received is older than the one already in the buffer, it is ignored.
* @param participantId
* @param item
*/
public isOutdated(membership: CallMembershipIdentityParts, item: InboundEncryptionSession): boolean {
const mapKey = getEncryptionKeyMapKey(membership);
if (!this.tsBuffer.has(mapKey)) {
this.tsBuffer.set(mapKey, new Map<number, number>());
}
const latestTimestamp = this.tsBuffer.get(mapKey)?.get(item.keyIndex);
if (latestTimestamp && latestTimestamp > item.creationTS) {
// The existing key is more recent, ignore this one
return true;
}
this.tsBuffer.get(mapKey)!.set(item.keyIndex, item.creationTS);
return false;
}
}
/**
* Converts a slot ID into it's component application and ID portions.
* @param slotId e.g. `m.call#call_id`
* @throws If the format of `slotId` is invalid.
*/
export function slotIdToDescription(slotId: string): SlotDescription {
const [application, id, ...unexpectedAdditionalValues] = slotId.split("#");
if (unexpectedAdditionalValues.length) {
throw Error(
"MatrixRTC Slot IDs *must* only contain two components seperated by one '#'. Additional '#' characters detected.",
);
}
return { application, id };
}
/**
* Converts a SlotDescription into it's slot ID format.
*/
export function computeSlotId(slotDescription: SlotDescription): string {
return `${slotDescription.application}#${slotDescription.id}`;
}

View File

@@ -14,28 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type MatrixClient } from "../client.ts";
import { RelationType, UNSTABLE_MSC3089_BRANCH } from "../@types/event.ts";
import { type IContent, type MatrixEvent } from "./event.ts";
import { type MSC3089TreeSpace } from "./MSC3089TreeSpace.ts";
import { EventTimeline } from "./event-timeline.ts";
import { type FileType } from "../http-api/index.ts";
import type { ISendEventResponse } from "../@types/requests.ts";
import { type EncryptedFile } from "../@types/media.ts";
export interface MSC3089EventContent {
active?: boolean;
name?: string;
locked?: boolean;
version?: number;
}
export interface MSC3089EventContent {
active?: boolean;
name?: string;
locked?: boolean;
version?: number;
}
import { MatrixClient } from "../client";
import { IEncryptedFile, RelationType, UNSTABLE_MSC3089_BRANCH } from "../@types/event";
import { IContent, MatrixEvent } from "./event";
import { MSC3089TreeSpace } from "./MSC3089TreeSpace";
import { EventTimeline } from "./event-timeline";
import { FileType } from "../http-api";
import type { ISendEventResponse } from "../@types/requests";
/**
* Represents a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) branch - a reference
@@ -146,7 +131,7 @@ export class MSC3089Branch {
* Gets information about the file needed to download it.
* @returns Information about the file.
*/
public async getFileInfo(): Promise<{ info: EncryptedFile; httpUrl: string }> {
public async getFileInfo(): Promise<{ info: IEncryptedFile; httpUrl: string }> {
const event = await this.getFileEvent();
const file = event.getOriginalContent()["file"];
@@ -177,8 +162,9 @@ export class MSC3089Branch {
if (!event) throw new Error("Failed to find event");
// Sometimes the event isn't decrypted for us, so do that.
await this.client.decryptEventIfNeeded(event);
// Sometimes the event isn't decrypted for us, so do that. We specifically set `emit: true`
// to ensure that the relations system in the sdk will function.
await this.client.decryptEventIfNeeded(event, { emit: true, isRetry: true });
return event;
}
@@ -194,11 +180,11 @@ export class MSC3089Branch {
public async createNewVersion(
name: string,
encryptedContents: FileType,
info: EncryptedFile,
info: Partial<IEncryptedFile>,
additionalContent?: IContent,
): Promise<ISendEventResponse> {
const fileEventResponse = await this.directory.createFile(name, encryptedContents, info, {
...additionalContent,
...(additionalContent ?? {}),
"m.new_content": true,
"m.relates_to": {
rel_type: RelationType.Replace,

View File

@@ -14,11 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type MatrixClient } from "../client.ts";
import { EventType, MsgType, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_LEAF } from "../@types/event.ts";
import { type Room } from "./room.ts";
import { logger } from "../logger.ts";
import { type IContent, type MatrixEvent } from "./event.ts";
import promiseRetry from "p-retry";
import { MatrixClient } from "../client";
import { EventType, IEncryptedFile, MsgType, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_LEAF } from "../@types/event";
import { Room } from "./room";
import { logger } from "../logger";
import { IContent, MatrixEvent } from "./event";
import {
averageBetweenStrings,
DEFAULT_ALPHABET,
@@ -26,14 +28,11 @@ import {
nextString,
prevString,
simpleRetryOperation,
} from "../utils.ts";
import { MSC3089Branch } from "./MSC3089Branch.ts";
import { type ISendEventResponse } from "../@types/requests.ts";
import { type FileType, MatrixError } from "../http-api/index.ts";
import { KnownMembership } from "../@types/membership.ts";
import { type RoomPowerLevelsEventContent, type SpaceChildEventContent } from "../@types/state_events.ts";
import type { EncryptedFile, FileContent } from "../@types/media.ts";
import { type EmptyObject } from "../@types/common.ts";
} from "../utils";
import { MSC3089Branch } from "./MSC3089Branch";
import { isRoomSharedHistory } from "../crypto/algorithms/megolm";
import { ISendEventResponse } from "../@types/requests";
import { FileType } from "../http-api";
/**
* The recommended defaults for a tree space's power levels. Note that this
@@ -78,12 +77,6 @@ export enum TreePermissions {
Owner = "owner", // "Admin" or PL100
}
declare module "../@types/media" {
interface FileContent {
[UNSTABLE_MSC3089_LEAF.name]?: EmptyObject;
}
}
/**
* Represents a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089)
* file tree Space. Note that this is UNSTABLE and subject to breaking changes
@@ -92,10 +85,7 @@ declare module "../@types/media" {
export class MSC3089TreeSpace {
public readonly room: Room;
public constructor(
private client: MatrixClient,
public readonly roomId: string,
) {
public constructor(private client: MatrixClient, public readonly roomId: string) {
this.room = this.client.getRoom(this.roomId)!;
if (!this.room) throw new Error("Unknown room");
@@ -134,27 +124,40 @@ export class MSC3089TreeSpace {
* @param userId - The user ID to invite.
* @param andSubspaces - True (default) to invite the user to all
* directories/subspaces too, recursively.
* @param shareHistoryKeys - True (default) to share encryption keys
* with the invited user. This will allow them to decrypt the events (files)
* in the tree. Keys will not be shared if the room is lacking appropriate
* history visibility (by default, history visibility is "shared" in trees,
* which is an appropriate visibility for these purposes).
* @returns Promise which resolves when complete.
*/
public async invite(userId: string, andSubspaces = true): Promise<void> {
public async invite(userId: string, andSubspaces = true, shareHistoryKeys = true): Promise<void> {
const promises: Promise<void>[] = [this.retryInvite(userId)];
if (andSubspaces) {
promises.push(...this.getDirectories().map((d) => d.invite(userId, andSubspaces)));
promises.push(...this.getDirectories().map((d) => d.invite(userId, andSubspaces, shareHistoryKeys)));
}
await Promise.all(promises);
return Promise.all(promises).then(() => {
// Note: key sharing is default on because for file trees it is relatively important that the invite
// target can actually decrypt the files. The implied use case is that by inviting a user to the tree
// it means the sender would like the receiver to view/download the files contained within, much like
// sharing a folder in other circles.
if (shareHistoryKeys && isRoomSharedHistory(this.room)) {
// noinspection JSIgnoredPromiseFromCall - we aren't concerned as much if this fails.
this.client.sendSharedHistoryKeys(this.roomId, [userId]);
}
});
}
private async retryInvite(userId: string): Promise<void> {
await simpleRetryOperation(
() => this.client.invite(this.roomId, userId),
(e) => {
private retryInvite(userId: string): Promise<void> {
return simpleRetryOperation(async () => {
await this.client.invite(this.roomId, userId).catch((e) => {
// We don't want to retry permission errors forever...
if (e instanceof MatrixError && e.errcode === "M_FORBIDDEN") {
return false;
if (e?.errcode === "M_FORBIDDEN") {
throw new promiseRetry.AbortError(e);
}
return true;
},
);
throw e;
});
});
}
/**
@@ -169,7 +172,7 @@ export class MSC3089TreeSpace {
const currentPls = this.room.currentState.getStateEvents(EventType.RoomPowerLevels, "");
if (Array.isArray(currentPls)) throw new Error("Unexpected return type for power levels");
const pls = currentPls?.getContent<RoomPowerLevelsEventContent>() || {};
const pls = currentPls?.getContent() || {};
const viewLevel = pls["users_default"] || 0;
const editLevel = pls["events_default"] || 50;
const adminLevel = pls["events"]?.[EventType.RoomPowerLevels] || 100;
@@ -227,7 +230,7 @@ export class MSC3089TreeSpace {
this.roomId,
EventType.SpaceChild,
{
via: [this.client.getDomain()!],
via: [this.client.getDomain()],
},
directory.roomId,
);
@@ -236,7 +239,7 @@ export class MSC3089TreeSpace {
directory.roomId,
EventType.SpaceParent,
{
via: [this.client.getDomain()!],
via: [this.client.getDomain()],
},
this.roomId,
);
@@ -285,11 +288,11 @@ export class MSC3089TreeSpace {
await dir.delete();
}
const kickMemberships = [KnownMembership.Invite, KnownMembership.Knock, KnownMembership.Join];
const kickMemberships = ["invite", "knock", "join"];
const members = this.room.currentState.getStateEvents(EventType.RoomMember);
for (const member of members) {
const isNotUs = member.getStateKey() !== this.client.getUserId();
if (isNotUs && kickMemberships.includes(member.getContent().membership! as KnownMembership)) {
if (isNotUs && kickMemberships.includes(member.getContent().membership!)) {
const stateKey = member.getStateKey();
if (!stateKey) {
throw new Error("State key not found for branch");
@@ -443,9 +446,7 @@ export class MSC3089TreeSpace {
// XXX: We should be creating gaps to avoid conflicts
lastOrder = lastOrder ? nextString(lastOrder) : DEFAULT_ALPHABET[0];
const currentChild = parentRoom.currentState.getStateEvents(EventType.SpaceChild, target.roomId);
const content = currentChild?.getContent<SpaceChildEventContent>() ?? {
via: [this.client.getDomain()!],
};
const content = currentChild?.getContent() ?? { via: [this.client.getDomain()] };
await this.client.sendStateEvent(
parentRoom.roomId,
EventType.SpaceChild,
@@ -468,7 +469,7 @@ export class MSC3089TreeSpace {
// Now we can finally update our own order state
const currentChild = parentRoom.currentState.getStateEvents(EventType.SpaceChild, this.roomId);
const content = currentChild?.getContent<SpaceChildEventContent>() ?? { via: [this.client.getDomain()!] };
const content = currentChild?.getContent() ?? { via: [this.client.getDomain()] };
await this.client.sendStateEvent(
parentRoom.roomId,
EventType.SpaceChild,
@@ -494,7 +495,7 @@ export class MSC3089TreeSpace {
public async createFile(
name: string,
encryptedContents: FileType,
info: EncryptedFile,
info: Partial<IEncryptedFile>,
additionalContent?: IContent,
): Promise<ISendEventResponse> {
const { content_uri: mxc } = await this.client.uploadContent(encryptedContents, {
@@ -502,7 +503,7 @@ export class MSC3089TreeSpace {
});
info.url = mxc;
const fileContent: FileContent = {
const fileContent = {
msgtype: MsgType.File,
body: name,
url: mxc,
@@ -521,7 +522,7 @@ export class MSC3089TreeSpace {
...additionalContent,
...fileContent,
[UNSTABLE_MSC3089_LEAF.name]: {},
} as FileContent);
});
await this.client.sendStateEvent(
this.roomId,

View File

@@ -14,16 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type MBeaconEventContent } from "../@types/beacon.ts";
import {
type BeaconInfoState,
type BeaconLocationState,
parseBeaconContent,
parseBeaconInfoContent,
} from "../content-helpers.ts";
import { type MatrixEvent } from "./event.ts";
import { sortEventsByLatestContentTimestamp } from "../utils.ts";
import { TypedEventEmitter } from "./typed-event-emitter.ts";
import { MBeaconEventContent } from "../@types/beacon";
import { BeaconInfoState, BeaconLocationState, parseBeaconContent, parseBeaconInfoContent } from "../content-helpers";
import { MatrixEvent } from "./event";
import { sortEventsByLatestContentTimestamp } from "../utils";
import { TypedEventEmitter } from "./typed-event-emitter";
export enum BeaconEvent {
New = "Beacon.new",
@@ -38,6 +33,7 @@ export type BeaconEventHandlerMap = {
[BeaconEvent.LivenessChange]: (isLive: boolean, beacon: Beacon) => void;
[BeaconEvent.Destroy]: (beaconIdentifier: string) => void;
[BeaconEvent.LocationUpdate]: (locationState: BeaconLocationState) => void;
[BeaconEvent.Destroy]: (beaconIdentifier: string) => void;
};
export const isTimestampInDuration = (startTimestamp: number, durationMs: number, timestamp: number): boolean =>

Some files were not shown because too many files have changed in this diff Show More