@vex-chat/spire 3.0.1 → 4.1.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 (90) hide show
  1. package/README.md +9 -8
  2. package/dist/Database.d.ts +20 -11
  3. package/dist/Database.js +229 -118
  4. package/dist/Database.js.map +1 -1
  5. package/dist/Spire.d.ts +4 -2
  6. package/dist/Spire.js +155 -72
  7. package/dist/Spire.js.map +1 -1
  8. package/dist/db/schema.d.ts +0 -2
  9. package/dist/migrations/2026-04-06_initial-schema.js +4 -5
  10. package/dist/migrations/2026-04-06_initial-schema.js.map +1 -1
  11. package/dist/migrations/{2026-04-14_argon2id-password-hashing.d.ts → 2026-07-14_query-indexes.d.ts} +1 -1
  12. package/dist/migrations/2026-07-14_query-indexes.js +31 -0
  13. package/dist/migrations/2026-07-14_query-indexes.js.map +1 -0
  14. package/dist/server/avatar.js +33 -6
  15. package/dist/server/avatar.js.map +1 -1
  16. package/dist/server/cliPasskeyPage.js +109 -11
  17. package/dist/server/cliPasskeyPage.js.map +1 -1
  18. package/dist/server/errors.js +8 -0
  19. package/dist/server/errors.js.map +1 -1
  20. package/dist/server/file.js +35 -12
  21. package/dist/server/file.js.map +1 -1
  22. package/dist/server/index.d.ts +8 -0
  23. package/dist/server/index.js +319 -56
  24. package/dist/server/index.js.map +1 -1
  25. package/dist/server/passkey.js +367 -29
  26. package/dist/server/passkey.js.map +1 -1
  27. package/dist/server/passkeyDevices.js +1 -0
  28. package/dist/server/passkeyDevices.js.map +1 -1
  29. package/dist/server/password.d.ts +7 -0
  30. package/dist/server/password.js +58 -0
  31. package/dist/server/password.js.map +1 -0
  32. package/dist/server/permissions.d.ts +1 -0
  33. package/dist/server/permissions.js +11 -2
  34. package/dist/server/permissions.js.map +1 -1
  35. package/dist/server/rateLimit.d.ts +11 -0
  36. package/dist/server/rateLimit.js +43 -4
  37. package/dist/server/rateLimit.js.map +1 -1
  38. package/dist/server/serverIcon.d.ts +10 -0
  39. package/dist/server/serverIcon.js +158 -0
  40. package/dist/server/serverIcon.js.map +1 -0
  41. package/dist/server/user.d.ts +1 -1
  42. package/dist/server/user.js +40 -9
  43. package/dist/server/user.js.map +1 -1
  44. package/dist/types/express.d.ts +3 -4
  45. package/dist/types/express.js.map +1 -1
  46. package/dist/utils/authJwt.d.ts +8 -0
  47. package/dist/utils/authJwt.js +25 -0
  48. package/dist/utils/authJwt.js.map +1 -0
  49. package/dist/utils/loadEnv.js +4 -0
  50. package/dist/utils/loadEnv.js.map +1 -1
  51. package/dist/utils/preKeySignature.d.ts +1 -1
  52. package/dist/utils/preKeySignature.js +7 -11
  53. package/dist/utils/preKeySignature.js.map +1 -1
  54. package/package.json +7 -7
  55. package/src/Database.ts +294 -152
  56. package/src/Spire.ts +412 -285
  57. package/src/__tests__/Database.spec.ts +126 -3
  58. package/src/__tests__/browserPasskeyAuthentication.spec.ts +192 -0
  59. package/src/__tests__/browserPasskeyRegistration.spec.ts +182 -0
  60. package/src/__tests__/cliPasskeyPage.spec.ts +6 -0
  61. package/src/__tests__/connectAuth.spec.ts +146 -4
  62. package/src/__tests__/deviceTokenRevalidation.spec.ts +57 -3
  63. package/src/__tests__/passkeyDevices.spec.ts +94 -0
  64. package/src/__tests__/passkeys.spec.ts +7 -2
  65. package/src/__tests__/password.spec.ts +223 -0
  66. package/src/__tests__/permissions.spec.ts +50 -0
  67. package/src/__tests__/preKeySignature.spec.ts +20 -3
  68. package/src/__tests__/serverManagement.spec.ts +262 -0
  69. package/src/db/schema.ts +0 -2
  70. package/src/migrations/2026-04-06_initial-schema.ts +4 -5
  71. package/src/migrations/2026-07-14_query-indexes.ts +34 -0
  72. package/src/server/avatar.ts +32 -6
  73. package/src/server/cliPasskeyPage.ts +109 -11
  74. package/src/server/errors.ts +7 -0
  75. package/src/server/file.ts +34 -13
  76. package/src/server/index.ts +494 -139
  77. package/src/server/passkey.ts +531 -31
  78. package/src/server/passkeyDevices.ts +1 -0
  79. package/src/server/password.ts +96 -0
  80. package/src/server/permissions.ts +20 -2
  81. package/src/server/rateLimit.ts +47 -4
  82. package/src/server/serverIcon.ts +199 -0
  83. package/src/server/user.ts +44 -9
  84. package/src/types/express.ts +3 -4
  85. package/src/utils/authJwt.ts +34 -0
  86. package/src/utils/loadEnv.ts +4 -0
  87. package/src/utils/preKeySignature.ts +12 -15
  88. package/dist/migrations/2026-04-14_argon2id-password-hashing.js +0 -17
  89. package/dist/migrations/2026-04-14_argon2id-password-hashing.js.map +0 -1
  90. package/src/migrations/2026-04-14_argon2id-password-hashing.ts +0 -24
@@ -13,12 +13,16 @@ import * as fsp from "node:fs/promises";
13
13
  import express from "express";
14
14
 
15
15
  import { type KeyPair, XUtils } from "@vex-chat/crypto";
16
- import { PreKeysWSSchema, TokenScopes, UserSchema } from "@vex-chat/types";
16
+ import {
17
+ MAX_FILE_UPLOAD_ENCODED_BODY_BYTES,
18
+ PreKeysWSSchema,
19
+ TokenScopes,
20
+ UserSchema,
21
+ } from "@vex-chat/types";
17
22
 
18
23
  import cors from "cors";
19
24
  import { fileTypeFromBuffer, fileTypeFromFile } from "file-type";
20
25
  import helmet from "helmet";
21
- import jwt from "jsonwebtoken";
22
26
  import multer from "multer";
23
27
  import parseDuration from "parse-duration";
24
28
  import { stringify as uuidStringify } from "uuid";
@@ -26,7 +30,7 @@ import { z } from "zod/v4";
26
30
 
27
31
  import { POWER_LEVELS } from "../ClientManager.ts";
28
32
  import { JWT_EXPIRY } from "../Spire.ts";
29
- import { getJwtSecret } from "../utils/jwtSecret.ts";
33
+ import { signAuthJwt, verifyAuthJwt } from "../utils/authJwt.ts";
30
34
  import { msgpack } from "../utils/msgpack.ts";
31
35
  import { verifyPreKeyWsSignature } from "../utils/preKeySignature.ts";
32
36
  import { spireXSignOpenAsync } from "../utils/spireXSignOpenAsync.ts";
@@ -41,12 +45,15 @@ import { getInviteRouter } from "./invite.ts";
41
45
  import { setupDocs } from "./openapi.ts";
42
46
  import { getPasskeyRouter } from "./passkey.ts";
43
47
  import { getPasskeyDeviceRouter } from "./passkeyDevices.ts";
48
+ import { getPasswordRouter } from "./password.ts";
44
49
  import {
50
+ canDeletePermission,
45
51
  hasAnyPermission,
46
52
  hasPermission,
47
53
  userHasPermission,
48
54
  } from "./permissions.ts";
49
- import { globalLimiter, keyBundleLimiter } from "./rateLimit.ts";
55
+ import { globalLimiter, keyBundleLimiter, uploadLimiter } from "./rateLimit.ts";
56
+ import { deleteServerIconFile, getServerIconRouter } from "./serverIcon.ts";
50
57
  import { getUserRouter } from "./user.ts";
51
58
  import { censorUser, getParam, getUser } from "./utils.ts";
52
59
  import { getWellKnownRouter } from "./wellKnown.ts";
@@ -60,34 +67,59 @@ export const ALLOWED_IMAGE_TYPES = [
60
67
  "image/gif",
61
68
  "image/apng",
62
69
  "image/avif",
70
+ "image/webp",
63
71
  ];
64
72
 
65
73
  // ── Zod schemas for trust-boundary validation ──────────────────────────
66
74
  const invitePayload = z.object({
67
- duration: z.string().min(1),
68
- serverID: z.string().min(1),
75
+ duration: z.string().min(1).max(32),
76
+ serverID: z.string().min(1).max(128),
69
77
  });
70
78
 
71
79
  const channelPayload = z.object({
72
- name: z.string().min(1).max(255),
80
+ name: z.string().trim().min(1).max(100),
81
+ });
82
+
83
+ const serverUpdatePayload = z.object({
84
+ name: z.string().trim().min(1).max(100),
85
+ });
86
+
87
+ const permissionRolePayload = z.object({
88
+ powerLevel: z.union([z.literal(0), z.literal(50), z.literal(100)]),
73
89
  });
74
90
 
75
- const deviceListPayload = z.array(z.string());
91
+ const deviceListPayload = z.array(z.string().min(1).max(128)).max(256);
76
92
 
77
93
  const connectPayload = z.object({
78
- signed: z.custom<Uint8Array>((val) => val instanceof Uint8Array),
94
+ signed: z.custom<Uint8Array>(
95
+ (val) => val instanceof Uint8Array && val.byteLength <= 16_384,
96
+ ),
79
97
  });
80
98
 
81
99
  const safePathParam = z.string().regex(/^[a-zA-Z0-9._-]+$/);
82
100
 
83
101
  const emojiPayload = z.object({
84
- file: z.string().optional(),
85
- name: z.string().min(1),
86
- signed: z.string().optional(),
102
+ file: z.string().max(350_000).optional(),
103
+ name: z.string().trim().min(1).max(64),
104
+ signed: z.string().max(8192).optional(),
87
105
  });
106
+ const emojiUpload = multer({
107
+ limits: { fields: 3, files: 1, fileSize: 256_000, parts: 4 },
108
+ });
109
+
110
+ const DEVELOPMENT_CORS_ORIGINS: Array<RegExp | string> = [
111
+ /^https?:\/\/localhost(?::\d{1,5})?$/,
112
+ /^https?:\/\/127\.0\.0\.1(?::\d{1,5})?$/,
113
+ /^https?:\/\/\[::1\](?::\d{1,5})?$/,
114
+ "capacitor://localhost",
115
+ "http://tauri.localhost",
116
+ "https://tauri.localhost",
117
+ "tauri://localhost",
118
+ ];
88
119
 
89
120
  const jwtUserPayload = z.object({
90
121
  exp: z.number().optional(),
122
+ scope: z.literal("user"),
91
123
  user: UserSchema,
92
124
  });
93
125
 
@@ -100,6 +132,7 @@ const jwtDevicePayload = z.object({
100
132
  owner: z.string(),
101
133
  signKey: z.string(),
102
134
  }),
135
+ scope: z.literal("device"),
103
136
  });
104
137
 
105
138
  const jwtPasskeyPayload = z.object({
@@ -121,7 +154,7 @@ const checkAuth: express.RequestHandler = (req, _res, next) => {
121
154
  const token = extractBearer(req);
122
155
  if (token) {
123
156
  try {
124
- const result = jwt.verify(token, getJwtSecret());
157
+ const result = verifyAuthJwt(token);
125
158
  // Passkey-scoped JWTs share the bearer slot with regular
126
159
  // user JWTs but carry a `scope: "passkey"` discriminator
127
160
  // and a `passkey` claim. Populate `req.passkey` and the
@@ -158,7 +191,7 @@ export function createCheckDevice(
158
191
  const token = req.headers["x-device-token"];
159
192
  if (typeof token === "string" && token) {
160
193
  try {
161
- const result = jwt.verify(token, getJwtSecret());
194
+ const result = verifyAuthJwt(token);
162
195
  const parsed = jwtDevicePayload.safeParse(result);
163
196
  if (parsed.success) {
164
197
  const device = await currentTokenDevice(
@@ -180,6 +213,29 @@ export function createCheckDevice(
180
213
  };
181
214
  }
182
215
 
216
+ /**
217
+ * Revalidate the credential behind a passkey-scoped bearer on every request.
218
+ * Passkey JWTs are short-lived, but removing a credential must revoke its
219
+ * recovery and account-administration access immediately.
220
+ */
221
+ export function createCheckPasskey(
222
+ db: Pick<Database, "retrievePasskeyInternal">,
223
+ ): express.RequestHandler {
224
+ return async (req, _res, next) => {
225
+ if (req.passkey) {
226
+ const passkey = await db.retrievePasskeyInternal(
227
+ req.passkey.passkeyID,
228
+ );
229
+ if (!passkey || !req.user || passkey.userID !== req.user.userID) {
230
+ delete req.bearerToken;
231
+ delete req.passkey;
232
+ delete req.user;
233
+ }
234
+ }
235
+ next();
236
+ };
237
+ }
238
+
183
239
  async function currentTokenDevice(
184
240
  db: Pick<Database, "retrieveDevice">,
185
241
  tokenDevice: Device,
@@ -192,7 +248,7 @@ async function currentTokenDevice(
192
248
  }
193
249
 
194
250
  export const protect: express.RequestHandler = (req, res, next) => {
195
- if (!req.user) {
251
+ if (!req.user || req.passkey) {
196
252
  res.sendStatus(401);
197
253
  return;
198
254
  }
@@ -200,6 +256,15 @@ export const protect: express.RequestHandler = (req, res, next) => {
200
256
  next();
201
257
  };
202
258
 
259
+ /** Require either an approved device or a fresh passkey session. */
260
+ export const protectAnyAuth: express.RequestHandler = (req, res, next) => {
261
+ if (!req.user || (!req.device && !req.passkey)) {
262
+ res.sendStatus(401);
263
+ return;
264
+ }
265
+ next();
266
+ };
267
+
203
268
  /**
204
269
  * Restrict a route to passkey-scoped JWTs (used by the parallel
205
270
  * `/user/:id/passkey/devices/...` admin/recovery routes). A
@@ -228,7 +293,7 @@ export const msgpackParser: express.RequestHandler = (req, res, next) => {
228
293
  next();
229
294
  };
230
295
 
231
- const directories = ["files", "avatars"];
296
+ const directories = ["files", "avatars", "server-icons"];
232
297
  for (const dir of directories) {
233
298
  if (!fs.existsSync(dir)) {
234
299
  fs.mkdirSync(dir);
@@ -249,14 +314,35 @@ export const initApp = (
249
314
  ) => void,
250
315
  disconnectDevices?: (deviceIDs: string[]) => void,
251
316
  ) => {
317
+ const notifyServerChange = async (
318
+ serverID: string,
319
+ additionalUserIDs: readonly string[] = [],
320
+ ): Promise<void> => {
321
+ const affectedUsers = await db.retrieveAffectedUsers(serverID);
322
+ const userIDs = new Set([
323
+ ...affectedUsers.map((user) => user.userID),
324
+ ...additionalUserIDs,
325
+ ]);
326
+ for (const userID of userIDs) {
327
+ notify(userID, "serverChange", crypto.randomUUID(), serverID);
328
+ }
329
+ };
330
+
252
331
  // INIT ROUTERS
253
- const userRouter = getUserRouter(db, tokenValidator, notify);
332
+ const userRouter = getUserRouter(
333
+ db,
334
+ tokenValidator,
335
+ notify,
336
+ disconnectDevices,
337
+ );
254
338
  const fileRouter = getFileRouter(db);
255
339
  const avatarRouter = getAvatarRouter();
256
340
  const billingRouter = getBillingRouter(db, notify);
257
341
  const entitlementRouter = getEntitlementRouter(db, notify);
258
342
  const inviteRouter = getInviteRouter(db, tokenValidator, notify);
259
343
  const passkeyRouter = getPasskeyRouter(db);
344
+ const passwordRouter = getPasswordRouter(db);
345
+ const serverIconRouter = getServerIconRouter(db, notifyServerChange);
260
346
  const passkeyDeviceRouter = getPasskeyDeviceRouter(
261
347
  db,
262
348
  notify,
@@ -277,6 +363,17 @@ export const initApp = (
277
363
  // spends any cycles on body parsing, helmet, or auth. See
278
364
  // src/server/rateLimit.ts.
279
365
  api.use(globalLimiter);
366
+ // Base64 expands a 25 MiB encrypted upload to about 33.4 MiB. Give only
367
+ // the JSON fallback route enough room for that encoded payload and its
368
+ // JSON/msgpack envelope; unrelated routes keep the tighter global cap.
369
+ api.use(
370
+ "/file/json",
371
+ express.json({ limit: MAX_FILE_UPLOAD_ENCODED_BODY_BYTES }),
372
+ express.raw({
373
+ limit: MAX_FILE_UPLOAD_ENCODED_BODY_BYTES,
374
+ type: "application/msgpack",
375
+ }),
376
+ );
280
377
  api.use(express.json({ limit: "20mb" }));
281
378
  api.use(
282
379
  express.raw({
@@ -310,7 +407,9 @@ export const initApp = (
310
407
  origin:
311
408
  corsOrigins.length > 0
312
409
  ? corsOrigins
313
- : true /* reflect request Origin */,
410
+ : process.env["NODE_ENV"] === "production"
411
+ ? false
412
+ : DEVELOPMENT_CORS_ORIGINS,
314
413
  }),
315
414
  );
316
415
 
@@ -323,10 +422,20 @@ export const initApp = (
323
422
 
324
423
  api.use(msgpackParser);
325
424
  api.use(checkAuth);
425
+ api.use(createCheckPasskey(db));
326
426
  api.use(createCheckDevice(db));
327
427
 
328
428
  api.get("/server/:id", protect, async (req, res) => {
329
- const server = await db.retrieveServer(getParam(req, "id"));
429
+ const serverID = getParam(req, "id");
430
+ const userDetails = getUser(req);
431
+ const permissions = await db.retrievePermissions(
432
+ userDetails.userID,
433
+ "server",
434
+ );
435
+ if (!hasAnyPermission(permissions, serverID)) {
436
+ return res.sendStatus(403);
437
+ }
438
+ const server = await db.retrieveServer(serverID);
330
439
 
331
440
  if (server) {
332
441
  return res.send(msgpack.encode(server));
@@ -335,11 +444,83 @@ export const initApp = (
335
444
  }
336
445
  });
337
446
 
447
+ api.patch("/server/:id", protect, async (req, res) => {
448
+ const parsed = serverUpdatePayload.safeParse(req.body);
449
+ if (!parsed.success) {
450
+ res.status(400).json({
451
+ error: "Invalid server update",
452
+ issues: parsed.error.issues,
453
+ });
454
+ return;
455
+ }
456
+
457
+ const serverID = getParam(req, "id");
458
+ const permissions = await db.retrievePermissions(
459
+ getUser(req).userID,
460
+ "server",
461
+ );
462
+ if (!hasPermission(permissions, serverID, POWER_LEVELS.CREATE)) {
463
+ res.sendStatus(403);
464
+ return;
465
+ }
466
+
467
+ const server = await db.updateServer(serverID, parsed.data);
468
+ if (!server) {
469
+ res.sendStatus(404);
470
+ return;
471
+ }
472
+ await notifyServerChange(serverID);
473
+ res.send(msgpack.encode(server));
474
+ });
475
+
476
+ api.post("/servers", protect, async (req, res) => {
477
+ const parsed = serverUpdatePayload.safeParse(req.body);
478
+ if (!parsed.success) {
479
+ res.status(400).json({
480
+ error: "Invalid server",
481
+ issues: parsed.error.issues,
482
+ });
483
+ return;
484
+ }
485
+
486
+ const server = await db.createServer(
487
+ parsed.data.name,
488
+ getUser(req).userID,
489
+ );
490
+ res.send(msgpack.encode(server));
491
+ });
492
+
493
+ // Compatibility route for clients released before server names moved into
494
+ // the request body. New clients use POST /servers so Unicode names work.
338
495
  api.post("/server/:name", protect, async (req, res) => {
339
496
  const userDetails = getUser(req);
340
- const serverName = atob(getParam(req, "name"));
497
+ const encodedName = getParam(req, "name");
498
+ if (encodedName.length > 256) {
499
+ res.sendStatus(400);
500
+ return;
501
+ }
502
+ let decodedName: string;
503
+ try {
504
+ decodedName = atob(encodedName);
505
+ } catch {
506
+ res.sendStatus(400);
507
+ return;
508
+ }
509
+ const parsedName = z
510
+ .string()
511
+ .trim()
512
+ .min(1)
513
+ .max(100)
514
+ .safeParse(decodedName);
515
+ if (!parsedName.success) {
516
+ res.sendStatus(400);
517
+ return;
518
+ }
341
519
 
342
- const server = await db.createServer(serverName, userDetails.userID);
520
+ const server = await db.createServer(
521
+ parsedName.data,
522
+ userDetails.userID,
523
+ );
343
524
  res.send(msgpack.encode(server));
344
525
  });
345
526
 
@@ -429,11 +610,22 @@ export const initApp = (
429
610
  "server",
430
611
  );
431
612
  if (hasPermission(permissions, serverID, POWER_LEVELS.DELETE)) {
613
+ const server = await db.retrieveServer(serverID);
614
+ if (!server) {
615
+ res.sendStatus(404);
616
+ return;
617
+ }
618
+ const affectedUsers = await db.retrieveAffectedUsers(serverID);
432
619
  await db.deleteServer(serverID);
620
+ if (server.icon) await deleteServerIconFile(server.icon);
621
+ await notifyServerChange(
622
+ serverID,
623
+ affectedUsers.map((user) => user.userID),
624
+ );
433
625
  res.sendStatus(200);
434
626
  return;
435
627
  }
436
- res.sendStatus(401);
628
+ res.sendStatus(403);
437
629
  });
438
630
 
439
631
  api.post("/server/:id/channels", protect, async (req, res) => {
@@ -456,20 +648,10 @@ export const initApp = (
456
648
  if (hasPermission(permissions, serverID, POWER_LEVELS.CREATE)) {
457
649
  const channel = await db.createChannel(name, serverID);
458
650
  res.send(msgpack.encode(channel));
459
-
460
- const affectedUsers = await db.retrieveAffectedUsers(serverID);
461
- // tell everyone about server change
462
- for (const user of affectedUsers) {
463
- notify(
464
- user.userID,
465
- "serverChange",
466
- crypto.randomUUID(),
467
- serverID,
468
- );
469
- }
651
+ await notifyServerChange(serverID);
470
652
  return;
471
653
  }
472
- res.sendStatus(401);
654
+ res.sendStatus(403);
473
655
  });
474
656
 
475
657
  api.get("/server/:id/channels", protect, async (req, res) => {
@@ -488,7 +670,16 @@ export const initApp = (
488
670
  });
489
671
 
490
672
  api.get("/server/:serverID/emoji", protect, async (req, res) => {
491
- const rows = await db.retrieveEmojiList(getParam(req, "serverID"));
673
+ const serverID = getParam(req, "serverID");
674
+ const permissions = await db.retrievePermissions(
675
+ getUser(req).userID,
676
+ "server",
677
+ );
678
+ if (!hasAnyPermission(permissions, serverID)) {
679
+ res.sendStatus(403);
680
+ return;
681
+ }
682
+ const rows = await db.retrieveEmojiList(serverID);
492
683
  res.send(msgpack.encode(rows));
493
684
  });
494
685
 
@@ -506,6 +697,42 @@ export const initApp = (
506
697
  res.send(msgpack.encode(permissions));
507
698
  });
508
699
 
700
+ api.patch("/channel/:id", protect, async (req, res) => {
701
+ const parsed = channelPayload.safeParse(req.body);
702
+ if (!parsed.success) {
703
+ res.status(400).json({
704
+ error: "Invalid channel update",
705
+ issues: parsed.error.issues,
706
+ });
707
+ return;
708
+ }
709
+
710
+ const channelID = getParam(req, "id");
711
+ const channel = await db.retrieveChannel(channelID);
712
+ if (!channel) {
713
+ res.sendStatus(404);
714
+ return;
715
+ }
716
+ const permissions = await db.retrievePermissions(
717
+ getUser(req).userID,
718
+ "server",
719
+ );
720
+ if (
721
+ !hasPermission(permissions, channel.serverID, POWER_LEVELS.DELETE)
722
+ ) {
723
+ res.sendStatus(403);
724
+ return;
725
+ }
726
+
727
+ const updated = await db.updateChannel(channelID, parsed.data.name);
728
+ if (!updated) {
729
+ res.sendStatus(404);
730
+ return;
731
+ }
732
+ await notifyServerChange(channel.serverID);
733
+ res.send(msgpack.encode(updated));
734
+ });
735
+
509
736
  api.delete("/channel/:id", protect, async (req, res) => {
510
737
  const channelID = getParam(req, "id");
511
738
  const userDetails = getUser(req);
@@ -524,40 +751,90 @@ export const initApp = (
524
751
  for (const permission of permissions) {
525
752
  if (
526
753
  permission.resourceID === channel.serverID &&
527
- permission.powerLevel > 50
754
+ permission.powerLevel >= POWER_LEVELS.DELETE
528
755
  ) {
529
- await db.deleteChannel(channelID);
530
-
531
- res.sendStatus(200);
532
-
533
- const affectedUsers = await db.retrieveAffectedUsers(
534
- channel.serverID,
535
- );
536
- // tell everyone about server change
537
- for (const user of affectedUsers) {
538
- notify(
539
- user.userID,
540
- "serverChange",
541
- crypto.randomUUID(),
542
- channel.serverID,
543
- );
756
+ const deleted = await db.deleteChannelIfNotLast(channelID);
757
+ if (!deleted) {
758
+ res.status(409).json({
759
+ error: "A server must have at least one channel.",
760
+ });
761
+ return;
544
762
  }
763
+ await notifyServerChange(channel.serverID);
764
+ res.sendStatus(200);
545
765
  return;
546
766
  }
547
767
  }
548
- res.sendStatus(401);
768
+ res.sendStatus(403);
549
769
  });
550
770
 
551
771
  api.get("/channel/:id", protect, async (req, res) => {
552
772
  const channel = await db.retrieveChannel(getParam(req, "id"));
553
773
 
554
774
  if (channel) {
775
+ const permissions = await db.retrievePermissions(
776
+ getUser(req).userID,
777
+ "server",
778
+ );
779
+ if (!hasAnyPermission(permissions, channel.serverID)) {
780
+ return res.sendStatus(403);
781
+ }
555
782
  return res.send(msgpack.encode(channel));
556
783
  } else {
557
784
  return res.sendStatus(404);
558
785
  }
559
786
  });
560
787
 
788
+ api.patch("/permission/:permissionID", protect, async (req, res) => {
789
+ const parsed = permissionRolePayload.safeParse(req.body);
790
+ if (!parsed.success) {
791
+ res.status(400).json({
792
+ error: "Invalid role",
793
+ issues: parsed.error.issues,
794
+ });
795
+ return;
796
+ }
797
+
798
+ const target = await db.retrievePermission(
799
+ getParam(req, "permissionID"),
800
+ );
801
+ if (!target) {
802
+ res.sendStatus(404);
803
+ return;
804
+ }
805
+ if (target.resourceType !== "server") {
806
+ res.sendStatus(400);
807
+ return;
808
+ }
809
+
810
+ const actor = getUser(req);
811
+ if (target.userID === actor.userID) {
812
+ res.status(400).json({
813
+ error: "Use another owner to change your role.",
814
+ });
815
+ return;
816
+ }
817
+ const actorPermissions = await db.retrievePermissions(
818
+ actor.userID,
819
+ "server",
820
+ );
821
+ if (!hasPermission(actorPermissions, target.resourceID, 100)) {
822
+ res.sendStatus(403);
823
+ return;
824
+ }
825
+
826
+ const updated = await db.updatePermissionPowerLevel(
827
+ target.permissionID,
828
+ parsed.data.powerLevel,
829
+ );
830
+ if (!updated) {
831
+ res.sendStatus(404);
832
+ return;
833
+ }
834
+ await notifyServerChange(target.resourceID);
835
+ res.send(msgpack.encode(updated));
836
+ });
837
+
561
838
  api.delete("/permission/:permissionID", protect, async (req, res) => {
562
839
  const permissionID = getParam(req, "permissionID");
563
840
  const userDetails = getUser(req);
@@ -572,19 +849,44 @@ export const initApp = (
572
849
  permToDelete.resourceType,
573
850
  );
574
851
 
575
- for (const perm of permissions) {
852
+ if (
853
+ canDeletePermission(
854
+ permissions,
855
+ userDetails.userID,
856
+ permToDelete,
857
+ POWER_LEVELS.DELETE,
858
+ )
859
+ ) {
576
860
  if (
577
- perm.resourceID === permToDelete.resourceID &&
578
- (perm.userID === userDetails.userID ||
579
- (perm.powerLevel > POWER_LEVELS.DELETE &&
580
- perm.powerLevel > permToDelete.powerLevel))
861
+ permToDelete.resourceType === "server" &&
862
+ permToDelete.powerLevel >= 100
581
863
  ) {
582
- await db.deletePermission(permToDelete.permissionID);
583
- res.sendStatus(200);
584
- return;
864
+ const resourcePermissions =
865
+ await db.retrievePermissionsByResourceID(
866
+ permToDelete.resourceID,
867
+ );
868
+ const hasAnotherOwner = resourcePermissions.some(
869
+ (permission) =>
870
+ permission.permissionID !== permToDelete.permissionID &&
871
+ permission.powerLevel >= 100,
872
+ );
873
+ if (!hasAnotherOwner) {
874
+ res.status(409).json({
875
+ error: "Delete the server instead of leaving it without an owner.",
876
+ });
877
+ return;
878
+ }
585
879
  }
880
+ await db.deletePermission(permToDelete.permissionID);
881
+ if (permToDelete.resourceType === "server") {
882
+ await notifyServerChange(permToDelete.resourceID, [
883
+ permToDelete.userID,
884
+ ]);
885
+ }
886
+ res.sendStatus(200);
887
+ return;
586
888
  }
587
- res.sendStatus(401);
889
+ res.sendStatus(403);
588
890
  });
589
891
 
590
892
  api.post("/userList/:channelID", protect, async (req, res) => {
@@ -661,6 +963,10 @@ export const initApp = (
661
963
  res.sendStatus(401);
662
964
  return;
663
965
  }
966
+ if (deviceDetails.deviceID !== getParam(req, "id")) {
967
+ res.sendStatus(403);
968
+ return;
969
+ }
664
970
  const inbox = await db.retrieveMail(deviceDetails.deviceID);
665
971
  res.send(msgpack.encode(inbox));
666
972
  });
@@ -692,10 +998,7 @@ export const initApp = (
692
998
  regKey &&
693
999
  tokenValidator(uuidStringify(regKey), TokenScopes.Connect)
694
1000
  ) {
695
- const token = jwt.sign({ device }, getJwtSecret(), {
696
- expiresIn: JWT_EXPIRY,
697
- });
698
- jwt.verify(token, getJwtSecret());
1001
+ const token = signAuthJwt({ device, scope: "device" }, JWT_EXPIRY);
699
1002
 
700
1003
  res.send(msgpack.encode({ deviceToken: token }));
701
1004
  } else {
@@ -709,6 +1012,10 @@ export const initApp = (
709
1012
  res.sendStatus(401);
710
1013
  return;
711
1014
  }
1015
+ if (deviceDetails.deviceID !== getParam(req, "id")) {
1016
+ res.sendStatus(403);
1017
+ return;
1018
+ }
712
1019
  const count = await db.getOTKCount(deviceDetails.deviceID);
713
1020
  res.send(msgpack.encode({ count }));
714
1021
  });
@@ -747,7 +1054,9 @@ export const initApp = (
747
1054
  res.status(400).send({ error: "Prekey deviceID mismatch." });
748
1055
  return;
749
1056
  }
750
- if (!(await verifyPreKeyWsSignature(preKey, device.signKey))) {
1057
+ if (
1058
+ !(await verifyPreKeyWsSignature(preKey, device.signKey, "signed"))
1059
+ ) {
751
1060
  res.status(401).send({ error: "Prekey signature invalid." });
752
1061
  return;
753
1062
  }
@@ -796,7 +1105,11 @@ export const initApp = (
796
1105
  return;
797
1106
  }
798
1107
  if (
799
- !(await verifyPreKeyWsSignature(submittedOTK, device.signKey))
1108
+ !(await verifyPreKeyWsSignature(
1109
+ submittedOTK,
1110
+ device.signKey,
1111
+ "one-time",
1112
+ ))
800
1113
  ) {
801
1114
  res.status(401).send({ error: "OTK signature invalid." });
802
1115
  return;
@@ -809,6 +1122,18 @@ export const initApp = (
809
1122
 
810
1123
  api.get("/emoji/:emojiID/details", protect, async (req, res) => {
811
1124
  const emoji = await db.retrieveEmoji(getParam(req, "emojiID"));
1125
+ if (!emoji) {
1126
+ res.sendStatus(404);
1127
+ return;
1128
+ }
1129
+ const permissions = await db.retrievePermissions(
1130
+ getUser(req).userID,
1131
+ "server",
1132
+ );
1133
+ if (!hasAnyPermission(permissions, emoji.owner)) {
1134
+ res.sendStatus(403);
1135
+ return;
1136
+ }
812
1137
  res.send(msgpack.encode(emoji));
813
1138
  });
814
1139
 
@@ -818,6 +1143,19 @@ export const initApp = (
818
1143
  res.sendStatus(400);
819
1144
  return;
820
1145
  }
1146
+ const emoji = await db.retrieveEmoji(safeId.data);
1147
+ if (!emoji) {
1148
+ res.sendStatus(404);
1149
+ return;
1150
+ }
1151
+ const permissions = await db.retrievePermissions(
1152
+ getUser(req).userID,
1153
+ "server",
1154
+ );
1155
+ if (!hasAnyPermission(permissions, emoji.owner)) {
1156
+ res.sendStatus(403);
1157
+ return;
1158
+ }
821
1159
  const filePath = "./emoji/" + safeId.data;
822
1160
  const typeDetails = await fileTypeFromFile(filePath).catch(() => null);
823
1161
  if (!typeDetails) {
@@ -835,92 +1173,106 @@ export const initApp = (
835
1173
  stream.pipe(res);
836
1174
  });
837
1175
 
838
- api.post("/emoji/:serverID/json", protect, async (req, res) => {
839
- const parsedPayload = emojiPayload.safeParse(req.body);
840
- if (!parsedPayload.success) {
841
- res.status(400).json({
842
- error: "Invalid emoji payload",
843
- issues: parsedPayload.error.issues,
844
- });
845
- return;
846
- }
847
- const payload = parsedPayload.data;
1176
+ api.post(
1177
+ "/emoji/:serverID/json",
1178
+ uploadLimiter,
1179
+ protect,
1180
+ async (req, res) => {
1181
+ const parsedPayload = emojiPayload.safeParse(req.body);
1182
+ if (!parsedPayload.success) {
1183
+ res.status(400).json({
1184
+ error: "Invalid emoji payload",
1185
+ issues: parsedPayload.error.issues,
1186
+ });
1187
+ return;
1188
+ }
1189
+ const payload = parsedPayload.data;
848
1190
 
849
- const userDetails = getUser(req);
850
- const device = req.device;
1191
+ const userDetails = getUser(req);
1192
+ const device = req.device;
851
1193
 
852
- if (!device) {
853
- res.sendStatus(401);
854
- return;
855
- }
1194
+ if (!device) {
1195
+ res.sendStatus(401);
1196
+ return;
1197
+ }
856
1198
 
857
- if (!payload.file) {
858
- res.sendStatus(400);
859
- return;
860
- }
1199
+ if (!payload.file) {
1200
+ res.sendStatus(400);
1201
+ return;
1202
+ }
861
1203
 
862
- const buf = Buffer.from(XUtils.decodeBase64(payload.file));
863
- const serverEntry = await db.retrieveServer(getParam(req, "serverID"));
1204
+ let buf: Buffer;
1205
+ try {
1206
+ buf = Buffer.from(XUtils.decodeBase64(payload.file));
1207
+ } catch {
1208
+ res.status(400).send({ error: "Emoji must be valid base64." });
1209
+ return;
1210
+ }
1211
+ const serverEntry = await db.retrieveServer(
1212
+ getParam(req, "serverID"),
1213
+ );
864
1214
 
865
- const permissionList = await db.retrievePermissionsByResourceID(
866
- getParam(req, "serverID"),
867
- );
1215
+ const permissionList = await db.retrievePermissionsByResourceID(
1216
+ getParam(req, "serverID"),
1217
+ );
868
1218
 
869
- if (
870
- !userHasPermission(
871
- permissionList,
872
- userDetails.userID,
873
- POWER_LEVELS.EMOJI,
874
- )
875
- ) {
876
- res.sendStatus(401);
877
- return;
878
- }
879
- if (!serverEntry) {
880
- res.sendStatus(404);
881
- return;
882
- }
883
- if (!payload.name) {
884
- res.sendStatus(400);
885
- return;
886
- }
887
- if (Buffer.byteLength(buf) > 256000) {
888
- res.sendStatus(413);
889
- return;
890
- }
1219
+ if (
1220
+ !userHasPermission(
1221
+ permissionList,
1222
+ userDetails.userID,
1223
+ POWER_LEVELS.EMOJI,
1224
+ )
1225
+ ) {
1226
+ res.sendStatus(401);
1227
+ return;
1228
+ }
1229
+ if (!serverEntry) {
1230
+ res.sendStatus(404);
1231
+ return;
1232
+ }
1233
+ if (!payload.name) {
1234
+ res.sendStatus(400);
1235
+ return;
1236
+ }
1237
+ if (Buffer.byteLength(buf) > 256000) {
1238
+ res.sendStatus(413);
1239
+ return;
1240
+ }
891
1241
 
892
- const mimeType = await fileTypeFromBuffer(buf);
893
- if (!ALLOWED_IMAGE_TYPES.includes(mimeType?.mime || "no/type")) {
894
- res.status(400).send({
895
- error:
896
- "Unsupported file type. Expected jpeg, png, gif, apng, or avif but received " +
897
- String(mimeType?.ext),
898
- });
899
- return;
900
- }
1242
+ const mimeType = await fileTypeFromBuffer(buf);
1243
+ if (!ALLOWED_IMAGE_TYPES.includes(mimeType?.mime || "no/type")) {
1244
+ res.status(400).send({
1245
+ error:
1246
+ "Unsupported file type. Expected jpeg, png, gif, apng, or avif but received " +
1247
+ String(mimeType?.ext),
1248
+ });
1249
+ return;
1250
+ }
901
1251
 
902
- const emoji: Emoji = {
903
- emojiID: crypto.randomUUID(),
904
- name: payload.name,
905
- owner: getParam(req, "serverID"),
906
- };
1252
+ const emoji: Emoji = {
1253
+ emojiID: crypto.randomUUID(),
1254
+ name: payload.name,
1255
+ owner: getParam(req, "serverID"),
1256
+ };
907
1257
 
908
- await db.createEmoji(emoji);
1258
+ await db.createEmoji(emoji);
909
1259
 
910
- try {
911
- // write the file to disk
912
- await fsp.writeFile("emoji/" + emoji.emojiID, buf);
913
- res.send(msgpack.encode(emoji));
914
- } catch (_err: unknown) {
915
- // debugger: emoji write failed
916
- res.sendStatus(500);
917
- }
918
- });
1260
+ try {
1261
+ // write the file to disk
1262
+ await fsp.writeFile("emoji/" + emoji.emojiID, buf);
1263
+ res.send(msgpack.encode(emoji));
1264
+ } catch (_err: unknown) {
1265
+ // debugger: emoji write failed
1266
+ res.sendStatus(500);
1267
+ }
1268
+ },
1269
+ );
919
1270
 
920
1271
  api.post(
921
1272
  "/emoji/:serverID",
1273
+ uploadLimiter,
922
1274
  protect,
923
- multer().single("emoji"),
1275
+ emojiUpload.single("emoji"),
924
1276
  async (req, res) => {
925
1277
  const parsedPayload = emojiPayload.safeParse(req.body);
926
1278
  if (!parsedPayload.success) {
@@ -1017,6 +1369,7 @@ export const initApp = (
1017
1369
  api.use(entitlementRouter);
1018
1370
  api.use(passkeyRouter);
1019
1371
  api.use(passkeyDeviceRouter);
1372
+ api.use(passwordRouter);
1020
1373
 
1021
1374
  api.use("/user", userRouter);
1022
1375
 
@@ -1024,6 +1377,8 @@ export const initApp = (
1024
1377
 
1025
1378
  api.use("/avatar", avatarRouter);
1026
1379
 
1380
+ api.use("/server-icon", serverIconRouter);
1381
+
1027
1382
  api.use("/invite", inviteRouter);
1028
1383
 
1029
1384
  setupDocs(api);