@vex-chat/spire 1.11.4 → 2.0.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.
Files changed (49) hide show
  1. package/README.md +9 -7
  2. package/dist/ClientManager.d.ts +2 -1
  3. package/dist/ClientManager.js +4 -1
  4. package/dist/ClientManager.js.map +1 -1
  5. package/dist/Database.d.ts +12 -0
  6. package/dist/Database.js +100 -16
  7. package/dist/Database.js.map +1 -1
  8. package/dist/NotificationService.d.ts +4 -0
  9. package/dist/NotificationService.js +54 -10
  10. package/dist/NotificationService.js.map +1 -1
  11. package/dist/Spire.d.ts +3 -0
  12. package/dist/Spire.js +158 -4
  13. package/dist/Spire.js.map +1 -1
  14. package/dist/migrations/2026-05-03_passkeys.js +2 -2
  15. package/dist/migrations/2026-05-03_passkeys.js.map +1 -1
  16. package/dist/server/index.d.ts +2 -2
  17. package/dist/server/index.js +18 -4
  18. package/dist/server/index.js.map +1 -1
  19. package/dist/server/passkey.js +24 -16
  20. package/dist/server/passkey.js.map +1 -1
  21. package/dist/server/passkeyDevices.d.ts +2 -2
  22. package/dist/server/passkeyDevices.js +14 -15
  23. package/dist/server/passkeyDevices.js.map +1 -1
  24. package/dist/server/rateLimit.d.ts +6 -2
  25. package/dist/server/rateLimit.js +13 -10
  26. package/dist/server/rateLimit.js.map +1 -1
  27. package/dist/server/user.d.ts +14 -0
  28. package/dist/server/user.js +48 -0
  29. package/dist/server/user.js.map +1 -1
  30. package/dist/utils/loadEnv.d.ts +3 -0
  31. package/dist/utils/loadEnv.js +63 -3
  32. package/dist/utils/loadEnv.js.map +1 -1
  33. package/package.json +6 -6
  34. package/src/ClientManager.ts +7 -0
  35. package/src/Database.ts +136 -16
  36. package/src/NotificationService.ts +84 -11
  37. package/src/Spire.ts +233 -2
  38. package/src/__tests__/Database.spec.ts +191 -2
  39. package/src/__tests__/loadEnv.spec.ts +91 -0
  40. package/src/__tests__/notifyFanout.spec.ts +109 -0
  41. package/src/__tests__/passkeys.spec.ts +46 -0
  42. package/src/__tests__/rateLimit.spec.ts +60 -0
  43. package/src/migrations/2026-05-03_passkeys.ts +2 -2
  44. package/src/server/index.ts +26 -3
  45. package/src/server/passkey.ts +25 -17
  46. package/src/server/passkeyDevices.ts +17 -14
  47. package/src/server/rateLimit.ts +18 -10
  48. package/src/server/user.ts +66 -0
  49. package/src/utils/loadEnv.ts +79 -3
package/src/Spire.ts CHANGED
@@ -4,7 +4,13 @@
4
4
  * Commercial licenses available at vex.wtf
5
5
  */
6
6
 
7
- import type { ActionToken, BaseMsg, User } from "@vex-chat/types";
7
+ import type {
8
+ ActionToken,
9
+ BaseMsg,
10
+ Device,
11
+ MailWS,
12
+ User,
13
+ } from "@vex-chat/types";
8
14
  import type { IncomingMessage, Server } from "http";
9
15
 
10
16
  import { EventEmitter } from "events";
@@ -100,6 +106,46 @@ const mailPostPayload = z.object({
100
106
  header: z.custom<Uint8Array>((val) => val instanceof Uint8Array),
101
107
  mail: MailWSSchema,
102
108
  });
109
+ const MAIL_BATCH_MAX_ITEMS = 256;
110
+ const mailBatchPostPayload = z.object({
111
+ mails: z.array(mailPostPayload).min(1).max(MAIL_BATCH_MAX_ITEMS),
112
+ });
113
+
114
+ interface MailBatchResult {
115
+ error?: string;
116
+ index: number;
117
+ mailID?: string;
118
+ ok: boolean;
119
+ recipient?: string;
120
+ status?: number;
121
+ }
122
+
123
+ interface ValidatedMailBatchEntry {
124
+ header: Uint8Array;
125
+ index: number;
126
+ mail: MailWS;
127
+ recipientDevice: Device;
128
+ }
129
+
130
+ export async function passkeySecondFactorError(
131
+ db: Database,
132
+ userID: string,
133
+ passkeyID: string | undefined,
134
+ mismatchError: string,
135
+ ): Promise<null | string> {
136
+ const passkeys = await db.retrievePasskeysByUser(userID);
137
+ if (passkeys.length === 0) {
138
+ return null;
139
+ }
140
+ if (!passkeyID) {
141
+ return "Passkey verification required.";
142
+ }
143
+ const passkey = await db.retrievePasskeyInternal(passkeyID);
144
+ if (!passkey || passkey.userID !== userID) {
145
+ return mismatchError;
146
+ }
147
+ return null;
148
+ }
103
149
 
104
150
  const notificationSubscribePayload = z.object({
105
151
  channel: z.literal("expo"),
@@ -344,6 +390,19 @@ export class Spire extends EventEmitter {
344
390
  }
345
391
  }
346
392
 
393
+ private disconnectDevices(deviceIDs: string[]): void {
394
+ if (deviceIDs.length === 0) {
395
+ return;
396
+ }
397
+ const ids = new Set(deviceIDs);
398
+ for (const client of [...this.clients]) {
399
+ const deviceID = client.getDeviceID();
400
+ if (deviceID !== null && ids.has(deviceID)) {
401
+ client.disconnect();
402
+ }
403
+ }
404
+ }
405
+
347
406
  private init(apiPort: number): void {
348
407
  // Request traces (UUIDs and device public-key path segments redacted
349
408
  // in the `url` token). Enabled in all envs, including production.
@@ -381,6 +440,7 @@ export class Spire extends EventEmitter {
381
440
  this.validateToken.bind(this),
382
441
  this.signKeys,
383
442
  this.notify.bind(this),
443
+ this.disconnectDevices.bind(this),
384
444
  );
385
445
 
386
446
  // WS auth: client sends { type: "auth", token } as first message
@@ -748,6 +808,22 @@ export class Spire extends EventEmitter {
748
808
  .send({ error: "Device owner not found." });
749
809
  }
750
810
 
811
+ const passkeyError = await passkeySecondFactorError(
812
+ this.db,
813
+ user.userID,
814
+ req.passkey?.passkeyID,
815
+ "Passkey verification does not match this device.",
816
+ );
817
+ if (passkeyError) {
818
+ const body: { error: string; username?: string } = {
819
+ error: passkeyError,
820
+ };
821
+ if (passkeyError === "Passkey verification required.") {
822
+ body.username = user.username;
823
+ }
824
+ return res.status(403).send(body);
825
+ }
826
+
751
827
  // Issue short-lived JWT (1 hour, not 7 days)
752
828
  const token = jwt.sign(
753
829
  { user: censorUser(user) },
@@ -813,9 +889,129 @@ export class Spire extends EventEmitter {
813
889
  null,
814
890
  mail.recipient,
815
891
  mail.authorID,
892
+ mail.nonce,
816
893
  );
817
894
  });
818
895
 
896
+ this.api.post("/mail/batch", protect, async (req, res) => {
897
+ const senderDeviceDetails = req.device;
898
+ if (!senderDeviceDetails) {
899
+ res.sendStatus(401);
900
+ return;
901
+ }
902
+ const authorUserDetails = getUser(req);
903
+
904
+ const parsed = mailBatchPostPayload.safeParse(req.body);
905
+ if (!parsed.success) {
906
+ res.status(400).json({
907
+ error: "Invalid mail batch payload",
908
+ issues: parsed.error.issues,
909
+ });
910
+ return;
911
+ }
912
+
913
+ const results: MailBatchResult[] = [];
914
+ const validEntries: ValidatedMailBatchEntry[] = [];
915
+ for (const [index, item] of parsed.data.mails.entries()) {
916
+ const { header, mail } = item;
917
+ try {
918
+ const { recipientDevice } = await validateMailIngress(
919
+ this.db,
920
+ mail,
921
+ senderDeviceDetails.deviceID,
922
+ authorUserDetails.userID,
923
+ );
924
+ validEntries.push({
925
+ header,
926
+ index,
927
+ mail,
928
+ recipientDevice,
929
+ });
930
+ } catch (err: unknown) {
931
+ const status =
932
+ err instanceof MailIngressValidationError
933
+ ? err.status
934
+ : 500;
935
+ const message =
936
+ err instanceof Error ? err.message : String(err);
937
+ results[index] = {
938
+ error: message,
939
+ index,
940
+ mailID: mail.mailID,
941
+ ok: false,
942
+ recipient: mail.recipient,
943
+ status,
944
+ };
945
+ }
946
+ }
947
+
948
+ const deliveredEntries: ValidatedMailBatchEntry[] = [];
949
+ if (validEntries.length > 0) {
950
+ try {
951
+ await this.db.saveMailBatch(
952
+ validEntries.map((entry) => ({
953
+ header: entry.header,
954
+ mail: entry.mail,
955
+ senderDeviceID: senderDeviceDetails.deviceID,
956
+ userID: authorUserDetails.userID,
957
+ })),
958
+ );
959
+ for (const entry of validEntries) {
960
+ deliveredEntries.push(entry);
961
+ results[entry.index] = {
962
+ index: entry.index,
963
+ mailID: entry.mail.mailID,
964
+ ok: true,
965
+ recipient: entry.mail.recipient,
966
+ };
967
+ }
968
+ } catch {
969
+ for (const entry of validEntries) {
970
+ try {
971
+ await this.db.saveMail(
972
+ entry.mail,
973
+ entry.header,
974
+ senderDeviceDetails.deviceID,
975
+ authorUserDetails.userID,
976
+ );
977
+ deliveredEntries.push(entry);
978
+ results[entry.index] = {
979
+ index: entry.index,
980
+ mailID: entry.mail.mailID,
981
+ ok: true,
982
+ recipient: entry.mail.recipient,
983
+ };
984
+ } catch (err: unknown) {
985
+ results[entry.index] = {
986
+ error:
987
+ err instanceof Error
988
+ ? err.message
989
+ : String(err),
990
+ index: entry.index,
991
+ mailID: entry.mail.mailID,
992
+ ok: false,
993
+ recipient: entry.mail.recipient,
994
+ status: 500,
995
+ };
996
+ }
997
+ }
998
+ }
999
+ }
1000
+
1001
+ res.send(msgpack.encode({ results }));
1002
+ for (const entry of deliveredEntries) {
1003
+ this.notify(
1004
+ entry.recipientDevice.owner,
1005
+ "mail",
1006
+ crypto.randomUUID(),
1007
+ null,
1008
+ entry.mail.recipient,
1009
+ entry.mail.authorID,
1010
+ entry.mail.nonce,
1011
+ );
1012
+ }
1013
+ });
1014
+
819
1015
  this.api.post(
820
1016
  "/device/:id/notifications/subscriptions",
821
1017
  protect,
@@ -909,6 +1105,19 @@ export class Spire extends EventEmitter {
909
1105
  await this.db.rehashPassword(userEntry.userID, newHash);
910
1106
  }
911
1107
 
1108
+ const passkeyError = await passkeySecondFactorError(
1109
+ this.db,
1110
+ userEntry.userID,
1111
+ req.passkey?.passkeyID,
1112
+ "Passkey verification does not match this account.",
1113
+ );
1114
+ if (passkeyError) {
1115
+ res.status(403).send({
1116
+ error: passkeyError,
1117
+ });
1118
+ return;
1119
+ }
1120
+
912
1121
  const token = jwt.sign(
913
1122
  { user: censorUser(userEntry) },
914
1123
  getJwtSecret(),
@@ -1032,7 +1241,27 @@ export class Spire extends EventEmitter {
1032
1241
  res.sendStatus(500);
1033
1242
  return;
1034
1243
  }
1035
- res.send(msgpack.encode(censorUser(user)));
1244
+ const device = await this.db.retrieveDevice(
1245
+ normalizedPayload.signKey,
1246
+ );
1247
+ if (!device) {
1248
+ res.sendStatus(500);
1249
+ return;
1250
+ }
1251
+ const censored = censorUser(user);
1252
+ const token = jwt.sign(
1253
+ { user: censored },
1254
+ getJwtSecret(),
1255
+ { expiresIn: JWT_EXPIRY },
1256
+ );
1257
+ jwt.verify(token, getJwtSecret());
1258
+ res.send(
1259
+ msgpack.encode({
1260
+ device,
1261
+ token,
1262
+ user: censored,
1263
+ }),
1264
+ );
1036
1265
  }
1037
1266
  } else if (regKey && regKey.length !== 16) {
1038
1267
  res.status(400).send({
@@ -1076,11 +1305,13 @@ export class Spire extends EventEmitter {
1076
1305
  data?: unknown,
1077
1306
  deviceID?: string,
1078
1307
  headlessPushUserID?: string,
1308
+ mailNonce?: Uint8Array,
1079
1309
  ): void {
1080
1310
  this.notifications.notify({
1081
1311
  data,
1082
1312
  event,
1083
1313
  ...(headlessPushUserID ? { headlessPushUserID } : {}),
1314
+ ...(mailNonce ? { mailNonce } : {}),
1084
1315
  transmissionID,
1085
1316
  userID,
1086
1317
  ...(deviceID ? { deviceID } : {}),
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { SpireOptions } from "../Spire.ts";
8
- import type { MailWS, PreKeysWS } from "@vex-chat/types";
8
+ import type { DevicePayload, MailWS, PreKeysWS } from "@vex-chat/types";
9
9
 
10
10
  import { XUtils } from "@vex-chat/crypto";
11
11
  import { MailType } from "@vex-chat/types";
@@ -64,6 +64,19 @@ describe("Database", () => {
64
64
  signature,
65
65
  };
66
66
 
67
+ const devicePayload = (
68
+ deviceName: string,
69
+ signKey: string,
70
+ ): DevicePayload => ({
71
+ deviceName,
72
+ preKey: testSQLPreKey.publicKey,
73
+ preKeyIndex: 1,
74
+ preKeySignature: testSQLPreKey.signature,
75
+ signed: "00",
76
+ signKey,
77
+ username: "alice",
78
+ });
79
+
67
80
  const options: SpireOptions = {
68
81
  dbType: "sqlite3mem",
69
82
  };
@@ -156,6 +169,100 @@ describe("Database", () => {
156
169
  });
157
170
  });
158
171
 
172
+ describe("recoverDevice", () => {
173
+ it("creates a new device and revokes all previous devices for the user", async () => {
174
+ expect.assertions(8);
175
+
176
+ const provider = new Database(options);
177
+ await new Promise<void>((resolve, reject) => {
178
+ provider.once("ready", () => {
179
+ void (async () => {
180
+ try {
181
+ const oldA = await provider.createDevice(
182
+ userID,
183
+ devicePayload("old-a", "a".repeat(64)),
184
+ );
185
+ const oldB = await provider.createDevice(
186
+ userID,
187
+ devicePayload("old-b", "b".repeat(64)),
188
+ );
189
+ await provider.saveOTK(userID, oldA.deviceID, [
190
+ {
191
+ deviceID: oldA.deviceID,
192
+ index: 1,
193
+ publicKey,
194
+ signature,
195
+ },
196
+ ]);
197
+ await provider.saveNotificationSubscription({
198
+ channel: "expo",
199
+ deviceID: oldA.deviceID,
200
+ events: ["deviceRequest"],
201
+ platform: "ios",
202
+ token: "ExponentPushToken[old-a]",
203
+ userID,
204
+ });
205
+ await provider.saveNotificationSubscription({
206
+ channel: "expo",
207
+ deviceID: oldB.deviceID,
208
+ events: ["*"],
209
+ platform: "android",
210
+ token: "ExponentPushToken[old-b]",
211
+ userID,
212
+ });
213
+
214
+ const recovered = await provider.recoverDevice(
215
+ userID,
216
+ devicePayload("recovered", "c".repeat(64)),
217
+ );
218
+
219
+ expect(recovered.revokedDeviceIDs.sort()).toEqual(
220
+ [oldA.deviceID, oldB.deviceID].sort(),
221
+ );
222
+ expect(
223
+ await provider.retrieveDevice(oldA.deviceID),
224
+ ).toBeNull();
225
+ expect(
226
+ await provider.retrieveDevice(oldB.deviceID),
227
+ ).toBeNull();
228
+ expect(
229
+ await provider.retrieveDevice(
230
+ recovered.device.deviceID,
231
+ ),
232
+ ).toEqual(recovered.device);
233
+ expect(
234
+ await provider.retrieveUserDeviceList([userID]),
235
+ ).toEqual([recovered.device]);
236
+ expect(
237
+ await provider.getPreKeys(oldA.deviceID),
238
+ ).toBeNull();
239
+ expect(
240
+ await provider.getOTK(oldA.deviceID),
241
+ ).toBeNull();
242
+ expect(
243
+ await provider.retrieveNotificationSubscriptions(
244
+ {
245
+ event: "deviceRequest",
246
+ userID,
247
+ },
248
+ ),
249
+ ).toEqual([]);
250
+ await provider.close();
251
+ resolve();
252
+ } catch (e: unknown) {
253
+ await provider.close().catch(() => {
254
+ /* ignore cleanup failure */
255
+ });
256
+ reject(
257
+ e instanceof Error ? e : new Error(String(e)),
258
+ );
259
+ }
260
+ })();
261
+ });
262
+ });
263
+ });
264
+ });
265
+
159
266
  describe("retrieveMail", () => {
160
267
  it("returns queued mail in send order for logged-out batch drains", async () => {
161
268
  expect.assertions(1);
@@ -231,9 +338,80 @@ describe("Database", () => {
231
338
  });
232
339
  });
233
340
 
341
+ describe("saveMailBatch", () => {
342
+ it("stores multiple queued mail rows in one insert", async () => {
343
+ expect.assertions(1);
344
+
345
+ const provider = new Database(options);
346
+ await new Promise<void>((resolve, reject) => {
347
+ provider.once("ready", () => {
348
+ void (async () => {
349
+ try {
350
+ const mail = (
351
+ mailID: string,
352
+ nonce: number,
353
+ ): MailWS => ({
354
+ authorID: userID,
355
+ cipher: new Uint8Array([1, nonce]),
356
+ extra: new Uint8Array([2, nonce]),
357
+ forward: false,
358
+ group: null,
359
+ mailID,
360
+ mailType: MailType.initial,
361
+ nonce: new Uint8Array([3, nonce]),
362
+ readerID: userID,
363
+ recipient: deviceID,
364
+ sender: "sender-a",
365
+ });
366
+
367
+ await provider.saveMailBatch([
368
+ {
369
+ header: new Uint8Array([4, 1]),
370
+ mail: mail(
371
+ "00000000-0000-0000-0000-000000000011",
372
+ 1,
373
+ ),
374
+ senderDeviceID: "sender-a",
375
+ userID,
376
+ },
377
+ {
378
+ header: new Uint8Array([4, 2]),
379
+ mail: mail(
380
+ "00000000-0000-0000-0000-000000000012",
381
+ 2,
382
+ ),
383
+ senderDeviceID: "sender-a",
384
+ userID,
385
+ },
386
+ ]);
387
+
388
+ const result =
389
+ await provider.retrieveMail(deviceID);
390
+ expect(
391
+ result.map(
392
+ ([, body]: [Uint8Array, MailWS, string]) =>
393
+ body.mailID,
394
+ ),
395
+ ).toEqual([
396
+ "00000000-0000-0000-0000-000000000011",
397
+ "00000000-0000-0000-0000-000000000012",
398
+ ]);
399
+ await provider.close();
400
+ resolve();
401
+ } catch (e: unknown) {
402
+ reject(
403
+ e instanceof Error ? e : new Error(String(e)),
404
+ );
405
+ }
406
+ })();
407
+ });
408
+ });
409
+ });
410
+ });
411
+
234
412
  describe("notification subscriptions", () => {
235
413
  it("upserts and filters Expo push subscriptions by event", async () => {
236
- expect.assertions(7);
414
+ expect.assertions(8);
237
415
 
238
416
  const provider = new Database(options);
239
417
  await new Promise<void>((resolve, reject) => {
@@ -297,6 +475,17 @@ describe("Database", () => {
297
475
  );
298
476
  expect(deviceSubsAfterUpdate).toHaveLength(1);
299
477
 
478
+ await provider.deleteDevice(deviceID);
479
+ expect(
480
+ await provider.retrieveNotificationSubscriptions(
481
+ {
482
+ deviceID,
483
+ event: "deviceRequest",
484
+ userID,
485
+ },
486
+ ),
487
+ ).toEqual([]);
488
+
300
489
  await provider.close();
301
490
  resolve();
302
491
  } catch (e: unknown) {
@@ -0,0 +1,91 @@
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 { describe, expect, it } from "vitest";
8
+
9
+ import {
10
+ isSpireFipsEnabled,
11
+ normalizeEnvValue,
12
+ validateSpireRuntimeEnv,
13
+ } from "../utils/loadEnv.ts";
14
+
15
+ const TWEETNACL_SPK = "ab".repeat(64);
16
+ const FIPS_SPK = "cd".repeat(90);
17
+ const JWT_SECRET = "ef".repeat(32);
18
+
19
+ describe("normalizeEnvValue", () => {
20
+ it("strips matching quotes from compose/dotenv values", () => {
21
+ expect(normalizeEnvValue(`"${TWEETNACL_SPK}"`)).toBe(TWEETNACL_SPK);
22
+ expect(normalizeEnvValue(`'${JWT_SECRET}'`)).toBe(JWT_SECRET);
23
+ });
24
+
25
+ it("trims whitespace without stripping unmatched quotes", () => {
26
+ expect(normalizeEnvValue(` ${TWEETNACL_SPK} `)).toBe(TWEETNACL_SPK);
27
+ expect(normalizeEnvValue(`"${TWEETNACL_SPK}`)).toBe(
28
+ `"${TWEETNACL_SPK}`,
29
+ );
30
+ });
31
+ });
32
+
33
+ describe("isSpireFipsEnabled", () => {
34
+ it("accepts true and 1 only", () => {
35
+ expect(isSpireFipsEnabled("true")).toBe(true);
36
+ expect(isSpireFipsEnabled('"true"')).toBe(true);
37
+ expect(isSpireFipsEnabled("1")).toBe(true);
38
+ expect(isSpireFipsEnabled("false")).toBe(false);
39
+ expect(isSpireFipsEnabled(undefined)).toBe(false);
40
+ });
41
+ });
42
+
43
+ describe("validateSpireRuntimeEnv", () => {
44
+ it("accepts quoted compose-safe tweetnacl keys", () => {
45
+ expect(() => {
46
+ validateSpireRuntimeEnv({
47
+ JWT_SECRET: `"${JWT_SECRET}"`,
48
+ SPIRE_FIPS: "false",
49
+ SPK: `"${TWEETNACL_SPK}"`,
50
+ });
51
+ }).not.toThrow();
52
+ });
53
+
54
+ it("rejects non-hex SPK values before crypto init", () => {
55
+ expect(() => {
56
+ validateSpireRuntimeEnv({
57
+ JWT_SECRET,
58
+ SPK: `"${TWEETNACL_SPK}`,
59
+ });
60
+ }).toThrow(/SPK must be an even-length hex string/);
61
+ });
62
+
63
+ it("rejects a FIPS flag with a tweetnacl-sized SPK", () => {
64
+ expect(() => {
65
+ validateSpireRuntimeEnv({
66
+ JWT_SECRET,
67
+ SPIRE_FIPS: "true",
68
+ SPK: TWEETNACL_SPK,
69
+ });
70
+ }).toThrow(/requires an SPK from .*gen-spk-fips/);
71
+ });
72
+
73
+ it("rejects a FIPS-sized SPK without the FIPS flag", () => {
74
+ expect(() => {
75
+ validateSpireRuntimeEnv({
76
+ JWT_SECRET,
77
+ SPIRE_FIPS: "false",
78
+ SPK: FIPS_SPK,
79
+ });
80
+ }).toThrow(/SPK must be 128 hex characters/);
81
+ });
82
+
83
+ it("rejects reusing SPK as JWT_SECRET", () => {
84
+ expect(() => {
85
+ validateSpireRuntimeEnv({
86
+ JWT_SECRET: TWEETNACL_SPK,
87
+ SPK: TWEETNACL_SPK,
88
+ });
89
+ }).toThrow(/JWT_SECRET must be separate from SPK/);
90
+ });
91
+ });