@vex-chat/libvex 7.3.0 → 7.4.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. */
@@ -1417,6 +1366,14 @@ export class Client {
1417
1366
 
1418
1367
  private static readonly NOT_FOUND_TTL = 30 * 60 * 1000;
1419
1368
 
1369
+ /** Store subscription billing methods. */
1370
+ public billing: Billing = {
1371
+ products: this.getBillingProducts.bind(this),
1372
+ retrieve: this.getBillingAccountState.bind(this),
1373
+ submitAppleTransaction: this.submitAppleTransaction.bind(this),
1374
+ submitGooglePurchase: this.submitGooglePurchase.bind(this),
1375
+ };
1376
+
1420
1377
  /**
1421
1378
  * Voice-call signaling operations.
1422
1379
  *
@@ -1425,21 +1382,25 @@ export class Client {
1425
1382
  */
1426
1383
  public calls: Calls = {
1427
1384
  accept: (callID: string, signal?: CallSignalPayload) =>
1428
- this.sendEncryptedCallAction("accept", callID, signal),
1385
+ this.sendCallResource("ACCEPT", {
1386
+ callID,
1387
+ ...(signal ? { signal } : {}),
1388
+ }),
1429
1389
  active: this.fetchActiveCalls.bind(this),
1430
- cancel: (callID: string) =>
1431
- this.sendEncryptedCallAction("cancel", callID),
1432
- hangup: (callID: string) =>
1433
- this.sendEncryptedCallAction("hangup", callID),
1390
+ cancel: (callID: string) => this.sendCallResource("CANCEL", { callID }),
1391
+ hangup: (callID: string) => this.sendCallResource("HANGUP", { callID }),
1434
1392
  ice: (callID: string, signal: CallSignalPayload) =>
1435
- this.sendEncryptedCallAction("ice", callID, signal),
1393
+ this.sendCallResource("ICE", { callID, signal }),
1436
1394
  iceServers: this.fetchIceServers.bind(this),
1437
- reject: (callID: string) =>
1438
- this.sendEncryptedCallAction("reject", callID),
1395
+ reject: (callID: string) => this.sendCallResource("REJECT", { callID }),
1439
1396
  signal: (callID: string, signal: CallSignalPayload) =>
1440
- this.sendEncryptedCallAction("signal", callID, signal),
1397
+ this.sendCallResource("SIGNAL", { callID, signal }),
1441
1398
  startDM: (recipientUserID: string, signal?: CallSignalPayload) =>
1442
- this.startEncryptedDmCall(recipientUserID, signal),
1399
+ this.sendCallResource("INVITE", {
1400
+ conversationType: "dm",
1401
+ recipientUserID,
1402
+ ...(signal ? { signal } : {}),
1403
+ }),
1443
1404
  };
1444
1405
 
1445
1406
  /**
@@ -1521,6 +1482,12 @@ export class Client {
1521
1482
  retrieveList: this.retrieveEmojiList.bind(this),
1522
1483
  };
1523
1484
 
1485
+ /** Account entitlement methods. */
1486
+ public entitlements: Entitlements = {
1487
+ retrieve: this.getAccountEntitlements.bind(this),
1488
+ setDevTier: this.setDevAccountTier.bind(this),
1489
+ };
1490
+
1524
1491
  /** File upload/download methods. */
1525
1492
  public files: Files = {
1526
1493
  /**
@@ -1753,8 +1720,6 @@ export class Client {
1753
1720
 
1754
1721
  private autoReconnectEnabled = false;
1755
1722
 
1756
- private readonly callStates = new Map<string, EncryptedCallState>();
1757
-
1758
1723
  private readonly cryptoProfile: CryptoProfile;
1759
1724
 
1760
1725
  private readonly database: Storage;
@@ -1764,12 +1729,13 @@ export class Client {
1764
1729
  private readonly decryptFailureCounts = new Map<string, number>();
1765
1730
 
1766
1731
  private device?: Device;
1767
- private deviceRecords: Record<string, Device> = {};
1768
1732
 
1733
+ private deviceRecords: Record<string, Device> = {};
1769
1734
  // ── Event subscription (composition over inheritance) ───────────────
1770
1735
  private readonly emitter = new EventEmitter<ClientEvents>();
1771
1736
 
1772
1737
  private fetchingMail: boolean = false;
1738
+
1773
1739
  private firstMailFetch = true;
1774
1740
  private readonly forwarded = new Set<string>();
1775
1741
  private readonly host: string;
@@ -1777,8 +1743,8 @@ export class Client {
1777
1743
  /** Cancels in-flight HTTP work on `close()` so `postAuth`/`getMail` cannot hang forever. */
1778
1744
  private readonly httpAbortController = new AbortController();
1779
1745
  private readonly idKeys: KeyPair | null;
1780
-
1781
1746
  private isAlive: boolean = true;
1747
+
1782
1748
  private localMessageRetentionDays: number;
1783
1749
  private localRetentionPurgeTimer: null | ReturnType<typeof setInterval> =
1784
1750
  null;
@@ -2666,92 +2632,6 @@ export class Client {
2666
2632
  this.acknowledgeInboundMail(mail);
2667
2633
  }
2668
2634
 
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
2635
  private async approveDeviceRequest(requestID: string): Promise<Device> {
2756
2636
  const req = await this.getDeviceRegistrationRequest(requestID);
2757
2637
  if (!req) {
@@ -2833,50 +2713,6 @@ export class Client {
2833
2713
  return decodeHttpResponse(PasskeyOptionsCodec, response.data);
2834
2714
  }
2835
2715
 
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
2716
  private censorPreKey(preKey: PreKeysSQL): PreKeysWS {
2881
2717
  if (!preKey.index) {
2882
2718
  throw new Error("Key index is required.");
@@ -3035,7 +2871,6 @@ export class Client {
3035
2871
  * errors should not reject the full read pipeline.
3036
2872
  */
3037
2873
  allowKeyBundleFailure = false,
3038
- notify?: MailNotificationHint,
3039
2874
  ): Promise<Message | null> {
3040
2875
  return this.runWithThisCryptoProfile(async () => {
3041
2876
  let keyBundle: KeyBundle;
@@ -3153,13 +2988,12 @@ export class Client {
3153
2988
  recipient: device.deviceID,
3154
2989
  sender: this.getDevice().deviceID,
3155
2990
  };
3156
- const wireMail: MailWS = notify ? { ...mail, notify } : mail;
3157
2991
 
3158
2992
  const hmac = xHMAC(mail, SK);
3159
2993
 
3160
2994
  const msg: ResourceMsg = {
3161
2995
  action: "CREATE",
3162
- data: wireMail,
2996
+ data: mail,
3163
2997
  resourceType: "mail",
3164
2998
  transmissionID: uuid.v4(),
3165
2999
  type: "resource",
@@ -3183,41 +3017,35 @@ export class Client {
3183
3017
 
3184
3018
  this.emitter.emit("session", sessionEntry, user);
3185
3019
 
3186
- const rawPlaintext = forward ? "" : XUtils.encodeUTF8(message);
3187
- const callEnvelope = forward
3188
- ? null
3189
- : decodeCallEnvelopePlaintext(rawPlaintext);
3020
+ // emit the message
3190
3021
  const forwardedMsg = forward
3191
3022
  ? messageSchema.parse(msgpack.decode(message))
3192
3023
  : null;
3193
- const emitMsg: Message | null = forwardedMsg
3024
+ const shouldEmitHandshakeMessage = forward || message.length > 0;
3025
+ const emitMsg: Message = forwardedMsg
3194
3026
  ? { ...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) {
3027
+ : {
3028
+ authorID: mail.authorID,
3029
+ decrypted: true,
3030
+ direction: "outgoing",
3031
+ forward: mail.forward,
3032
+ group: mail.group ? uuid.stringify(mail.group) : null,
3033
+ mailID: mail.mailID,
3034
+ ...messageFromDecodedPlaintext(
3035
+ decodeMessagePlaintext(XUtils.encodeUTF8(message)),
3036
+ ),
3037
+ nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
3038
+ readerID: mail.readerID,
3039
+ recipient: mail.recipient,
3040
+ sender: mail.sender,
3041
+ timestamp: new Date().toISOString(),
3042
+ };
3043
+ if (shouldEmitHandshakeMessage) {
3216
3044
  this.emitter.emit("message", emitMsg);
3217
3045
  }
3218
3046
 
3219
- await this.deliverMailResource(msg, hmac, wireMail);
3220
- return emitMsg;
3047
+ await this.deliverMailResource(msg, hmac, mail);
3048
+ return shouldEmitHandshakeMessage ? emitMsg : null;
3221
3049
  });
3222
3050
  }
3223
3051
 
@@ -3258,68 +3086,6 @@ export class Client {
3258
3086
  await this.http.delete(this.getHost() + "/server/" + serverID);
3259
3087
  }
3260
3088
 
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
3089
  private deliverMailResource(
3324
3090
  msg: ResourceMsg,
3325
3091
  header: Uint8Array,
@@ -3405,61 +3171,13 @@ export class Client {
3405
3171
  });
3406
3172
  }
3407
3173
 
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 };
3174
+ private async fetchActiveCalls(): Promise<CallSession[]> {
3175
+ const res = await this.http.get(this.getHost() + "/calls/active", {
3176
+ responseType: "json",
3177
+ });
3178
+ return z.object({ calls: z.array(CallSessionSchema) }).parse(res.data)
3179
+ .calls;
3461
3180
  }
3462
-
3463
3181
  private async fetchIceServers(): Promise<IceServerConfig[]> {
3464
3182
  const res = await this.http.get(this.getHost() + "/calls/ice-servers", {
3465
3183
  responseType: "json",
@@ -3580,24 +3298,6 @@ export class Client {
3580
3298
  throw new Error(`${base}${this.deviceListFailureDetail(lastErr)}`);
3581
3299
  }
3582
3300
 
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
3301
  /**
3602
3302
  * Finish a passkey login and adopt the resulting JWT as the
3603
3303
  * client's bearer token. After this call, `client.passkeys.*`
@@ -3812,6 +3512,23 @@ export class Client {
3812
3512
  });
3813
3513
  }
3814
3514
 
3515
+ private async getAccountEntitlements(): Promise<AccountEntitlements> {
3516
+ const res = await this.http.get(
3517
+ this.getHost() + "/user/" + this.getUser().userID + "/entitlements",
3518
+ );
3519
+ return decodeHttpResponse(AccountEntitlementsCodec, res.data);
3520
+ }
3521
+
3522
+ private async getBillingAccountState(): Promise<BillingAccountState> {
3523
+ const res = await this.http.get(this.getHost() + "/billing/account");
3524
+ return decodeHttpResponse(BillingAccountStateCodec, res.data);
3525
+ }
3526
+
3527
+ private async getBillingProducts(): Promise<BillingProduct[]> {
3528
+ const res = await this.http.get(this.getHost() + "/billing/products");
3529
+ return decodeHttpResponse(BillingProductArrayCodec, res.data);
3530
+ }
3531
+
3815
3532
  private async getChannelByID(channelID: string): Promise<Channel | null> {
3816
3533
  try {
3817
3534
  const res = await this.http.get(
@@ -4135,14 +3852,6 @@ export class Client {
4135
3852
  }
4136
3853
  break;
4137
3854
  }
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
3855
  case "deviceRequest": {
4147
3856
  const parsed = deviceRequestNotifyData.safeParse(msg.data);
4148
3857
  if (parsed.success) {
@@ -4404,84 +4113,6 @@ export class Client {
4404
4113
  return decodeHttpResponse(PasskeyArrayCodec, response.data);
4405
4114
  }
4406
4115
 
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
4116
  private async markSessionVerified(sessionID: string) {
4486
4117
  return this.database.markSessionVerified(sessionID);
4487
4118
  }
@@ -4710,41 +4341,6 @@ export class Client {
4710
4341
  }
4711
4342
  }
4712
4343
 
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
4344
  private async publishPendingDeviceRegistration(args: {
4749
4345
  challenge: string;
4750
4346
  requestID: string;
@@ -5100,61 +4696,46 @@ export class Client {
5100
4696
  if (!mail.forward) {
5101
4697
  plaintext = XUtils.encodeUTF8(unsealed);
5102
4698
  }
5103
- const callEnvelope = mail.forward
4699
+ const decodedPlaintext = mail.forward
5104
4700
  ? 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
- }
4701
+ : decodeMessagePlaintext(plaintext);
4702
+
4703
+ // emit the message
4704
+ const fwdMsg1 = mail.forward
4705
+ ? messageSchema.parse(msgpack.decode(unsealed))
4706
+ : null;
4707
+ const message: Message = fwdMsg1
4708
+ ? {
4709
+ ...normalizeForwardedMessage(fwdMsg1),
4710
+ forward: true,
4711
+ }
4712
+ : {
4713
+ authorID: mail.authorID,
4714
+ decrypted: true,
4715
+ direction: "incoming",
4716
+ forward: mail.forward,
4717
+ group: mail.group
4718
+ ? uuid.stringify(mail.group)
4719
+ : null,
4720
+ mailID: mail.mailID,
4721
+ ...messageFromDecodedPlaintext(
4722
+ decodedPlaintext ?? {
4723
+ message: plaintext,
4724
+ },
4725
+ ),
4726
+ nonce: XUtils.encodeHex(
4727
+ new Uint8Array(mail.nonce),
4728
+ ),
4729
+ readerID: mail.readerID,
4730
+ recipient: mail.recipient,
4731
+ sender: mail.sender,
4732
+ timestamp: timestamp,
4733
+ };
4734
+
4735
+ const shouldEmitIncomingInitial =
4736
+ mail.forward || plaintext.length > 0;
4737
+ if (shouldEmitIncomingInitial) {
4738
+ this.emitter.emit("message", message);
5158
4739
  }
5159
4740
  if (libvexDebugDmEnabled()) {
5160
4741
  try {
@@ -5414,51 +4995,37 @@ export class Client {
5414
4995
  ? messageSchema.parse(msgpack.decode(decrypted))
5415
4996
  : null;
5416
4997
  const rawIncoming = XUtils.encodeUTF8(decrypted);
5417
- const callEnvelope = mail.forward
4998
+ const decodedPlaintext = mail.forward
5418
4999
  ? 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
- }
5000
+ : decodeMessagePlaintext(rawIncoming);
5001
+ const message: Message = fwdMsg2
5002
+ ? {
5003
+ ...normalizeForwardedMessage(fwdMsg2),
5004
+ forward: true,
5005
+ }
5006
+ : {
5007
+ authorID: mail.authorID,
5008
+ decrypted: true,
5009
+ direction: "incoming",
5010
+ forward: mail.forward,
5011
+ group: mail.group
5012
+ ? uuid.stringify(mail.group)
5013
+ : null,
5014
+ mailID: mail.mailID,
5015
+ ...messageFromDecodedPlaintext(
5016
+ decodedPlaintext ?? {
5017
+ message: rawIncoming,
5018
+ },
5019
+ ),
5020
+ nonce: XUtils.encodeHex(
5021
+ new Uint8Array(mail.nonce),
5022
+ ),
5023
+ readerID: mail.readerID,
5024
+ recipient: mail.recipient,
5025
+ sender: mail.sender,
5026
+ timestamp: timestamp,
5027
+ };
5028
+ this.emitter.emit("message", message);
5462
5029
 
5463
5030
  const sqlPatch = sessionToSqlPatch(session);
5464
5031
  const persisted: SessionSQL = {
@@ -5874,71 +5441,87 @@ export class Client {
5874
5441
  }
5875
5442
  }
5876
5443
 
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,
5444
+ private async sendCallResource(
5445
+ action: string,
5446
+ data: CallResourceData,
5901
5447
  ): 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
- );
5448
+ const msg: ResourceMsg = {
5449
+ action,
5450
+ data,
5451
+ resourceType: "call",
5452
+ transmissionID: uuid.v4(),
5453
+ type: "resource",
5454
+ };
5930
5455
 
5931
- await this.deliverCallEnvelopeBatch({
5932
- bodies,
5933
- mailID: uuid.v4(),
5934
- targetUser,
5935
- });
5456
+ return await new Promise<CallEvent>((resolve, reject) => {
5457
+ const settle = (err: null | unknown, event?: CallEvent) => {
5458
+ this.socket.off("message", callback);
5459
+ if (err !== null) {
5460
+ reject(errorFromUnknown(err));
5461
+ return;
5462
+ }
5463
+ if (!event) {
5464
+ reject(new Error("Call signaling response was empty."));
5465
+ return;
5466
+ }
5467
+ resolve(event);
5468
+ };
5936
5469
 
5937
- if (targets.length === 1) {
5938
- state.peerDeviceID = targets[0]?.deviceID;
5939
- }
5940
- state.session.expiresAt = expiresAt;
5941
- return this.markLocalCallAction(state, action, signal);
5470
+ const callback = (packedMsg: Uint8Array) => {
5471
+ const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
5472
+ if (receivedMsg.transmissionID !== msg.transmissionID) {
5473
+ return;
5474
+ }
5475
+
5476
+ const parsed = WSMessageSchema.safeParse(receivedMsg);
5477
+ if (!parsed.success) {
5478
+ settle(
5479
+ "Call signaling failed: " + JSON.stringify(receivedMsg),
5480
+ );
5481
+ return;
5482
+ }
5483
+
5484
+ if (parsed.data.type === "success") {
5485
+ const event = CallEventSchema.safeParse(parsed.data.data);
5486
+ if (!event.success) {
5487
+ settle(
5488
+ "Invalid call signaling response: " +
5489
+ JSON.stringify(event.error.issues),
5490
+ );
5491
+ return;
5492
+ }
5493
+ settle(null, event.data);
5494
+ return;
5495
+ }
5496
+
5497
+ if (parsed.data.type === "error") {
5498
+ settle(new Error(parsed.data.error));
5499
+ return;
5500
+ }
5501
+
5502
+ if (
5503
+ parsed.data.type === "notify" &&
5504
+ (parsed.data.event === "call" ||
5505
+ parsed.data.event === "callInvite")
5506
+ ) {
5507
+ const event = CallEventSchema.safeParse(parsed.data.data);
5508
+ if (event.success) {
5509
+ settle(null, event.data);
5510
+ }
5511
+ return;
5512
+ }
5513
+
5514
+ settle(
5515
+ "Unexpected call signaling response: " +
5516
+ JSON.stringify(parsed.data),
5517
+ );
5518
+ };
5519
+
5520
+ this.socket.on("message", callback);
5521
+ this.send(msg).catch((err: unknown) => {
5522
+ settle(err);
5523
+ });
5524
+ });
5942
5525
  }
5943
5526
 
5944
5527
  private async sendGroupMessage(
@@ -6077,7 +5660,6 @@ export class Client {
6077
5660
  mailID: null | string,
6078
5661
  forward: boolean,
6079
5662
  retry = false,
6080
- notify?: MailNotificationHint,
6081
5663
  ): Promise<Message | null> {
6082
5664
  while (this.sending.has(device.deviceID)) {
6083
5665
  await sleep(100);
@@ -6104,7 +5686,6 @@ export class Client {
6104
5686
  mailID,
6105
5687
  forward,
6106
5688
  false,
6107
- notify,
6108
5689
  );
6109
5690
  if (libvexDebugDmEnabled()) {
6110
5691
  debugLibvexDm("sendMail: createSession returned", {
@@ -6147,11 +5728,10 @@ export class Client {
6147
5728
  recipient: device.deviceID,
6148
5729
  sender: this.getDevice().deviceID,
6149
5730
  };
6150
- const wireMail: MailWS = notify ? { ...mail, notify } : mail;
6151
5731
 
6152
5732
  const msgb: ResourceMsg = {
6153
5733
  action: "CREATE",
6154
- data: wireMail,
5734
+ data: mail,
6155
5735
  resourceType: "mail",
6156
5736
  transmissionID: uuid.v4(),
6157
5737
  type: "resource",
@@ -6159,36 +5739,28 @@ export class Client {
6159
5739
 
6160
5740
  const hmac = xHMAC(mail, messageKey);
6161
5741
 
6162
- const rawPlaintext = forward ? "" : XUtils.encodeUTF8(msg);
6163
- const callEnvelope = forward
6164
- ? null
6165
- : decodeCallEnvelopePlaintext(rawPlaintext);
6166
5742
  const fwdOut = forward
6167
5743
  ? messageSchema.parse(msgpack.decode(msg))
6168
5744
  : null;
6169
- const outMsg: Message | null = fwdOut
5745
+ const outMsg: Message = fwdOut
6170
5746
  ? { ...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
- }
5747
+ : {
5748
+ authorID: mail.authorID,
5749
+ decrypted: true,
5750
+ direction: "outgoing",
5751
+ forward: mail.forward,
5752
+ group: mail.group ? uuid.stringify(mail.group) : null,
5753
+ mailID: mail.mailID,
5754
+ ...messageFromDecodedPlaintext(
5755
+ decodeMessagePlaintext(XUtils.encodeUTF8(msg)),
5756
+ ),
5757
+ nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
5758
+ readerID: mail.readerID,
5759
+ recipient: mail.recipient,
5760
+ sender: mail.sender,
5761
+ timestamp: new Date().toISOString(),
5762
+ };
5763
+ this.emitter.emit("message", outMsg);
6192
5764
 
6193
5765
  const sqlPatch = sessionToSqlPatch(session);
6194
5766
  const persisted: SessionSQL = {
@@ -6215,7 +5787,7 @@ export class Client {
6215
5787
  await this.database.saveSession(persisted);
6216
5788
  this.sessionRecords[XUtils.encodeHex(session.publicKey)] = session;
6217
5789
 
6218
- await this.deliverMailResource(msgb, hmac, wireMail);
5790
+ await this.deliverMailResource(msgb, hmac, mail);
6219
5791
  return outMsg;
6220
5792
  } finally {
6221
5793
  this.sending.delete(device.deviceID);
@@ -6230,7 +5802,6 @@ export class Client {
6230
5802
  mailID: null | string,
6231
5803
  forward: boolean,
6232
5804
  forceFreshSession = false,
6233
- notify?: MailNotificationHint,
6234
5805
  ): Promise<Message | null> {
6235
5806
  try {
6236
5807
  return await this.sendMail(
@@ -6241,7 +5812,6 @@ export class Client {
6241
5812
  mailID,
6242
5813
  forward,
6243
5814
  forceFreshSession,
6244
- notify,
6245
5815
  );
6246
5816
  } catch (err: unknown) {
6247
5817
  if (!this.shouldRetryDeliveryWithFreshSession(err)) {
@@ -6255,7 +5825,6 @@ export class Client {
6255
5825
  mailID,
6256
5826
  forward,
6257
5827
  true,
6258
- notify,
6259
5828
  );
6260
5829
  }
6261
5830
  }
@@ -6433,37 +6002,27 @@ export class Client {
6433
6002
  this.send(receipt).catch(ignoreSocketTeardown);
6434
6003
  }
6435
6004
 
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
6005
  private setAlive(status: boolean) {
6464
6006
  this.isAlive = status;
6465
6007
  }
6466
6008
 
6009
+ private async setDevAccountTier(
6010
+ tier: AccountTier,
6011
+ options?: { expiresAt?: null | string },
6012
+ ): Promise<AccountEntitlements> {
6013
+ const res = await this.http.patch(
6014
+ this.getHost() +
6015
+ "/__dev/user/" +
6016
+ this.getUser().userID +
6017
+ "/entitlements",
6018
+ {
6019
+ expiresAt: options?.expiresAt ?? null,
6020
+ tier,
6021
+ },
6022
+ );
6023
+ return decodeHttpResponse(AccountEntitlementsCodec, res.data);
6024
+ }
6025
+
6467
6026
  private setUser(user: User): void {
6468
6027
  this.user = user;
6469
6028
  // Fresh identity / token: drop stale 404 negative-cache entries so a
@@ -6506,74 +6065,24 @@ export class Client {
6506
6065
  );
6507
6066
  }
6508
6067
 
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
- }),
6068
+ private async submitAppleTransaction(
6069
+ request: AppleTransactionVerificationRequest,
6070
+ ): Promise<BillingAccountState> {
6071
+ const res = await this.http.post(
6072
+ this.getHost() + "/billing/apple/transactions",
6073
+ request,
6563
6074
  );
6564
- await this.deliverCallEnvelopeBatch({
6565
- bodies,
6566
- mailID: uuid.v4(),
6567
- targetUser: user,
6568
- });
6075
+ return decodeHttpResponse(BillingAccountStateCodec, res.data);
6076
+ }
6569
6077
 
6570
- return {
6571
- action: "invite",
6572
- call: cloneCallSession(session),
6573
- fromDeviceID: this.getDevice().deviceID,
6574
- fromUserID: this.getUser().userID,
6575
- ...(signal ? { signal } : {}),
6576
- };
6078
+ private async submitGooglePurchase(
6079
+ request: GooglePurchaseVerificationRequest,
6080
+ ): Promise<BillingAccountState> {
6081
+ const res = await this.http.post(
6082
+ this.getHost() + "/billing/google/purchases",
6083
+ request,
6084
+ );
6085
+ return decodeHttpResponse(BillingAccountStateCodec, res.data);
6577
6086
  }
6578
6087
 
6579
6088
  private async submitOTK(amount: number) {
@@ -6709,18 +6218,4 @@ export class Client {
6709
6218
  return null;
6710
6219
  }
6711
6220
  }
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
6221
  }