@vex-chat/spire 1.10.4 → 1.11.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/dist/ClientManager.d.ts +1 -1
- package/dist/ClientManager.js +12 -2
- package/dist/ClientManager.js.map +1 -1
- package/dist/Database.d.ts +31 -0
- package/dist/Database.js +83 -0
- package/dist/Database.js.map +1 -1
- package/dist/NotificationService.d.ts +35 -0
- package/dist/NotificationService.js +386 -0
- package/dist/NotificationService.js.map +1 -0
- package/dist/Spire.d.ts +1 -0
- package/dist/Spire.js +61 -39
- package/dist/Spire.js.map +1 -1
- package/dist/db/schema.d.ts +16 -0
- package/dist/migrations/2026-05-10_notification-subscriptions.d.ts +8 -0
- package/dist/migrations/2026-05-10_notification-subscriptions.js +46 -0
- package/dist/migrations/2026-05-10_notification-subscriptions.js.map +1 -0
- package/dist/migrations/2026-05-15_notification-subscription-unique-key.d.ts +8 -0
- package/dist/migrations/2026-05-15_notification-subscription-unique-key.js +28 -0
- package/dist/migrations/2026-05-15_notification-subscription-unique-key.js.map +1 -0
- package/package.json +5 -5
- package/src/ClientManager.ts +20 -2
- package/src/Database.ts +145 -0
- package/src/NotificationService.ts +535 -0
- package/src/Spire.ts +83 -40
- package/src/__tests__/Database.spec.ts +124 -0
- package/src/__tests__/clientManagerReceipt.spec.ts +106 -0
- package/src/__tests__/notifyFanout.spec.ts +397 -32
- package/src/db/schema.ts +21 -1
- package/src/migrations/2026-05-10_notification-subscriptions.ts +52 -0
- package/src/migrations/2026-05-15_notification-subscription-unique-key.ts +33 -0
|
@@ -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
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
|
-
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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);
|