@vex-chat/libvex 9.0.0 → 9.1.1
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 +105 -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 +3 -3
- package/src/Client.ts +181 -40
- package/src/__tests__/harness/shared-suite.ts +34 -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,19 @@ export class Client {
|
|
|
3778
3805
|
return decodeHttpResponse(ServerChannelBootstrapCodec, res.data);
|
|
3779
3806
|
}
|
|
3780
3807
|
|
|
3808
|
+
private getServerIconURL(iconID: string): string {
|
|
3809
|
+
const normalizedIconID = iconID.trim();
|
|
3810
|
+
if (!normalizedIconID) {
|
|
3811
|
+
throw new Error("Server icon ID cannot be empty.");
|
|
3812
|
+
}
|
|
3813
|
+
|
|
3814
|
+
return (
|
|
3815
|
+
this.getHost() +
|
|
3816
|
+
"/server-icon/" +
|
|
3817
|
+
encodeURIComponent(normalizedIconID)
|
|
3818
|
+
);
|
|
3819
|
+
}
|
|
3820
|
+
|
|
3781
3821
|
private async getServerList(): Promise<Server[]> {
|
|
3782
3822
|
const res = await this.http.get(
|
|
3783
3823
|
this.getHost() + "/user/" + this.getUser().userID + "/servers",
|
|
@@ -3894,6 +3934,13 @@ export class Client {
|
|
|
3894
3934
|
}
|
|
3895
3935
|
}
|
|
3896
3936
|
break;
|
|
3937
|
+
case "serverChange": {
|
|
3938
|
+
const parsed = serverChangeNotifyData.safeParse(msg.data);
|
|
3939
|
+
if (parsed.success) {
|
|
3940
|
+
this.emitter.emit("serverChange", parsed.data);
|
|
3941
|
+
}
|
|
3942
|
+
break;
|
|
3943
|
+
}
|
|
3897
3944
|
default:
|
|
3898
3945
|
break;
|
|
3899
3946
|
}
|
|
@@ -5314,6 +5361,13 @@ export class Client {
|
|
|
5314
5361
|
);
|
|
5315
5362
|
}
|
|
5316
5363
|
|
|
5364
|
+
private async removeServerIcon(serverID: string): Promise<Server> {
|
|
5365
|
+
const res = await this.http.delete(
|
|
5366
|
+
this.getHost() + "/server-icon/" + serverID,
|
|
5367
|
+
);
|
|
5368
|
+
return decodeHttpResponse(ServerCodec, res.data);
|
|
5369
|
+
}
|
|
5370
|
+
|
|
5317
5371
|
private async replaceAccountPassword(payload: {
|
|
5318
5372
|
currentPassword?: string;
|
|
5319
5373
|
newPassword: string;
|
|
@@ -5720,8 +5774,8 @@ export class Client {
|
|
|
5720
5774
|
a.deviceID.localeCompare(b.deviceID, "en"),
|
|
5721
5775
|
);
|
|
5722
5776
|
|
|
5723
|
-
|
|
5724
|
-
|
|
5777
|
+
const successfulOwnerIDs = new Set<string>();
|
|
5778
|
+
const lastErrorByOwnerID = new Map<string, unknown>();
|
|
5725
5779
|
for (
|
|
5726
5780
|
let index = 0;
|
|
5727
5781
|
index < stableDevices.length;
|
|
@@ -5732,15 +5786,19 @@ export class Client {
|
|
|
5732
5786
|
index + MAIL_FANOUT_CONCURRENCY,
|
|
5733
5787
|
);
|
|
5734
5788
|
const results = await Promise.all(
|
|
5735
|
-
batch.map(async (device)
|
|
5789
|
+
batch.map(async (device) => {
|
|
5736
5790
|
const ownerRecord =
|
|
5737
5791
|
device.owner === myUserID
|
|
5738
5792
|
? this.getUser()
|
|
5739
5793
|
: this.userRecords[device.owner];
|
|
5740
5794
|
if (!ownerRecord) {
|
|
5741
|
-
return
|
|
5742
|
-
|
|
5743
|
-
|
|
5795
|
+
return {
|
|
5796
|
+
device,
|
|
5797
|
+
error: new Error(
|
|
5798
|
+
`Missing owner record for device ${device.deviceID}.`,
|
|
5799
|
+
),
|
|
5800
|
+
ok: false as const,
|
|
5801
|
+
};
|
|
5744
5802
|
}
|
|
5745
5803
|
try {
|
|
5746
5804
|
await this.sendMailWithRecovery(
|
|
@@ -5751,35 +5809,41 @@ export class Client {
|
|
|
5751
5809
|
mailID,
|
|
5752
5810
|
false,
|
|
5753
5811
|
);
|
|
5754
|
-
return
|
|
5755
|
-
} catch (
|
|
5756
|
-
return
|
|
5812
|
+
return { device, ok: true as const };
|
|
5813
|
+
} catch (error: unknown) {
|
|
5814
|
+
return { device, error, ok: false as const };
|
|
5757
5815
|
}
|
|
5758
5816
|
}),
|
|
5759
5817
|
);
|
|
5760
5818
|
for (const result of results) {
|
|
5761
|
-
if (result
|
|
5762
|
-
|
|
5763
|
-
|
|
5819
|
+
if (result.ok) {
|
|
5820
|
+
successfulOwnerIDs.add(result.device.owner);
|
|
5821
|
+
} else {
|
|
5822
|
+
lastErrorByOwnerID.set(result.device.owner, result.error);
|
|
5764
5823
|
}
|
|
5765
5824
|
}
|
|
5766
|
-
if (failCount === stableDevices.length) {
|
|
5767
|
-
break;
|
|
5768
|
-
}
|
|
5769
5825
|
}
|
|
5770
5826
|
|
|
5771
|
-
|
|
5772
|
-
|
|
5773
|
-
|
|
5774
|
-
|
|
5775
|
-
|
|
5776
|
-
if (
|
|
5777
|
-
const
|
|
5778
|
-
|
|
5779
|
-
`${String(stableDevices.length)} peer device(s).`,
|
|
5827
|
+
const requiredOwnerIDs =
|
|
5828
|
+
peerUserIDs.length > 0 ? peerUserIDs : [myUserID];
|
|
5829
|
+
const unreachableOwnerIDs = requiredOwnerIDs.filter(
|
|
5830
|
+
(ownerID) => !successfulOwnerIDs.has(ownerID),
|
|
5831
|
+
);
|
|
5832
|
+
if (unreachableOwnerIDs.length > 0) {
|
|
5833
|
+
const lastError = lastErrorByOwnerID.get(
|
|
5834
|
+
unreachableOwnerIDs.at(-1) ?? "",
|
|
5780
5835
|
);
|
|
5781
|
-
|
|
5782
|
-
|
|
5836
|
+
if (
|
|
5837
|
+
unreachableOwnerIDs.length === requiredOwnerIDs.length &&
|
|
5838
|
+
lastError instanceof Error
|
|
5839
|
+
) {
|
|
5840
|
+
throw lastError;
|
|
5841
|
+
}
|
|
5842
|
+
const deliveryError = new Error(
|
|
5843
|
+
`Message could not be delivered to ${String(unreachableOwnerIDs.length)} channel member(s).`,
|
|
5844
|
+
);
|
|
5845
|
+
deliveryError.cause = lastError;
|
|
5846
|
+
throw deliveryError;
|
|
5783
5847
|
}
|
|
5784
5848
|
}
|
|
5785
5849
|
|
|
@@ -6030,6 +6094,7 @@ export class Client {
|
|
|
6030
6094
|
}
|
|
6031
6095
|
let lastErr: unknown;
|
|
6032
6096
|
let failCount = 0;
|
|
6097
|
+
let successCount = 0;
|
|
6033
6098
|
// One logical DM fan-outs to multiple recipient devices. Reuse a
|
|
6034
6099
|
// single mailID so local/UI dedupe treats it as one message.
|
|
6035
6100
|
const messageMailID = uuid.v4();
|
|
@@ -6093,28 +6158,20 @@ export class Client {
|
|
|
6093
6158
|
if (result !== undefined) {
|
|
6094
6159
|
lastErr = result;
|
|
6095
6160
|
failCount += 1;
|
|
6161
|
+
} else {
|
|
6162
|
+
successCount += 1;
|
|
6096
6163
|
}
|
|
6097
6164
|
}
|
|
6098
6165
|
if (failCount === deviceList.length) {
|
|
6099
6166
|
break;
|
|
6100
6167
|
}
|
|
6101
6168
|
}
|
|
6102
|
-
if (
|
|
6169
|
+
if (successCount === 0) {
|
|
6103
6170
|
const base =
|
|
6104
6171
|
lastErr instanceof Error
|
|
6105
6172
|
? lastErr
|
|
6106
6173
|
: 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;
|
|
6174
|
+
throw base;
|
|
6118
6175
|
}
|
|
6119
6176
|
if (
|
|
6120
6177
|
userID !== this.getUser().userID &&
|
|
@@ -6160,6 +6217,18 @@ export class Client {
|
|
|
6160
6217
|
return decodeHttpResponse(AccountEntitlementsCodec, res.data);
|
|
6161
6218
|
}
|
|
6162
6219
|
|
|
6220
|
+
private async setServerMemberRole(
|
|
6221
|
+
permissionID: string,
|
|
6222
|
+
powerLevel: 0 | 50 | 100,
|
|
6223
|
+
): Promise<Permission> {
|
|
6224
|
+
const res = await this.http.patch(
|
|
6225
|
+
this.getHost() + "/permission/" + permissionID,
|
|
6226
|
+
msgpack.encode({ powerLevel }),
|
|
6227
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
6228
|
+
);
|
|
6229
|
+
return decodeHttpResponse(PermissionCodec, res.data);
|
|
6230
|
+
}
|
|
6231
|
+
|
|
6163
6232
|
private setUser(user: User): void {
|
|
6164
6233
|
this.user = user;
|
|
6165
6234
|
// Fresh identity / token: drop stale 404 negative-cache entries so a
|
|
@@ -6240,6 +6309,30 @@ export class Client {
|
|
|
6240
6309
|
);
|
|
6241
6310
|
}
|
|
6242
6311
|
|
|
6312
|
+
private async updateChannel(
|
|
6313
|
+
channelID: string,
|
|
6314
|
+
name: string,
|
|
6315
|
+
): Promise<Channel> {
|
|
6316
|
+
const res = await this.http.patch(
|
|
6317
|
+
this.getHost() + "/channel/" + channelID,
|
|
6318
|
+
msgpack.encode({ name }),
|
|
6319
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
6320
|
+
);
|
|
6321
|
+
return decodeHttpResponse(ChannelCodec, res.data);
|
|
6322
|
+
}
|
|
6323
|
+
|
|
6324
|
+
private async updateServer(
|
|
6325
|
+
serverID: string,
|
|
6326
|
+
name: string,
|
|
6327
|
+
): Promise<Server> {
|
|
6328
|
+
const res = await this.http.patch(
|
|
6329
|
+
this.getHost() + "/server/" + serverID,
|
|
6330
|
+
msgpack.encode({ name }),
|
|
6331
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
6332
|
+
);
|
|
6333
|
+
return decodeHttpResponse(ServerCodec, res.data);
|
|
6334
|
+
}
|
|
6335
|
+
|
|
6243
6336
|
private async uploadAvatar(avatar: Uint8Array): Promise<void> {
|
|
6244
6337
|
const canUseMultipart =
|
|
6245
6338
|
typeof FormData !== "undefined" &&
|
|
@@ -6355,4 +6448,52 @@ export class Client {
|
|
|
6355
6448
|
return null;
|
|
6356
6449
|
}
|
|
6357
6450
|
}
|
|
6451
|
+
|
|
6452
|
+
private async uploadServerIcon(
|
|
6453
|
+
serverID: string,
|
|
6454
|
+
icon: Uint8Array,
|
|
6455
|
+
): Promise<Server> {
|
|
6456
|
+
const canUseMultipart =
|
|
6457
|
+
typeof FormData !== "undefined" &&
|
|
6458
|
+
(() => {
|
|
6459
|
+
try {
|
|
6460
|
+
void new Blob([new Uint8Array([1, 2, 3])]);
|
|
6461
|
+
return true;
|
|
6462
|
+
} catch {
|
|
6463
|
+
return false;
|
|
6464
|
+
}
|
|
6465
|
+
})();
|
|
6466
|
+
|
|
6467
|
+
if (canUseMultipart) {
|
|
6468
|
+
const payload = new FormData();
|
|
6469
|
+
payload.set("icon", new Blob([new Uint8Array(icon)]));
|
|
6470
|
+
const res = await this.http.post(
|
|
6471
|
+
this.getHost() + "/server-icon/" + serverID,
|
|
6472
|
+
payload,
|
|
6473
|
+
{
|
|
6474
|
+
headers: { "Content-Type": "multipart/form-data" },
|
|
6475
|
+
onUploadProgress: (progressEvent) => {
|
|
6476
|
+
const { loaded, total = 0 } = progressEvent;
|
|
6477
|
+
this.emitter.emit("fileProgress", {
|
|
6478
|
+
direction: "upload",
|
|
6479
|
+
loaded,
|
|
6480
|
+
progress: Math.round(
|
|
6481
|
+
(loaded * 100) / (progressEvent.total ?? 1),
|
|
6482
|
+
),
|
|
6483
|
+
token: serverID,
|
|
6484
|
+
total,
|
|
6485
|
+
});
|
|
6486
|
+
},
|
|
6487
|
+
},
|
|
6488
|
+
);
|
|
6489
|
+
return decodeHttpResponse(ServerCodec, res.data);
|
|
6490
|
+
}
|
|
6491
|
+
|
|
6492
|
+
const res = await this.http.post(
|
|
6493
|
+
this.getHost() + "/server-icon/" + serverID + "/json",
|
|
6494
|
+
msgpack.encode({ file: XUtils.encodeBase64(icon) }),
|
|
6495
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
6496
|
+
);
|
|
6497
|
+
return decodeHttpResponse(ServerCodec, res.data);
|
|
6498
|
+
}
|
|
6358
6499
|
}
|
|
@@ -412,6 +412,31 @@ 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
|
+
expect(client.servers.iconURL(` ${withIcon.icon!} `)).toBe(
|
|
430
|
+
client.servers.iconURL(withIcon.icon!),
|
|
431
|
+
);
|
|
432
|
+
expect(() => client.servers.iconURL(" ")).toThrow(
|
|
433
|
+
"Server icon ID cannot be empty.",
|
|
434
|
+
);
|
|
435
|
+
const withoutIcon = await client.servers.removeIcon(
|
|
436
|
+
server.serverID,
|
|
437
|
+
);
|
|
438
|
+
expect(withoutIcon.icon).toBeUndefined();
|
|
439
|
+
|
|
415
440
|
await client.servers.delete(server.serverID);
|
|
416
441
|
const afterDelete = await client.servers.retrieve();
|
|
417
442
|
expect(
|
|
@@ -429,6 +454,12 @@ export function platformSuite(
|
|
|
429
454
|
const byID = await client.channels.retrieveByID(channel.channelID);
|
|
430
455
|
expect(byID?.channelID).toBe(channel.channelID);
|
|
431
456
|
|
|
457
|
+
const renamed = await client.channels.update(
|
|
458
|
+
channel.channelID,
|
|
459
|
+
"Renamed Channel",
|
|
460
|
+
);
|
|
461
|
+
expect(renamed.name).toBe("Renamed Channel");
|
|
462
|
+
|
|
432
463
|
await client.channels.delete(channel.channelID);
|
|
433
464
|
const channels = await client.channels.retrieve(server.serverID);
|
|
434
465
|
expect(
|
|
@@ -436,6 +467,9 @@ export function platformSuite(
|
|
|
436
467
|
).toBe(false);
|
|
437
468
|
// Default channel still exists
|
|
438
469
|
expect(channels.length).toBe(1);
|
|
470
|
+
await expect(
|
|
471
|
+
client.channels.delete(channels[0]!.channelID),
|
|
472
|
+
).rejects.toThrow();
|
|
439
473
|
|
|
440
474
|
await client.servers.delete(server.serverID);
|
|
441
475
|
});
|
|
@@ -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 {
|