@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.
@@ -0,0 +1,386 @@
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
+ const EXPO_PUSH_ENDPOINT = "https://exp.host/--/api/v2/push/send";
7
+ const EXPO_RECEIPT_ENDPOINT = "https://exp.host/--/api/v2/push/getReceipts";
8
+ const EXPO_BATCH_SIZE = 100;
9
+ const EXPO_RECEIPT_DELAY_MS = 15 * 60 * 1000;
10
+ const EXPO_REQUEST_TIMEOUT_MS = 10_000;
11
+ const ANDROID_PUSH_CHANNEL_ID = "vex-push-messages-v2";
12
+ const MESSAGE_PUSH_COLLAPSE_ID = "vex-message-summary";
13
+ export class NotificationService {
14
+ clients;
15
+ db;
16
+ pendingReceipts = new Map();
17
+ receiptDelayMs;
18
+ removeClient;
19
+ constructor(db, clients, removeClient, options = {}) {
20
+ this.clients = clients;
21
+ this.db = db;
22
+ this.receiptDelayMs = options.receiptDelayMs ?? EXPO_RECEIPT_DELAY_MS;
23
+ this.removeClient = removeClient;
24
+ }
25
+ notify(dispatch) {
26
+ this.notifyWebSocket(dispatch);
27
+ void this.notifyPush(dispatch).catch((err) => {
28
+ // Push is best-effort; websocket/inbox delivery remain authoritative.
29
+ console.warn("[spire-notify] Expo push fanout failed", {
30
+ event: dispatch.event,
31
+ message: err instanceof Error ? err.message : String(err),
32
+ transmissionID: dispatch.transmissionID,
33
+ userID: dispatch.userID,
34
+ });
35
+ });
36
+ }
37
+ async checkExpoReceipts(receiptIDs) {
38
+ const pendingIDs = receiptIDs.filter((id) => this.pendingReceipts.has(id));
39
+ if (pendingIDs.length === 0)
40
+ return;
41
+ let payload;
42
+ try {
43
+ const res = await fetchWithTimeout(EXPO_RECEIPT_ENDPOINT, {
44
+ body: JSON.stringify({ ids: pendingIDs }),
45
+ headers: {
46
+ Accept: "application/json",
47
+ "Content-Type": "application/json",
48
+ },
49
+ method: "POST",
50
+ }, "Expo receipt request");
51
+ if (!res.ok) {
52
+ throw new Error(`Expo receipt request failed with status ${res.status.toString()}`);
53
+ }
54
+ payload = parseExpoReceiptResponse(await res.json());
55
+ }
56
+ catch (err) {
57
+ this.dropPendingReceipts(pendingIDs);
58
+ throw err;
59
+ }
60
+ if (payload.errors.length > 0) {
61
+ console.warn("[spire-notify] Expo receipt request errors", {
62
+ errors: payload.errors,
63
+ receiptIDs: pendingIDs,
64
+ });
65
+ }
66
+ const receipts = payload.receipts;
67
+ for (const receiptID of pendingIDs) {
68
+ const receipt = receipts[receiptID];
69
+ if (!receipt) {
70
+ console.warn("[spire-notify] Expo receipt missing", {
71
+ receiptID,
72
+ });
73
+ this.pendingReceipts.delete(receiptID);
74
+ continue;
75
+ }
76
+ const pending = this.pendingReceipts.get(receiptID);
77
+ this.pendingReceipts.delete(receiptID);
78
+ if (!pending || receipt.status === "ok")
79
+ continue;
80
+ await this.handleExpoDeliveryError("receipt", pending.subscription, {
81
+ event: pending.event,
82
+ transmissionID: pending.transmissionID,
83
+ }, receipt);
84
+ }
85
+ }
86
+ dropPendingReceipts(receiptIDs) {
87
+ for (const receiptID of receiptIDs) {
88
+ this.pendingReceipts.delete(receiptID);
89
+ }
90
+ }
91
+ async handleExpoDeliveryError(stage, subscription, dispatch, err) {
92
+ const code = err.details?.error;
93
+ console.warn("[spire-notify] Expo push delivery error", {
94
+ code,
95
+ event: dispatch.event,
96
+ message: err.message,
97
+ stage,
98
+ subscriptionID: subscription.subscriptionID,
99
+ transmissionID: dispatch.transmissionID,
100
+ });
101
+ if (code !== "DeviceNotRegistered")
102
+ return;
103
+ await this.db.removeNotificationSubscription({
104
+ deviceID: subscription.deviceID,
105
+ subscriptionID: subscription.subscriptionID,
106
+ userID: subscription.userID,
107
+ });
108
+ }
109
+ async notifyPush(dispatch) {
110
+ const query = {
111
+ event: dispatch.event,
112
+ userID: dispatch.userID,
113
+ };
114
+ const subscriptions = await this.db.retrieveNotificationSubscriptions(dispatch.deviceID
115
+ ? { ...query, deviceID: dispatch.deviceID }
116
+ : query);
117
+ const expoSubscriptions = subscriptions;
118
+ if (expoSubscriptions.length === 0) {
119
+ console.info("[spire-notify] no Expo push subscriptions", {
120
+ deviceScoped: Boolean(dispatch.deviceID),
121
+ event: dispatch.event,
122
+ transmissionID: dispatch.transmissionID,
123
+ userID: dispatch.userID,
124
+ });
125
+ return;
126
+ }
127
+ for (let i = 0; i < expoSubscriptions.length; i += EXPO_BATCH_SIZE) {
128
+ const batch = expoSubscriptions.slice(i, i + EXPO_BATCH_SIZE);
129
+ await this.sendExpoBatch(batch, dispatch);
130
+ }
131
+ }
132
+ notifyWebSocket(dispatch) {
133
+ const msg = {
134
+ data: dispatch.data,
135
+ event: dispatch.event,
136
+ transmissionID: dispatch.transmissionID,
137
+ type: "notify",
138
+ };
139
+ const snapshot = this.clients.slice();
140
+ for (const client of snapshot) {
141
+ try {
142
+ if (client.hasFailed()) {
143
+ this.removeClient(client);
144
+ continue;
145
+ }
146
+ if (dispatch.deviceID) {
147
+ const currentDeviceID = client.getDeviceID();
148
+ if (currentDeviceID === null) {
149
+ this.removeClient(client);
150
+ continue;
151
+ }
152
+ if (currentDeviceID === dispatch.deviceID) {
153
+ client.send(msg);
154
+ }
155
+ continue;
156
+ }
157
+ const currentUserID = client.getUserID();
158
+ if (currentUserID === null) {
159
+ this.removeClient(client);
160
+ continue;
161
+ }
162
+ if (currentUserID === dispatch.userID) {
163
+ client.send(msg);
164
+ }
165
+ }
166
+ catch (_err) {
167
+ this.removeClient(client);
168
+ }
169
+ }
170
+ }
171
+ scheduleReceiptCheck(receiptIDs) {
172
+ const timer = setTimeout(() => {
173
+ void this.checkExpoReceipts(receiptIDs).catch((err) => {
174
+ console.warn("[spire-notify] Expo receipt check failed", err instanceof Error ? err.message : String(err));
175
+ });
176
+ }, this.receiptDelayMs);
177
+ timer.unref?.();
178
+ }
179
+ async sendExpoBatch(subscriptions, dispatch) {
180
+ const headless = shouldSendHeadlessPush(dispatch);
181
+ const messages = subscriptions.map((sub) => expoMessageForSubscription(sub, dispatch, headless));
182
+ console.info("[spire-notify] sending Expo push batch", {
183
+ channelIDs: [
184
+ ...new Set(messages
185
+ .map((msg) => msg["channelId"])
186
+ .filter((value) => {
187
+ return typeof value === "string";
188
+ })),
189
+ ],
190
+ event: dispatch.event,
191
+ headless,
192
+ platforms: [...new Set(subscriptions.map((sub) => sub.platform))],
193
+ size: subscriptions.length,
194
+ transmissionID: dispatch.transmissionID,
195
+ });
196
+ const res = await fetchWithTimeout(EXPO_PUSH_ENDPOINT, {
197
+ body: JSON.stringify(messages),
198
+ headers: {
199
+ Accept: "application/json",
200
+ "Content-Type": "application/json",
201
+ },
202
+ method: "POST",
203
+ }, "Expo push request");
204
+ if (!res.ok) {
205
+ const body = await res.text().catch(() => "");
206
+ throw new Error(`Expo push request failed with status ${res.status.toString()}${body ? `: ${body.slice(0, 500)}` : ""}`);
207
+ }
208
+ const payload = parseExpoPushResponse(await res.json());
209
+ if (payload.errors.length > 0) {
210
+ console.warn("[spire-notify] Expo push request errors", {
211
+ errors: payload.errors,
212
+ event: dispatch.event,
213
+ transmissionID: dispatch.transmissionID,
214
+ });
215
+ }
216
+ const tickets = payload.tickets;
217
+ console.info("[spire-notify] Expo push tickets received", {
218
+ errors: tickets.filter((ticket) => ticket.status === "error")
219
+ .length,
220
+ event: dispatch.event,
221
+ ok: tickets.filter((ticket) => ticket.status === "ok").length,
222
+ transmissionID: dispatch.transmissionID,
223
+ });
224
+ for (const [idx, ticket] of tickets.entries()) {
225
+ const subscription = subscriptions[idx];
226
+ if (!subscription)
227
+ continue;
228
+ if (ticket.status === "error") {
229
+ await this.handleExpoDeliveryError("ticket", subscription, dispatch, ticket);
230
+ continue;
231
+ }
232
+ this.pendingReceipts.set(ticket.id, {
233
+ event: dispatch.event,
234
+ subscription,
235
+ transmissionID: dispatch.transmissionID,
236
+ });
237
+ }
238
+ const receiptIDs = tickets.flatMap((ticket) => ticket.status === "ok" ? [ticket.id] : []);
239
+ if (receiptIDs.length > 0) {
240
+ this.scheduleReceiptCheck(receiptIDs);
241
+ }
242
+ }
243
+ }
244
+ function bodyForEvent(event) {
245
+ if (event === "mail")
246
+ return undefined;
247
+ if (event === "deviceRequest")
248
+ return "Review the device request.";
249
+ if (event === "deviceListChanged")
250
+ return "Your device list changed.";
251
+ return "Open Vex for the latest update.";
252
+ }
253
+ function expoMessageForSubscription(subscription, dispatch, headless) {
254
+ if (headless) {
255
+ return {
256
+ _contentAvailable: true,
257
+ data: {
258
+ deviceID: dispatch.deviceID ?? null,
259
+ event: dispatch.event,
260
+ headless: true,
261
+ transmissionID: dispatch.transmissionID,
262
+ },
263
+ priority: subscription.platform === "android" ? "high" : undefined,
264
+ to: subscription.token,
265
+ };
266
+ }
267
+ const title = titleForEvent(dispatch.event);
268
+ const body = bodyForEvent(dispatch.event);
269
+ const data = {
270
+ deviceID: dispatch.deviceID ?? null,
271
+ event: dispatch.event,
272
+ title,
273
+ transmissionID: dispatch.transmissionID,
274
+ };
275
+ const message = {
276
+ channelId: subscription.platform === "android"
277
+ ? ANDROID_PUSH_CHANNEL_ID
278
+ : undefined,
279
+ data,
280
+ priority: subscription.platform === "android" ? "high" : undefined,
281
+ // Do not set `sound: "default"` here. Current mobile native
282
+ // notification modules can treat it as a custom sound resource named
283
+ // "default" instead of the platform default, which causes warnings when
284
+ // no such bundled resource exists.
285
+ title,
286
+ to: subscription.token,
287
+ };
288
+ if (body) {
289
+ message["body"] = body;
290
+ }
291
+ if (dispatch.event === "mail") {
292
+ message["collapseId"] = MESSAGE_PUSH_COLLAPSE_ID;
293
+ if (subscription.platform === "android") {
294
+ message["tag"] = MESSAGE_PUSH_COLLAPSE_ID;
295
+ }
296
+ }
297
+ return message;
298
+ }
299
+ async function fetchWithTimeout(input, init, label) {
300
+ const controller = new AbortController();
301
+ const timer = setTimeout(() => {
302
+ controller.abort();
303
+ }, EXPO_REQUEST_TIMEOUT_MS);
304
+ timer.unref?.();
305
+ try {
306
+ return await fetch(input, {
307
+ ...init,
308
+ signal: controller.signal,
309
+ });
310
+ }
311
+ catch (err) {
312
+ if (err instanceof Error &&
313
+ (err.name === "AbortError" || err.name === "TimeoutError")) {
314
+ throw new Error(`${label} timed out after ${EXPO_REQUEST_TIMEOUT_MS.toString()}ms`, { cause: err });
315
+ }
316
+ throw err;
317
+ }
318
+ finally {
319
+ clearTimeout(timer);
320
+ }
321
+ }
322
+ function isExpoPushReceipt(value) {
323
+ if (!isRecord(value))
324
+ return false;
325
+ return value["status"] === "ok" || value["status"] === "error";
326
+ }
327
+ function isExpoPushTicket(value) {
328
+ if (!isRecord(value))
329
+ return false;
330
+ if (value["status"] === "ok") {
331
+ return typeof value["id"] === "string";
332
+ }
333
+ return value["status"] === "error";
334
+ }
335
+ function isRecord(value) {
336
+ return typeof value === "object" && value !== null;
337
+ }
338
+ function normalizeExpoTickets(data) {
339
+ if (!data)
340
+ return [];
341
+ const tickets = Array.isArray(data) ? data : [data];
342
+ return tickets.filter(isExpoPushTicket);
343
+ }
344
+ function parseExpoPushResponse(raw) {
345
+ if (!isRecord(raw)) {
346
+ return { errors: [], tickets: [] };
347
+ }
348
+ return {
349
+ errors: Array.isArray(raw["errors"]) ? raw["errors"] : [],
350
+ tickets: normalizeExpoTickets(raw["data"]),
351
+ };
352
+ }
353
+ function parseExpoReceiptResponse(raw) {
354
+ if (!isRecord(raw)) {
355
+ return { errors: [], receipts: {} };
356
+ }
357
+ return {
358
+ errors: Array.isArray(raw["errors"]) ? raw["errors"] : [],
359
+ receipts: parseExpoReceipts(raw["data"]),
360
+ };
361
+ }
362
+ function parseExpoReceipts(data) {
363
+ if (!isRecord(data))
364
+ return {};
365
+ const receipts = {};
366
+ for (const [receiptID, receipt] of Object.entries(data)) {
367
+ if (isExpoPushReceipt(receipt)) {
368
+ receipts[receiptID] = receipt;
369
+ }
370
+ }
371
+ return receipts;
372
+ }
373
+ function shouldSendHeadlessPush(dispatch) {
374
+ return (dispatch.headlessPushUserID !== undefined &&
375
+ dispatch.userID === dispatch.headlessPushUserID);
376
+ }
377
+ function titleForEvent(event) {
378
+ if (event === "mail")
379
+ return "New Message";
380
+ if (event === "deviceRequest")
381
+ return "Device approval request";
382
+ if (event === "deviceListChanged")
383
+ return "Vex device update";
384
+ return "Vex notification";
385
+ }
386
+ //# sourceMappingURL=NotificationService.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"NotificationService.js","sourceRoot":"","sources":["../src/NotificationService.ts"],"names":[],"mappings":"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"}
package/dist/Spire.d.ts CHANGED
@@ -34,6 +34,7 @@ export declare class Spire extends EventEmitter {
34
34
  private dbReady;
35
35
  private deviceChallenges;
36
36
  private mailPruneInterval;
37
+ private notifications;
37
38
  private queuedRequestIncrements;
38
39
  private requestsTotal;
39
40
  private requestsTotalLoaded;
package/dist/Spire.js CHANGED
@@ -20,6 +20,7 @@ import { WebSocketServer } from "ws";
20
20
  import { z } from "zod/v4";
21
21
  import { ClientManager } from "./ClientManager.js";
22
22
  import { Database, hashPasswordArgon2, verifyPassword } from "./Database.js";
23
+ import { NotificationService } from "./NotificationService.js";
23
24
  import { initApp, protect } from "./server/index.js";
24
25
  import { MailIngressValidationError, validateMailIngress, } from "./server/mailIngress.js";
25
26
  import { authLimiter, devApiKeySkipsRateLimits } from "./server/rateLimit.js";
@@ -69,6 +70,12 @@ const mailPostPayload = z.object({
69
70
  header: z.custom((val) => val instanceof Uint8Array),
70
71
  mail: MailWSSchema,
71
72
  });
73
+ const notificationSubscribePayload = z.object({
74
+ channel: z.literal("expo"),
75
+ events: z.array(z.string().min(1)).default(["mail"]),
76
+ platform: z.enum(["android", "ios", "web"]).optional(),
77
+ token: z.string().min(1),
78
+ });
72
79
  const directories = ["files", "avatars", "emoji"];
73
80
  for (const dir of directories) {
74
81
  if (!fs.existsSync(dir)) {
@@ -132,6 +139,7 @@ export class Spire extends EventEmitter {
132
139
  dbReady = false;
133
140
  deviceChallenges = new Map();
134
141
  mailPruneInterval = null;
142
+ notifications;
135
143
  queuedRequestIncrements = 0;
136
144
  requestsTotal = 0;
137
145
  requestsTotalLoaded = false;
@@ -164,6 +172,7 @@ export class Spire extends EventEmitter {
164
172
  // If spire is deployed without a proxy, set this to 0 instead.
165
173
  this.api.set("trust proxy", 1);
166
174
  this.db = new Database(options);
175
+ this.notifications = new NotificationService(this.db, this.clients, this.removeClient.bind(this));
167
176
  this.db.on("ready", () => {
168
177
  this.dbReady = true;
169
178
  void this.db.pruneExpiredMail().catch(() => {
@@ -618,7 +627,52 @@ export class Spire extends EventEmitter {
618
627
  }
619
628
  await this.db.saveMail(mail, header, senderDeviceDetails.deviceID, authorUserDetails.userID);
620
629
  res.sendStatus(200);
621
- this.notify(recipientDeviceDetails.owner, "mail", crypto.randomUUID(), null, mail.recipient);
630
+ this.notify(recipientDeviceDetails.owner, "mail", crypto.randomUUID(), null, mail.recipient, mail.authorID);
631
+ });
632
+ this.api.post("/device/:id/notifications/subscriptions", protect, async (req, res) => {
633
+ const device = req.device;
634
+ if (!device) {
635
+ res.sendStatus(401);
636
+ return;
637
+ }
638
+ if (device.deviceID !== getParam(req, "id")) {
639
+ res.sendStatus(403);
640
+ return;
641
+ }
642
+ const parsed = notificationSubscribePayload.safeParse(req.body);
643
+ if (!parsed.success) {
644
+ res.status(400).json({
645
+ error: "Invalid notification subscription payload",
646
+ issues: parsed.error.issues,
647
+ });
648
+ return;
649
+ }
650
+ const subscription = await this.db.saveNotificationSubscription({
651
+ channel: parsed.data.channel,
652
+ deviceID: device.deviceID,
653
+ events: parsed.data.events,
654
+ platform: parsed.data.platform ?? null,
655
+ token: parsed.data.token,
656
+ userID: getUser(req).userID,
657
+ });
658
+ res.status(201).json(subscription);
659
+ });
660
+ this.api.delete("/device/:id/notifications/subscriptions/:subscriptionID", protect, async (req, res) => {
661
+ const device = req.device;
662
+ if (!device) {
663
+ res.sendStatus(401);
664
+ return;
665
+ }
666
+ if (device.deviceID !== getParam(req, "id")) {
667
+ res.sendStatus(403);
668
+ return;
669
+ }
670
+ const removed = await this.db.removeNotificationSubscription({
671
+ deviceID: device.deviceID,
672
+ subscriptionID: getParam(req, "subscriptionID"),
673
+ userID: getUser(req).userID,
674
+ });
675
+ res.sendStatus(removed ? 204 : 404);
622
676
  });
623
677
  this.api.post("/auth", authLimiter, async (req, res) => {
624
678
  const parsed = authPayload.safeParse(req.body);
@@ -759,47 +813,15 @@ export class Spire extends EventEmitter {
759
813
  });
760
814
  });
761
815
  }
762
- notify(userID, event, transmissionID, data, deviceID) {
763
- const msg = {
816
+ notify(userID, event, transmissionID, data, deviceID, headlessPushUserID) {
817
+ this.notifications.notify({
764
818
  data,
765
819
  event,
820
+ ...(headlessPushUserID ? { headlessPushUserID } : {}),
766
821
  transmissionID,
767
- type: "notify",
768
- };
769
- // Snapshot the array so that a synchronous fail/prune inside send
770
- // cannot corrupt the iteration. Each client is isolated so one stale
771
- // manager cannot abort delivery to later recipients.
772
- const snapshot = this.clients.slice();
773
- for (const client of snapshot) {
774
- try {
775
- if (client.hasFailed()) {
776
- this.removeClient(client);
777
- continue;
778
- }
779
- if (deviceID) {
780
- const currentDeviceID = client.getDeviceID();
781
- if (currentDeviceID === null) {
782
- this.removeClient(client);
783
- continue;
784
- }
785
- if (currentDeviceID === deviceID) {
786
- client.send(msg);
787
- }
788
- continue;
789
- }
790
- const currentUserID = client.getUserID();
791
- if (currentUserID === null) {
792
- this.removeClient(client);
793
- continue;
794
- }
795
- if (currentUserID === userID) {
796
- client.send(msg);
797
- }
798
- }
799
- catch (_err) {
800
- this.removeClient(client);
801
- }
802
- }
822
+ userID,
823
+ ...(deviceID ? { deviceID } : {}),
824
+ });
803
825
  }
804
826
  removeClient(client) {
805
827
  const idx = this.clients.indexOf(client);