@vex-chat/spire 2.3.4 → 2.5.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.
@@ -4,6 +4,7 @@
4
4
  * Commercial licenses available at vex.wtf
5
5
  */
6
6
 
7
+ import type { CallManager } from "./CallManager.ts";
7
8
  import type { Database } from "./Database.ts";
8
9
  import type {
9
10
  BaseMsg,
@@ -27,6 +28,7 @@ import { MailWSSchema, SocketAuthErrors } from "@vex-chat/types";
27
28
 
28
29
  import { parse as uuidParse, validate as uuidValidate } from "uuid";
29
30
 
31
+ import { callWakeDispatchData } from "./server/callWake.ts";
30
32
  import { validateMailIngress } from "./server/mailIngress.ts";
31
33
  import { TOKEN_EXPIRY } from "./Spire.ts";
32
34
  import { createUint8UUID } from "./utils/createUint8UUID.ts";
@@ -44,7 +46,9 @@ function emptyHeader() {
44
46
  return new Uint8Array(32);
45
47
  }
46
48
 
47
- const MAX_MSG_SIZE = 2048;
49
+ // WebRTC SDP offers/answers are commonly several KB before encryption/packing.
50
+ // Keep this well below HTTP body limits while allowing first-party call signals.
51
+ const MAX_MSG_SIZE = 64 * 1024;
48
52
 
49
53
  // How many ping cycles in a row are allowed to go without a pong
50
54
  // before we declare the connection dead. With a 5s ping interval
@@ -68,6 +72,7 @@ const PING_INTERVAL_MS = 5000;
68
72
  export class ClientManager extends EventEmitter {
69
73
  private alive: boolean = true;
70
74
  private authed: boolean = false;
75
+ private callManager: CallManager;
71
76
  private challengeID: Uint8Array = createUint8UUID();
72
77
  private conn: WebSocket;
73
78
  private db: Database;
@@ -89,6 +94,7 @@ export class ClientManager extends EventEmitter {
89
94
  constructor(
90
95
  ws: WebSocket,
91
96
  db: Database,
97
+ callManager: CallManager,
92
98
  notify: (
93
99
  userID: string,
94
100
  event: string,
@@ -103,6 +109,7 @@ export class ClientManager extends EventEmitter {
103
109
  super();
104
110
  this.conn = ws;
105
111
  this.db = db;
112
+ this.callManager = callManager;
106
113
  this.user = null;
107
114
  this.userDetails = userDetails;
108
115
  this.device = null;
@@ -280,6 +287,25 @@ export class ClientManager extends EventEmitter {
280
287
 
281
288
  private async parseResourceMsg(msg: ResourceMsg, header: Uint8Array) {
282
289
  switch (msg.resourceType) {
290
+ case "call":
291
+ try {
292
+ const event = await this.callManager.handleResource({
293
+ action: msg.action,
294
+ actor: {
295
+ device: this.getDevice(),
296
+ user: this.getUser(),
297
+ },
298
+ data: msg.data,
299
+ transmissionID: msg.transmissionID,
300
+ });
301
+ this.sendSuccess(msg.transmissionID, event);
302
+ } catch (err: unknown) {
303
+ this.sendErr(
304
+ msg.transmissionID,
305
+ err instanceof Error ? err.message : String(err),
306
+ );
307
+ }
308
+ break;
283
309
  case "mail":
284
310
  if (msg.action === "CREATE") {
285
311
  const mailResult = MailWSSchema.safeParse(msg.data);
@@ -308,15 +334,28 @@ export class ClientManager extends EventEmitter {
308
334
  );
309
335
 
310
336
  this.sendSuccess(msg.transmissionID, null);
311
- this.notify(
312
- recipientDevice.owner,
313
- "mail",
314
- msg.transmissionID,
315
- null,
316
- mail.recipient,
317
- mail.authorID,
318
- mail.nonce,
319
- );
337
+ const callWake = callWakeDispatchData(mail);
338
+ if (callWake) {
339
+ this.notify(
340
+ recipientDevice.owner,
341
+ "callWake",
342
+ msg.transmissionID,
343
+ callWake,
344
+ mail.recipient,
345
+ undefined,
346
+ mail.nonce,
347
+ );
348
+ } else {
349
+ this.notify(
350
+ recipientDevice.owner,
351
+ "mail",
352
+ msg.transmissionID,
353
+ null,
354
+ mail.recipient,
355
+ mail.authorID,
356
+ mail.nonce,
357
+ );
358
+ }
320
359
  } catch (err: unknown) {
321
360
  this.sendErr(msg.transmissionID, String(err));
322
361
  }
package/src/Database.ts CHANGED
@@ -130,8 +130,10 @@ export interface InternalUserRecord extends UserRecord {
130
130
  hashAlgo: string;
131
131
  }
132
132
 
133
+ export type NotificationChannel = "apnsVoip" | "expo" | "fcmCall";
134
+
133
135
  export interface NotificationSubscription {
134
- channel: "expo";
136
+ channel: NotificationChannel;
135
137
  createdAt: string;
136
138
  deviceID: string;
137
139
  enabled: boolean;
@@ -144,7 +146,7 @@ export interface NotificationSubscription {
144
146
  }
145
147
 
146
148
  export interface SaveNotificationSubscriptionInput {
147
- channel: "expo";
149
+ channel: NotificationChannel;
148
150
  deviceID: string;
149
151
  events: string[];
150
152
  platform?: null | string;
@@ -1627,6 +1629,13 @@ function normalizeRegistrationUsername(
1627
1629
  return `key_${seed}`;
1628
1630
  }
1629
1631
 
1632
+ function parseNotificationChannel(channel: string): NotificationChannel {
1633
+ if (channel === "apnsVoip" || channel === "expo" || channel === "fcmCall") {
1634
+ return channel;
1635
+ }
1636
+ return "expo";
1637
+ }
1638
+
1630
1639
  function toDevice(row: {
1631
1640
  deleted: number;
1632
1641
  deviceID: string;
@@ -1676,7 +1685,7 @@ function toNotificationSubscription(row: {
1676
1685
  }): NotificationSubscription {
1677
1686
  return {
1678
1687
  ...row,
1679
- channel: "expo",
1688
+ channel: parseNotificationChannel(row.channel),
1680
1689
  enabled: Boolean(row.enabled),
1681
1690
  events: decodeNotificationEvents(row.events),
1682
1691
  };
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Copyright (c) 2020-2026 Vex Heavy Industries LLC
3
+ * Licensed under AGPL-3.0. See LICENSE for details.
4
+ * Commercial licenses available at vex.wtf
5
+ */
6
+
7
+ import type { IceServerConfig } from "@vex-chat/types";
8
+
9
+ import { IceServerConfigSchema } from "@vex-chat/types";
10
+
11
+ import { z } from "zod/v4";
12
+
13
+ const CLOUDFLARE_TURN_ENDPOINT = "https://rtc.live.cloudflare.com/v1/turn/keys";
14
+ const DEFAULT_CLOUDFLARE_TURN_TIMEOUT_MS = 5_000;
15
+ const DEFAULT_CLOUDFLARE_TURN_TTL_SECONDS = 86_400;
16
+
17
+ const cloudflareIceServersResponseSchema = z.object({
18
+ iceServers: z.array(IceServerConfigSchema),
19
+ });
20
+
21
+ export async function resolveIceServersFromEnv(): Promise<IceServerConfig[]> {
22
+ const jsonOverride = readJsonIceServersFromEnv();
23
+ if (jsonOverride) {
24
+ return jsonOverride;
25
+ }
26
+
27
+ const staticServers = readStaticIceServersFromEnv();
28
+ const cloudflareServers = await readCloudflareIceServersFromEnv();
29
+ return [...staticServers, ...cloudflareServers];
30
+ }
31
+
32
+ async function readCloudflareIceServersFromEnv(): Promise<IceServerConfig[]> {
33
+ const keyID = readEnv([
34
+ "SPIRE_CLOUDFLARE_TURN_KEY_ID",
35
+ "CLOUDFLARE_TURN_KEY_ID",
36
+ "TURN_KEY_ID",
37
+ ]);
38
+ const apiToken = readEnv([
39
+ "SPIRE_CLOUDFLARE_TURN_API_TOKEN",
40
+ "CLOUDFLARE_TURN_API_TOKEN",
41
+ "TURN_KEY_API_TOKEN",
42
+ ]);
43
+ const endpointOverride = readEnv(["SPIRE_CLOUDFLARE_TURN_ENDPOINT"]);
44
+ if (!keyID && !apiToken && !endpointOverride) {
45
+ return [];
46
+ }
47
+
48
+ if (!apiToken || (!keyID && !endpointOverride)) {
49
+ console.warn(
50
+ "[spire-calls] Cloudflare TURN is partially configured; set SPIRE_CLOUDFLARE_TURN_KEY_ID and SPIRE_CLOUDFLARE_TURN_API_TOKEN",
51
+ );
52
+ return [];
53
+ }
54
+
55
+ let endpoint = endpointOverride;
56
+ if (!endpoint) {
57
+ if (!keyID) {
58
+ console.warn(
59
+ "[spire-calls] Cloudflare TURN is partially configured; set SPIRE_CLOUDFLARE_TURN_KEY_ID and SPIRE_CLOUDFLARE_TURN_API_TOKEN",
60
+ );
61
+ return [];
62
+ }
63
+ endpoint = `${CLOUDFLARE_TURN_ENDPOINT}/${encodeURIComponent(keyID)}/credentials/generate-ice-servers`;
64
+ }
65
+ const ttl = readPositiveIntegerEnv(
66
+ "SPIRE_CLOUDFLARE_TURN_TTL_SECONDS",
67
+ DEFAULT_CLOUDFLARE_TURN_TTL_SECONDS,
68
+ );
69
+ const timeoutMs = readPositiveIntegerEnv(
70
+ "SPIRE_CLOUDFLARE_TURN_TIMEOUT_MS",
71
+ DEFAULT_CLOUDFLARE_TURN_TIMEOUT_MS,
72
+ );
73
+
74
+ const controller = new AbortController();
75
+ const timeout = setTimeout(() => {
76
+ controller.abort();
77
+ }, timeoutMs);
78
+
79
+ try {
80
+ const response = await fetch(endpoint, {
81
+ body: JSON.stringify({ ttl }),
82
+ headers: {
83
+ Authorization: `Bearer ${apiToken}`,
84
+ "Content-Type": "application/json",
85
+ },
86
+ method: "POST",
87
+ signal: controller.signal,
88
+ });
89
+
90
+ if (!response.ok) {
91
+ throw new Error(
92
+ `Cloudflare TURN credential request failed with ${response.status}`,
93
+ );
94
+ }
95
+
96
+ const payload = await response.json();
97
+ return cloudflareIceServersResponseSchema.parse(payload).iceServers;
98
+ } catch (err: unknown) {
99
+ console.warn(
100
+ "[spire-calls] failed to fetch Cloudflare TURN credentials",
101
+ err instanceof Error ? err.message : String(err),
102
+ );
103
+ return [];
104
+ } finally {
105
+ clearTimeout(timeout);
106
+ }
107
+ }
108
+
109
+ function readEnv(names: string[]): string | undefined {
110
+ for (const name of names) {
111
+ const value = process.env[name]?.trim();
112
+ if (value) {
113
+ return value;
114
+ }
115
+ }
116
+ return undefined;
117
+ }
118
+
119
+ function readJsonIceServersFromEnv(): IceServerConfig[] | null {
120
+ const json = process.env["SPIRE_ICE_SERVERS"]?.trim();
121
+ if (!json) {
122
+ return null;
123
+ }
124
+
125
+ try {
126
+ const parsed = JSON.parse(json) as unknown;
127
+ return z.array(IceServerConfigSchema).parse(parsed);
128
+ } catch (err: unknown) {
129
+ console.warn(
130
+ "[spire-calls] ignoring invalid SPIRE_ICE_SERVERS",
131
+ err instanceof Error ? err.message : String(err),
132
+ );
133
+ return null;
134
+ }
135
+ }
136
+
137
+ function readPositiveIntegerEnv(name: string, fallback: number): number {
138
+ const raw = process.env[name]?.trim();
139
+ if (!raw) {
140
+ return fallback;
141
+ }
142
+
143
+ const parsed = Number.parseInt(raw, 10);
144
+ if (Number.isInteger(parsed) && parsed > 0) {
145
+ return parsed;
146
+ }
147
+
148
+ console.warn(
149
+ `[spire-calls] ignoring invalid ${name}; expected a positive integer`,
150
+ );
151
+ return fallback;
152
+ }
153
+
154
+ function readStaticIceServersFromEnv(): IceServerConfig[] {
155
+ const servers: IceServerConfig[] = [];
156
+ for (const url of splitCsvEnv("SPIRE_STUN_URLS")) {
157
+ servers.push({ urls: url });
158
+ }
159
+
160
+ const turnUrls = splitCsvEnv("SPIRE_TURN_URLS");
161
+ if (turnUrls.length > 0) {
162
+ const username = process.env["SPIRE_TURN_USERNAME"]?.trim();
163
+ const credential = process.env["SPIRE_TURN_CREDENTIAL"]?.trim();
164
+ const [firstTurnUrl] = turnUrls;
165
+ if (!firstTurnUrl) {
166
+ return servers;
167
+ }
168
+ const urls: string | string[] =
169
+ turnUrls.length === 1 ? firstTurnUrl : turnUrls;
170
+ servers.push({
171
+ ...(credential ? { credential } : {}),
172
+ urls,
173
+ ...(username ? { username } : {}),
174
+ });
175
+ }
176
+
177
+ return servers;
178
+ }
179
+
180
+ function splitCsvEnv(name: string): string[] {
181
+ return (process.env[name] ?? "")
182
+ .split(",")
183
+ .map((value) => value.trim())
184
+ .filter((value) => value.length > 0);
185
+ }