@vex-chat/spire 2.5.0 → 2.6.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/BillingVerification.d.ts +40 -0
- package/dist/BillingVerification.js +535 -0
- package/dist/BillingVerification.js.map +1 -0
- package/dist/ClientManager.js +1 -8
- package/dist/ClientManager.js.map +1 -1
- package/dist/Database.d.ts +41 -4
- package/dist/Database.js +285 -8
- package/dist/Database.js.map +1 -1
- package/dist/NotificationService.d.ts +0 -2
- package/dist/NotificationService.js +4 -321
- package/dist/NotificationService.js.map +1 -1
- package/dist/Spire.js +5 -21
- package/dist/Spire.js.map +1 -1
- package/dist/db/schema.d.ts +50 -0
- package/dist/migrations/2026-06-24_account-entitlements.d.ts +8 -0
- package/dist/migrations/2026-06-24_account-entitlements.js +20 -0
- package/dist/migrations/2026-06-24_account-entitlements.js.map +1 -0
- package/dist/migrations/2026-07-02_store-subscriptions.d.ts +8 -0
- package/dist/migrations/2026-07-02_store-subscriptions.js +83 -0
- package/dist/migrations/2026-07-02_store-subscriptions.js.map +1 -0
- package/dist/server/billing.d.ts +14 -0
- package/dist/server/billing.js +234 -0
- package/dist/server/billing.js.map +1 -0
- package/dist/server/entitlements.d.ts +8 -0
- package/dist/server/entitlements.js +71 -0
- package/dist/server/entitlements.js.map +1 -0
- package/dist/server/index.js +6 -0
- package/dist/server/index.js.map +1 -1
- package/package.json +4 -4
- package/src/BillingVerification.ts +800 -0
- package/src/ClientManager.ts +9 -23
- package/src/Database.ts +423 -12
- package/src/NotificationService.ts +4 -428
- package/src/Spire.ts +21 -52
- package/src/__tests__/Database.spec.ts +6 -3
- package/src/__tests__/billing.spec.ts +282 -0
- package/src/__tests__/entitlements.spec.ts +241 -0
- package/src/__tests__/notifyFanout.spec.ts +0 -136
- package/src/db/schema.ts +66 -1
- package/src/migrations/2026-06-24_account-entitlements.ts +23 -0
- package/src/migrations/2026-07-02_store-subscriptions.ts +92 -0
- package/src/server/billing.ts +361 -0
- package/src/server/entitlements.ts +104 -0
- package/src/server/index.ts +6 -0
- package/dist/server/callWake.d.ts +0 -13
- package/dist/server/callWake.js +0 -18
- package/dist/server/callWake.js.map +0 -1
- package/src/server/callWake.ts +0 -30
|
@@ -8,14 +8,8 @@ import type { ClientManager } from "./ClientManager.ts";
|
|
|
8
8
|
import type { Database, NotificationSubscription } from "./Database.ts";
|
|
9
9
|
import type { NotifyMsg } from "@vex-chat/types";
|
|
10
10
|
|
|
11
|
-
import { readFileSync } from "node:fs";
|
|
12
|
-
import * as http2 from "node:http2";
|
|
13
|
-
|
|
14
|
-
import jwt from "jsonwebtoken";
|
|
15
|
-
|
|
16
11
|
const EXPO_PUSH_ENDPOINT = "https://exp.host/--/api/v2/push/send";
|
|
17
12
|
const EXPO_RECEIPT_ENDPOINT = "https://exp.host/--/api/v2/push/getReceipts";
|
|
18
|
-
const FCM_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
|
|
19
13
|
const EXPO_BATCH_SIZE = 100;
|
|
20
14
|
const EXPO_RECEIPT_DELAY_MS = 15 * 60 * 1000;
|
|
21
15
|
const EXPO_REQUEST_TIMEOUT_MS = 10_000;
|
|
@@ -33,19 +27,6 @@ export interface NotificationDispatch {
|
|
|
33
27
|
userID: string;
|
|
34
28
|
}
|
|
35
29
|
|
|
36
|
-
interface ApnsConfig {
|
|
37
|
-
environment: "production" | "sandbox";
|
|
38
|
-
keyID: string;
|
|
39
|
-
privateKey: string;
|
|
40
|
-
teamID: string;
|
|
41
|
-
topic: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
interface ApnsResponse {
|
|
45
|
-
body: string;
|
|
46
|
-
status: number;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
30
|
type ExpoErrorDetails = {
|
|
50
31
|
[key: string]: unknown;
|
|
51
32
|
error?: string;
|
|
@@ -72,12 +53,6 @@ type ExpoPushTicket =
|
|
|
72
53
|
status: "ok";
|
|
73
54
|
};
|
|
74
55
|
|
|
75
|
-
interface FcmConfig {
|
|
76
|
-
clientEmail: string;
|
|
77
|
-
privateKey: string;
|
|
78
|
-
projectID: string;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
56
|
interface NotificationServiceOptions {
|
|
82
57
|
receiptDelayMs?: number;
|
|
83
58
|
}
|
|
@@ -234,21 +209,9 @@ export class NotificationService {
|
|
|
234
209
|
? { ...query, deviceID: dispatch.deviceID }
|
|
235
210
|
: query,
|
|
236
211
|
);
|
|
237
|
-
const expoSubscriptions = subscriptions
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const apnsSubscriptions = subscriptions.filter(
|
|
241
|
-
(sub) => sub.channel === "apnsVoip",
|
|
242
|
-
);
|
|
243
|
-
const fcmSubscriptions = subscriptions.filter(
|
|
244
|
-
(sub) => sub.channel === "fcmCall",
|
|
245
|
-
);
|
|
246
|
-
if (
|
|
247
|
-
expoSubscriptions.length === 0 &&
|
|
248
|
-
apnsSubscriptions.length === 0 &&
|
|
249
|
-
fcmSubscriptions.length === 0
|
|
250
|
-
) {
|
|
251
|
-
console.info("[spire-notify] no push subscriptions", {
|
|
212
|
+
const expoSubscriptions = subscriptions;
|
|
213
|
+
if (expoSubscriptions.length === 0) {
|
|
214
|
+
console.info("[spire-notify] no Expo push subscriptions", {
|
|
252
215
|
deviceScoped: Boolean(dispatch.deviceID),
|
|
253
216
|
event: dispatch.event,
|
|
254
217
|
transmissionID: dispatch.transmissionID,
|
|
@@ -261,14 +224,6 @@ export class NotificationService {
|
|
|
261
224
|
const batch = expoSubscriptions.slice(i, i + EXPO_BATCH_SIZE);
|
|
262
225
|
await this.sendExpoBatch(batch, dispatch);
|
|
263
226
|
}
|
|
264
|
-
if (dispatch.event === "callWake") {
|
|
265
|
-
if (apnsSubscriptions.length > 0) {
|
|
266
|
-
await this.sendApnsVoipBatch(apnsSubscriptions, dispatch);
|
|
267
|
-
}
|
|
268
|
-
if (fcmSubscriptions.length > 0) {
|
|
269
|
-
await this.sendFcmCallBatch(fcmSubscriptions, dispatch);
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
227
|
}
|
|
273
228
|
|
|
274
229
|
private async notifyPushIfMailPending(
|
|
@@ -373,59 +328,6 @@ export class NotificationService {
|
|
|
373
328
|
(timer as { unref?: () => void }).unref?.();
|
|
374
329
|
}
|
|
375
330
|
|
|
376
|
-
private async sendApnsVoipBatch(
|
|
377
|
-
subscriptions: NotificationSubscription[],
|
|
378
|
-
dispatch: NotificationDispatch,
|
|
379
|
-
): Promise<void> {
|
|
380
|
-
const config = readApnsConfig();
|
|
381
|
-
if (!config) {
|
|
382
|
-
console.warn("[spire-notify] APNs VoIP env is not configured", {
|
|
383
|
-
event: dispatch.event,
|
|
384
|
-
size: subscriptions.length,
|
|
385
|
-
transmissionID: dispatch.transmissionID,
|
|
386
|
-
});
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
const providerToken = jwt.sign({}, config.privateKey, {
|
|
391
|
-
algorithm: "ES256",
|
|
392
|
-
expiresIn: "50m",
|
|
393
|
-
header: { alg: "ES256", kid: config.keyID },
|
|
394
|
-
issuer: config.teamID,
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
for (const subscription of subscriptions) {
|
|
398
|
-
const response = await sendApnsVoipNotification(
|
|
399
|
-
config,
|
|
400
|
-
providerToken,
|
|
401
|
-
subscription,
|
|
402
|
-
dispatch,
|
|
403
|
-
);
|
|
404
|
-
if (response.status >= 200 && response.status < 300) {
|
|
405
|
-
continue;
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
console.warn("[spire-notify] APNs VoIP delivery failed", {
|
|
409
|
-
body: response.body.slice(0, 500),
|
|
410
|
-
status: response.status,
|
|
411
|
-
subscriptionID: subscription.subscriptionID,
|
|
412
|
-
transmissionID: dispatch.transmissionID,
|
|
413
|
-
});
|
|
414
|
-
if (
|
|
415
|
-
response.status === 400 ||
|
|
416
|
-
response.status === 410 ||
|
|
417
|
-
response.body.includes("BadDeviceToken") ||
|
|
418
|
-
response.body.includes("Unregistered")
|
|
419
|
-
) {
|
|
420
|
-
await this.db.removeNotificationSubscription({
|
|
421
|
-
deviceID: subscription.deviceID,
|
|
422
|
-
subscriptionID: subscription.subscriptionID,
|
|
423
|
-
userID: subscription.userID,
|
|
424
|
-
});
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
331
|
private async sendExpoBatch(
|
|
430
332
|
subscriptions: NotificationSubscription[],
|
|
431
333
|
dispatch: NotificationDispatch,
|
|
@@ -517,68 +419,10 @@ export class NotificationService {
|
|
|
517
419
|
}
|
|
518
420
|
}
|
|
519
421
|
|
|
520
|
-
private async sendFcmCallBatch(
|
|
521
|
-
subscriptions: NotificationSubscription[],
|
|
522
|
-
dispatch: NotificationDispatch,
|
|
523
|
-
): Promise<void> {
|
|
524
|
-
const config = readFcmConfig();
|
|
525
|
-
if (!config) {
|
|
526
|
-
console.warn("[spire-notify] FCM call env is not configured", {
|
|
527
|
-
event: dispatch.event,
|
|
528
|
-
size: subscriptions.length,
|
|
529
|
-
transmissionID: dispatch.transmissionID,
|
|
530
|
-
});
|
|
531
|
-
return;
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
const accessToken = await fetchFcmAccessToken(config);
|
|
535
|
-
for (const subscription of subscriptions) {
|
|
536
|
-
const res = await fetchWithTimeout(
|
|
537
|
-
`https://fcm.googleapis.com/v1/projects/${encodeURIComponent(
|
|
538
|
-
config.projectID,
|
|
539
|
-
)}/messages:send`,
|
|
540
|
-
{
|
|
541
|
-
body: JSON.stringify(
|
|
542
|
-
fcmCallMessageForSubscription(subscription, dispatch),
|
|
543
|
-
),
|
|
544
|
-
headers: {
|
|
545
|
-
Authorization: `Bearer ${accessToken}`,
|
|
546
|
-
"Content-Type": "application/json",
|
|
547
|
-
},
|
|
548
|
-
method: "POST",
|
|
549
|
-
},
|
|
550
|
-
"FCM call push request",
|
|
551
|
-
);
|
|
552
|
-
if (res.ok) {
|
|
553
|
-
continue;
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
const body = await res.text().catch(() => "");
|
|
557
|
-
console.warn("[spire-notify] FCM call delivery failed", {
|
|
558
|
-
body: body.slice(0, 500),
|
|
559
|
-
status: res.status,
|
|
560
|
-
subscriptionID: subscription.subscriptionID,
|
|
561
|
-
transmissionID: dispatch.transmissionID,
|
|
562
|
-
});
|
|
563
|
-
if (
|
|
564
|
-
res.status === 400 ||
|
|
565
|
-
res.status === 404 ||
|
|
566
|
-
body.includes("UNREGISTERED") ||
|
|
567
|
-
body.includes("registration-token-not-registered")
|
|
568
|
-
) {
|
|
569
|
-
await this.db.removeNotificationSubscription({
|
|
570
|
-
deviceID: subscription.deviceID,
|
|
571
|
-
subscriptionID: subscription.subscriptionID,
|
|
572
|
-
userID: subscription.userID,
|
|
573
|
-
});
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
|
|
578
422
|
private startPush(dispatch: NotificationDispatch): void {
|
|
579
423
|
void this.notifyPush(dispatch).catch((err: unknown) => {
|
|
580
424
|
// Push is best-effort; websocket/inbox delivery remain authoritative.
|
|
581
|
-
console.warn("[spire-notify] push fanout failed", {
|
|
425
|
+
console.warn("[spire-notify] Expo push fanout failed", {
|
|
582
426
|
event: dispatch.event,
|
|
583
427
|
message: err instanceof Error ? err.message : String(err),
|
|
584
428
|
transmissionID: dispatch.transmissionID,
|
|
@@ -588,19 +432,7 @@ export class NotificationService {
|
|
|
588
432
|
}
|
|
589
433
|
}
|
|
590
434
|
|
|
591
|
-
function apnsExpiration(dispatch: NotificationDispatch): string {
|
|
592
|
-
const expiresAt = notifyDataFields(dispatch.data)["expiresAt"];
|
|
593
|
-
if (typeof expiresAt === "string") {
|
|
594
|
-
const epochSeconds = Math.floor(Date.parse(expiresAt) / 1000);
|
|
595
|
-
if (Number.isFinite(epochSeconds) && epochSeconds > 0) {
|
|
596
|
-
return String(epochSeconds);
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
return String(Math.floor((Date.now() + 60_000) / 1000));
|
|
600
|
-
}
|
|
601
|
-
|
|
602
435
|
function bodyForEvent(event: string): string | undefined {
|
|
603
|
-
if (event === "callWake") return "Incoming voice call.";
|
|
604
436
|
if (event === "callInvite") return "Incoming voice call.";
|
|
605
437
|
if (event === "mail") return undefined;
|
|
606
438
|
if (event === "deviceRequest") return "Review the device request.";
|
|
@@ -608,18 +440,6 @@ function bodyForEvent(event: string): string | undefined {
|
|
|
608
440
|
return "Open Vex for the latest update.";
|
|
609
441
|
}
|
|
610
442
|
|
|
611
|
-
function callWakeTtl(dispatch: NotificationDispatch): string {
|
|
612
|
-
const expiresAt = notifyDataFields(dispatch.data)["expiresAt"];
|
|
613
|
-
if (typeof expiresAt !== "string") {
|
|
614
|
-
return "60s";
|
|
615
|
-
}
|
|
616
|
-
const ms = Date.parse(expiresAt) - Date.now();
|
|
617
|
-
if (!Number.isFinite(ms) || ms <= 0) {
|
|
618
|
-
return "1s";
|
|
619
|
-
}
|
|
620
|
-
return `${Math.max(1, Math.ceil(ms / 1000)).toString()}s`;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
443
|
function expoMessageForSubscription(
|
|
624
444
|
subscription: NotificationSubscription,
|
|
625
445
|
dispatch: NotificationDispatch,
|
|
@@ -642,7 +462,6 @@ function expoMessageForSubscription(
|
|
|
642
462
|
const title = titleForEvent(dispatch.event);
|
|
643
463
|
const body = bodyForEvent(dispatch.event);
|
|
644
464
|
const data = {
|
|
645
|
-
...notifyDataFields(dispatch.data),
|
|
646
465
|
deviceID: dispatch.deviceID ?? null,
|
|
647
466
|
event: dispatch.event,
|
|
648
467
|
title,
|
|
@@ -674,73 +493,6 @@ function expoMessageForSubscription(
|
|
|
674
493
|
return message;
|
|
675
494
|
}
|
|
676
495
|
|
|
677
|
-
function fcmCallMessageForSubscription(
|
|
678
|
-
subscription: NotificationSubscription,
|
|
679
|
-
dispatch: NotificationDispatch,
|
|
680
|
-
): Record<string, unknown> {
|
|
681
|
-
return {
|
|
682
|
-
message: {
|
|
683
|
-
android: {
|
|
684
|
-
priority: "HIGH",
|
|
685
|
-
ttl: callWakeTtl(dispatch),
|
|
686
|
-
},
|
|
687
|
-
data: stringRecord({
|
|
688
|
-
...notifyDataFields(dispatch.data),
|
|
689
|
-
deviceID: dispatch.deviceID ?? "",
|
|
690
|
-
event: dispatch.event,
|
|
691
|
-
transmissionID: dispatch.transmissionID,
|
|
692
|
-
}),
|
|
693
|
-
token: subscription.token,
|
|
694
|
-
},
|
|
695
|
-
};
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
async function fetchFcmAccessToken(config: FcmConfig): Promise<string> {
|
|
699
|
-
const assertion = jwt.sign(
|
|
700
|
-
{
|
|
701
|
-
scope: "https://www.googleapis.com/auth/firebase.messaging",
|
|
702
|
-
},
|
|
703
|
-
config.privateKey,
|
|
704
|
-
{
|
|
705
|
-
algorithm: "RS256",
|
|
706
|
-
audience: FCM_TOKEN_ENDPOINT,
|
|
707
|
-
expiresIn: "55m",
|
|
708
|
-
issuer: config.clientEmail,
|
|
709
|
-
subject: config.clientEmail,
|
|
710
|
-
},
|
|
711
|
-
);
|
|
712
|
-
const body = new URLSearchParams({
|
|
713
|
-
assertion,
|
|
714
|
-
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
715
|
-
});
|
|
716
|
-
const res = await fetchWithTimeout(
|
|
717
|
-
FCM_TOKEN_ENDPOINT,
|
|
718
|
-
{
|
|
719
|
-
body,
|
|
720
|
-
headers: {
|
|
721
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
722
|
-
},
|
|
723
|
-
method: "POST",
|
|
724
|
-
},
|
|
725
|
-
"FCM OAuth token request",
|
|
726
|
-
);
|
|
727
|
-
if (!res.ok) {
|
|
728
|
-
const responseBody = await res.text().catch(() => "");
|
|
729
|
-
throw new Error(
|
|
730
|
-
`FCM OAuth token request failed with status ${res.status.toString()}${
|
|
731
|
-
responseBody ? `: ${responseBody.slice(0, 500)}` : ""
|
|
732
|
-
}`,
|
|
733
|
-
);
|
|
734
|
-
}
|
|
735
|
-
const raw = await res.json();
|
|
736
|
-
if (!isRecord(raw) || typeof raw["access_token"] !== "string") {
|
|
737
|
-
throw new Error(
|
|
738
|
-
"FCM OAuth token response did not include access_token",
|
|
739
|
-
);
|
|
740
|
-
}
|
|
741
|
-
return raw["access_token"];
|
|
742
|
-
}
|
|
743
|
-
|
|
744
496
|
async function fetchWithTimeout(
|
|
745
497
|
input: string,
|
|
746
498
|
init: RequestInit,
|
|
@@ -795,24 +547,6 @@ function normalizeExpoTickets(data: unknown): ExpoPushTicket[] {
|
|
|
795
547
|
return tickets.filter(isExpoPushTicket);
|
|
796
548
|
}
|
|
797
549
|
|
|
798
|
-
function normalizePrivateKey(value: string): string {
|
|
799
|
-
return value.replaceAll("\\n", "\n");
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
function notifyDataFields(data: unknown): Record<string, unknown> {
|
|
803
|
-
if (!isRecord(data)) {
|
|
804
|
-
return {};
|
|
805
|
-
}
|
|
806
|
-
const out: Record<string, unknown> = {};
|
|
807
|
-
for (const key of ["callID", "expiresAt", "mailID", "mailNonce"]) {
|
|
808
|
-
const value = data[key];
|
|
809
|
-
if (typeof value === "string") {
|
|
810
|
-
out[key] = value;
|
|
811
|
-
}
|
|
812
|
-
}
|
|
813
|
-
return out;
|
|
814
|
-
}
|
|
815
|
-
|
|
816
550
|
function parseExpoPushResponse(raw: unknown): ParsedExpoPushResponse {
|
|
817
551
|
if (!isRecord(raw)) {
|
|
818
552
|
return { errors: [], tickets: [] };
|
|
@@ -844,153 +578,6 @@ function parseExpoReceipts(data: unknown): Record<string, ExpoPushReceipt> {
|
|
|
844
578
|
return receipts;
|
|
845
579
|
}
|
|
846
580
|
|
|
847
|
-
function readApnsConfig(): ApnsConfig | null {
|
|
848
|
-
const teamID = readEnv("SPIRE_APNS_TEAM_ID");
|
|
849
|
-
const keyID = readEnv("SPIRE_APNS_KEY_ID");
|
|
850
|
-
const bundleID = readEnv("SPIRE_APNS_BUNDLE_ID");
|
|
851
|
-
const topic = readEnv("SPIRE_APNS_TOPIC") ?? `${bundleID ?? ""}.voip`;
|
|
852
|
-
const privateKey = readPrivateKeyEnv(
|
|
853
|
-
"SPIRE_APNS_PRIVATE_KEY",
|
|
854
|
-
"SPIRE_APNS_PRIVATE_KEY_FILE",
|
|
855
|
-
);
|
|
856
|
-
if (!teamID || !keyID || !bundleID || !topic || !privateKey) {
|
|
857
|
-
return null;
|
|
858
|
-
}
|
|
859
|
-
const environment =
|
|
860
|
-
readEnv("SPIRE_APNS_ENV") === "sandbox" ? "sandbox" : "production";
|
|
861
|
-
return {
|
|
862
|
-
environment,
|
|
863
|
-
keyID,
|
|
864
|
-
privateKey,
|
|
865
|
-
teamID,
|
|
866
|
-
topic,
|
|
867
|
-
};
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
function readEnv(name: string): null | string {
|
|
871
|
-
const value = process.env[name]?.trim();
|
|
872
|
-
return value && value.length > 0 ? value : null;
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
function readFcmConfig(): FcmConfig | null {
|
|
876
|
-
const json = readEnv("SPIRE_FCM_SERVICE_ACCOUNT_JSON");
|
|
877
|
-
if (json) {
|
|
878
|
-
try {
|
|
879
|
-
const raw = JSON.parse(json) as unknown;
|
|
880
|
-
if (
|
|
881
|
-
isRecord(raw) &&
|
|
882
|
-
typeof raw["client_email"] === "string" &&
|
|
883
|
-
typeof raw["private_key"] === "string" &&
|
|
884
|
-
typeof raw["project_id"] === "string"
|
|
885
|
-
) {
|
|
886
|
-
return {
|
|
887
|
-
clientEmail: raw["client_email"],
|
|
888
|
-
privateKey: normalizePrivateKey(raw["private_key"]),
|
|
889
|
-
projectID: raw["project_id"],
|
|
890
|
-
};
|
|
891
|
-
}
|
|
892
|
-
} catch {
|
|
893
|
-
return null;
|
|
894
|
-
}
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
const clientEmail = readEnv("SPIRE_FCM_CLIENT_EMAIL");
|
|
898
|
-
const projectID = readEnv("SPIRE_FCM_PROJECT_ID");
|
|
899
|
-
const privateKey = readPrivateKeyEnv(
|
|
900
|
-
"SPIRE_FCM_PRIVATE_KEY",
|
|
901
|
-
"SPIRE_FCM_PRIVATE_KEY_FILE",
|
|
902
|
-
);
|
|
903
|
-
if (!clientEmail || !projectID || !privateKey) {
|
|
904
|
-
return null;
|
|
905
|
-
}
|
|
906
|
-
return { clientEmail, privateKey, projectID };
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
function readPrivateKeyEnv(valueName: string, fileName: string): null | string {
|
|
910
|
-
const inline = readEnv(valueName);
|
|
911
|
-
if (inline) {
|
|
912
|
-
return normalizePrivateKey(inline);
|
|
913
|
-
}
|
|
914
|
-
const file = readEnv(fileName);
|
|
915
|
-
if (!file) {
|
|
916
|
-
return null;
|
|
917
|
-
}
|
|
918
|
-
try {
|
|
919
|
-
return normalizePrivateKey(readFileSync(file, "utf8"));
|
|
920
|
-
} catch {
|
|
921
|
-
return null;
|
|
922
|
-
}
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
function sendApnsVoipNotification(
|
|
926
|
-
config: ApnsConfig,
|
|
927
|
-
providerToken: string,
|
|
928
|
-
subscription: NotificationSubscription,
|
|
929
|
-
dispatch: NotificationDispatch,
|
|
930
|
-
): Promise<ApnsResponse> {
|
|
931
|
-
const host =
|
|
932
|
-
config.environment === "sandbox"
|
|
933
|
-
? "api.sandbox.push.apple.com"
|
|
934
|
-
: "api.push.apple.com";
|
|
935
|
-
const payload = JSON.stringify({
|
|
936
|
-
aps: {
|
|
937
|
-
"content-available": 1,
|
|
938
|
-
},
|
|
939
|
-
...notifyDataFields(dispatch.data),
|
|
940
|
-
event: dispatch.event,
|
|
941
|
-
transmissionID: dispatch.transmissionID,
|
|
942
|
-
});
|
|
943
|
-
|
|
944
|
-
return new Promise<ApnsResponse>((resolve, reject) => {
|
|
945
|
-
const client = http2.connect(`https://${host}`);
|
|
946
|
-
const timer = setTimeout(() => {
|
|
947
|
-
client.close();
|
|
948
|
-
reject(
|
|
949
|
-
new Error(
|
|
950
|
-
`APNs VoIP request timed out after ${EXPO_REQUEST_TIMEOUT_MS.toString()}ms`,
|
|
951
|
-
),
|
|
952
|
-
);
|
|
953
|
-
}, EXPO_REQUEST_TIMEOUT_MS);
|
|
954
|
-
(timer as { unref?: () => void }).unref?.();
|
|
955
|
-
|
|
956
|
-
client.once("error", (err) => {
|
|
957
|
-
clearTimeout(timer);
|
|
958
|
-
reject(err);
|
|
959
|
-
});
|
|
960
|
-
|
|
961
|
-
const req = client.request({
|
|
962
|
-
":method": "POST",
|
|
963
|
-
":path": `/3/device/${subscription.token}`,
|
|
964
|
-
"apns-expiration": apnsExpiration(dispatch),
|
|
965
|
-
"apns-priority": "10",
|
|
966
|
-
"apns-push-type": "voip",
|
|
967
|
-
"apns-topic": config.topic,
|
|
968
|
-
authorization: `bearer ${providerToken}`,
|
|
969
|
-
});
|
|
970
|
-
let status = 0;
|
|
971
|
-
let body = "";
|
|
972
|
-
req.setEncoding("utf8");
|
|
973
|
-
req.on("response", (headers) => {
|
|
974
|
-
const rawStatus = headers[":status"];
|
|
975
|
-
status = typeof rawStatus === "number" ? rawStatus : 0;
|
|
976
|
-
});
|
|
977
|
-
req.on("data", (chunk: string) => {
|
|
978
|
-
body += chunk;
|
|
979
|
-
});
|
|
980
|
-
req.on("error", (err) => {
|
|
981
|
-
clearTimeout(timer);
|
|
982
|
-
client.close();
|
|
983
|
-
reject(err);
|
|
984
|
-
});
|
|
985
|
-
req.on("end", () => {
|
|
986
|
-
clearTimeout(timer);
|
|
987
|
-
client.close();
|
|
988
|
-
resolve({ body, status });
|
|
989
|
-
});
|
|
990
|
-
req.end(payload);
|
|
991
|
-
});
|
|
992
|
-
}
|
|
993
|
-
|
|
994
581
|
function shouldDeferMailPush(
|
|
995
582
|
dispatch: NotificationDispatch,
|
|
996
583
|
websocketDeliveries: number,
|
|
@@ -1013,18 +600,7 @@ function shouldSendHeadlessPush(dispatch: NotificationDispatch): boolean {
|
|
|
1013
600
|
);
|
|
1014
601
|
}
|
|
1015
602
|
|
|
1016
|
-
function stringRecord(input: Record<string, unknown>): Record<string, string> {
|
|
1017
|
-
const out: Record<string, string> = {};
|
|
1018
|
-
for (const [key, value] of Object.entries(input)) {
|
|
1019
|
-
if (typeof value === "string") {
|
|
1020
|
-
out[key] = value;
|
|
1021
|
-
}
|
|
1022
|
-
}
|
|
1023
|
-
return out;
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
603
|
function titleForEvent(event: string): string {
|
|
1027
|
-
if (event === "callWake") return "Incoming Vex call";
|
|
1028
604
|
if (event === "callInvite") return "Incoming Vex call";
|
|
1029
605
|
if (event === "mail") return "New Message";
|
|
1030
606
|
if (event === "deviceRequest") return "Device approval request";
|
package/src/Spire.ts
CHANGED
|
@@ -48,7 +48,6 @@ import { ClientManager } from "./ClientManager.ts";
|
|
|
48
48
|
import { Database, hashPasswordArgon2, verifyPassword } from "./Database.ts";
|
|
49
49
|
import { resolveIceServersFromEnv } from "./IceServers.ts";
|
|
50
50
|
import { NotificationService } from "./NotificationService.ts";
|
|
51
|
-
import { callWakeDispatchData } from "./server/callWake.ts";
|
|
52
51
|
import { initApp, protect } from "./server/index.ts";
|
|
53
52
|
import {
|
|
54
53
|
MailIngressValidationError,
|
|
@@ -134,8 +133,8 @@ interface ValidatedMailBatchEntry {
|
|
|
134
133
|
}
|
|
135
134
|
|
|
136
135
|
const notificationSubscribePayload = z.object({
|
|
137
|
-
channel: z.
|
|
138
|
-
events: z.array(z.string().min(1)).
|
|
136
|
+
channel: z.literal("expo"),
|
|
137
|
+
events: z.array(z.string().min(1)).default(["mail"]),
|
|
139
138
|
platform: z.enum(["android", "ios", "web"]).optional(),
|
|
140
139
|
token: z.string().min(1),
|
|
141
140
|
});
|
|
@@ -872,28 +871,15 @@ export class Spire extends EventEmitter {
|
|
|
872
871
|
);
|
|
873
872
|
|
|
874
873
|
res.sendStatus(200);
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
mail.nonce,
|
|
885
|
-
);
|
|
886
|
-
} else {
|
|
887
|
-
this.notify(
|
|
888
|
-
recipientDeviceDetails.owner,
|
|
889
|
-
"mail",
|
|
890
|
-
crypto.randomUUID(),
|
|
891
|
-
null,
|
|
892
|
-
mail.recipient,
|
|
893
|
-
mail.authorID,
|
|
894
|
-
mail.nonce,
|
|
895
|
-
);
|
|
896
|
-
}
|
|
874
|
+
this.notify(
|
|
875
|
+
recipientDeviceDetails.owner,
|
|
876
|
+
"mail",
|
|
877
|
+
crypto.randomUUID(),
|
|
878
|
+
null,
|
|
879
|
+
mail.recipient,
|
|
880
|
+
mail.authorID,
|
|
881
|
+
mail.nonce,
|
|
882
|
+
);
|
|
897
883
|
});
|
|
898
884
|
|
|
899
885
|
this.api.post("/mail/batch", protect, async (req, res) => {
|
|
@@ -1003,28 +989,15 @@ export class Spire extends EventEmitter {
|
|
|
1003
989
|
|
|
1004
990
|
res.send(msgpack.encode({ results }));
|
|
1005
991
|
for (const entry of deliveredEntries) {
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
entry.mail.nonce,
|
|
1016
|
-
);
|
|
1017
|
-
} else {
|
|
1018
|
-
this.notify(
|
|
1019
|
-
entry.recipientDevice.owner,
|
|
1020
|
-
"mail",
|
|
1021
|
-
crypto.randomUUID(),
|
|
1022
|
-
null,
|
|
1023
|
-
entry.mail.recipient,
|
|
1024
|
-
entry.mail.authorID,
|
|
1025
|
-
entry.mail.nonce,
|
|
1026
|
-
);
|
|
1027
|
-
}
|
|
992
|
+
this.notify(
|
|
993
|
+
entry.recipientDevice.owner,
|
|
994
|
+
"mail",
|
|
995
|
+
crypto.randomUUID(),
|
|
996
|
+
null,
|
|
997
|
+
entry.mail.recipient,
|
|
998
|
+
entry.mail.authorID,
|
|
999
|
+
entry.mail.nonce,
|
|
1000
|
+
);
|
|
1028
1001
|
}
|
|
1029
1002
|
});
|
|
1030
1003
|
|
|
@@ -1070,11 +1043,7 @@ export class Spire extends EventEmitter {
|
|
|
1070
1043
|
{
|
|
1071
1044
|
channel: parsed.data.channel,
|
|
1072
1045
|
deviceID: device.deviceID,
|
|
1073
|
-
events:
|
|
1074
|
-
parsed.data.events ??
|
|
1075
|
-
(parsed.data.channel === "expo"
|
|
1076
|
-
? ["mail"]
|
|
1077
|
-
: ["callWake"]),
|
|
1046
|
+
events: parsed.data.events,
|
|
1078
1047
|
platform: parsed.data.platform ?? null,
|
|
1079
1048
|
token: parsed.data.token,
|
|
1080
1049
|
userID: getUser(req).userID,
|
|
@@ -298,23 +298,26 @@ describe("Database", () => {
|
|
|
298
298
|
sender: "sender-a",
|
|
299
299
|
time,
|
|
300
300
|
});
|
|
301
|
+
const baseTimeMs = Date.now() - 60_000;
|
|
302
|
+
const isoTime = (offsetMs: number) =>
|
|
303
|
+
new Date(baseTimeMs + offsetMs).toISOString();
|
|
301
304
|
|
|
302
305
|
await provider["db"]
|
|
303
306
|
.insertInto("mail")
|
|
304
307
|
.values([
|
|
305
308
|
mail(
|
|
306
309
|
"00000000-0000-0000-0000-000000000003",
|
|
307
|
-
|
|
310
|
+
isoTime(2_000),
|
|
308
311
|
"06",
|
|
309
312
|
),
|
|
310
313
|
mail(
|
|
311
314
|
"00000000-0000-0000-0000-000000000001",
|
|
312
|
-
|
|
315
|
+
isoTime(0),
|
|
313
316
|
"04",
|
|
314
317
|
),
|
|
315
318
|
mail(
|
|
316
319
|
"00000000-0000-0000-0000-000000000002",
|
|
317
|
-
|
|
320
|
+
isoTime(1_000),
|
|
318
321
|
"05",
|
|
319
322
|
),
|
|
320
323
|
])
|