@vex-chat/spire 1.10.4 → 1.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,11 +5,12 @@
5
5
  */
6
6
 
7
7
  import type { ClientManager } from "../ClientManager.ts";
8
+ import type { Database, NotificationSubscription } from "../Database.ts";
8
9
  import type { BaseMsg } from "@vex-chat/types";
9
10
 
10
- import { describe, expect, it, vi } from "vitest";
11
+ import { afterEach, describe, expect, it, vi } from "vitest";
11
12
 
12
- import { Spire } from "../Spire.ts";
13
+ import { NotificationService } from "../NotificationService.ts";
13
14
 
14
15
  interface FakeClient {
15
16
  getDeviceID: () => null | string;
@@ -18,21 +19,44 @@ interface FakeClient {
18
19
  send: (msg: BaseMsg) => void;
19
20
  }
20
21
 
21
- type NotifyFn = (
22
- userID: string,
23
- event: string,
24
- transmissionID: string,
25
- data?: unknown,
26
- deviceID?: string,
27
- ) => void;
28
-
29
- function createSpireHarness(clients: FakeClient[]) {
30
- const spire = Object.create(Spire.prototype) as {
31
- clients: ClientManager[];
32
- notify: NotifyFn;
22
+ const subscription: NotificationSubscription = {
23
+ channel: "expo",
24
+ createdAt: "2026-05-12T00:00:00.000Z",
25
+ deviceID: "device-b",
26
+ enabled: true,
27
+ events: ["mail"],
28
+ platform: "android",
29
+ subscriptionID: "sub-b",
30
+ token: "ExponentPushToken[test]",
31
+ updatedAt: "2026-05-12T00:00:00.000Z",
32
+ userID: "user-b",
33
+ };
34
+
35
+ function createSpireHarness(
36
+ clients: FakeClient[],
37
+ subscriptions: NotificationSubscription[] = [],
38
+ removeNotificationSubscription = vi.fn(() => Promise.resolve(true)),
39
+ ) {
40
+ const retrieveNotificationSubscriptions = vi.fn(() =>
41
+ Promise.resolve(subscriptions),
42
+ );
43
+ const db = {
44
+ removeNotificationSubscription,
45
+ retrieveNotificationSubscriptions,
46
+ } as unknown as Database;
47
+ const managers = clients as unknown as ClientManager[];
48
+ const removeClient = (client: ClientManager) => {
49
+ const idx = managers.indexOf(client);
50
+ if (idx >= 0) managers.splice(idx, 1);
51
+ };
52
+ const notifications = new NotificationService(db, managers, removeClient);
53
+ return {
54
+ clients: managers,
55
+ db,
56
+ notifications,
57
+ removeNotificationSubscription,
58
+ retrieveNotificationSubscriptions,
33
59
  };
34
- spire.clients = clients as unknown as ClientManager[];
35
- return spire;
36
60
  }
37
61
 
38
62
  function fakeClient(overrides: Partial<FakeClient> = {}): FakeClient {
@@ -45,7 +69,20 @@ function fakeClient(overrides: Partial<FakeClient> = {}): FakeClient {
45
69
  };
46
70
  }
47
71
 
72
+ function pendingReceiptCount(service: NotificationService): number {
73
+ return (
74
+ service as unknown as {
75
+ pendingReceipts: Map<string, unknown>;
76
+ }
77
+ ).pendingReceipts.size;
78
+ }
79
+
48
80
  describe("Spire notify fanout", () => {
81
+ afterEach(() => {
82
+ vi.restoreAllMocks();
83
+ vi.useRealTimers();
84
+ });
85
+
49
86
  it("continues device fanout after pruning a stale client", () => {
50
87
  const stale = fakeClient({
51
88
  getDeviceID: vi.fn(() => null),
@@ -56,19 +93,23 @@ describe("Spire notify fanout", () => {
56
93
  const other = fakeClient({
57
94
  getDeviceID: vi.fn(() => "device-c"),
58
95
  });
59
- const spire = createSpireHarness([stale, recipient, other]);
96
+ const { clients, notifications } = createSpireHarness([
97
+ stale,
98
+ recipient,
99
+ other,
100
+ ]);
60
101
 
61
- spire.notify(
62
- "user-b",
63
- "mail",
64
- "00000000-0000-0000-0000-000000000001",
65
- null,
66
- "device-b",
67
- );
102
+ notifications.notify({
103
+ data: null,
104
+ deviceID: "device-b",
105
+ event: "mail",
106
+ transmissionID: "00000000-0000-0000-0000-000000000001",
107
+ userID: "user-b",
108
+ });
68
109
 
69
110
  expect(recipient.send).toHaveBeenCalledTimes(1);
70
111
  expect(other.send).not.toHaveBeenCalled();
71
- expect(spire.clients).toEqual([recipient, other]);
112
+ expect(clients).toEqual([recipient, other]);
72
113
  });
73
114
 
74
115
  it("continues user fanout after a client inspection throws", () => {
@@ -83,16 +124,340 @@ describe("Spire notify fanout", () => {
83
124
  const other = fakeClient({
84
125
  getUserID: vi.fn(() => "user-c"),
85
126
  });
86
- const spire = createSpireHarness([broken, recipient, other]);
127
+ const { clients, notifications } = createSpireHarness([
128
+ broken,
129
+ recipient,
130
+ other,
131
+ ]);
87
132
 
88
- spire.notify(
89
- "user-b",
90
- "device_pending_enrollment",
91
- "00000000-0000-0000-0000-000000000002",
92
- );
133
+ notifications.notify({
134
+ event: "device_pending_enrollment",
135
+ transmissionID: "00000000-0000-0000-0000-000000000002",
136
+ userID: "user-b",
137
+ });
93
138
 
94
139
  expect(recipient.send).toHaveBeenCalledTimes(1);
95
140
  expect(other.send).not.toHaveBeenCalled();
96
- expect(spire.clients).toEqual([recipient, other]);
141
+ expect(clients).toEqual([recipient, other]);
142
+ });
143
+
144
+ it("uses headless Expo pushes for sender-owned mail while keeping websocket fanout", async () => {
145
+ const recipient = fakeClient({
146
+ getDeviceID: vi.fn(() => "device-b"),
147
+ });
148
+ const fetchMock = vi.fn().mockResolvedValueOnce({
149
+ json: () =>
150
+ Promise.resolve({
151
+ data: [{ id: "receipt-a", status: "ok" }],
152
+ }),
153
+ ok: true,
154
+ });
155
+ vi.stubGlobal("fetch", fetchMock);
156
+
157
+ const { notifications, retrieveNotificationSubscriptions } =
158
+ createSpireHarness([recipient], [subscription]);
159
+
160
+ notifications.notify({
161
+ deviceID: "device-b",
162
+ event: "mail",
163
+ headlessPushUserID: "user-b",
164
+ transmissionID: "00000000-0000-0000-0000-000000000007",
165
+ userID: "user-b",
166
+ });
167
+
168
+ expect(recipient.send).toHaveBeenCalledTimes(1);
169
+ await vi.waitFor(() => {
170
+ expect(fetchMock).toHaveBeenCalledTimes(1);
171
+ });
172
+ expect(retrieveNotificationSubscriptions).toHaveBeenCalledWith({
173
+ deviceID: "device-b",
174
+ event: "mail",
175
+ userID: "user-b",
176
+ });
177
+
178
+ const init = fetchMock.mock.calls[0]?.[1] as
179
+ | undefined
180
+ | { body?: unknown };
181
+ const messages = JSON.parse(String(init?.body)) as Array<{
182
+ _contentAvailable?: boolean;
183
+ body?: string;
184
+ channelId?: string;
185
+ data?: Record<string, unknown>;
186
+ tag?: string;
187
+ title?: string;
188
+ }>;
189
+ expect(messages[0]?._contentAvailable).toBe(true);
190
+ expect(messages[0]).not.toHaveProperty("body");
191
+ expect(messages[0]).not.toHaveProperty("channelId");
192
+ expect(messages[0]).not.toHaveProperty("tag");
193
+ expect(messages[0]).not.toHaveProperty("title");
194
+ expect(messages[0]?.data).toMatchObject({
195
+ deviceID: "device-b",
196
+ event: "mail",
197
+ headless: true,
198
+ transmissionID: "00000000-0000-0000-0000-000000000007",
199
+ });
200
+ });
201
+
202
+ it("checks Expo receipts and removes unregistered devices", async () => {
203
+ vi.useFakeTimers();
204
+ const fetchMock = vi
205
+ .fn()
206
+ .mockResolvedValueOnce({
207
+ json: () =>
208
+ Promise.resolve({
209
+ data: [{ id: "receipt-a", status: "ok" }],
210
+ }),
211
+ ok: true,
212
+ })
213
+ .mockResolvedValueOnce({
214
+ json: () =>
215
+ Promise.resolve({
216
+ data: {
217
+ "receipt-a": {
218
+ details: { error: "DeviceNotRegistered" },
219
+ message: "device is not registered",
220
+ status: "error",
221
+ },
222
+ },
223
+ }),
224
+ ok: true,
225
+ });
226
+ vi.stubGlobal("fetch", fetchMock);
227
+
228
+ const { db, removeNotificationSubscription } = createSpireHarness(
229
+ [],
230
+ [subscription],
231
+ );
232
+ const service = new NotificationService(db, [], () => {}, {
233
+ receiptDelayMs: 1,
234
+ });
235
+
236
+ service.notify({
237
+ deviceID: subscription.deviceID,
238
+ event: "mail",
239
+ transmissionID: "00000000-0000-0000-0000-000000000003",
240
+ userID: subscription.userID,
241
+ });
242
+
243
+ await vi.advanceTimersByTimeAsync(1);
244
+
245
+ await vi.waitFor(() => {
246
+ expect(fetchMock).toHaveBeenCalledTimes(2);
247
+ });
248
+ expect(fetchMock.mock.calls[1]?.[0]).toBe(
249
+ "https://exp.host/--/api/v2/push/getReceipts",
250
+ );
251
+ expect(removeNotificationSubscription).toHaveBeenCalledWith({
252
+ deviceID: subscription.deviceID,
253
+ subscriptionID: subscription.subscriptionID,
254
+ userID: subscription.userID,
255
+ });
256
+ });
257
+
258
+ it("does not leave stale pending receipts after receipt lookup failure", async () => {
259
+ vi.useFakeTimers();
260
+ const fetchMock = vi
261
+ .fn()
262
+ .mockResolvedValueOnce({
263
+ json: () =>
264
+ Promise.resolve({
265
+ data: [{ id: "receipt-a", status: "ok" }],
266
+ }),
267
+ ok: true,
268
+ })
269
+ .mockResolvedValueOnce({
270
+ ok: false,
271
+ status: 503,
272
+ });
273
+ vi.stubGlobal("fetch", fetchMock);
274
+
275
+ const { db } = createSpireHarness([], [subscription]);
276
+ const service = new NotificationService(db, [], () => {}, {
277
+ receiptDelayMs: 1,
278
+ });
279
+
280
+ service.notify({
281
+ deviceID: subscription.deviceID,
282
+ event: "mail",
283
+ transmissionID: "00000000-0000-0000-0000-000000000004",
284
+ userID: subscription.userID,
285
+ });
286
+
287
+ await vi.advanceTimersByTimeAsync(1);
288
+
289
+ await vi.waitFor(() => {
290
+ expect(fetchMock).toHaveBeenCalledTimes(2);
291
+ expect(pendingReceiptCount(service)).toBe(0);
292
+ });
293
+ });
294
+
295
+ it("does not leave stale pending receipts after receipt fetch rejection", async () => {
296
+ vi.useFakeTimers();
297
+ const fetchMock = vi
298
+ .fn()
299
+ .mockResolvedValueOnce({
300
+ json: () =>
301
+ Promise.resolve({
302
+ data: [{ id: "receipt-a", status: "ok" }],
303
+ }),
304
+ ok: true,
305
+ })
306
+ .mockRejectedValueOnce(new Error("network unavailable"));
307
+ vi.stubGlobal("fetch", fetchMock);
308
+
309
+ const { db } = createSpireHarness([], [subscription]);
310
+ const service = new NotificationService(db, [], () => {}, {
311
+ receiptDelayMs: 1,
312
+ });
313
+
314
+ service.notify({
315
+ deviceID: subscription.deviceID,
316
+ event: "mail",
317
+ transmissionID: "00000000-0000-0000-0000-000000000008",
318
+ userID: subscription.userID,
319
+ });
320
+
321
+ await vi.advanceTimersByTimeAsync(1);
322
+
323
+ await vi.waitFor(() => {
324
+ expect(fetchMock).toHaveBeenCalledTimes(2);
325
+ expect(pendingReceiptCount(service)).toBe(0);
326
+ });
327
+ });
328
+
329
+ it("does not leave stale pending receipts after receipt lookup timeout", async () => {
330
+ vi.useFakeTimers();
331
+ const fetchMock = vi
332
+ .fn()
333
+ .mockResolvedValueOnce({
334
+ json: () =>
335
+ Promise.resolve({
336
+ data: [{ id: "receipt-a", status: "ok" }],
337
+ }),
338
+ ok: true,
339
+ })
340
+ .mockImplementationOnce((_input: string, init?: RequestInit) => {
341
+ return new Promise<Response>((_resolve, reject) => {
342
+ const abortError = new Error("aborted");
343
+ abortError.name = "AbortError";
344
+ const signal = init?.signal;
345
+ if (signal?.aborted) {
346
+ reject(abortError);
347
+ return;
348
+ }
349
+ signal?.addEventListener(
350
+ "abort",
351
+ () => {
352
+ reject(abortError);
353
+ },
354
+ { once: true },
355
+ );
356
+ });
357
+ });
358
+ vi.stubGlobal("fetch", fetchMock);
359
+
360
+ const { db } = createSpireHarness([], [subscription]);
361
+ const service = new NotificationService(db, [], () => {}, {
362
+ receiptDelayMs: 1,
363
+ });
364
+
365
+ service.notify({
366
+ deviceID: subscription.deviceID,
367
+ event: "mail",
368
+ transmissionID: "00000000-0000-0000-0000-000000000009",
369
+ userID: subscription.userID,
370
+ });
371
+
372
+ await vi.advanceTimersByTimeAsync(1);
373
+ await vi.waitFor(() => {
374
+ expect(fetchMock).toHaveBeenCalledTimes(2);
375
+ });
376
+ await vi.advanceTimersByTimeAsync(10_000);
377
+
378
+ await vi.waitFor(() => {
379
+ expect(pendingReceiptCount(service)).toBe(0);
380
+ });
381
+ });
382
+
383
+ it("sends Android Expo pushes on the mobile push channel", async () => {
384
+ const fetchMock = vi.fn().mockResolvedValueOnce({
385
+ json: () =>
386
+ Promise.resolve({
387
+ data: [{ id: "receipt-a", status: "ok" }],
388
+ }),
389
+ ok: true,
390
+ });
391
+ vi.stubGlobal("fetch", fetchMock);
392
+
393
+ const { db } = createSpireHarness([], [subscription]);
394
+ const service = new NotificationService(db, [], () => {});
395
+
396
+ await service["notifyPush"]({
397
+ deviceID: subscription.deviceID,
398
+ event: "mail",
399
+ transmissionID: "00000000-0000-0000-0000-000000000006",
400
+ userID: subscription.userID,
401
+ });
402
+
403
+ const init = fetchMock.mock.calls[0]?.[1] as
404
+ | undefined
405
+ | { body?: unknown };
406
+ const messages = JSON.parse(String(init?.body)) as Array<{
407
+ channelId?: string;
408
+ collapseId?: string;
409
+ data?: Record<string, unknown>;
410
+ priority?: string;
411
+ tag?: string;
412
+ title?: string;
413
+ }>;
414
+ expect(messages[0]?.collapseId).toBe("vex-message-summary");
415
+ expect(messages[0]?.channelId).toBe("vex-push-messages-v2");
416
+ expect(messages[0]?.priority).toBe("high");
417
+ expect(messages[0]?.tag).toBe("vex-message-summary");
418
+ expect(messages[0]?.title).toBe("New Message");
419
+ expect(messages[0]).not.toHaveProperty("body");
420
+ expect(messages[0]?.data).toMatchObject({
421
+ event: "mail",
422
+ title: "New Message",
423
+ transmissionID: "00000000-0000-0000-0000-000000000006",
424
+ });
425
+ });
426
+
427
+ it("awaits ticket error cleanup so rejection stays on notifyPush", async () => {
428
+ const cleanupError = new Error("database unavailable");
429
+ const removeNotificationSubscription = vi.fn(() =>
430
+ Promise.reject(cleanupError),
431
+ );
432
+ const fetchMock = vi.fn().mockResolvedValueOnce({
433
+ json: () =>
434
+ Promise.resolve({
435
+ data: [
436
+ {
437
+ details: { error: "DeviceNotRegistered" },
438
+ message: "device is not registered",
439
+ status: "error",
440
+ },
441
+ ],
442
+ }),
443
+ ok: true,
444
+ });
445
+ vi.stubGlobal("fetch", fetchMock);
446
+
447
+ const { db } = createSpireHarness(
448
+ [],
449
+ [subscription],
450
+ removeNotificationSubscription,
451
+ );
452
+ const service = new NotificationService(db, [], () => {});
453
+
454
+ await expect(
455
+ service["notifyPush"]({
456
+ deviceID: subscription.deviceID,
457
+ event: "mail",
458
+ transmissionID: "00000000-0000-0000-0000-000000000005",
459
+ userID: subscription.userID,
460
+ }),
461
+ ).rejects.toThrow(cleanupError);
97
462
  });
98
463
  });
package/src/db/schema.ts CHANGED
@@ -90,8 +90,10 @@ export type NewFile = Insertable<FilesTable>;
90
90
 
91
91
  export type NewInvite = Insertable<InvitesTable>;
92
92
  export type NewMail = Insertable<MailTable>;
93
- export type NewOneTimeKey = Insertable<OneTimeKeysTable>;
93
+ export type NewNotificationSubscription =
94
+ Insertable<NotificationSubscriptionsTable>;
94
95
 
96
+ export type NewOneTimeKey = Insertable<OneTimeKeysTable>;
95
97
  export type NewPasskey = Insertable<PasskeysTable>;
96
98
  export type NewPermission = Insertable<PermissionsTable>;
97
99
  export type NewPreKey = Insertable<PreKeysTable>;
@@ -99,6 +101,23 @@ export type NewPreKey = Insertable<PreKeysTable>;
99
101
  export type NewServer = Insertable<ServersTable>;
100
102
  export type NewServiceMetric = Insertable<ServiceMetricsTable>;
101
103
  export type NewUser = Insertable<UsersTable>;
104
+ export type NotificationSubscriptionRow =
105
+ Selectable<NotificationSubscriptionsTable>;
106
+
107
+ export interface NotificationSubscriptionsTable {
108
+ channel: string;
109
+ createdAt: string;
110
+ deviceID: string;
111
+ enabled: number;
112
+ events: string;
113
+ platform: null | string;
114
+ subscriptionID: string;
115
+ token: string;
116
+ updatedAt: string;
117
+ userID: string;
118
+ }
119
+ export type NotificationSubscriptionUpdate =
120
+ Updateable<NotificationSubscriptionsTable>;
102
121
  export type OneTimeKeyRow = Selectable<OneTimeKeysTable>;
103
122
 
104
123
  export interface OneTimeKeysTable {
@@ -153,6 +172,7 @@ export interface ServerDatabase {
153
172
  files: FilesTable;
154
173
  invites: InvitesTable;
155
174
  mail: MailTable;
175
+ notification_subscriptions: NotificationSubscriptionsTable;
156
176
  oneTimeKeys: OneTimeKeysTable;
157
177
  passkeys: PasskeysTable;
158
178
  permissions: PermissionsTable;
@@ -0,0 +1,52 @@
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 { Kysely } from "kysely";
8
+
9
+ export async function down(db: Kysely<unknown>): Promise<void> {
10
+ await db.schema
11
+ .dropTable("notification_subscriptions")
12
+ .ifExists()
13
+ .execute();
14
+ }
15
+
16
+ export async function up(db: Kysely<unknown>): Promise<void> {
17
+ await db.schema
18
+ .createTable("notification_subscriptions")
19
+ .ifNotExists()
20
+ .addColumn("subscriptionID", "varchar(255)", (cb) => cb.primaryKey())
21
+ .addColumn("userID", "varchar(255)", (cb) => cb.notNull())
22
+ .addColumn("deviceID", "varchar(255)", (cb) => cb.notNull())
23
+ .addColumn("channel", "varchar(32)", (cb) => cb.notNull())
24
+ .addColumn("token", "text", (cb) => cb.notNull())
25
+ .addColumn("platform", "varchar(32)")
26
+ .addColumn("events", "text", (cb) => cb.notNull())
27
+ .addColumn("enabled", "integer", (cb) => cb.notNull().defaultTo(1))
28
+ .addColumn("createdAt", "text", (cb) => cb.notNull())
29
+ .addColumn("updatedAt", "text", (cb) => cb.notNull())
30
+ .execute();
31
+
32
+ await db.schema
33
+ .createIndex("notification_subscriptions_device_idx")
34
+ .ifNotExists()
35
+ .on("notification_subscriptions")
36
+ .columns(["deviceID", "channel"])
37
+ .execute();
38
+
39
+ await db.schema
40
+ .createIndex("notification_subscriptions_user_idx")
41
+ .ifNotExists()
42
+ .on("notification_subscriptions")
43
+ .columns(["userID", "channel"])
44
+ .execute();
45
+
46
+ await db.schema
47
+ .createIndex("notification_subscriptions_token_idx")
48
+ .ifNotExists()
49
+ .on("notification_subscriptions")
50
+ .columns(["channel", "token"])
51
+ .execute();
52
+ }
@@ -0,0 +1,33 @@
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 Kysely, sql } from "kysely";
8
+
9
+ const UNIQUE_INDEX_NAME =
10
+ "notification_subscriptions_channel_device_token_unique_idx";
11
+
12
+ export async function down(db: Kysely<unknown>): Promise<void> {
13
+ await db.schema.dropIndex(UNIQUE_INDEX_NAME).ifExists().execute();
14
+ }
15
+
16
+ export async function up(db: Kysely<unknown>): Promise<void> {
17
+ await sql`
18
+ DELETE FROM notification_subscriptions
19
+ WHERE rowid NOT IN (
20
+ SELECT MAX(rowid)
21
+ FROM notification_subscriptions
22
+ GROUP BY channel, deviceID, token
23
+ )
24
+ `.execute(db);
25
+
26
+ await db.schema
27
+ .createIndex(UNIQUE_INDEX_NAME)
28
+ .ifNotExists()
29
+ .on("notification_subscriptions")
30
+ .columns(["channel", "deviceID", "token"])
31
+ .unique()
32
+ .execute();
33
+ }