@vex-chat/spire 1.10.1 → 1.10.3
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/README.md +2 -1
- package/dist/ClientManager.d.ts +3 -0
- package/dist/ClientManager.js +18 -6
- package/dist/ClientManager.js.map +1 -1
- package/dist/Database.d.ts +1 -3
- package/dist/Database.js +4 -3
- package/dist/Database.js.map +1 -1
- package/dist/Spire.d.ts +1 -0
- package/dist/Spire.js +53 -29
- package/dist/Spire.js.map +1 -1
- package/dist/mailRetention.d.ts +2 -2
- package/dist/mailRetention.js +25 -3
- package/dist/mailRetention.js.map +1 -1
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +29 -16
- package/dist/server/index.js.map +1 -1
- package/dist/server/mailIngress.d.ts +15 -0
- package/dist/server/mailIngress.js +30 -0
- package/dist/server/mailIngress.js.map +1 -0
- package/dist/server/rateLimit.d.ts +6 -0
- package/dist/server/rateLimit.js +18 -0
- package/dist/server/rateLimit.js.map +1 -1
- package/package.json +5 -5
- package/src/ClientManager.ts +26 -11
- package/src/Database.ts +4 -3
- package/src/Spire.ts +64 -30
- package/src/__tests__/Database.spec.ts +77 -1
- package/src/__tests__/deviceTokenRevalidation.spec.ts +104 -0
- package/src/__tests__/mailIngress.spec.ts +122 -0
- package/src/__tests__/mailRetention.spec.ts +70 -0
- package/src/__tests__/notifyFanout.spec.ts +98 -0
- package/src/mailRetention.ts +36 -5
- package/src/server/index.ts +55 -26
- package/src/server/mailIngress.ts +60 -0
- package/src/server/rateLimit.ts +21 -0
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { SpireOptions } from "../Spire.ts";
|
|
8
|
-
import type { PreKeysWS } from "@vex-chat/types";
|
|
8
|
+
import type { MailWS, PreKeysWS } from "@vex-chat/types";
|
|
9
9
|
|
|
10
10
|
import { XUtils } from "@vex-chat/crypto";
|
|
11
|
+
import { MailType } from "@vex-chat/types";
|
|
11
12
|
|
|
12
13
|
import * as uuid from "uuid";
|
|
13
14
|
import { describe, expect, it, vi } from "vitest";
|
|
@@ -154,4 +155,79 @@ describe("Database", () => {
|
|
|
154
155
|
});
|
|
155
156
|
});
|
|
156
157
|
});
|
|
158
|
+
|
|
159
|
+
describe("retrieveMail", () => {
|
|
160
|
+
it("returns queued mail in send order for logged-out batch drains", async () => {
|
|
161
|
+
expect.assertions(1);
|
|
162
|
+
|
|
163
|
+
const provider = new Database(options);
|
|
164
|
+
await new Promise<void>((resolve, reject) => {
|
|
165
|
+
provider.once("ready", () => {
|
|
166
|
+
void (async () => {
|
|
167
|
+
try {
|
|
168
|
+
const mail = (
|
|
169
|
+
mailID: string,
|
|
170
|
+
time: string,
|
|
171
|
+
nonce: string,
|
|
172
|
+
) => ({
|
|
173
|
+
authorID: userID,
|
|
174
|
+
cipher: "01",
|
|
175
|
+
extra: "02",
|
|
176
|
+
forward: 0,
|
|
177
|
+
group: null,
|
|
178
|
+
header: "03",
|
|
179
|
+
mailID,
|
|
180
|
+
mailType: MailType.initial,
|
|
181
|
+
nonce,
|
|
182
|
+
readerID: userID,
|
|
183
|
+
recipient: deviceID,
|
|
184
|
+
sender: "sender-a",
|
|
185
|
+
time,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await provider["db"]
|
|
189
|
+
.insertInto("mail")
|
|
190
|
+
.values([
|
|
191
|
+
mail(
|
|
192
|
+
"00000000-0000-0000-0000-000000000003",
|
|
193
|
+
"2026-05-07T12:00:02.000Z",
|
|
194
|
+
"06",
|
|
195
|
+
),
|
|
196
|
+
mail(
|
|
197
|
+
"00000000-0000-0000-0000-000000000001",
|
|
198
|
+
"2026-05-07T12:00:00.000Z",
|
|
199
|
+
"04",
|
|
200
|
+
),
|
|
201
|
+
mail(
|
|
202
|
+
"00000000-0000-0000-0000-000000000002",
|
|
203
|
+
"2026-05-07T12:00:01.000Z",
|
|
204
|
+
"05",
|
|
205
|
+
),
|
|
206
|
+
])
|
|
207
|
+
.execute();
|
|
208
|
+
|
|
209
|
+
const result =
|
|
210
|
+
await provider.retrieveMail(deviceID);
|
|
211
|
+
expect(
|
|
212
|
+
result.map(
|
|
213
|
+
([, body]: [Uint8Array, MailWS, string]) =>
|
|
214
|
+
body.mailID,
|
|
215
|
+
),
|
|
216
|
+
).toEqual([
|
|
217
|
+
"00000000-0000-0000-0000-000000000001",
|
|
218
|
+
"00000000-0000-0000-0000-000000000002",
|
|
219
|
+
"00000000-0000-0000-0000-000000000003",
|
|
220
|
+
]);
|
|
221
|
+
await provider.close();
|
|
222
|
+
resolve();
|
|
223
|
+
} catch (e: unknown) {
|
|
224
|
+
reject(
|
|
225
|
+
e instanceof Error ? e : new Error(String(e)),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
})();
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
});
|
|
157
233
|
});
|
|
@@ -0,0 +1,104 @@
|
|
|
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 { Device, User } from "@vex-chat/types";
|
|
8
|
+
import type express from "express";
|
|
9
|
+
|
|
10
|
+
import jwt from "jsonwebtoken";
|
|
11
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
12
|
+
|
|
13
|
+
import { createCheckDevice } from "../server/index.ts";
|
|
14
|
+
|
|
15
|
+
const jwtSecret = "test-jwt-secret";
|
|
16
|
+
|
|
17
|
+
const user: User = {
|
|
18
|
+
lastSeen: new Date(0).toISOString(),
|
|
19
|
+
userID: "user-a",
|
|
20
|
+
username: "alice",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const device: Device = {
|
|
24
|
+
deleted: false,
|
|
25
|
+
deviceID: "device-a",
|
|
26
|
+
lastLogin: new Date(0).toISOString(),
|
|
27
|
+
name: "desktop",
|
|
28
|
+
owner: user.userID,
|
|
29
|
+
signKey: "sign-key-a",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function makeReq(token: string, reqUser: undefined | User = user) {
|
|
33
|
+
return {
|
|
34
|
+
headers: { "x-device-token": token },
|
|
35
|
+
user: reqUser,
|
|
36
|
+
} as unknown as express.Request;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makeToken(tokenDevice: Device = device): string {
|
|
40
|
+
return jwt.sign({ device: tokenDevice }, jwtSecret);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("createCheckDevice", () => {
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
delete process.env["JWT_SECRET"];
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("sets req.device from the current non-deleted database row", async () => {
|
|
49
|
+
process.env["JWT_SECRET"] = jwtSecret;
|
|
50
|
+
const db = {
|
|
51
|
+
retrieveDevice: vi.fn(() => Promise.resolve(device)),
|
|
52
|
+
};
|
|
53
|
+
const req = makeReq(makeToken());
|
|
54
|
+
const next = vi.fn();
|
|
55
|
+
|
|
56
|
+
await createCheckDevice(db)(req, {} as express.Response, next);
|
|
57
|
+
|
|
58
|
+
expect(req.device).toEqual(device);
|
|
59
|
+
expect(db.retrieveDevice).toHaveBeenCalledWith(device.deviceID);
|
|
60
|
+
expect(next).toHaveBeenCalledOnce();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("does not trust a token for a deleted or missing device", async () => {
|
|
64
|
+
process.env["JWT_SECRET"] = jwtSecret;
|
|
65
|
+
const db = {
|
|
66
|
+
retrieveDevice: vi.fn(() => Promise.resolve(null)),
|
|
67
|
+
};
|
|
68
|
+
const req = makeReq(makeToken());
|
|
69
|
+
|
|
70
|
+
await createCheckDevice(db)(req, {} as express.Response, vi.fn());
|
|
71
|
+
|
|
72
|
+
expect(req.device).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("does not trust a token whose signing key no longer matches", async () => {
|
|
76
|
+
process.env["JWT_SECRET"] = jwtSecret;
|
|
77
|
+
const db = {
|
|
78
|
+
retrieveDevice: vi.fn(() =>
|
|
79
|
+
Promise.resolve({ ...device, signKey: "rotated-sign-key" }),
|
|
80
|
+
),
|
|
81
|
+
};
|
|
82
|
+
const req = makeReq(makeToken());
|
|
83
|
+
|
|
84
|
+
await createCheckDevice(db)(req, {} as express.Response, vi.fn());
|
|
85
|
+
|
|
86
|
+
expect(req.device).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("does not bind a device token to a different bearer user", async () => {
|
|
90
|
+
process.env["JWT_SECRET"] = jwtSecret;
|
|
91
|
+
const db = {
|
|
92
|
+
retrieveDevice: vi.fn(() => Promise.resolve(device)),
|
|
93
|
+
};
|
|
94
|
+
const req = makeReq(makeToken(), {
|
|
95
|
+
lastSeen: new Date(0).toISOString(),
|
|
96
|
+
userID: "user-b",
|
|
97
|
+
username: "bob",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await createCheckDevice(db)(req, {} as express.Response, vi.fn());
|
|
101
|
+
|
|
102
|
+
expect(req.device).toBeUndefined();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
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 { Device, MailWS } from "@vex-chat/types";
|
|
8
|
+
|
|
9
|
+
import { MailType } from "@vex-chat/types";
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it, vi } from "vitest";
|
|
12
|
+
|
|
13
|
+
import { validateMailIngress } from "../server/mailIngress.ts";
|
|
14
|
+
|
|
15
|
+
const senderDeviceID = "sender-device";
|
|
16
|
+
const senderUserID = "sender-user";
|
|
17
|
+
const recipientDeviceID = "recipient-device";
|
|
18
|
+
const recipientUserID = "recipient-user";
|
|
19
|
+
|
|
20
|
+
const recipientDevice: Device = {
|
|
21
|
+
deleted: false,
|
|
22
|
+
deviceID: recipientDeviceID,
|
|
23
|
+
lastLogin: new Date(0).toISOString(),
|
|
24
|
+
name: "recipient",
|
|
25
|
+
owner: recipientUserID,
|
|
26
|
+
signKey: "recipient-sign-key",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function makeDb(device: Device | null = recipientDevice) {
|
|
30
|
+
return {
|
|
31
|
+
retrieveDevice: vi.fn(() => Promise.resolve(device)),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeMail(overrides: Partial<MailWS> = {}): MailWS {
|
|
36
|
+
return {
|
|
37
|
+
authorID: senderUserID,
|
|
38
|
+
cipher: new Uint8Array([1, 2, 3]),
|
|
39
|
+
extra: new Uint8Array([4, 5, 6]),
|
|
40
|
+
forward: false,
|
|
41
|
+
group: null,
|
|
42
|
+
mailID: "mail-id",
|
|
43
|
+
mailType: MailType.initial,
|
|
44
|
+
nonce: new Uint8Array([7, 8, 9]),
|
|
45
|
+
readerID: recipientUserID,
|
|
46
|
+
recipient: recipientDeviceID,
|
|
47
|
+
sender: senderDeviceID,
|
|
48
|
+
...overrides,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("validateMailIngress", () => {
|
|
53
|
+
it("accepts mail bound to the authenticated sender and recipient owner", async () => {
|
|
54
|
+
const db = makeDb();
|
|
55
|
+
|
|
56
|
+
await expect(
|
|
57
|
+
validateMailIngress(db, makeMail(), senderDeviceID, senderUserID),
|
|
58
|
+
).resolves.toEqual({ recipientDevice });
|
|
59
|
+
expect(db.retrieveDevice).toHaveBeenCalledWith(recipientDeviceID);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("rejects sender spoofing before looking up the recipient", async () => {
|
|
63
|
+
const db = makeDb();
|
|
64
|
+
|
|
65
|
+
await expect(
|
|
66
|
+
validateMailIngress(
|
|
67
|
+
db,
|
|
68
|
+
makeMail({ sender: "other-device" }),
|
|
69
|
+
senderDeviceID,
|
|
70
|
+
senderUserID,
|
|
71
|
+
),
|
|
72
|
+
).rejects.toMatchObject({
|
|
73
|
+
message: "Mail sender does not match the authenticated device.",
|
|
74
|
+
status: 403,
|
|
75
|
+
});
|
|
76
|
+
expect(db.retrieveDevice).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("rejects author spoofing before looking up the recipient", async () => {
|
|
80
|
+
const db = makeDb();
|
|
81
|
+
|
|
82
|
+
await expect(
|
|
83
|
+
validateMailIngress(
|
|
84
|
+
db,
|
|
85
|
+
makeMail({ authorID: "other-user" }),
|
|
86
|
+
senderDeviceID,
|
|
87
|
+
senderUserID,
|
|
88
|
+
),
|
|
89
|
+
).rejects.toMatchObject({
|
|
90
|
+
message: "Mail author does not match the authenticated user.",
|
|
91
|
+
status: 403,
|
|
92
|
+
});
|
|
93
|
+
expect(db.retrieveDevice).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("rejects mail to a missing or deleted recipient device", async () => {
|
|
97
|
+
const db = makeDb(null);
|
|
98
|
+
|
|
99
|
+
await expect(
|
|
100
|
+
validateMailIngress(db, makeMail(), senderDeviceID, senderUserID),
|
|
101
|
+
).rejects.toMatchObject({
|
|
102
|
+
message: "No associated user record found for recipient device.",
|
|
103
|
+
status: 400,
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("rejects reader spoofing against the recipient device owner", async () => {
|
|
108
|
+
const db = makeDb();
|
|
109
|
+
|
|
110
|
+
await expect(
|
|
111
|
+
validateMailIngress(
|
|
112
|
+
db,
|
|
113
|
+
makeMail({ readerID: "other-reader" }),
|
|
114
|
+
senderDeviceID,
|
|
115
|
+
senderUserID,
|
|
116
|
+
),
|
|
117
|
+
).rejects.toMatchObject({
|
|
118
|
+
message: "Mail reader does not match the recipient device owner.",
|
|
119
|
+
status: 400,
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
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 { afterEach, describe, expect, it } from "vitest";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_SERVER_MAIL_RETENTION_DAYS,
|
|
11
|
+
serverMailRetentionCutoffIso,
|
|
12
|
+
serverMailRetentionMs,
|
|
13
|
+
} from "../mailRetention.ts";
|
|
14
|
+
|
|
15
|
+
const MS_PER_DAY = 86_400_000;
|
|
16
|
+
const MS_PER_HOUR = 3_600_000;
|
|
17
|
+
|
|
18
|
+
describe("server mail retention config", () => {
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
delete process.env["SPIRE_MAIL_RETENTION_DAYS"];
|
|
21
|
+
delete process.env["SPIRE_MAIL_RETENTION_TTL"];
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("defaults to thirty days", () => {
|
|
25
|
+
expect(serverMailRetentionMs()).toBe(
|
|
26
|
+
DEFAULT_SERVER_MAIL_RETENTION_DAYS * MS_PER_DAY,
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("accepts duration strings from SPIRE_MAIL_RETENTION_TTL", () => {
|
|
31
|
+
process.env["SPIRE_MAIL_RETENTION_TTL"] = "6h";
|
|
32
|
+
|
|
33
|
+
expect(serverMailRetentionMs()).toBe(6 * MS_PER_HOUR);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("accepts day-count compatibility config", () => {
|
|
37
|
+
process.env["SPIRE_MAIL_RETENTION_DAYS"] = "7";
|
|
38
|
+
|
|
39
|
+
expect(serverMailRetentionMs()).toBe(7 * MS_PER_DAY);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("prefers TTL config over day-count compatibility config", () => {
|
|
43
|
+
process.env["SPIRE_MAIL_RETENTION_DAYS"] = "7";
|
|
44
|
+
process.env["SPIRE_MAIL_RETENTION_TTL"] = "24h";
|
|
45
|
+
|
|
46
|
+
expect(serverMailRetentionMs()).toBe(MS_PER_DAY);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("clamps invalid and unsafe values to safe bounds", () => {
|
|
50
|
+
process.env["SPIRE_MAIL_RETENTION_TTL"] = "not-a-duration";
|
|
51
|
+
expect(serverMailRetentionMs()).toBe(
|
|
52
|
+
DEFAULT_SERVER_MAIL_RETENTION_DAYS * MS_PER_DAY,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
process.env["SPIRE_MAIL_RETENTION_TTL"] = "1m";
|
|
56
|
+
expect(serverMailRetentionMs()).toBe(MS_PER_HOUR);
|
|
57
|
+
|
|
58
|
+
process.env["SPIRE_MAIL_RETENTION_TTL"] = "999d";
|
|
59
|
+
expect(serverMailRetentionMs()).toBe(365 * MS_PER_DAY);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("uses the configured retention when building the cutoff", () => {
|
|
63
|
+
process.env["SPIRE_MAIL_RETENTION_TTL"] = "24h";
|
|
64
|
+
const now = Date.parse("2026-05-07T12:00:00.000Z");
|
|
65
|
+
|
|
66
|
+
expect(serverMailRetentionCutoffIso(now)).toBe(
|
|
67
|
+
"2026-05-06T12:00:00.000Z",
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
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 { ClientManager } from "../ClientManager.ts";
|
|
8
|
+
import type { BaseMsg } from "@vex-chat/types";
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it, vi } from "vitest";
|
|
11
|
+
|
|
12
|
+
import { Spire } from "../Spire.ts";
|
|
13
|
+
|
|
14
|
+
interface FakeClient {
|
|
15
|
+
getDeviceID: () => null | string;
|
|
16
|
+
getUserID: () => null | string;
|
|
17
|
+
hasFailed: () => boolean;
|
|
18
|
+
send: (msg: BaseMsg) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
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;
|
|
33
|
+
};
|
|
34
|
+
spire.clients = clients as unknown as ClientManager[];
|
|
35
|
+
return spire;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function fakeClient(overrides: Partial<FakeClient> = {}): FakeClient {
|
|
39
|
+
return {
|
|
40
|
+
getDeviceID: vi.fn(() => "device-a"),
|
|
41
|
+
getUserID: vi.fn(() => "user-a"),
|
|
42
|
+
hasFailed: vi.fn(() => false),
|
|
43
|
+
send: vi.fn(),
|
|
44
|
+
...overrides,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("Spire notify fanout", () => {
|
|
49
|
+
it("continues device fanout after pruning a stale client", () => {
|
|
50
|
+
const stale = fakeClient({
|
|
51
|
+
getDeviceID: vi.fn(() => null),
|
|
52
|
+
});
|
|
53
|
+
const recipient = fakeClient({
|
|
54
|
+
getDeviceID: vi.fn(() => "device-b"),
|
|
55
|
+
});
|
|
56
|
+
const other = fakeClient({
|
|
57
|
+
getDeviceID: vi.fn(() => "device-c"),
|
|
58
|
+
});
|
|
59
|
+
const spire = createSpireHarness([stale, recipient, other]);
|
|
60
|
+
|
|
61
|
+
spire.notify(
|
|
62
|
+
"user-b",
|
|
63
|
+
"mail",
|
|
64
|
+
"00000000-0000-0000-0000-000000000001",
|
|
65
|
+
null,
|
|
66
|
+
"device-b",
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
expect(recipient.send).toHaveBeenCalledTimes(1);
|
|
70
|
+
expect(other.send).not.toHaveBeenCalled();
|
|
71
|
+
expect(spire.clients).toEqual([recipient, other]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("continues user fanout after a client inspection throws", () => {
|
|
75
|
+
const broken = fakeClient({
|
|
76
|
+
getUserID: vi.fn(() => {
|
|
77
|
+
throw new Error("stale client");
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
const recipient = fakeClient({
|
|
81
|
+
getUserID: vi.fn(() => "user-b"),
|
|
82
|
+
});
|
|
83
|
+
const other = fakeClient({
|
|
84
|
+
getUserID: vi.fn(() => "user-c"),
|
|
85
|
+
});
|
|
86
|
+
const spire = createSpireHarness([broken, recipient, other]);
|
|
87
|
+
|
|
88
|
+
spire.notify(
|
|
89
|
+
"user-b",
|
|
90
|
+
"device_pending_enrollment",
|
|
91
|
+
"00000000-0000-0000-0000-000000000002",
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
expect(recipient.send).toHaveBeenCalledTimes(1);
|
|
95
|
+
expect(other.send).not.toHaveBeenCalled();
|
|
96
|
+
expect(spire.clients).toEqual([recipient, other]);
|
|
97
|
+
});
|
|
98
|
+
});
|
package/src/mailRetention.ts
CHANGED
|
@@ -4,10 +4,15 @@
|
|
|
4
4
|
* Commercial licenses available at vex.wtf
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
import parseDuration from "parse-duration";
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_SERVER_MAIL_RETENTION_DAYS = 30;
|
|
9
10
|
|
|
10
11
|
const MS_PER_DAY = 86_400_000;
|
|
12
|
+
const DEFAULT_SERVER_MAIL_RETENTION_MS =
|
|
13
|
+
DEFAULT_SERVER_MAIL_RETENTION_DAYS * MS_PER_DAY;
|
|
14
|
+
const MIN_SERVER_MAIL_RETENTION_MS = 60 * 60 * 1000;
|
|
15
|
+
const MAX_SERVER_MAIL_RETENTION_MS = 365 * MS_PER_DAY;
|
|
11
16
|
|
|
12
17
|
/**
|
|
13
18
|
* ISO-8601 cutoff: mail with `time` strictly before this must not be retained
|
|
@@ -16,7 +21,33 @@ const MS_PER_DAY = 86_400_000;
|
|
|
16
21
|
export function serverMailRetentionCutoffIso(
|
|
17
22
|
nowMs: number = Date.now(),
|
|
18
23
|
): string {
|
|
19
|
-
return new Date(
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
return new Date(nowMs - serverMailRetentionMs()).toISOString();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function serverMailRetentionMs(): number {
|
|
28
|
+
const ttl = process.env["SPIRE_MAIL_RETENTION_TTL"]?.trim();
|
|
29
|
+
if (ttl) {
|
|
30
|
+
const parsed = parseDuration(ttl, "ms");
|
|
31
|
+
return clampRetentionMs(parsed);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const days = process.env["SPIRE_MAIL_RETENTION_DAYS"]?.trim();
|
|
35
|
+
if (days) {
|
|
36
|
+
const parsed = Number(days);
|
|
37
|
+
return clampRetentionMs(
|
|
38
|
+
Number.isFinite(parsed) ? parsed * MS_PER_DAY : undefined,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return DEFAULT_SERVER_MAIL_RETENTION_MS;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function clampRetentionMs(value: null | number | undefined): number {
|
|
46
|
+
if (!Number.isFinite(value) || value === undefined || value === null) {
|
|
47
|
+
return DEFAULT_SERVER_MAIL_RETENTION_MS;
|
|
48
|
+
}
|
|
49
|
+
return Math.min(
|
|
50
|
+
MAX_SERVER_MAIL_RETENTION_MS,
|
|
51
|
+
Math.max(MIN_SERVER_MAIL_RETENTION_MS, Math.floor(value)),
|
|
52
|
+
);
|
|
22
53
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Database } from "../Database.ts";
|
|
8
|
-
import type { Emoji } from "@vex-chat/types";
|
|
8
|
+
import type { Device, Emoji } from "@vex-chat/types";
|
|
9
9
|
|
|
10
10
|
import * as fs from "node:fs";
|
|
11
11
|
import * as fsp from "node:fs/promises";
|
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
hasPermission,
|
|
43
43
|
userHasPermission,
|
|
44
44
|
} from "./permissions.ts";
|
|
45
|
-
import { globalLimiter } from "./rateLimit.ts";
|
|
45
|
+
import { globalLimiter, keyBundleLimiter } from "./rateLimit.ts";
|
|
46
46
|
import { getUserRouter } from "./user.ts";
|
|
47
47
|
import { censorUser, getParam, getUser } from "./utils.ts";
|
|
48
48
|
import { getWellKnownRouter } from "./wellKnown.ts";
|
|
@@ -147,21 +147,45 @@ const checkAuth: express.RequestHandler = (req, _res, next) => {
|
|
|
147
147
|
next();
|
|
148
148
|
};
|
|
149
149
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
150
|
+
export function createCheckDevice(
|
|
151
|
+
db: Pick<Database, "retrieveDevice">,
|
|
152
|
+
): express.RequestHandler {
|
|
153
|
+
return async (req, _res, next) => {
|
|
154
|
+
const token = req.headers["x-device-token"];
|
|
155
|
+
if (typeof token === "string" && token) {
|
|
156
|
+
try {
|
|
157
|
+
const result = jwt.verify(token, getJwtSecret());
|
|
158
|
+
const parsed = jwtDevicePayload.safeParse(result);
|
|
159
|
+
if (parsed.success) {
|
|
160
|
+
const device = await currentTokenDevice(
|
|
161
|
+
db,
|
|
162
|
+
parsed.data.device,
|
|
163
|
+
);
|
|
164
|
+
if (
|
|
165
|
+
device &&
|
|
166
|
+
(!req.user || device.owner === req.user.userID)
|
|
167
|
+
) {
|
|
168
|
+
req.device = device;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
// Device token verification/revalidation failed — continue without device.
|
|
158
173
|
}
|
|
159
|
-
} catch {
|
|
160
|
-
// Device token verification failed — continue without device
|
|
161
174
|
}
|
|
175
|
+
next();
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function currentTokenDevice(
|
|
180
|
+
db: Pick<Database, "retrieveDevice">,
|
|
181
|
+
tokenDevice: Device,
|
|
182
|
+
): Promise<Device | null> {
|
|
183
|
+
const device = await db.retrieveDevice(tokenDevice.deviceID);
|
|
184
|
+
if (!device || device.signKey !== tokenDevice.signKey) {
|
|
185
|
+
return null;
|
|
162
186
|
}
|
|
163
|
-
|
|
164
|
-
}
|
|
187
|
+
return device;
|
|
188
|
+
}
|
|
165
189
|
|
|
166
190
|
export const protect: express.RequestHandler = (req, res, next) => {
|
|
167
191
|
if (!req.user) {
|
|
@@ -282,7 +306,7 @@ export const initApp = (
|
|
|
282
306
|
api.use(helmet());
|
|
283
307
|
api.use(msgpackParser);
|
|
284
308
|
api.use(checkAuth);
|
|
285
|
-
api.use(
|
|
309
|
+
api.use(createCheckDevice(db));
|
|
286
310
|
|
|
287
311
|
api.get("/server/:id", protect, async (req, res) => {
|
|
288
312
|
const server = await db.retrieveServer(getParam(req, "id"));
|
|
@@ -596,18 +620,23 @@ export const initApp = (
|
|
|
596
620
|
}
|
|
597
621
|
});
|
|
598
622
|
|
|
599
|
-
api.post(
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
623
|
+
api.post(
|
|
624
|
+
"/device/:id/keyBundle",
|
|
625
|
+
protect,
|
|
626
|
+
keyBundleLimiter,
|
|
627
|
+
async (req, res) => {
|
|
628
|
+
try {
|
|
629
|
+
const keyBundle = await db.getKeyBundle(getParam(req, "id"));
|
|
630
|
+
if (keyBundle) {
|
|
631
|
+
res.send(msgpack.encode(keyBundle));
|
|
632
|
+
} else {
|
|
633
|
+
res.sendStatus(404);
|
|
634
|
+
}
|
|
635
|
+
} catch {
|
|
636
|
+
res.sendStatus(500);
|
|
606
637
|
}
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
}
|
|
610
|
-
});
|
|
638
|
+
},
|
|
639
|
+
);
|
|
611
640
|
|
|
612
641
|
api.post("/device/:id/mail", protect, async (req, res) => {
|
|
613
642
|
const deviceDetails = req.device;
|