@vex-chat/libvex 7.3.0 → 8.0.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/src/Client.ts CHANGED
@@ -14,10 +14,14 @@ import type {
14
14
  } from "./types/index.js";
15
15
  import type { KeyPair } from "@vex-chat/crypto";
16
16
  import type {
17
+ AccountEntitlements,
18
+ AccountTier,
17
19
  ActionToken,
18
- CallAction,
19
- CallEnvelopeBody,
20
+ AppleTransactionVerificationRequest,
21
+ BillingAccountState,
22
+ BillingProduct,
20
23
  CallEvent,
24
+ CallResourceData,
21
25
  CallSession,
22
26
  CallSignalPayload,
23
27
  ChallMsg,
@@ -27,10 +31,10 @@ import type {
27
31
  Emoji,
28
32
  FileResponse,
29
33
  FileSQL,
34
+ GooglePurchaseVerificationRequest,
30
35
  IceServerConfig,
31
36
  Invite,
32
37
  KeyBundle,
33
- MailNotificationHint,
34
38
  MailWS,
35
39
  NotifyMsg,
36
40
  Passkey,
@@ -44,7 +48,6 @@ import type {
44
48
  Server,
45
49
  ServerChannelBootstrap,
46
50
  SessionSQL,
47
- SignedCallEnvelope,
48
51
  } from "@vex-chat/types";
49
52
  import type { ClientMessage } from "@vex-chat/types";
50
53
 
@@ -78,13 +81,12 @@ import {
78
81
  XUtils,
79
82
  } from "@vex-chat/crypto";
80
83
  import {
81
- CallEnvelopeBodySchema,
82
84
  CallEventSchema,
85
+ CallSessionSchema,
83
86
  IceServerConfigSchema,
84
87
  MailType,
85
88
  MailWSSchema,
86
89
  PermissionSchema,
87
- SignedCallEnvelopeSchema,
88
90
  WSMessageSchema,
89
91
  } from "@vex-chat/types";
90
92
 
@@ -202,6 +204,16 @@ function debugLibvexDm(
202
204
  console.error(`[libvex:debug-dm] ${payload}`);
203
205
  }
204
206
 
207
+ function errorFromUnknown(err: unknown): Error {
208
+ if (err instanceof Error) {
209
+ return err;
210
+ }
211
+ if (typeof err === "string") {
212
+ return new Error(err);
213
+ }
214
+ return new Error(JSON.stringify(err));
215
+ }
216
+
205
217
  function ignoreSocketTeardown(err: unknown): void {
206
218
  if (err instanceof WebSocketNotOpenError) return;
207
219
  // Re-throw anything else as a real unhandled rejection so it
@@ -329,8 +341,11 @@ function spireErrorBodyMessage(data: unknown, max = 8_000): string {
329
341
 
330
342
  import { msgpack } from "./codec.js";
331
343
  import {
344
+ AccountEntitlementsCodec,
332
345
  ActionTokenCodec,
333
346
  AuthResponseCodec,
347
+ BillingAccountStateCodec,
348
+ BillingProductArrayCodec,
334
349
  ChannelArrayCodec,
335
350
  ChannelCodec,
336
351
  ConnectResponseCodec,
@@ -368,6 +383,24 @@ import { uuidToUint8 } from "./utils/uint8uuid.js";
368
383
 
369
384
  const _protocolMsgRegex = /��\w+:\w+��/g;
370
385
 
386
+ /**
387
+ * @ignore
388
+ */
389
+ export interface Billing {
390
+ /** Returns the configured StoreKit / Play Billing product catalog. */
391
+ products: () => Promise<BillingProduct[]>;
392
+ /** Returns the current billing subscriptions and derived entitlements. */
393
+ retrieve: () => Promise<BillingAccountState>;
394
+ /** Submits an App Store transaction for server-side verification. */
395
+ submitAppleTransaction: (
396
+ request: AppleTransactionVerificationRequest,
397
+ ) => Promise<BillingAccountState>;
398
+ /** Submits a Google Play purchase token for server-side verification. */
399
+ submitGooglePurchase: (
400
+ request: GooglePurchaseVerificationRequest,
401
+ ) => Promise<BillingAccountState>;
402
+ }
403
+
371
404
  /**
372
405
  * Voice-call signaling operations.
373
406
  *
@@ -445,6 +478,18 @@ export interface ClientOptions {
445
478
 
446
479
  export type DeviceRegistrationResult = Device | PendingDeviceRegistration;
447
480
 
481
+ /**
482
+ * Permission is a permission to a resource.
483
+ *
484
+ * Common fields:
485
+ * - `permissionID`: unique permission row ID
486
+ * - `userID`: user receiving this grant
487
+ * - `resourceID`: target server/channel/etc.
488
+ * - `resourceType`: type string for the resource
489
+ * - `powerLevel`: authorization level
490
+ */
491
+ export type { Permission } from "@vex-chat/types";
492
+
448
493
  /**
449
494
  * @ignore
450
495
  */
@@ -529,18 +574,6 @@ export interface Devices {
529
574
  retrieve: (deviceIdentifier: string) => Promise<Device | null>;
530
575
  }
531
576
 
532
- /**
533
- * Permission is a permission to a resource.
534
- *
535
- * Common fields:
536
- * - `permissionID`: unique permission row ID
537
- * - `userID`: user receiving this grant
538
- * - `resourceID`: target server/channel/etc.
539
- * - `resourceType`: type string for the resource
540
- * - `powerLevel`: authorization level
541
- */
542
- export type { Permission } from "@vex-chat/types";
543
-
544
577
  /**
545
578
  * @ignore
546
579
  */
@@ -557,6 +590,23 @@ export interface Emojis {
557
590
  retrieveList: (serverID: string) => Promise<Emoji[]>;
558
591
  }
559
592
 
593
+ /**
594
+ * @ignore
595
+ */
596
+ export interface Entitlements {
597
+ /** Retrieves the server-authoritative entitlement snapshot. */
598
+ retrieve: () => Promise<AccountEntitlements>;
599
+ /**
600
+ * Local development helper. Spire only serves this endpoint when
601
+ * `VEX_ENABLE_DEV_ENTITLEMENTS=1`, `DEV_API_KEY` is configured, and
602
+ * `NODE_ENV` is not `production`.
603
+ */
604
+ setDevTier: (
605
+ tier: AccountTier,
606
+ options?: { expiresAt?: null | string },
607
+ ) => Promise<AccountEntitlements>;
608
+ }
609
+
560
610
  /**
561
611
  * Device record associated with a user account.
562
612
  *
@@ -724,7 +774,7 @@ export type { Server } from "@vex-chat/types";
724
774
  export type { ServerChannelBootstrap } from "@vex-chat/types";
725
775
 
726
776
  export interface NotificationSubscription {
727
- channel: NotificationSubscriptionChannel;
777
+ channel: "expo";
728
778
  createdAt: string;
729
779
  deviceID: string;
730
780
  enabled: boolean;
@@ -736,17 +786,9 @@ export interface NotificationSubscription {
736
786
  userID: string;
737
787
  }
738
788
 
739
- export type NotificationSubscriptionChannel = "apnsVoip" | "expo" | "fcmCall";
740
-
741
- const NotificationSubscriptionChannelSchema = z.enum([
742
- "apnsVoip",
743
- "expo",
744
- "fcmCall",
745
- ]);
746
-
747
789
  const NotificationSubscriptionSchema: z.ZodType<NotificationSubscription> =
748
790
  z.object({
749
- channel: NotificationSubscriptionChannelSchema,
791
+ channel: z.literal("expo"),
750
792
  createdAt: z.string(),
751
793
  deviceID: z.string(),
752
794
  enabled: z.boolean(),
@@ -759,7 +801,7 @@ const NotificationSubscriptionSchema: z.ZodType<NotificationSubscription> =
759
801
  });
760
802
 
761
803
  export interface NotificationSubscriptionInput {
762
- channel: NotificationSubscriptionChannel;
804
+ channel: "expo";
763
805
  events?: string[];
764
806
  platform?: "android" | "ios" | "web";
765
807
  token: string;
@@ -941,35 +983,17 @@ const messageSchema: z.ZodType<Message> = z.object({
941
983
  timestamp: z.string(),
942
984
  });
943
985
 
944
- const CALL_ENVELOPE_PREFIX = "vex-call:1\n";
945
- const CALL_INVITE_TTL_MS = 60_000;
946
- const CALL_MAX_TTL_MS = 2 * 60 * 60 * 1000;
947
986
  const MESSAGE_BLOB_PREFIX = "vex-message:1\n";
948
987
  const MAIL_FANOUT_CONCURRENCY = 8;
949
988
  const MAIL_BATCH_MAX_SIZE = 32;
950
989
  const MAIL_BATCH_FLUSH_DELAY_MS = 8;
951
990
 
952
- interface CallWakeNotifyData {
953
- callID: string;
954
- expiresAt?: string | undefined;
955
- mailID?: string | undefined;
956
- mailNonce?: string | undefined;
957
- }
958
-
959
991
  interface DecodedMessagePlaintext {
960
992
  extra?: null | string | undefined;
961
993
  message: string;
962
994
  retentionHintDays?: number | undefined;
963
995
  }
964
996
 
965
- interface EncryptedCallState {
966
- peerDeviceID?: string | undefined;
967
- peerUserID: string;
968
- pendingPeerDevices: Device[];
969
- sequence: number;
970
- session: CallSession;
971
- }
972
-
973
997
  interface PendingMailBatchDelivery {
974
998
  header: Uint8Array;
975
999
  mail: MailWS;
@@ -991,62 +1015,6 @@ const mailBatchResponseSchema = z.object({
991
1015
  ),
992
1016
  });
993
1017
 
994
- const callWakeNotifyData = z.object({
995
- callID: z.string(),
996
- expiresAt: z.string().optional(),
997
- mailID: z.string().optional(),
998
- mailNonce: z.string().optional(),
999
- });
1000
-
1001
- function canonicalizeJson(value: unknown): unknown {
1002
- if (Array.isArray(value)) {
1003
- return value.map((item) => canonicalizeJson(item));
1004
- }
1005
- if (!isRecord(value)) {
1006
- return value;
1007
- }
1008
- const out: Record<string, unknown> = {};
1009
- for (const key of Object.keys(value).sort()) {
1010
- const item = value[key];
1011
- if (item !== undefined) {
1012
- out[key] = canonicalizeJson(item);
1013
- }
1014
- }
1015
- return out;
1016
- }
1017
-
1018
- function canonicalJsonBytes(value: unknown): Uint8Array {
1019
- return XUtils.decodeUTF8(
1020
- JSON.stringify(canonicalizeJson(jsonWireValue(value))),
1021
- );
1022
- }
1023
-
1024
- function cloneCallSession(session: CallSession): CallSession {
1025
- return {
1026
- ...session,
1027
- participants: session.participants.map((participant) => ({
1028
- ...participant,
1029
- })),
1030
- };
1031
- }
1032
-
1033
- function decodeCallEnvelopePlaintext(
1034
- plaintext: string,
1035
- ): null | SignedCallEnvelope {
1036
- if (!plaintext.startsWith(CALL_ENVELOPE_PREFIX)) {
1037
- return null;
1038
- }
1039
- try {
1040
- const raw = JSON.parse(
1041
- plaintext.slice(CALL_ENVELOPE_PREFIX.length),
1042
- ) as unknown;
1043
- const parsed = SignedCallEnvelopeSchema.safeParse(raw);
1044
- return parsed.success ? parsed.data : null;
1045
- } catch {
1046
- return null;
1047
- }
1048
- }
1049
-
1050
1018
  function decodeMessageBlob(body: string): DecodedMessagePlaintext {
1051
1019
  if (!body.startsWith(MESSAGE_BLOB_PREFIX)) {
1052
1020
  return { message: body };
@@ -1084,10 +1052,6 @@ function decodeMessagePlaintext(plaintext: string): DecodedMessagePlaintext {
1084
1052
  : blob;
1085
1053
  }
1086
1054
 
1087
- function encodeCallEnvelopePlaintext(envelope: SignedCallEnvelope): Uint8Array {
1088
- return XUtils.decodeUTF8(CALL_ENVELOPE_PREFIX + JSON.stringify(envelope));
1089
- }
1090
-
1091
1055
  function encodeMessagePlaintext(
1092
1056
  message: string,
1093
1057
  opts?: MessageSendOptions,
@@ -1103,13 +1067,6 @@ function encodeMessagePlaintext(
1103
1067
  return formatVexRetentionEnvelope(body, opts?.retentionHintDays);
1104
1068
  }
1105
1069
 
1106
- function jsonWireValue(value: unknown): unknown {
1107
- // Match the payload JSON.stringify will send, including RTC toJSON() output.
1108
- const encoded = JSON.stringify(value);
1109
- const parsed: unknown = JSON.parse(encoded);
1110
- return parsed;
1111
- }
1112
-
1113
1070
  function messageFromDecodedPlaintext(
1114
1071
  decoded: DecodedMessagePlaintext,
1115
1072
  ): Pick<Message, "extra" | "message" | "retentionHintDays"> {
@@ -1122,12 +1079,6 @@ function messageFromDecodedPlaintext(
1122
1079
  };
1123
1080
  }
1124
1081
 
1125
- function normalizeCallEnvelopeBodyForWire(
1126
- body: CallEnvelopeBody,
1127
- ): CallEnvelopeBody {
1128
- return CallEnvelopeBodySchema.parse(jsonWireValue(body));
1129
- }
1130
-
1131
1082
  function normalizeForwardedMessage(message: Message): Message {
1132
1083
  const decoded = decodeMessagePlaintext(message.message);
1133
1084
  return {
@@ -1169,8 +1120,6 @@ const retryRequestNotifyData = z.union([
1169
1120
  export interface ClientEvents {
1170
1121
  /** Voice-call signaling changed or an incoming call was received. */
1171
1122
  call: (event: CallEvent) => void;
1172
- /** Native/mobile call wake hint arrived; clients should sync call mail. */
1173
- callWake: (wake: CallWakeNotifyData) => void;
1174
1123
  /** The client has been shut down (via {@link Client.close}). */
1175
1124
  closed: () => void;
1176
1125
  /** WebSocket authorized by the server; pre-auth setup begins. */
@@ -1357,8 +1306,10 @@ export interface Users {
1357
1306
  * const client = await Client.create(privateKey);
1358
1307
  *
1359
1308
  * // you must register once before you can log in
1360
- * await client.register(Client.randomUsername());
1361
- * await client.login();
1309
+ * const username = Client.randomUsername();
1310
+ * const password = "correct horse battery staple";
1311
+ * await client.register(username, password);
1312
+ * await client.login(username, password);
1362
1313
  *
1363
1314
  * // The ready event fires after connect() finishes post-auth setup.
1364
1315
  * // Wait for it before performing messaging or user operations.
@@ -1417,6 +1368,14 @@ export class Client {
1417
1368
 
1418
1369
  private static readonly NOT_FOUND_TTL = 30 * 60 * 1000;
1419
1370
 
1371
+ /** Store subscription billing methods. */
1372
+ public billing: Billing = {
1373
+ products: this.getBillingProducts.bind(this),
1374
+ retrieve: this.getBillingAccountState.bind(this),
1375
+ submitAppleTransaction: this.submitAppleTransaction.bind(this),
1376
+ submitGooglePurchase: this.submitGooglePurchase.bind(this),
1377
+ };
1378
+
1420
1379
  /**
1421
1380
  * Voice-call signaling operations.
1422
1381
  *
@@ -1425,21 +1384,25 @@ export class Client {
1425
1384
  */
1426
1385
  public calls: Calls = {
1427
1386
  accept: (callID: string, signal?: CallSignalPayload) =>
1428
- this.sendEncryptedCallAction("accept", callID, signal),
1387
+ this.sendCallResource("ACCEPT", {
1388
+ callID,
1389
+ ...(signal ? { signal } : {}),
1390
+ }),
1429
1391
  active: this.fetchActiveCalls.bind(this),
1430
- cancel: (callID: string) =>
1431
- this.sendEncryptedCallAction("cancel", callID),
1432
- hangup: (callID: string) =>
1433
- this.sendEncryptedCallAction("hangup", callID),
1392
+ cancel: (callID: string) => this.sendCallResource("CANCEL", { callID }),
1393
+ hangup: (callID: string) => this.sendCallResource("HANGUP", { callID }),
1434
1394
  ice: (callID: string, signal: CallSignalPayload) =>
1435
- this.sendEncryptedCallAction("ice", callID, signal),
1395
+ this.sendCallResource("ICE", { callID, signal }),
1436
1396
  iceServers: this.fetchIceServers.bind(this),
1437
- reject: (callID: string) =>
1438
- this.sendEncryptedCallAction("reject", callID),
1397
+ reject: (callID: string) => this.sendCallResource("REJECT", { callID }),
1439
1398
  signal: (callID: string, signal: CallSignalPayload) =>
1440
- this.sendEncryptedCallAction("signal", callID, signal),
1399
+ this.sendCallResource("SIGNAL", { callID, signal }),
1441
1400
  startDM: (recipientUserID: string, signal?: CallSignalPayload) =>
1442
- this.startEncryptedDmCall(recipientUserID, signal),
1401
+ this.sendCallResource("INVITE", {
1402
+ conversationType: "dm",
1403
+ recipientUserID,
1404
+ ...(signal ? { signal } : {}),
1405
+ }),
1443
1406
  };
1444
1407
 
1445
1408
  /**
@@ -1521,6 +1484,12 @@ export class Client {
1521
1484
  retrieveList: this.retrieveEmojiList.bind(this),
1522
1485
  };
1523
1486
 
1487
+ /** Account entitlement methods. */
1488
+ public entitlements: Entitlements = {
1489
+ retrieve: this.getAccountEntitlements.bind(this),
1490
+ setDevTier: this.setDevAccountTier.bind(this),
1491
+ };
1492
+
1524
1493
  /** File upload/download methods. */
1525
1494
  public files: Files = {
1526
1495
  /**
@@ -1753,8 +1722,6 @@ export class Client {
1753
1722
 
1754
1723
  private autoReconnectEnabled = false;
1755
1724
 
1756
- private readonly callStates = new Map<string, EncryptedCallState>();
1757
-
1758
1725
  private readonly cryptoProfile: CryptoProfile;
1759
1726
 
1760
1727
  private readonly database: Storage;
@@ -1764,12 +1731,13 @@ export class Client {
1764
1731
  private readonly decryptFailureCounts = new Map<string, number>();
1765
1732
 
1766
1733
  private device?: Device;
1767
- private deviceRecords: Record<string, Device> = {};
1768
1734
 
1735
+ private deviceRecords: Record<string, Device> = {};
1769
1736
  // ── Event subscription (composition over inheritance) ───────────────
1770
1737
  private readonly emitter = new EventEmitter<ClientEvents>();
1771
1738
 
1772
1739
  private fetchingMail: boolean = false;
1740
+
1773
1741
  private firstMailFetch = true;
1774
1742
  private readonly forwarded = new Set<string>();
1775
1743
  private readonly host: string;
@@ -1777,8 +1745,8 @@ export class Client {
1777
1745
  /** Cancels in-flight HTTP work on `close()` so `postAuth`/`getMail` cannot hang forever. */
1778
1746
  private readonly httpAbortController = new AbortController();
1779
1747
  private readonly idKeys: KeyPair | null;
1780
-
1781
1748
  private isAlive: boolean = true;
1749
+
1782
1750
  private localMessageRetentionDays: number;
1783
1751
  private localRetentionPurgeTimer: null | ReturnType<typeof setInterval> =
1784
1752
  null;
@@ -2400,12 +2368,12 @@ export class Client {
2400
2368
  * Registers a new account on the server.
2401
2369
  *
2402
2370
  * @param username - Optional username to register (must be unique when provided).
2403
- * @param password - Optional legacy password used when talking to pre-keycluster servers.
2371
+ * @param password - Password for new accounts. Existing-account device approval requests may omit it.
2404
2372
  * @returns `[user, null]` on success, `[null, error]` on failure.
2405
2373
  *
2406
2374
  * @example
2407
2375
  * ```ts
2408
- * const [user, err] = await client.register("MyUsername");
2376
+ * const [user, err] = await client.register("MyUsername", "correct horse battery staple");
2409
2377
  * ```
2410
2378
  */
2411
2379
  public async register(
@@ -2427,7 +2395,7 @@ export class Client {
2427
2395
  const resolvedPassword =
2428
2396
  password?.trim().length !== 0 && password !== undefined
2429
2397
  ? password
2430
- : uuid.v4();
2398
+ : undefined;
2431
2399
  const signKey = XUtils.encodeHex(this.signKeys.publicKey);
2432
2400
  const signed = XUtils.encodeHex(
2433
2401
  await xSignAsync(
@@ -2438,7 +2406,6 @@ export class Client {
2438
2406
  const preKeyIndex = this.xKeyRing.preKeys.index;
2439
2407
  const regMsg: RegistrationPayload = {
2440
2408
  deviceName: this.options?.deviceName ?? "unknown",
2441
- password: resolvedPassword,
2442
2409
  preKey: XUtils.encodeHex(
2443
2410
  this.xKeyRing.preKeys.keyPair.publicKey,
2444
2411
  ),
@@ -2450,6 +2417,9 @@ export class Client {
2450
2417
  signKey,
2451
2418
  username: resolvedUsername,
2452
2419
  };
2420
+ if (resolvedPassword !== undefined) {
2421
+ regMsg.password = resolvedPassword;
2422
+ }
2453
2423
  try {
2454
2424
  const res = await this.http.post(
2455
2425
  this.getHost() + "/register",
@@ -2502,6 +2472,14 @@ export class Client {
2502
2472
  this.setUser(legacyUser);
2503
2473
 
2504
2474
  // Legacy servers require /auth after /register to get a JWT.
2475
+ if (resolvedPassword === undefined) {
2476
+ return [
2477
+ null,
2478
+ new Error(
2479
+ "Legacy register succeeded without a password, so the SDK could not complete login.",
2480
+ ),
2481
+ ];
2482
+ }
2505
2483
  const loginResult = await this.login(
2506
2484
  resolvedUsername,
2507
2485
  resolvedPassword,
@@ -2666,92 +2644,6 @@ export class Client {
2666
2644
  this.acknowledgeInboundMail(mail);
2667
2645
  }
2668
2646
 
2669
- private applyCallEnvelopeBody(body: CallEnvelopeBody): CallEvent {
2670
- const localUserID = this.getUser().userID;
2671
- const peerUserID =
2672
- body.fromUserID === localUserID ? body.toUserID : body.fromUserID;
2673
- const peerDeviceID =
2674
- body.fromUserID === localUserID
2675
- ? body.toDeviceID
2676
- : body.fromDeviceID;
2677
- let state = this.callStates.get(body.callID);
2678
- if (!state) {
2679
- state = {
2680
- peerDeviceID,
2681
- peerUserID,
2682
- pendingPeerDevices: [],
2683
- sequence: 0,
2684
- session: this.sessionFromCallEnvelope(body),
2685
- };
2686
- }
2687
-
2688
- state.peerUserID = peerUserID;
2689
- if (body.fromUserID !== localUserID || body.action === "accept") {
2690
- state.peerDeviceID = peerDeviceID;
2691
- }
2692
- state.sequence = Math.max(state.sequence, body.sequence);
2693
- state.session.expiresAt = body.expiresAt;
2694
-
2695
- const now = new Date().toISOString();
2696
- switch (body.action) {
2697
- case "accept":
2698
- state.session.status = "active";
2699
- this.upsertCallParticipant(state.session, {
2700
- acceptedAt: now,
2701
- deviceID: body.fromDeviceID,
2702
- joinedAt: now,
2703
- state: "accepted",
2704
- userID: body.fromUserID,
2705
- });
2706
- break;
2707
- case "cancel":
2708
- case "end":
2709
- case "hangup":
2710
- case "reject":
2711
- case "timeout":
2712
- state.session.status = "ended";
2713
- state.session.endedAt = now;
2714
- this.upsertCallParticipant(state.session, {
2715
- leftAt: now,
2716
- state: body.action === "reject" ? "rejected" : "left",
2717
- userID: body.fromUserID,
2718
- });
2719
- break;
2720
- case "ice":
2721
- case "signal":
2722
- break;
2723
- case "invite":
2724
- state.session.status = "ringing";
2725
- this.upsertCallParticipant(state.session, {
2726
- acceptedAt: body.createdAt,
2727
- deviceID: body.createdByDeviceID,
2728
- joinedAt: body.createdAt,
2729
- state: "accepted",
2730
- userID: body.createdBy,
2731
- });
2732
- this.upsertCallParticipant(state.session, {
2733
- state: "ringing",
2734
- userID: body.toUserID,
2735
- });
2736
- break;
2737
- }
2738
-
2739
- const event: CallEvent = {
2740
- action: body.action,
2741
- call: cloneCallSession(state.session),
2742
- fromDeviceID: body.fromDeviceID,
2743
- fromUserID: body.fromUserID,
2744
- ...(body.signal ? { signal: body.signal } : {}),
2745
- };
2746
-
2747
- if (state.session.status === "ended") {
2748
- this.callStates.delete(body.callID);
2749
- } else {
2750
- this.callStates.set(body.callID, state);
2751
- }
2752
- return event;
2753
- }
2754
-
2755
2647
  private async approveDeviceRequest(requestID: string): Promise<Device> {
2756
2648
  const req = await this.getDeviceRegistrationRequest(requestID);
2757
2649
  if (!req) {
@@ -2833,50 +2725,6 @@ export class Client {
2833
2725
  return decodeHttpResponse(PasskeyOptionsCodec, response.data);
2834
2726
  }
2835
2727
 
2836
- private async callEnvelopeForBody(
2837
- body: CallEnvelopeBody,
2838
- ): Promise<SignedCallEnvelope> {
2839
- const wireBody = normalizeCallEnvelopeBodyForWire(body);
2840
- const signed = await xSignAsync(
2841
- canonicalJsonBytes(wireBody),
2842
- this.signKeys.secretKey,
2843
- );
2844
- return {
2845
- body: wireBody,
2846
- signed: XUtils.encodeHex(signed),
2847
- };
2848
- }
2849
-
2850
- private async callTargetsForState(
2851
- state: EncryptedCallState,
2852
- ): Promise<Device[]> {
2853
- if (state.peerDeviceID) {
2854
- const cached = this.deviceRecords[state.peerDeviceID];
2855
- const device =
2856
- cached ?? (await this.getDeviceByID(state.peerDeviceID));
2857
- if (!device) {
2858
- throw new Error(
2859
- `Call peer device not found: ${state.peerDeviceID}`,
2860
- );
2861
- }
2862
- return [device];
2863
- }
2864
- return state.pendingPeerDevices;
2865
- }
2866
-
2867
- private callWakeForEnvelope(
2868
- body: CallEnvelopeBody,
2869
- ): MailNotificationHint | undefined {
2870
- if (body.action !== "invite") {
2871
- return undefined;
2872
- }
2873
- return {
2874
- callID: body.callID,
2875
- event: "callWake",
2876
- expiresAt: body.expiresAt,
2877
- };
2878
- }
2879
-
2880
2728
  private censorPreKey(preKey: PreKeysSQL): PreKeysWS {
2881
2729
  if (!preKey.index) {
2882
2730
  throw new Error("Key index is required.");
@@ -3035,7 +2883,6 @@ export class Client {
3035
2883
  * errors should not reject the full read pipeline.
3036
2884
  */
3037
2885
  allowKeyBundleFailure = false,
3038
- notify?: MailNotificationHint,
3039
2886
  ): Promise<Message | null> {
3040
2887
  return this.runWithThisCryptoProfile(async () => {
3041
2888
  let keyBundle: KeyBundle;
@@ -3153,13 +3000,12 @@ export class Client {
3153
3000
  recipient: device.deviceID,
3154
3001
  sender: this.getDevice().deviceID,
3155
3002
  };
3156
- const wireMail: MailWS = notify ? { ...mail, notify } : mail;
3157
3003
 
3158
3004
  const hmac = xHMAC(mail, SK);
3159
3005
 
3160
3006
  const msg: ResourceMsg = {
3161
3007
  action: "CREATE",
3162
- data: wireMail,
3008
+ data: mail,
3163
3009
  resourceType: "mail",
3164
3010
  transmissionID: uuid.v4(),
3165
3011
  type: "resource",
@@ -3183,41 +3029,35 @@ export class Client {
3183
3029
 
3184
3030
  this.emitter.emit("session", sessionEntry, user);
3185
3031
 
3186
- const rawPlaintext = forward ? "" : XUtils.encodeUTF8(message);
3187
- const callEnvelope = forward
3188
- ? null
3189
- : decodeCallEnvelopePlaintext(rawPlaintext);
3032
+ // emit the message
3190
3033
  const forwardedMsg = forward
3191
3034
  ? messageSchema.parse(msgpack.decode(message))
3192
3035
  : null;
3193
- const emitMsg: Message | null = forwardedMsg
3036
+ const shouldEmitHandshakeMessage = forward || message.length > 0;
3037
+ const emitMsg: Message = forwardedMsg
3194
3038
  ? { ...normalizeForwardedMessage(forwardedMsg), forward: true }
3195
- : callEnvelope
3196
- ? null
3197
- : message.length > 0
3198
- ? {
3199
- authorID: mail.authorID,
3200
- decrypted: true,
3201
- direction: "outgoing",
3202
- forward: mail.forward,
3203
- group: mail.group ? uuid.stringify(mail.group) : null,
3204
- mailID: mail.mailID,
3205
- ...messageFromDecodedPlaintext(
3206
- decodeMessagePlaintext(rawPlaintext),
3207
- ),
3208
- nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
3209
- readerID: mail.readerID,
3210
- recipient: mail.recipient,
3211
- sender: mail.sender,
3212
- timestamp: new Date().toISOString(),
3213
- }
3214
- : null;
3215
- if (emitMsg) {
3039
+ : {
3040
+ authorID: mail.authorID,
3041
+ decrypted: true,
3042
+ direction: "outgoing",
3043
+ forward: mail.forward,
3044
+ group: mail.group ? uuid.stringify(mail.group) : null,
3045
+ mailID: mail.mailID,
3046
+ ...messageFromDecodedPlaintext(
3047
+ decodeMessagePlaintext(XUtils.encodeUTF8(message)),
3048
+ ),
3049
+ nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
3050
+ readerID: mail.readerID,
3051
+ recipient: mail.recipient,
3052
+ sender: mail.sender,
3053
+ timestamp: new Date().toISOString(),
3054
+ };
3055
+ if (shouldEmitHandshakeMessage) {
3216
3056
  this.emitter.emit("message", emitMsg);
3217
3057
  }
3218
3058
 
3219
- await this.deliverMailResource(msg, hmac, wireMail);
3220
- return emitMsg;
3059
+ await this.deliverMailResource(msg, hmac, mail);
3060
+ return shouldEmitHandshakeMessage ? emitMsg : null;
3221
3061
  });
3222
3062
  }
3223
3063
 
@@ -3258,68 +3098,6 @@ export class Client {
3258
3098
  await this.http.delete(this.getHost() + "/server/" + serverID);
3259
3099
  }
3260
3100
 
3261
- private async deliverCallEnvelopeBatch(args: {
3262
- bodies: CallEnvelopeBody[];
3263
- mailID: string;
3264
- targetUser: User;
3265
- }): Promise<void> {
3266
- let failCount = 0;
3267
- let lastErr: unknown;
3268
- for (
3269
- let index = 0;
3270
- index < args.bodies.length;
3271
- index += MAIL_FANOUT_CONCURRENCY
3272
- ) {
3273
- const batch = args.bodies.slice(
3274
- index,
3275
- index + MAIL_FANOUT_CONCURRENCY,
3276
- );
3277
- const results = await Promise.all(
3278
- batch.map(async (body): Promise<undefined | unknown> => {
3279
- try {
3280
- const targetDevice =
3281
- this.deviceRecords[body.toDeviceID] ??
3282
- (await this.getDeviceByID(body.toDeviceID));
3283
- if (!targetDevice) {
3284
- throw new Error(
3285
- `Call target device not found: ${body.toDeviceID}`,
3286
- );
3287
- }
3288
- await this.sendCallEnvelopeMail({
3289
- body,
3290
- mailID: args.mailID,
3291
- notify: this.callWakeForEnvelope(body),
3292
- targetDevice,
3293
- targetUser: args.targetUser,
3294
- });
3295
- return undefined;
3296
- } catch (err: unknown) {
3297
- return err;
3298
- }
3299
- }),
3300
- );
3301
- for (const result of results) {
3302
- if (result !== undefined) {
3303
- lastErr = result;
3304
- failCount += 1;
3305
- }
3306
- }
3307
- }
3308
-
3309
- if (failCount > 0) {
3310
- const base =
3311
- lastErr instanceof Error ? lastErr : new Error(String(lastErr));
3312
- if (failCount === args.bodies.length) {
3313
- throw base;
3314
- }
3315
- const partial = new Error(
3316
- `Call signaling failed to reach ${String(failCount)} of ` +
3317
- `${String(args.bodies.length)} peer device(s).`,
3318
- );
3319
- partial.cause = base;
3320
- throw partial;
3321
- }
3322
- }
3323
3101
  private deliverMailResource(
3324
3102
  msg: ResourceMsg,
3325
3103
  header: Uint8Array,
@@ -3405,61 +3183,13 @@ export class Client {
3405
3183
  });
3406
3184
  }
3407
3185
 
3408
- private fetchActiveCalls(): Promise<CallSession[]> {
3409
- const now = Date.now();
3410
- const active: CallSession[] = [];
3411
- for (const [callID, state] of this.callStates.entries()) {
3412
- if (
3413
- state.session.status === "ended" ||
3414
- Date.parse(state.session.expiresAt) <= now
3415
- ) {
3416
- this.callStates.delete(callID);
3417
- continue;
3418
- }
3419
- active.push(cloneCallSession(state.session));
3420
- }
3421
- return Promise.resolve(active);
3422
- }
3423
-
3424
- private async fetchCallPeer(args: {
3425
- userID: string;
3426
- }): Promise<{ devices: Device[]; user: User }> {
3427
- const [user, err] = await this.fetchUser(args.userID);
3428
- if (err) {
3429
- throw err;
3430
- }
3431
- if (!user) {
3432
- throw new Error("Call peer not found.");
3433
- }
3434
-
3435
- const afterBackoff = await this.fetchUserDeviceListWithBackoff(
3436
- args.userID,
3437
- "peer",
3438
- );
3439
- let deviceListRaw: Device[];
3440
- try {
3441
- const again = await this.fetchUserDeviceListOnce(args.userID);
3442
- const byID = new Map<string, Device>();
3443
- for (const device of afterBackoff) {
3444
- byID.set(device.deviceID, device);
3445
- }
3446
- for (const device of again) {
3447
- byID.set(device.deviceID, device);
3448
- }
3449
- deviceListRaw = [...byID.values()];
3450
- } catch {
3451
- deviceListRaw = afterBackoff;
3452
- }
3453
-
3454
- const devices = deviceListRaw
3455
- .filter((device) => !device.deleted)
3456
- .sort((a, b) => a.deviceID.localeCompare(b.deviceID, "en"));
3457
- if (devices.length === 0) {
3458
- throw new Error("Call peer has no active devices.");
3459
- }
3460
- return { devices, user };
3186
+ private async fetchActiveCalls(): Promise<CallSession[]> {
3187
+ const res = await this.http.get(this.getHost() + "/calls/active", {
3188
+ responseType: "json",
3189
+ });
3190
+ return z.object({ calls: z.array(CallSessionSchema) }).parse(res.data)
3191
+ .calls;
3461
3192
  }
3462
-
3463
3193
  private async fetchIceServers(): Promise<IceServerConfig[]> {
3464
3194
  const res = await this.http.get(this.getHost() + "/calls/ice-servers", {
3465
3195
  responseType: "json",
@@ -3580,24 +3310,6 @@ export class Client {
3580
3310
  throw new Error(`${base}${this.deviceListFailureDetail(lastErr)}`);
3581
3311
  }
3582
3312
 
3583
- private async fetchUserOrThrow(userID: string): Promise<User> {
3584
- if (userID === this.getUser().userID) {
3585
- return this.getUser();
3586
- }
3587
- const cached = this.userRecords[userID];
3588
- if (cached) {
3589
- return cached;
3590
- }
3591
- const [user, err] = await this.fetchUser(userID);
3592
- if (err) {
3593
- throw err;
3594
- }
3595
- if (!user) {
3596
- throw new Error(`User not found: ${userID}`);
3597
- }
3598
- return user;
3599
- }
3600
-
3601
3313
  /**
3602
3314
  * Finish a passkey login and adopt the resulting JWT as the
3603
3315
  * client's bearer token. After this call, `client.passkeys.*`
@@ -3812,6 +3524,23 @@ export class Client {
3812
3524
  });
3813
3525
  }
3814
3526
 
3527
+ private async getAccountEntitlements(): Promise<AccountEntitlements> {
3528
+ const res = await this.http.get(
3529
+ this.getHost() + "/user/" + this.getUser().userID + "/entitlements",
3530
+ );
3531
+ return decodeHttpResponse(AccountEntitlementsCodec, res.data);
3532
+ }
3533
+
3534
+ private async getBillingAccountState(): Promise<BillingAccountState> {
3535
+ const res = await this.http.get(this.getHost() + "/billing/account");
3536
+ return decodeHttpResponse(BillingAccountStateCodec, res.data);
3537
+ }
3538
+
3539
+ private async getBillingProducts(): Promise<BillingProduct[]> {
3540
+ const res = await this.http.get(this.getHost() + "/billing/products");
3541
+ return decodeHttpResponse(BillingProductArrayCodec, res.data);
3542
+ }
3543
+
3815
3544
  private async getChannelByID(channelID: string): Promise<Channel | null> {
3816
3545
  try {
3817
3546
  const res = await this.http.get(
@@ -4135,14 +3864,6 @@ export class Client {
4135
3864
  }
4136
3865
  break;
4137
3866
  }
4138
- case "callWake": {
4139
- const parsed = callWakeNotifyData.safeParse(msg.data);
4140
- await this.getMail();
4141
- if (parsed.success) {
4142
- this.emitter.emit("callWake", parsed.data);
4143
- }
4144
- break;
4145
- }
4146
3867
  case "deviceRequest": {
4147
3868
  const parsed = deviceRequestNotifyData.safeParse(msg.data);
4148
3869
  if (parsed.success) {
@@ -4404,84 +4125,6 @@ export class Client {
4404
4125
  return decodeHttpResponse(PasskeyArrayCodec, response.data);
4405
4126
  }
4406
4127
 
4407
- private makeCallEnvelopeBody(args: {
4408
- action: CallAction;
4409
- expiresAt: string;
4410
- sequence: number;
4411
- signal?: CallSignalPayload | undefined;
4412
- state: EncryptedCallState;
4413
- toDeviceID: string;
4414
- toUserID: string;
4415
- }): CallEnvelopeBody {
4416
- return {
4417
- action: args.action,
4418
- callID: args.state.session.callID,
4419
- conversationID: args.state.session.conversationID,
4420
- conversationType: args.state.session.conversationType,
4421
- createdAt: args.state.session.createdAt,
4422
- createdBy: args.state.session.createdBy,
4423
- createdByDeviceID: args.state.session.createdByDeviceID,
4424
- expiresAt: args.expiresAt,
4425
- fromDeviceID: this.getDevice().deviceID,
4426
- fromUserID: this.getUser().userID,
4427
- media: "audio",
4428
- sequence: args.sequence,
4429
- ...(args.signal ? { signal: args.signal } : {}),
4430
- toDeviceID: args.toDeviceID,
4431
- toUserID: args.toUserID,
4432
- version: 1,
4433
- };
4434
- }
4435
-
4436
- private markLocalCallAction(
4437
- state: EncryptedCallState,
4438
- action: CallAction,
4439
- signal?: CallSignalPayload,
4440
- ): CallEvent {
4441
- const now = new Date().toISOString();
4442
- if (action === "accept") {
4443
- state.session.status = "active";
4444
- state.session.expiresAt = new Date(
4445
- Date.now() + CALL_MAX_TTL_MS,
4446
- ).toISOString();
4447
- this.upsertCallParticipant(state.session, {
4448
- acceptedAt: now,
4449
- deviceID: this.getDevice().deviceID,
4450
- joinedAt: now,
4451
- state: "accepted",
4452
- userID: this.getUser().userID,
4453
- });
4454
- } else if (
4455
- action === "cancel" ||
4456
- action === "end" ||
4457
- action === "hangup" ||
4458
- action === "reject" ||
4459
- action === "timeout"
4460
- ) {
4461
- state.session.status = "ended";
4462
- state.session.endedAt = now;
4463
- this.upsertCallParticipant(state.session, {
4464
- leftAt: now,
4465
- state: action === "reject" ? "rejected" : "left",
4466
- userID: this.getUser().userID,
4467
- });
4468
- }
4469
-
4470
- const event: CallEvent = {
4471
- action,
4472
- call: cloneCallSession(state.session),
4473
- fromDeviceID: this.getDevice().deviceID,
4474
- fromUserID: this.getUser().userID,
4475
- ...(signal ? { signal } : {}),
4476
- };
4477
- if (state.session.status === "ended") {
4478
- this.callStates.delete(state.session.callID);
4479
- } else {
4480
- this.callStates.set(state.session.callID, state);
4481
- }
4482
- return event;
4483
- }
4484
-
4485
4128
  private async markSessionVerified(sessionID: string) {
4486
4129
  return this.database.markSessionVerified(sessionID);
4487
4130
  }
@@ -4710,41 +4353,6 @@ export class Client {
4710
4353
  }
4711
4354
  }
4712
4355
 
4713
- private async processDecryptedCallEnvelope(args: {
4714
- envelope: SignedCallEnvelope;
4715
- mail: MailWS;
4716
- }): Promise<CallEvent | null> {
4717
- const body = args.envelope.body;
4718
- if (
4719
- body.fromDeviceID !== args.mail.sender ||
4720
- body.fromUserID !== args.mail.authorID ||
4721
- body.toDeviceID !== args.mail.recipient ||
4722
- body.toUserID !== args.mail.readerID ||
4723
- body.toDeviceID !== this.getDevice().deviceID ||
4724
- body.toUserID !== this.getUser().userID
4725
- ) {
4726
- return null;
4727
- }
4728
-
4729
- const senderDevice = await this.getDeviceByID(body.fromDeviceID);
4730
- if (!senderDevice || senderDevice.owner !== body.fromUserID) {
4731
- return null;
4732
- }
4733
-
4734
- const opened = await xSignOpenAsync(
4735
- XUtils.decodeHex(args.envelope.signed),
4736
- XUtils.decodeHex(senderDevice.signKey),
4737
- );
4738
- if (!opened) {
4739
- return null;
4740
- }
4741
- if (!XUtils.bytesEqual(opened, canonicalJsonBytes(body))) {
4742
- return null;
4743
- }
4744
-
4745
- return this.applyCallEnvelopeBody(body);
4746
- }
4747
-
4748
4356
  private async publishPendingDeviceRegistration(args: {
4749
4357
  challenge: string;
4750
4358
  requestID: string;
@@ -5100,61 +4708,46 @@ export class Client {
5100
4708
  if (!mail.forward) {
5101
4709
  plaintext = XUtils.encodeUTF8(unsealed);
5102
4710
  }
5103
- const callEnvelope = mail.forward
4711
+ const decodedPlaintext = mail.forward
5104
4712
  ? null
5105
- : decodeCallEnvelopePlaintext(plaintext);
5106
- if (callEnvelope) {
5107
- const event =
5108
- await this.processDecryptedCallEnvelope({
5109
- envelope: callEnvelope,
5110
- mail,
5111
- });
5112
- if (event) {
5113
- this.emitter.emit("call", event);
5114
- }
5115
- } else {
5116
- const decodedPlaintext = mail.forward
5117
- ? null
5118
- : decodeMessagePlaintext(plaintext);
5119
-
5120
- const fwdMsg1 = mail.forward
5121
- ? messageSchema.parse(
5122
- msgpack.decode(unsealed),
5123
- )
5124
- : null;
5125
- const message: Message = fwdMsg1
5126
- ? {
5127
- ...normalizeForwardedMessage(fwdMsg1),
5128
- forward: true,
5129
- }
5130
- : {
5131
- authorID: mail.authorID,
5132
- decrypted: true,
5133
- direction: "incoming",
5134
- forward: mail.forward,
5135
- group: mail.group
5136
- ? uuid.stringify(mail.group)
5137
- : null,
5138
- mailID: mail.mailID,
5139
- ...messageFromDecodedPlaintext(
5140
- decodedPlaintext ?? {
5141
- message: plaintext,
5142
- },
5143
- ),
5144
- nonce: XUtils.encodeHex(
5145
- new Uint8Array(mail.nonce),
5146
- ),
5147
- readerID: mail.readerID,
5148
- recipient: mail.recipient,
5149
- sender: mail.sender,
5150
- timestamp: timestamp,
5151
- };
5152
-
5153
- const shouldEmitIncomingInitial =
5154
- mail.forward || plaintext.length > 0;
5155
- if (shouldEmitIncomingInitial) {
5156
- this.emitter.emit("message", message);
5157
- }
4713
+ : decodeMessagePlaintext(plaintext);
4714
+
4715
+ // emit the message
4716
+ const fwdMsg1 = mail.forward
4717
+ ? messageSchema.parse(msgpack.decode(unsealed))
4718
+ : null;
4719
+ const message: Message = fwdMsg1
4720
+ ? {
4721
+ ...normalizeForwardedMessage(fwdMsg1),
4722
+ forward: true,
4723
+ }
4724
+ : {
4725
+ authorID: mail.authorID,
4726
+ decrypted: true,
4727
+ direction: "incoming",
4728
+ forward: mail.forward,
4729
+ group: mail.group
4730
+ ? uuid.stringify(mail.group)
4731
+ : null,
4732
+ mailID: mail.mailID,
4733
+ ...messageFromDecodedPlaintext(
4734
+ decodedPlaintext ?? {
4735
+ message: plaintext,
4736
+ },
4737
+ ),
4738
+ nonce: XUtils.encodeHex(
4739
+ new Uint8Array(mail.nonce),
4740
+ ),
4741
+ readerID: mail.readerID,
4742
+ recipient: mail.recipient,
4743
+ sender: mail.sender,
4744
+ timestamp: timestamp,
4745
+ };
4746
+
4747
+ const shouldEmitIncomingInitial =
4748
+ mail.forward || plaintext.length > 0;
4749
+ if (shouldEmitIncomingInitial) {
4750
+ this.emitter.emit("message", message);
5158
4751
  }
5159
4752
  if (libvexDebugDmEnabled()) {
5160
4753
  try {
@@ -5414,51 +5007,37 @@ export class Client {
5414
5007
  ? messageSchema.parse(msgpack.decode(decrypted))
5415
5008
  : null;
5416
5009
  const rawIncoming = XUtils.encodeUTF8(decrypted);
5417
- const callEnvelope = mail.forward
5010
+ const decodedPlaintext = mail.forward
5418
5011
  ? null
5419
- : decodeCallEnvelopePlaintext(rawIncoming);
5420
- if (callEnvelope) {
5421
- const event =
5422
- await this.processDecryptedCallEnvelope({
5423
- envelope: callEnvelope,
5424
- mail,
5425
- });
5426
- if (event) {
5427
- this.emitter.emit("call", event);
5428
- }
5429
- } else {
5430
- const decodedPlaintext = mail.forward
5431
- ? null
5432
- : decodeMessagePlaintext(rawIncoming);
5433
- const message: Message = fwdMsg2
5434
- ? {
5435
- ...normalizeForwardedMessage(fwdMsg2),
5436
- forward: true,
5437
- }
5438
- : {
5439
- authorID: mail.authorID,
5440
- decrypted: true,
5441
- direction: "incoming",
5442
- forward: mail.forward,
5443
- group: mail.group
5444
- ? uuid.stringify(mail.group)
5445
- : null,
5446
- mailID: mail.mailID,
5447
- ...messageFromDecodedPlaintext(
5448
- decodedPlaintext ?? {
5449
- message: rawIncoming,
5450
- },
5451
- ),
5452
- nonce: XUtils.encodeHex(
5453
- new Uint8Array(mail.nonce),
5454
- ),
5455
- readerID: mail.readerID,
5456
- recipient: mail.recipient,
5457
- sender: mail.sender,
5458
- timestamp: timestamp,
5459
- };
5460
- this.emitter.emit("message", message);
5461
- }
5012
+ : decodeMessagePlaintext(rawIncoming);
5013
+ const message: Message = fwdMsg2
5014
+ ? {
5015
+ ...normalizeForwardedMessage(fwdMsg2),
5016
+ forward: true,
5017
+ }
5018
+ : {
5019
+ authorID: mail.authorID,
5020
+ decrypted: true,
5021
+ direction: "incoming",
5022
+ forward: mail.forward,
5023
+ group: mail.group
5024
+ ? uuid.stringify(mail.group)
5025
+ : null,
5026
+ mailID: mail.mailID,
5027
+ ...messageFromDecodedPlaintext(
5028
+ decodedPlaintext ?? {
5029
+ message: rawIncoming,
5030
+ },
5031
+ ),
5032
+ nonce: XUtils.encodeHex(
5033
+ new Uint8Array(mail.nonce),
5034
+ ),
5035
+ readerID: mail.readerID,
5036
+ recipient: mail.recipient,
5037
+ sender: mail.sender,
5038
+ timestamp: timestamp,
5039
+ };
5040
+ this.emitter.emit("message", message);
5462
5041
 
5463
5042
  const sqlPatch = sessionToSqlPatch(session);
5464
5043
  const persisted: SessionSQL = {
@@ -5874,71 +5453,87 @@ export class Client {
5874
5453
  }
5875
5454
  }
5876
5455
 
5877
- private async sendCallEnvelopeMail(args: {
5878
- body: CallEnvelopeBody;
5879
- mailID: string;
5880
- notify?: MailNotificationHint | undefined;
5881
- targetDevice: Device;
5882
- targetUser: User;
5883
- }): Promise<void> {
5884
- const envelope = await this.callEnvelopeForBody(args.body);
5885
- await this.sendMailWithRecovery(
5886
- args.targetDevice,
5887
- args.targetUser,
5888
- encodeCallEnvelopePlaintext(envelope),
5889
- null,
5890
- args.mailID,
5891
- false,
5892
- false,
5893
- args.notify,
5894
- );
5895
- }
5896
-
5897
- private async sendEncryptedCallAction(
5898
- action: Exclude<CallAction, "end" | "invite" | "timeout">,
5899
- callID: string,
5900
- signal?: CallSignalPayload,
5456
+ private async sendCallResource(
5457
+ action: string,
5458
+ data: CallResourceData,
5901
5459
  ): Promise<CallEvent> {
5902
- const state = this.callStates.get(callID);
5903
- if (!state) {
5904
- throw new Error("Unknown encrypted call: " + callID);
5905
- }
5906
-
5907
- const targets = await this.callTargetsForState(state);
5908
- if (targets.length === 0) {
5909
- throw new Error("Call has no reachable peer devices.");
5910
- }
5911
-
5912
- const targetUser = await this.fetchUserOrThrow(state.peerUserID);
5913
- const sequence = state.sequence + 1;
5914
- state.sequence = sequence;
5915
- const expiresAt =
5916
- action === "accept"
5917
- ? new Date(Date.now() + CALL_MAX_TTL_MS).toISOString()
5918
- : state.session.expiresAt;
5919
- const bodies = targets.map((target) =>
5920
- this.makeCallEnvelopeBody({
5921
- action,
5922
- expiresAt,
5923
- sequence,
5924
- signal,
5925
- state,
5926
- toDeviceID: target.deviceID,
5927
- toUserID: target.owner,
5928
- }),
5929
- );
5460
+ const msg: ResourceMsg = {
5461
+ action,
5462
+ data,
5463
+ resourceType: "call",
5464
+ transmissionID: uuid.v4(),
5465
+ type: "resource",
5466
+ };
5930
5467
 
5931
- await this.deliverCallEnvelopeBatch({
5932
- bodies,
5933
- mailID: uuid.v4(),
5934
- targetUser,
5935
- });
5468
+ return await new Promise<CallEvent>((resolve, reject) => {
5469
+ const settle = (err: null | unknown, event?: CallEvent) => {
5470
+ this.socket.off("message", callback);
5471
+ if (err !== null) {
5472
+ reject(errorFromUnknown(err));
5473
+ return;
5474
+ }
5475
+ if (!event) {
5476
+ reject(new Error("Call signaling response was empty."));
5477
+ return;
5478
+ }
5479
+ resolve(event);
5480
+ };
5936
5481
 
5937
- if (targets.length === 1) {
5938
- state.peerDeviceID = targets[0]?.deviceID;
5939
- }
5940
- state.session.expiresAt = expiresAt;
5941
- return this.markLocalCallAction(state, action, signal);
5482
+ const callback = (packedMsg: Uint8Array) => {
5483
+ const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
5484
+ if (receivedMsg.transmissionID !== msg.transmissionID) {
5485
+ return;
5486
+ }
5487
+
5488
+ const parsed = WSMessageSchema.safeParse(receivedMsg);
5489
+ if (!parsed.success) {
5490
+ settle(
5491
+ "Call signaling failed: " + JSON.stringify(receivedMsg),
5492
+ );
5493
+ return;
5494
+ }
5495
+
5496
+ if (parsed.data.type === "success") {
5497
+ const event = CallEventSchema.safeParse(parsed.data.data);
5498
+ if (!event.success) {
5499
+ settle(
5500
+ "Invalid call signaling response: " +
5501
+ JSON.stringify(event.error.issues),
5502
+ );
5503
+ return;
5504
+ }
5505
+ settle(null, event.data);
5506
+ return;
5507
+ }
5508
+
5509
+ if (parsed.data.type === "error") {
5510
+ settle(new Error(parsed.data.error));
5511
+ return;
5512
+ }
5513
+
5514
+ if (
5515
+ parsed.data.type === "notify" &&
5516
+ (parsed.data.event === "call" ||
5517
+ parsed.data.event === "callInvite")
5518
+ ) {
5519
+ const event = CallEventSchema.safeParse(parsed.data.data);
5520
+ if (event.success) {
5521
+ settle(null, event.data);
5522
+ }
5523
+ return;
5524
+ }
5525
+
5526
+ settle(
5527
+ "Unexpected call signaling response: " +
5528
+ JSON.stringify(parsed.data),
5529
+ );
5530
+ };
5531
+
5532
+ this.socket.on("message", callback);
5533
+ this.send(msg).catch((err: unknown) => {
5534
+ settle(err);
5535
+ });
5536
+ });
5942
5537
  }
5943
5538
 
5944
5539
  private async sendGroupMessage(
@@ -6077,7 +5672,6 @@ export class Client {
6077
5672
  mailID: null | string,
6078
5673
  forward: boolean,
6079
5674
  retry = false,
6080
- notify?: MailNotificationHint,
6081
5675
  ): Promise<Message | null> {
6082
5676
  while (this.sending.has(device.deviceID)) {
6083
5677
  await sleep(100);
@@ -6104,7 +5698,6 @@ export class Client {
6104
5698
  mailID,
6105
5699
  forward,
6106
5700
  false,
6107
- notify,
6108
5701
  );
6109
5702
  if (libvexDebugDmEnabled()) {
6110
5703
  debugLibvexDm("sendMail: createSession returned", {
@@ -6147,11 +5740,10 @@ export class Client {
6147
5740
  recipient: device.deviceID,
6148
5741
  sender: this.getDevice().deviceID,
6149
5742
  };
6150
- const wireMail: MailWS = notify ? { ...mail, notify } : mail;
6151
5743
 
6152
5744
  const msgb: ResourceMsg = {
6153
5745
  action: "CREATE",
6154
- data: wireMail,
5746
+ data: mail,
6155
5747
  resourceType: "mail",
6156
5748
  transmissionID: uuid.v4(),
6157
5749
  type: "resource",
@@ -6159,36 +5751,28 @@ export class Client {
6159
5751
 
6160
5752
  const hmac = xHMAC(mail, messageKey);
6161
5753
 
6162
- const rawPlaintext = forward ? "" : XUtils.encodeUTF8(msg);
6163
- const callEnvelope = forward
6164
- ? null
6165
- : decodeCallEnvelopePlaintext(rawPlaintext);
6166
5754
  const fwdOut = forward
6167
5755
  ? messageSchema.parse(msgpack.decode(msg))
6168
5756
  : null;
6169
- const outMsg: Message | null = fwdOut
5757
+ const outMsg: Message = fwdOut
6170
5758
  ? { ...normalizeForwardedMessage(fwdOut), forward: true }
6171
- : callEnvelope
6172
- ? null
6173
- : {
6174
- authorID: mail.authorID,
6175
- decrypted: true,
6176
- direction: "outgoing",
6177
- forward: mail.forward,
6178
- group: mail.group ? uuid.stringify(mail.group) : null,
6179
- mailID: mail.mailID,
6180
- ...messageFromDecodedPlaintext(
6181
- decodeMessagePlaintext(rawPlaintext),
6182
- ),
6183
- nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
6184
- readerID: mail.readerID,
6185
- recipient: mail.recipient,
6186
- sender: mail.sender,
6187
- timestamp: new Date().toISOString(),
6188
- };
6189
- if (outMsg) {
6190
- this.emitter.emit("message", outMsg);
6191
- }
5759
+ : {
5760
+ authorID: mail.authorID,
5761
+ decrypted: true,
5762
+ direction: "outgoing",
5763
+ forward: mail.forward,
5764
+ group: mail.group ? uuid.stringify(mail.group) : null,
5765
+ mailID: mail.mailID,
5766
+ ...messageFromDecodedPlaintext(
5767
+ decodeMessagePlaintext(XUtils.encodeUTF8(msg)),
5768
+ ),
5769
+ nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
5770
+ readerID: mail.readerID,
5771
+ recipient: mail.recipient,
5772
+ sender: mail.sender,
5773
+ timestamp: new Date().toISOString(),
5774
+ };
5775
+ this.emitter.emit("message", outMsg);
6192
5776
 
6193
5777
  const sqlPatch = sessionToSqlPatch(session);
6194
5778
  const persisted: SessionSQL = {
@@ -6215,7 +5799,7 @@ export class Client {
6215
5799
  await this.database.saveSession(persisted);
6216
5800
  this.sessionRecords[XUtils.encodeHex(session.publicKey)] = session;
6217
5801
 
6218
- await this.deliverMailResource(msgb, hmac, wireMail);
5802
+ await this.deliverMailResource(msgb, hmac, mail);
6219
5803
  return outMsg;
6220
5804
  } finally {
6221
5805
  this.sending.delete(device.deviceID);
@@ -6230,7 +5814,6 @@ export class Client {
6230
5814
  mailID: null | string,
6231
5815
  forward: boolean,
6232
5816
  forceFreshSession = false,
6233
- notify?: MailNotificationHint,
6234
5817
  ): Promise<Message | null> {
6235
5818
  try {
6236
5819
  return await this.sendMail(
@@ -6241,7 +5824,6 @@ export class Client {
6241
5824
  mailID,
6242
5825
  forward,
6243
5826
  forceFreshSession,
6244
- notify,
6245
5827
  );
6246
5828
  } catch (err: unknown) {
6247
5829
  if (!this.shouldRetryDeliveryWithFreshSession(err)) {
@@ -6255,7 +5837,6 @@ export class Client {
6255
5837
  mailID,
6256
5838
  forward,
6257
5839
  true,
6258
- notify,
6259
5840
  );
6260
5841
  }
6261
5842
  }
@@ -6433,37 +6014,27 @@ export class Client {
6433
6014
  this.send(receipt).catch(ignoreSocketTeardown);
6434
6015
  }
6435
6016
 
6436
- private sessionFromCallEnvelope(body: CallEnvelopeBody): CallSession {
6437
- const session: CallSession = {
6438
- callID: body.callID,
6439
- conversationID: body.conversationID,
6440
- conversationType: body.conversationType,
6441
- createdAt: body.createdAt,
6442
- createdBy: body.createdBy,
6443
- createdByDeviceID: body.createdByDeviceID,
6444
- expiresAt: body.expiresAt,
6445
- media: "audio",
6446
- participants: [],
6447
- status: body.action === "invite" ? "ringing" : "active",
6448
- };
6449
- this.upsertCallParticipant(session, {
6450
- acceptedAt: body.createdAt,
6451
- deviceID: body.createdByDeviceID,
6452
- joinedAt: body.createdAt,
6453
- state: "accepted",
6454
- userID: body.createdBy,
6455
- });
6456
- this.upsertCallParticipant(session, {
6457
- state: "ringing",
6458
- userID: body.conversationID,
6459
- });
6460
- return session;
6461
- }
6462
-
6463
6017
  private setAlive(status: boolean) {
6464
6018
  this.isAlive = status;
6465
6019
  }
6466
6020
 
6021
+ private async setDevAccountTier(
6022
+ tier: AccountTier,
6023
+ options?: { expiresAt?: null | string },
6024
+ ): Promise<AccountEntitlements> {
6025
+ const res = await this.http.patch(
6026
+ this.getHost() +
6027
+ "/__dev/user/" +
6028
+ this.getUser().userID +
6029
+ "/entitlements",
6030
+ {
6031
+ expiresAt: options?.expiresAt ?? null,
6032
+ tier,
6033
+ },
6034
+ );
6035
+ return decodeHttpResponse(AccountEntitlementsCodec, res.data);
6036
+ }
6037
+
6467
6038
  private setUser(user: User): void {
6468
6039
  this.user = user;
6469
6040
  // Fresh identity / token: drop stale 404 negative-cache entries so a
@@ -6506,74 +6077,24 @@ export class Client {
6506
6077
  );
6507
6078
  }
6508
6079
 
6509
- private async startEncryptedDmCall(
6510
- recipientUserID: string,
6511
- signal?: CallSignalPayload,
6512
- ): Promise<CallEvent> {
6513
- const { devices, user } = await this.fetchCallPeer({
6514
- userID: recipientUserID,
6515
- });
6516
- const now = new Date();
6517
- const createdAt = now.toISOString();
6518
- const expiresAt = new Date(
6519
- now.getTime() + CALL_INVITE_TTL_MS,
6520
- ).toISOString();
6521
- const session: CallSession = {
6522
- callID: uuid.v4(),
6523
- conversationID: recipientUserID,
6524
- conversationType: "dm",
6525
- createdAt,
6526
- createdBy: this.getUser().userID,
6527
- createdByDeviceID: this.getDevice().deviceID,
6528
- expiresAt,
6529
- media: "audio",
6530
- participants: [
6531
- {
6532
- acceptedAt: createdAt,
6533
- deviceID: this.getDevice().deviceID,
6534
- joinedAt: createdAt,
6535
- state: "accepted",
6536
- userID: this.getUser().userID,
6537
- },
6538
- {
6539
- state: "ringing",
6540
- userID: recipientUserID,
6541
- },
6542
- ],
6543
- status: "ringing",
6544
- };
6545
- const state: EncryptedCallState = {
6546
- peerUserID: recipientUserID,
6547
- pendingPeerDevices: devices,
6548
- sequence: 1,
6549
- session,
6550
- };
6551
- this.callStates.set(session.callID, state);
6552
-
6553
- const bodies = devices.map((device) =>
6554
- this.makeCallEnvelopeBody({
6555
- action: "invite",
6556
- expiresAt,
6557
- sequence: state.sequence,
6558
- signal,
6559
- state,
6560
- toDeviceID: device.deviceID,
6561
- toUserID: recipientUserID,
6562
- }),
6080
+ private async submitAppleTransaction(
6081
+ request: AppleTransactionVerificationRequest,
6082
+ ): Promise<BillingAccountState> {
6083
+ const res = await this.http.post(
6084
+ this.getHost() + "/billing/apple/transactions",
6085
+ request,
6563
6086
  );
6564
- await this.deliverCallEnvelopeBatch({
6565
- bodies,
6566
- mailID: uuid.v4(),
6567
- targetUser: user,
6568
- });
6087
+ return decodeHttpResponse(BillingAccountStateCodec, res.data);
6088
+ }
6569
6089
 
6570
- return {
6571
- action: "invite",
6572
- call: cloneCallSession(session),
6573
- fromDeviceID: this.getDevice().deviceID,
6574
- fromUserID: this.getUser().userID,
6575
- ...(signal ? { signal } : {}),
6576
- };
6090
+ private async submitGooglePurchase(
6091
+ request: GooglePurchaseVerificationRequest,
6092
+ ): Promise<BillingAccountState> {
6093
+ const res = await this.http.post(
6094
+ this.getHost() + "/billing/google/purchases",
6095
+ request,
6096
+ );
6097
+ return decodeHttpResponse(BillingAccountStateCodec, res.data);
6577
6098
  }
6578
6099
 
6579
6100
  private async submitOTK(amount: number) {
@@ -6709,18 +6230,4 @@ export class Client {
6709
6230
  return null;
6710
6231
  }
6711
6232
  }
6712
-
6713
- private upsertCallParticipant(
6714
- session: CallSession,
6715
- patch: CallSession["participants"][number],
6716
- ): void {
6717
- const existing = session.participants.find(
6718
- (participant) => participant.userID === patch.userID,
6719
- );
6720
- if (!existing) {
6721
- session.participants.push({ ...patch });
6722
- return;
6723
- }
6724
- Object.assign(existing, patch);
6725
- }
6726
6233
  }