@vex-chat/libvex 9.0.0 → 9.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Client.d.ts +20 -0
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +99 -33
- package/dist/Client.js.map +1 -1
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +52 -59
- package/dist/storage/sqlite.js.map +1 -1
- package/package.json +1 -1
- package/src/Client.ts +172 -40
- package/src/__tests__/harness/shared-suite.ts +28 -0
- package/src/__tests__/multi-device-delivery.test.ts +147 -0
- package/src/__tests__/storage-sqlite.test.ts +27 -0
- package/src/storage/sqlite.ts +55 -57
package/src/Client.ts
CHANGED
|
@@ -455,6 +455,8 @@ export interface Channels {
|
|
|
455
455
|
retrieve: (serverID: string) => Promise<Channel[]>;
|
|
456
456
|
/** Gets one channel by ID. */
|
|
457
457
|
retrieveByID: (channelID: string) => Promise<Channel | null>;
|
|
458
|
+
/** Changes a channel's display name. */
|
|
459
|
+
update: (channelID: string, name: string) => Promise<Channel>;
|
|
458
460
|
/** Lists users currently visible in a channel. */
|
|
459
461
|
userList: (channelID: string) => Promise<User[]>;
|
|
460
462
|
}
|
|
@@ -1129,6 +1131,7 @@ const retryRequestNotifyData = z.union([
|
|
|
1129
1131
|
mailID: z.string(),
|
|
1130
1132
|
}),
|
|
1131
1133
|
]);
|
|
1134
|
+
const serverChangeNotifyData = z.string().min(1).max(128);
|
|
1132
1135
|
|
|
1133
1136
|
/**
|
|
1134
1137
|
* Event signatures emitted by {@link Client}.
|
|
@@ -1165,6 +1168,8 @@ export interface ClientEvents {
|
|
|
1165
1168
|
ready: () => void;
|
|
1166
1169
|
/** Session healing requested a retry for a specific mail ID. */
|
|
1167
1170
|
retryRequest: (retry: RetryRequest) => void;
|
|
1171
|
+
/** Server metadata, channels, membership, or permissions changed. */
|
|
1172
|
+
serverChange: (serverID: string) => void;
|
|
1168
1173
|
/** A new encryption session was established with a peer device. */
|
|
1169
1174
|
session: (session: Session, user: User) => void;
|
|
1170
1175
|
}
|
|
@@ -1210,6 +1215,11 @@ export interface Moderation {
|
|
|
1210
1215
|
fetchPermissionList: (serverID: string) => Promise<Permission[]>;
|
|
1211
1216
|
/** Removes a user from a server by revoking their server permission(s). */
|
|
1212
1217
|
kick: (userID: string, serverID: string) => Promise<void>;
|
|
1218
|
+
/** Changes a server member's role. */
|
|
1219
|
+
setRole: (
|
|
1220
|
+
permissionID: string,
|
|
1221
|
+
powerLevel: 0 | 50 | 100,
|
|
1222
|
+
) => Promise<Permission>;
|
|
1213
1223
|
}
|
|
1214
1224
|
|
|
1215
1225
|
/**
|
|
@@ -1230,14 +1240,22 @@ export interface Servers {
|
|
|
1230
1240
|
create: (name: string) => Promise<Server>;
|
|
1231
1241
|
/** Deletes a server. */
|
|
1232
1242
|
delete: (serverID: string) => Promise<void>;
|
|
1243
|
+
/** Returns the public URL for an immutable server icon ID. */
|
|
1244
|
+
iconURL: (iconID: string) => string;
|
|
1233
1245
|
/** Leaves a server by removing the user's permission entry. */
|
|
1234
1246
|
leave: (serverID: string) => Promise<void>;
|
|
1247
|
+
/** Removes a server's icon. */
|
|
1248
|
+
removeIcon: (serverID: string) => Promise<Server>;
|
|
1235
1249
|
/** Lists servers available to the authenticated user. */
|
|
1236
1250
|
retrieve: () => Promise<Server[]>;
|
|
1237
1251
|
/** Gets one server by ID. */
|
|
1238
1252
|
retrieveByID: (serverID: string) => Promise<null | Server>;
|
|
1239
1253
|
/** Fetches servers and channels in one request for fast bootstraps. */
|
|
1240
1254
|
retrieveWithChannels: () => Promise<ServerChannelBootstrap>;
|
|
1255
|
+
/** Uploads and sets a server icon. */
|
|
1256
|
+
setIcon: (serverID: string, icon: Uint8Array) => Promise<Server>;
|
|
1257
|
+
/** Changes a server's display name. */
|
|
1258
|
+
update: (serverID: string, name: string) => Promise<Server>;
|
|
1241
1259
|
}
|
|
1242
1260
|
|
|
1243
1261
|
/**
|
|
@@ -1458,6 +1476,8 @@ export class Client {
|
|
|
1458
1476
|
* @returns The Channel object, or null.
|
|
1459
1477
|
*/
|
|
1460
1478
|
retrieveByID: this.getChannelByID.bind(this),
|
|
1479
|
+
/** Changes a channel's display name. */
|
|
1480
|
+
update: this.updateChannel.bind(this),
|
|
1461
1481
|
/**
|
|
1462
1482
|
* Retrieves a channel's userlist.
|
|
1463
1483
|
* @param channelID - The channel to retrieve the userlist for.
|
|
@@ -1613,6 +1633,7 @@ export class Client {
|
|
|
1613
1633
|
public moderation: Moderation = {
|
|
1614
1634
|
fetchPermissionList: this.fetchPermissionList.bind(this),
|
|
1615
1635
|
kick: this.kickUser.bind(this),
|
|
1636
|
+
setRole: this.setServerMemberRole.bind(this),
|
|
1616
1637
|
};
|
|
1617
1638
|
|
|
1618
1639
|
/**
|
|
@@ -1673,7 +1694,9 @@ export class Client {
|
|
|
1673
1694
|
* @param serverID - The server to delete.
|
|
1674
1695
|
*/
|
|
1675
1696
|
delete: this.deleteServer.bind(this),
|
|
1697
|
+
iconURL: this.getServerIconURL.bind(this),
|
|
1676
1698
|
leave: this.leaveServer.bind(this),
|
|
1699
|
+
removeIcon: this.removeServerIcon.bind(this),
|
|
1677
1700
|
/**
|
|
1678
1701
|
* Retrieves all servers the logged in user has access to.
|
|
1679
1702
|
*
|
|
@@ -1687,6 +1710,8 @@ export class Client {
|
|
|
1687
1710
|
*/
|
|
1688
1711
|
retrieveByID: this.getServerByID.bind(this),
|
|
1689
1712
|
retrieveWithChannels: this.getServerChannelBootstrap.bind(this),
|
|
1713
|
+
setIcon: this.uploadServerIcon.bind(this),
|
|
1714
|
+
update: this.updateServer.bind(this),
|
|
1690
1715
|
};
|
|
1691
1716
|
|
|
1692
1717
|
/**
|
|
@@ -2855,7 +2880,9 @@ export class Client {
|
|
|
2855
2880
|
|
|
2856
2881
|
private async createServer(name: string): Promise<Server> {
|
|
2857
2882
|
const res = await this.http.post(
|
|
2858
|
-
this.getHost() + "/
|
|
2883
|
+
this.getHost() + "/servers",
|
|
2884
|
+
msgpack.encode({ name }),
|
|
2885
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
2859
2886
|
);
|
|
2860
2887
|
return decodeHttpResponse(ServerCodec, res.data);
|
|
2861
2888
|
}
|
|
@@ -3255,6 +3282,7 @@ export class Client {
|
|
|
3255
3282
|
return [null, isHttpError(err) ? err : null];
|
|
3256
3283
|
}
|
|
3257
3284
|
}
|
|
3285
|
+
|
|
3258
3286
|
private async fetchUserDeviceListOnce(userID: string): Promise<Device[]> {
|
|
3259
3287
|
if (this.isManualCloseInFlight()) {
|
|
3260
3288
|
return [];
|
|
@@ -3334,7 +3362,6 @@ export class Client {
|
|
|
3334
3362
|
this.http.defaults.headers.common.Authorization = `Bearer ${decoded.token}`;
|
|
3335
3363
|
return decoded;
|
|
3336
3364
|
}
|
|
3337
|
-
|
|
3338
3365
|
private async finishPasskeyRegistration(args: {
|
|
3339
3366
|
name: string;
|
|
3340
3367
|
requestID: string;
|
|
@@ -3778,6 +3805,10 @@ export class Client {
|
|
|
3778
3805
|
return decodeHttpResponse(ServerChannelBootstrapCodec, res.data);
|
|
3779
3806
|
}
|
|
3780
3807
|
|
|
3808
|
+
private getServerIconURL(iconID: string): string {
|
|
3809
|
+
return this.getHost() + "/server-icon/" + encodeURIComponent(iconID);
|
|
3810
|
+
}
|
|
3811
|
+
|
|
3781
3812
|
private async getServerList(): Promise<Server[]> {
|
|
3782
3813
|
const res = await this.http.get(
|
|
3783
3814
|
this.getHost() + "/user/" + this.getUser().userID + "/servers",
|
|
@@ -3894,6 +3925,13 @@ export class Client {
|
|
|
3894
3925
|
}
|
|
3895
3926
|
}
|
|
3896
3927
|
break;
|
|
3928
|
+
case "serverChange": {
|
|
3929
|
+
const parsed = serverChangeNotifyData.safeParse(msg.data);
|
|
3930
|
+
if (parsed.success) {
|
|
3931
|
+
this.emitter.emit("serverChange", parsed.data);
|
|
3932
|
+
}
|
|
3933
|
+
break;
|
|
3934
|
+
}
|
|
3897
3935
|
default:
|
|
3898
3936
|
break;
|
|
3899
3937
|
}
|
|
@@ -5314,6 +5352,13 @@ export class Client {
|
|
|
5314
5352
|
);
|
|
5315
5353
|
}
|
|
5316
5354
|
|
|
5355
|
+
private async removeServerIcon(serverID: string): Promise<Server> {
|
|
5356
|
+
const res = await this.http.delete(
|
|
5357
|
+
this.getHost() + "/server-icon/" + serverID,
|
|
5358
|
+
);
|
|
5359
|
+
return decodeHttpResponse(ServerCodec, res.data);
|
|
5360
|
+
}
|
|
5361
|
+
|
|
5317
5362
|
private async replaceAccountPassword(payload: {
|
|
5318
5363
|
currentPassword?: string;
|
|
5319
5364
|
newPassword: string;
|
|
@@ -5720,8 +5765,8 @@ export class Client {
|
|
|
5720
5765
|
a.deviceID.localeCompare(b.deviceID, "en"),
|
|
5721
5766
|
);
|
|
5722
5767
|
|
|
5723
|
-
|
|
5724
|
-
|
|
5768
|
+
const successfulOwnerIDs = new Set<string>();
|
|
5769
|
+
const lastErrorByOwnerID = new Map<string, unknown>();
|
|
5725
5770
|
for (
|
|
5726
5771
|
let index = 0;
|
|
5727
5772
|
index < stableDevices.length;
|
|
@@ -5732,15 +5777,19 @@ export class Client {
|
|
|
5732
5777
|
index + MAIL_FANOUT_CONCURRENCY,
|
|
5733
5778
|
);
|
|
5734
5779
|
const results = await Promise.all(
|
|
5735
|
-
batch.map(async (device)
|
|
5780
|
+
batch.map(async (device) => {
|
|
5736
5781
|
const ownerRecord =
|
|
5737
5782
|
device.owner === myUserID
|
|
5738
5783
|
? this.getUser()
|
|
5739
5784
|
: this.userRecords[device.owner];
|
|
5740
5785
|
if (!ownerRecord) {
|
|
5741
|
-
return
|
|
5742
|
-
|
|
5743
|
-
|
|
5786
|
+
return {
|
|
5787
|
+
device,
|
|
5788
|
+
error: new Error(
|
|
5789
|
+
`Missing owner record for device ${device.deviceID}.`,
|
|
5790
|
+
),
|
|
5791
|
+
ok: false as const,
|
|
5792
|
+
};
|
|
5744
5793
|
}
|
|
5745
5794
|
try {
|
|
5746
5795
|
await this.sendMailWithRecovery(
|
|
@@ -5751,35 +5800,41 @@ export class Client {
|
|
|
5751
5800
|
mailID,
|
|
5752
5801
|
false,
|
|
5753
5802
|
);
|
|
5754
|
-
return
|
|
5755
|
-
} catch (
|
|
5756
|
-
return
|
|
5803
|
+
return { device, ok: true as const };
|
|
5804
|
+
} catch (error: unknown) {
|
|
5805
|
+
return { device, error, ok: false as const };
|
|
5757
5806
|
}
|
|
5758
5807
|
}),
|
|
5759
5808
|
);
|
|
5760
5809
|
for (const result of results) {
|
|
5761
|
-
if (result
|
|
5762
|
-
|
|
5763
|
-
|
|
5810
|
+
if (result.ok) {
|
|
5811
|
+
successfulOwnerIDs.add(result.device.owner);
|
|
5812
|
+
} else {
|
|
5813
|
+
lastErrorByOwnerID.set(result.device.owner, result.error);
|
|
5764
5814
|
}
|
|
5765
5815
|
}
|
|
5766
|
-
if (failCount === stableDevices.length) {
|
|
5767
|
-
break;
|
|
5768
|
-
}
|
|
5769
5816
|
}
|
|
5770
5817
|
|
|
5771
|
-
|
|
5772
|
-
|
|
5773
|
-
|
|
5774
|
-
|
|
5775
|
-
|
|
5776
|
-
if (
|
|
5777
|
-
const
|
|
5778
|
-
|
|
5779
|
-
|
|
5818
|
+
const requiredOwnerIDs =
|
|
5819
|
+
peerUserIDs.length > 0 ? peerUserIDs : [myUserID];
|
|
5820
|
+
const unreachableOwnerIDs = requiredOwnerIDs.filter(
|
|
5821
|
+
(ownerID) => !successfulOwnerIDs.has(ownerID),
|
|
5822
|
+
);
|
|
5823
|
+
if (unreachableOwnerIDs.length > 0) {
|
|
5824
|
+
const lastError = lastErrorByOwnerID.get(
|
|
5825
|
+
unreachableOwnerIDs.at(-1) ?? "",
|
|
5826
|
+
);
|
|
5827
|
+
if (
|
|
5828
|
+
unreachableOwnerIDs.length === requiredOwnerIDs.length &&
|
|
5829
|
+
lastError instanceof Error
|
|
5830
|
+
) {
|
|
5831
|
+
throw lastError;
|
|
5832
|
+
}
|
|
5833
|
+
const deliveryError = new Error(
|
|
5834
|
+
`Message could not be delivered to ${String(unreachableOwnerIDs.length)} channel member(s).`,
|
|
5780
5835
|
);
|
|
5781
|
-
|
|
5782
|
-
throw
|
|
5836
|
+
deliveryError.cause = lastError;
|
|
5837
|
+
throw deliveryError;
|
|
5783
5838
|
}
|
|
5784
5839
|
}
|
|
5785
5840
|
|
|
@@ -6030,6 +6085,7 @@ export class Client {
|
|
|
6030
6085
|
}
|
|
6031
6086
|
let lastErr: unknown;
|
|
6032
6087
|
let failCount = 0;
|
|
6088
|
+
let successCount = 0;
|
|
6033
6089
|
// One logical DM fan-outs to multiple recipient devices. Reuse a
|
|
6034
6090
|
// single mailID so local/UI dedupe treats it as one message.
|
|
6035
6091
|
const messageMailID = uuid.v4();
|
|
@@ -6093,28 +6149,20 @@ export class Client {
|
|
|
6093
6149
|
if (result !== undefined) {
|
|
6094
6150
|
lastErr = result;
|
|
6095
6151
|
failCount += 1;
|
|
6152
|
+
} else {
|
|
6153
|
+
successCount += 1;
|
|
6096
6154
|
}
|
|
6097
6155
|
}
|
|
6098
6156
|
if (failCount === deviceList.length) {
|
|
6099
6157
|
break;
|
|
6100
6158
|
}
|
|
6101
6159
|
}
|
|
6102
|
-
if (
|
|
6160
|
+
if (successCount === 0) {
|
|
6103
6161
|
const base =
|
|
6104
6162
|
lastErr instanceof Error
|
|
6105
6163
|
? lastErr
|
|
6106
6164
|
: new Error(String(lastErr));
|
|
6107
|
-
|
|
6108
|
-
throw base;
|
|
6109
|
-
}
|
|
6110
|
-
// Multi-device: do not “succeed” when only one device of several got mail —
|
|
6111
|
-
// callers and tests have no per-device result and the other copy times out.
|
|
6112
|
-
const partial = new Error(
|
|
6113
|
-
`Direct message failed to reach ${String(failCount)} of ` +
|
|
6114
|
-
`${String(deviceList.length)} peer device(s) (X3DH/post).`,
|
|
6115
|
-
);
|
|
6116
|
-
partial.cause = base;
|
|
6117
|
-
throw partial;
|
|
6165
|
+
throw base;
|
|
6118
6166
|
}
|
|
6119
6167
|
if (
|
|
6120
6168
|
userID !== this.getUser().userID &&
|
|
@@ -6160,6 +6208,18 @@ export class Client {
|
|
|
6160
6208
|
return decodeHttpResponse(AccountEntitlementsCodec, res.data);
|
|
6161
6209
|
}
|
|
6162
6210
|
|
|
6211
|
+
private async setServerMemberRole(
|
|
6212
|
+
permissionID: string,
|
|
6213
|
+
powerLevel: 0 | 50 | 100,
|
|
6214
|
+
): Promise<Permission> {
|
|
6215
|
+
const res = await this.http.patch(
|
|
6216
|
+
this.getHost() + "/permission/" + permissionID,
|
|
6217
|
+
msgpack.encode({ powerLevel }),
|
|
6218
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
6219
|
+
);
|
|
6220
|
+
return decodeHttpResponse(PermissionCodec, res.data);
|
|
6221
|
+
}
|
|
6222
|
+
|
|
6163
6223
|
private setUser(user: User): void {
|
|
6164
6224
|
this.user = user;
|
|
6165
6225
|
// Fresh identity / token: drop stale 404 negative-cache entries so a
|
|
@@ -6240,6 +6300,30 @@ export class Client {
|
|
|
6240
6300
|
);
|
|
6241
6301
|
}
|
|
6242
6302
|
|
|
6303
|
+
private async updateChannel(
|
|
6304
|
+
channelID: string,
|
|
6305
|
+
name: string,
|
|
6306
|
+
): Promise<Channel> {
|
|
6307
|
+
const res = await this.http.patch(
|
|
6308
|
+
this.getHost() + "/channel/" + channelID,
|
|
6309
|
+
msgpack.encode({ name }),
|
|
6310
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
6311
|
+
);
|
|
6312
|
+
return decodeHttpResponse(ChannelCodec, res.data);
|
|
6313
|
+
}
|
|
6314
|
+
|
|
6315
|
+
private async updateServer(
|
|
6316
|
+
serverID: string,
|
|
6317
|
+
name: string,
|
|
6318
|
+
): Promise<Server> {
|
|
6319
|
+
const res = await this.http.patch(
|
|
6320
|
+
this.getHost() + "/server/" + serverID,
|
|
6321
|
+
msgpack.encode({ name }),
|
|
6322
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
6323
|
+
);
|
|
6324
|
+
return decodeHttpResponse(ServerCodec, res.data);
|
|
6325
|
+
}
|
|
6326
|
+
|
|
6243
6327
|
private async uploadAvatar(avatar: Uint8Array): Promise<void> {
|
|
6244
6328
|
const canUseMultipart =
|
|
6245
6329
|
typeof FormData !== "undefined" &&
|
|
@@ -6355,4 +6439,52 @@ export class Client {
|
|
|
6355
6439
|
return null;
|
|
6356
6440
|
}
|
|
6357
6441
|
}
|
|
6442
|
+
|
|
6443
|
+
private async uploadServerIcon(
|
|
6444
|
+
serverID: string,
|
|
6445
|
+
icon: Uint8Array,
|
|
6446
|
+
): Promise<Server> {
|
|
6447
|
+
const canUseMultipart =
|
|
6448
|
+
typeof FormData !== "undefined" &&
|
|
6449
|
+
(() => {
|
|
6450
|
+
try {
|
|
6451
|
+
void new Blob([new Uint8Array([1, 2, 3])]);
|
|
6452
|
+
return true;
|
|
6453
|
+
} catch {
|
|
6454
|
+
return false;
|
|
6455
|
+
}
|
|
6456
|
+
})();
|
|
6457
|
+
|
|
6458
|
+
if (canUseMultipart) {
|
|
6459
|
+
const payload = new FormData();
|
|
6460
|
+
payload.set("icon", new Blob([new Uint8Array(icon)]));
|
|
6461
|
+
const res = await this.http.post(
|
|
6462
|
+
this.getHost() + "/server-icon/" + serverID,
|
|
6463
|
+
payload,
|
|
6464
|
+
{
|
|
6465
|
+
headers: { "Content-Type": "multipart/form-data" },
|
|
6466
|
+
onUploadProgress: (progressEvent) => {
|
|
6467
|
+
const { loaded, total = 0 } = progressEvent;
|
|
6468
|
+
this.emitter.emit("fileProgress", {
|
|
6469
|
+
direction: "upload",
|
|
6470
|
+
loaded,
|
|
6471
|
+
progress: Math.round(
|
|
6472
|
+
(loaded * 100) / (progressEvent.total ?? 1),
|
|
6473
|
+
),
|
|
6474
|
+
token: serverID,
|
|
6475
|
+
total,
|
|
6476
|
+
});
|
|
6477
|
+
},
|
|
6478
|
+
},
|
|
6479
|
+
);
|
|
6480
|
+
return decodeHttpResponse(ServerCodec, res.data);
|
|
6481
|
+
}
|
|
6482
|
+
|
|
6483
|
+
const res = await this.http.post(
|
|
6484
|
+
this.getHost() + "/server-icon/" + serverID + "/json",
|
|
6485
|
+
msgpack.encode({ file: XUtils.encodeBase64(icon) }),
|
|
6486
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
6487
|
+
);
|
|
6488
|
+
return decodeHttpResponse(ServerCodec, res.data);
|
|
6489
|
+
}
|
|
6358
6490
|
}
|
|
@@ -412,6 +412,25 @@ export function platformSuite(
|
|
|
412
412
|
const byID = await client.servers.retrieveByID(server.serverID);
|
|
413
413
|
expect(byID?.serverID).toBe(server.serverID);
|
|
414
414
|
|
|
415
|
+
const renamed = await client.servers.update(
|
|
416
|
+
server.serverID,
|
|
417
|
+
"Renamed Server",
|
|
418
|
+
);
|
|
419
|
+
expect(renamed.name).toBe("Renamed Server");
|
|
420
|
+
|
|
421
|
+
const withIcon = await client.servers.setIcon(
|
|
422
|
+
server.serverID,
|
|
423
|
+
testImage,
|
|
424
|
+
);
|
|
425
|
+
expect(withIcon.icon).toEqual(expect.any(String));
|
|
426
|
+
expect(client.servers.iconURL(withIcon.icon!)).toContain(
|
|
427
|
+
`/server-icon/${withIcon.icon!}`,
|
|
428
|
+
);
|
|
429
|
+
const withoutIcon = await client.servers.removeIcon(
|
|
430
|
+
server.serverID,
|
|
431
|
+
);
|
|
432
|
+
expect(withoutIcon.icon).toBeUndefined();
|
|
433
|
+
|
|
415
434
|
await client.servers.delete(server.serverID);
|
|
416
435
|
const afterDelete = await client.servers.retrieve();
|
|
417
436
|
expect(
|
|
@@ -429,6 +448,12 @@ export function platformSuite(
|
|
|
429
448
|
const byID = await client.channels.retrieveByID(channel.channelID);
|
|
430
449
|
expect(byID?.channelID).toBe(channel.channelID);
|
|
431
450
|
|
|
451
|
+
const renamed = await client.channels.update(
|
|
452
|
+
channel.channelID,
|
|
453
|
+
"Renamed Channel",
|
|
454
|
+
);
|
|
455
|
+
expect(renamed.name).toBe("Renamed Channel");
|
|
456
|
+
|
|
432
457
|
await client.channels.delete(channel.channelID);
|
|
433
458
|
const channels = await client.channels.retrieve(server.serverID);
|
|
434
459
|
expect(
|
|
@@ -436,6 +461,9 @@ export function platformSuite(
|
|
|
436
461
|
).toBe(false);
|
|
437
462
|
// Default channel still exists
|
|
438
463
|
expect(channels.length).toBe(1);
|
|
464
|
+
await expect(
|
|
465
|
+
client.channels.delete(channels[0]!.channelID),
|
|
466
|
+
).rejects.toThrow();
|
|
439
467
|
|
|
440
468
|
await client.servers.delete(server.serverID);
|
|
441
469
|
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2020-2026 Vex Heavy Industries LLC
|
|
3
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
4
|
+
* Commercial licenses available at vex.wtf
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Device, User } from "@vex-chat/types";
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it, vi } from "vitest";
|
|
10
|
+
|
|
11
|
+
import { Client } from "../Client.js";
|
|
12
|
+
|
|
13
|
+
type SendGroupMessage = (
|
|
14
|
+
this: unknown,
|
|
15
|
+
channelID: string,
|
|
16
|
+
message: string,
|
|
17
|
+
) => Promise<void>;
|
|
18
|
+
|
|
19
|
+
type SendMessage = (
|
|
20
|
+
this: unknown,
|
|
21
|
+
userID: string,
|
|
22
|
+
message: string,
|
|
23
|
+
) => Promise<void>;
|
|
24
|
+
|
|
25
|
+
const now = "2026-07-14T00:00:00.000Z";
|
|
26
|
+
|
|
27
|
+
function device(deviceID: string, owner: string): Device {
|
|
28
|
+
return {
|
|
29
|
+
deleted: false,
|
|
30
|
+
deviceID,
|
|
31
|
+
lastLogin: now,
|
|
32
|
+
name: deviceID,
|
|
33
|
+
owner,
|
|
34
|
+
signKey: `${deviceID}-sign-key`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function user(userID: string, username: string): User {
|
|
39
|
+
return { lastSeen: now, userID, username };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("multi-device delivery", () => {
|
|
43
|
+
it("succeeds when one direct-message recipient device is unavailable", async () => {
|
|
44
|
+
const sender = user("user-a", "alice");
|
|
45
|
+
const recipient = user("user-b", "bob");
|
|
46
|
+
const unavailable = device("device-b-1", recipient.userID);
|
|
47
|
+
const available = device("device-b-2", recipient.userID);
|
|
48
|
+
const sendMailWithRecovery = vi.fn((target: Device) =>
|
|
49
|
+
target === unavailable
|
|
50
|
+
? Promise.reject(new Error("Device is unavailable."))
|
|
51
|
+
: Promise.resolve(null),
|
|
52
|
+
);
|
|
53
|
+
const fakeClient = {
|
|
54
|
+
fetchUser: vi.fn(() => Promise.resolve([recipient, null])),
|
|
55
|
+
fetchUserDeviceListOnce: vi.fn(() =>
|
|
56
|
+
Promise.resolve([unavailable, available]),
|
|
57
|
+
),
|
|
58
|
+
fetchUserDeviceListWithBackoff: vi.fn(() =>
|
|
59
|
+
Promise.resolve([unavailable, available]),
|
|
60
|
+
),
|
|
61
|
+
getUser: () => sender,
|
|
62
|
+
sendMailWithRecovery,
|
|
63
|
+
};
|
|
64
|
+
const sendMessage = Reflect.get(
|
|
65
|
+
Client.prototype,
|
|
66
|
+
"sendMessage",
|
|
67
|
+
) as SendMessage;
|
|
68
|
+
|
|
69
|
+
await expect(
|
|
70
|
+
sendMessage.call(fakeClient, recipient.userID, "hello"),
|
|
71
|
+
).resolves.toBeUndefined();
|
|
72
|
+
expect(sendMailWithRecovery).toHaveBeenCalledTimes(2);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("succeeds when another device for each group recipient receives the message", async () => {
|
|
76
|
+
const sender = user("user-a", "alice");
|
|
77
|
+
const recipient = user("user-b", "bob");
|
|
78
|
+
const ownDevice = device("device-a-1", sender.userID);
|
|
79
|
+
const unavailable = device("device-b-1", recipient.userID);
|
|
80
|
+
const available = device("device-b-2", recipient.userID);
|
|
81
|
+
const sendMailWithRecovery = vi.fn((target: Device) =>
|
|
82
|
+
target === unavailable
|
|
83
|
+
? Promise.reject(new Error("Device is unavailable."))
|
|
84
|
+
: Promise.resolve(null),
|
|
85
|
+
);
|
|
86
|
+
const fakeClient = {
|
|
87
|
+
fetchUserDeviceListWithBackoff: vi.fn(() =>
|
|
88
|
+
Promise.resolve([ownDevice]),
|
|
89
|
+
),
|
|
90
|
+
getMultiUserDeviceList: vi.fn(() =>
|
|
91
|
+
Promise.resolve([unavailable, available]),
|
|
92
|
+
),
|
|
93
|
+
getUser: () => sender,
|
|
94
|
+
getUserList: vi.fn(() => Promise.resolve([sender, recipient])),
|
|
95
|
+
sendMailWithRecovery,
|
|
96
|
+
userRecords: {} as Record<string, User>,
|
|
97
|
+
};
|
|
98
|
+
const sendGroupMessage = Reflect.get(
|
|
99
|
+
Client.prototype,
|
|
100
|
+
"sendGroupMessage",
|
|
101
|
+
) as SendGroupMessage;
|
|
102
|
+
|
|
103
|
+
await expect(
|
|
104
|
+
sendGroupMessage.call(
|
|
105
|
+
fakeClient,
|
|
106
|
+
"1b0a66e2-8275-4f8b-84d5-cb7309d41410",
|
|
107
|
+
"hello channel",
|
|
108
|
+
),
|
|
109
|
+
).resolves.toBeUndefined();
|
|
110
|
+
expect(sendMailWithRecovery).toHaveBeenCalledTimes(3);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("still fails a group send when a recipient has no successful device delivery", async () => {
|
|
114
|
+
const sender = user("user-a", "alice");
|
|
115
|
+
const recipient = user("user-b", "bob");
|
|
116
|
+
const ownDevice = device("device-a-1", sender.userID);
|
|
117
|
+
const recipientDevice = device("device-b-1", recipient.userID);
|
|
118
|
+
const fakeClient = {
|
|
119
|
+
fetchUserDeviceListWithBackoff: vi.fn(() =>
|
|
120
|
+
Promise.resolve([ownDevice]),
|
|
121
|
+
),
|
|
122
|
+
getMultiUserDeviceList: vi.fn(() =>
|
|
123
|
+
Promise.resolve([recipientDevice]),
|
|
124
|
+
),
|
|
125
|
+
getUser: () => sender,
|
|
126
|
+
getUserList: vi.fn(() => Promise.resolve([sender, recipient])),
|
|
127
|
+
sendMailWithRecovery: vi.fn((target: Device) =>
|
|
128
|
+
target === recipientDevice
|
|
129
|
+
? Promise.reject(new Error("Recipient is unavailable."))
|
|
130
|
+
: Promise.resolve(null),
|
|
131
|
+
),
|
|
132
|
+
userRecords: {} as Record<string, User>,
|
|
133
|
+
};
|
|
134
|
+
const sendGroupMessage = Reflect.get(
|
|
135
|
+
Client.prototype,
|
|
136
|
+
"sendGroupMessage",
|
|
137
|
+
) as SendGroupMessage;
|
|
138
|
+
|
|
139
|
+
await expect(
|
|
140
|
+
sendGroupMessage.call(
|
|
141
|
+
fakeClient,
|
|
142
|
+
"1b0a66e2-8275-4f8b-84d5-cb7309d41410",
|
|
143
|
+
"hello channel",
|
|
144
|
+
),
|
|
145
|
+
).rejects.toThrow("Recipient is unavailable.");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -138,6 +138,33 @@ describe("SqliteStorage message at-rest encryption", () => {
|
|
|
138
138
|
await storage.close();
|
|
139
139
|
}
|
|
140
140
|
});
|
|
141
|
+
|
|
142
|
+
it("atomically updates an existing Double Ratchet session", async () => {
|
|
143
|
+
const { storage } = makeStorage();
|
|
144
|
+
try {
|
|
145
|
+
await storage.init();
|
|
146
|
+
const session = makeSession({ Ns: 1 });
|
|
147
|
+
await storage.saveSession(session);
|
|
148
|
+
|
|
149
|
+
await storage.saveSession({
|
|
150
|
+
...session,
|
|
151
|
+
CKs: "77".repeat(32),
|
|
152
|
+
lastUsed: "2026-06-02T00:00:00.000Z",
|
|
153
|
+
Ns: 2,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const rows = await storage.getAllSessions();
|
|
157
|
+
expect(rows).toHaveLength(1);
|
|
158
|
+
expect(rows[0]).toMatchObject({
|
|
159
|
+
CKs: "77".repeat(32),
|
|
160
|
+
lastUsed: "2026-06-02T00:00:00.000Z",
|
|
161
|
+
Ns: 2,
|
|
162
|
+
sessionID: session.sessionID,
|
|
163
|
+
});
|
|
164
|
+
} finally {
|
|
165
|
+
await storage.close();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
141
168
|
});
|
|
142
169
|
|
|
143
170
|
function makeMessage(overrides: Partial<Message>): Message {
|