@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
@@ -7,18 +7,17 @@ import * as fs from "node:fs";
7
7
  import * as fsp from "node:fs/promises";
8
8
  import express from "express";
9
9
  import { XUtils } from "@vex-chat/crypto";
10
- import { PreKeysWSSchema, TokenScopes, UserSchema } from "@vex-chat/types";
10
+ import { MAX_FILE_UPLOAD_ENCODED_BODY_BYTES, PreKeysWSSchema, TokenScopes, UserSchema, } from "@vex-chat/types";
11
11
  import cors from "cors";
12
12
  import { fileTypeFromBuffer, fileTypeFromFile } from "file-type";
13
13
  import helmet from "helmet";
14
- import jwt from "jsonwebtoken";
15
14
  import multer from "multer";
16
15
  import parseDuration from "parse-duration";
17
16
  import { stringify as uuidStringify } from "uuid";
18
17
  import { z } from "zod/v4";
19
18
  import { POWER_LEVELS } from "../ClientManager.js";
20
19
  import { JWT_EXPIRY } from "../Spire.js";
21
- import { getJwtSecret } from "../utils/jwtSecret.js";
20
+ import { signAuthJwt, verifyAuthJwt } from "../utils/authJwt.js";
22
21
  import { msgpack } from "../utils/msgpack.js";
23
22
  import { verifyPreKeyWsSignature } from "../utils/preKeySignature.js";
24
23
  import { spireXSignOpenAsync } from "../utils/spireXSignOpenAsync.js";
@@ -32,8 +31,10 @@ import { getInviteRouter } from "./invite.js";
32
31
  import { setupDocs } from "./openapi.js";
33
32
  import { getPasskeyRouter } from "./passkey.js";
34
33
  import { getPasskeyDeviceRouter } from "./passkeyDevices.js";
35
- import { hasAnyPermission, hasPermission, userHasPermission, } from "./permissions.js";
36
- import { globalLimiter, keyBundleLimiter } from "./rateLimit.js";
34
+ import { getPasswordRouter } from "./password.js";
35
+ import { canDeletePermission, hasAnyPermission, hasPermission, userHasPermission, } from "./permissions.js";
36
+ import { globalLimiter, keyBundleLimiter, uploadLimiter } from "./rateLimit.js";
37
+ import { deleteServerIconFile, getServerIconRouter } from "./serverIcon.js";
37
38
  import { getUserRouter } from "./user.js";
38
39
  import { censorUser, getParam, getUser } from "./utils.js";
39
40
  import { getWellKnownRouter } from "./wellKnown.js";
@@ -45,27 +46,47 @@ export const ALLOWED_IMAGE_TYPES = [
45
46
  "image/gif",
46
47
  "image/apng",
47
48
  "image/avif",
49
+ "image/webp",
48
50
  ];
49
51
  // ── Zod schemas for trust-boundary validation ──────────────────────────
50
52
  const invitePayload = z.object({
51
- duration: z.string().min(1),
52
- serverID: z.string().min(1),
53
+ duration: z.string().min(1).max(32),
54
+ serverID: z.string().min(1).max(128),
53
55
  });
54
56
  const channelPayload = z.object({
55
- name: z.string().min(1).max(255),
57
+ name: z.string().trim().min(1).max(100),
56
58
  });
57
- const deviceListPayload = z.array(z.string());
59
+ const serverUpdatePayload = z.object({
60
+ name: z.string().trim().min(1).max(100),
61
+ });
62
+ const permissionRolePayload = z.object({
63
+ powerLevel: z.union([z.literal(0), z.literal(50), z.literal(100)]),
64
+ });
65
+ const deviceListPayload = z.array(z.string().min(1).max(128)).max(256);
58
66
  const connectPayload = z.object({
59
- signed: z.custom((val) => val instanceof Uint8Array),
67
+ signed: z.custom((val) => val instanceof Uint8Array && val.byteLength <= 16_384),
60
68
  });
61
69
  const safePathParam = z.string().regex(/^[a-zA-Z0-9._-]+$/);
62
70
  const emojiPayload = z.object({
63
- file: z.string().optional(),
64
- name: z.string().min(1),
65
- signed: z.string().optional(),
71
+ file: z.string().max(350_000).optional(),
72
+ name: z.string().trim().min(1).max(64),
73
+ signed: z.string().max(8192).optional(),
74
+ });
75
+ const emojiUpload = multer({
76
+ limits: { fields: 3, files: 1, fileSize: 256_000, parts: 4 },
66
77
  });
78
+ const DEVELOPMENT_CORS_ORIGINS = [
79
+ /^https?:\/\/localhost(?::\d{1,5})?$/,
80
+ /^https?:\/\/127\.0\.0\.1(?::\d{1,5})?$/,
81
+ /^https?:\/\/\[::1\](?::\d{1,5})?$/,
82
+ "capacitor://localhost",
83
+ "http://tauri.localhost",
84
+ "https://tauri.localhost",
85
+ "tauri://localhost",
86
+ ];
67
87
  const jwtUserPayload = z.object({
68
88
  exp: z.number().optional(),
89
+ scope: z.literal("user"),
69
90
  user: UserSchema,
70
91
  });
71
92
  const jwtDevicePayload = z.object({
@@ -77,6 +98,7 @@ const jwtDevicePayload = z.object({
77
98
  owner: z.string(),
78
99
  signKey: z.string(),
79
100
  }),
101
+ scope: z.literal("device"),
80
102
  });
81
103
  const jwtPasskeyPayload = z.object({
82
104
  passkey: z.object({
@@ -96,7 +118,7 @@ const checkAuth = (req, _res, next) => {
96
118
  const token = extractBearer(req);
97
119
  if (token) {
98
120
  try {
99
- const result = jwt.verify(token, getJwtSecret());
121
+ const result = verifyAuthJwt(token);
100
122
  // Passkey-scoped JWTs share the bearer slot with regular
101
123
  // user JWTs but carry a `scope: "passkey"` discriminator
102
124
  // and a `passkey` claim. Populate `req.passkey` and the
@@ -132,7 +154,7 @@ export function createCheckDevice(db) {
132
154
  const token = req.headers["x-device-token"];
133
155
  if (typeof token === "string" && token) {
134
156
  try {
135
- const result = jwt.verify(token, getJwtSecret());
157
+ const result = verifyAuthJwt(token);
136
158
  const parsed = jwtDevicePayload.safeParse(result);
137
159
  if (parsed.success) {
138
160
  const device = await currentTokenDevice(db, parsed.data.device);
@@ -149,6 +171,24 @@ export function createCheckDevice(db) {
149
171
  next();
150
172
  };
151
173
  }
174
+ /**
175
+ * Revalidate the credential behind a passkey-scoped bearer on every request.
176
+ * Passkey JWTs are short-lived, but removing a credential must revoke its
177
+ * recovery and account-administration access immediately.
178
+ */
179
+ export function createCheckPasskey(db) {
180
+ return async (req, _res, next) => {
181
+ if (req.passkey) {
182
+ const passkey = await db.retrievePasskeyInternal(req.passkey.passkeyID);
183
+ if (!passkey || !req.user || passkey.userID !== req.user.userID) {
184
+ delete req.bearerToken;
185
+ delete req.passkey;
186
+ delete req.user;
187
+ }
188
+ }
189
+ next();
190
+ };
191
+ }
152
192
  async function currentTokenDevice(db, tokenDevice) {
153
193
  const device = await db.retrieveDevice(tokenDevice.deviceID);
154
194
  if (!device || device.signKey !== tokenDevice.signKey) {
@@ -157,7 +197,15 @@ async function currentTokenDevice(db, tokenDevice) {
157
197
  return device;
158
198
  }
159
199
  export const protect = (req, res, next) => {
160
- if (!req.user) {
200
+ if (!req.user || req.passkey) {
201
+ res.sendStatus(401);
202
+ return;
203
+ }
204
+ next();
205
+ };
206
+ /** Require either an approved device or a fresh passkey session. */
207
+ export const protectAnyAuth = (req, res, next) => {
208
+ if (!req.user || (!req.device && !req.passkey)) {
161
209
  res.sendStatus(401);
162
210
  return;
163
211
  }
@@ -189,21 +237,33 @@ export const msgpackParser = (req, res, next) => {
189
237
  }
190
238
  next();
191
239
  };
192
- const directories = ["files", "avatars"];
240
+ const directories = ["files", "avatars", "server-icons"];
193
241
  for (const dir of directories) {
194
242
  if (!fs.existsSync(dir)) {
195
243
  fs.mkdirSync(dir);
196
244
  }
197
245
  }
198
246
  export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDevices) => {
247
+ const notifyServerChange = async (serverID, additionalUserIDs = []) => {
248
+ const affectedUsers = await db.retrieveAffectedUsers(serverID);
249
+ const userIDs = new Set([
250
+ ...affectedUsers.map((user) => user.userID),
251
+ ...additionalUserIDs,
252
+ ]);
253
+ for (const userID of userIDs) {
254
+ notify(userID, "serverChange", crypto.randomUUID(), serverID);
255
+ }
256
+ };
199
257
  // INIT ROUTERS
200
- const userRouter = getUserRouter(db, tokenValidator, notify);
258
+ const userRouter = getUserRouter(db, tokenValidator, notify, disconnectDevices);
201
259
  const fileRouter = getFileRouter(db);
202
260
  const avatarRouter = getAvatarRouter();
203
261
  const billingRouter = getBillingRouter(db, notify);
204
262
  const entitlementRouter = getEntitlementRouter(db, notify);
205
263
  const inviteRouter = getInviteRouter(db, tokenValidator, notify);
206
264
  const passkeyRouter = getPasskeyRouter(db);
265
+ const passwordRouter = getPasswordRouter(db);
266
+ const serverIconRouter = getServerIconRouter(db, notifyServerChange);
207
267
  const passkeyDeviceRouter = getPasskeyDeviceRouter(db, notify, disconnectDevices);
208
268
  // MIDDLEWARE
209
269
  // WebAuthn well-known association files (AASA, assetlinks.json)
@@ -217,6 +277,13 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
217
277
  // spends any cycles on body parsing, helmet, or auth. See
218
278
  // src/server/rateLimit.ts.
219
279
  api.use(globalLimiter);
280
+ // Base64 expands a 25 MiB encrypted upload to about 33.4 MiB. Give only
281
+ // the JSON fallback route enough room for that encoded payload and its
282
+ // JSON/msgpack envelope; unrelated routes keep the tighter global cap.
283
+ api.use("/file/json", express.json({ limit: MAX_FILE_UPLOAD_ENCODED_BODY_BYTES }), express.raw({
284
+ limit: MAX_FILE_UPLOAD_ENCODED_BODY_BYTES,
285
+ type: "application/msgpack",
286
+ }));
220
287
  api.use(express.json({ limit: "20mb" }));
221
288
  api.use(express.raw({
222
289
  limit: "20mb",
@@ -245,7 +312,9 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
245
312
  ],
246
313
  origin: corsOrigins.length > 0
247
314
  ? corsOrigins
248
- : true /* reflect request Origin */,
315
+ : process.env["NODE_ENV"] === "production"
316
+ ? false
317
+ : DEVELOPMENT_CORS_ORIGINS,
249
318
  }));
250
319
  api.use(helmet());
251
320
  // Browser bridge used by the CLI to run WebAuthn ceremonies at the same
@@ -254,9 +323,16 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
254
323
  api.use(getCliPasskeyPageRouter());
255
324
  api.use(msgpackParser);
256
325
  api.use(checkAuth);
326
+ api.use(createCheckPasskey(db));
257
327
  api.use(createCheckDevice(db));
258
328
  api.get("/server/:id", protect, async (req, res) => {
259
- const server = await db.retrieveServer(getParam(req, "id"));
329
+ const serverID = getParam(req, "id");
330
+ const userDetails = getUser(req);
331
+ const permissions = await db.retrievePermissions(userDetails.userID, "server");
332
+ if (!hasAnyPermission(permissions, serverID)) {
333
+ return res.sendStatus(403);
334
+ }
335
+ const server = await db.retrieveServer(serverID);
260
336
  if (server) {
261
337
  return res.send(msgpack.encode(server));
262
338
  }
@@ -264,10 +340,69 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
264
340
  return res.sendStatus(404);
265
341
  }
266
342
  });
343
+ api.patch("/server/:id", protect, async (req, res) => {
344
+ const parsed = serverUpdatePayload.safeParse(req.body);
345
+ if (!parsed.success) {
346
+ res.status(400).json({
347
+ error: "Invalid server update",
348
+ issues: parsed.error.issues,
349
+ });
350
+ return;
351
+ }
352
+ const serverID = getParam(req, "id");
353
+ const permissions = await db.retrievePermissions(getUser(req).userID, "server");
354
+ if (!hasPermission(permissions, serverID, POWER_LEVELS.CREATE)) {
355
+ res.sendStatus(403);
356
+ return;
357
+ }
358
+ const server = await db.updateServer(serverID, parsed.data);
359
+ if (!server) {
360
+ res.sendStatus(404);
361
+ return;
362
+ }
363
+ await notifyServerChange(serverID);
364
+ res.send(msgpack.encode(server));
365
+ });
366
+ api.post("/servers", protect, async (req, res) => {
367
+ const parsed = serverUpdatePayload.safeParse(req.body);
368
+ if (!parsed.success) {
369
+ res.status(400).json({
370
+ error: "Invalid server",
371
+ issues: parsed.error.issues,
372
+ });
373
+ return;
374
+ }
375
+ const server = await db.createServer(parsed.data.name, getUser(req).userID);
376
+ res.send(msgpack.encode(server));
377
+ });
378
+ // Compatibility route for clients released before server names moved into
379
+ // the request body. New clients use POST /servers so Unicode names work.
267
380
  api.post("/server/:name", protect, async (req, res) => {
268
381
  const userDetails = getUser(req);
269
- const serverName = atob(getParam(req, "name"));
270
- const server = await db.createServer(serverName, userDetails.userID);
382
+ const encodedName = getParam(req, "name");
383
+ if (encodedName.length > 256) {
384
+ res.sendStatus(400);
385
+ return;
386
+ }
387
+ let decodedName;
388
+ try {
389
+ decodedName = atob(encodedName);
390
+ }
391
+ catch {
392
+ res.sendStatus(400);
393
+ return;
394
+ }
395
+ const parsedName = z
396
+ .string()
397
+ .trim()
398
+ .min(1)
399
+ .max(100)
400
+ .safeParse(decodedName);
401
+ if (!parsedName.success) {
402
+ res.sendStatus(400);
403
+ return;
404
+ }
405
+ const server = await db.createServer(parsedName.data, userDetails.userID);
271
406
  res.send(msgpack.encode(server));
272
407
  });
273
408
  api.post("/server/:serverID/invites", protect, async (req, res) => {
@@ -315,11 +450,20 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
315
450
  const serverID = getParam(req, "id");
316
451
  const permissions = await db.retrievePermissions(userDetails.userID, "server");
317
452
  if (hasPermission(permissions, serverID, POWER_LEVELS.DELETE)) {
453
+ const server = await db.retrieveServer(serverID);
454
+ if (!server) {
455
+ res.sendStatus(404);
456
+ return;
457
+ }
458
+ const affectedUsers = await db.retrieveAffectedUsers(serverID);
318
459
  await db.deleteServer(serverID);
460
+ if (server.icon)
461
+ await deleteServerIconFile(server.icon);
462
+ await notifyServerChange(serverID, affectedUsers.map((user) => user.userID));
319
463
  res.sendStatus(200);
320
464
  return;
321
465
  }
322
- res.sendStatus(401);
466
+ res.sendStatus(403);
323
467
  });
324
468
  api.post("/server/:id/channels", protect, async (req, res) => {
325
469
  const userDetails = getUser(req);
@@ -338,14 +482,10 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
338
482
  if (hasPermission(permissions, serverID, POWER_LEVELS.CREATE)) {
339
483
  const channel = await db.createChannel(name, serverID);
340
484
  res.send(msgpack.encode(channel));
341
- const affectedUsers = await db.retrieveAffectedUsers(serverID);
342
- // tell everyone about server change
343
- for (const user of affectedUsers) {
344
- notify(user.userID, "serverChange", crypto.randomUUID(), serverID);
345
- }
485
+ await notifyServerChange(serverID);
346
486
  return;
347
487
  }
348
- res.sendStatus(401);
488
+ res.sendStatus(403);
349
489
  });
350
490
  api.get("/server/:id/channels", protect, async (req, res) => {
351
491
  const serverID = getParam(req, "id");
@@ -359,7 +499,13 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
359
499
  res.sendStatus(401);
360
500
  });
361
501
  api.get("/server/:serverID/emoji", protect, async (req, res) => {
362
- const rows = await db.retrieveEmojiList(getParam(req, "serverID"));
502
+ const serverID = getParam(req, "serverID");
503
+ const permissions = await db.retrievePermissions(getUser(req).userID, "server");
504
+ if (!hasAnyPermission(permissions, serverID)) {
505
+ res.sendStatus(403);
506
+ return;
507
+ }
508
+ const rows = await db.retrieveEmojiList(serverID);
363
509
  res.send(msgpack.encode(rows));
364
510
  });
365
511
  api.get("/server/:serverID/permissions", protect, async (req, res) => {
@@ -373,6 +519,34 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
373
519
  }
374
520
  res.send(msgpack.encode(permissions));
375
521
  });
522
+ api.patch("/channel/:id", protect, async (req, res) => {
523
+ const parsed = channelPayload.safeParse(req.body);
524
+ if (!parsed.success) {
525
+ res.status(400).json({
526
+ error: "Invalid channel update",
527
+ issues: parsed.error.issues,
528
+ });
529
+ return;
530
+ }
531
+ const channelID = getParam(req, "id");
532
+ const channel = await db.retrieveChannel(channelID);
533
+ if (!channel) {
534
+ res.sendStatus(404);
535
+ return;
536
+ }
537
+ const permissions = await db.retrievePermissions(getUser(req).userID, "server");
538
+ if (!hasPermission(permissions, channel.serverID, POWER_LEVELS.DELETE)) {
539
+ res.sendStatus(403);
540
+ return;
541
+ }
542
+ const updated = await db.updateChannel(channelID, parsed.data.name);
543
+ if (!updated) {
544
+ res.sendStatus(404);
545
+ return;
546
+ }
547
+ await notifyServerChange(channel.serverID);
548
+ res.send(msgpack.encode(updated));
549
+ });
376
550
  api.delete("/channel/:id", protect, async (req, res) => {
377
551
  const channelID = getParam(req, "id");
378
552
  const userDetails = getUser(req);
@@ -384,28 +558,72 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
384
558
  const permissions = await db.retrievePermissions(userDetails.userID, "server");
385
559
  for (const permission of permissions) {
386
560
  if (permission.resourceID === channel.serverID &&
387
- permission.powerLevel > 50) {
388
- await db.deleteChannel(channelID);
389
- res.sendStatus(200);
390
- const affectedUsers = await db.retrieveAffectedUsers(channel.serverID);
391
- // tell everyone about server change
392
- for (const user of affectedUsers) {
393
- notify(user.userID, "serverChange", crypto.randomUUID(), channel.serverID);
561
+ permission.powerLevel >= POWER_LEVELS.DELETE) {
562
+ const deleted = await db.deleteChannelIfNotLast(channelID);
563
+ if (!deleted) {
564
+ res.status(409).json({
565
+ error: "A server must have at least one channel.",
566
+ });
567
+ return;
394
568
  }
569
+ await notifyServerChange(channel.serverID);
570
+ res.sendStatus(200);
395
571
  return;
396
572
  }
397
573
  }
398
- res.sendStatus(401);
574
+ res.sendStatus(403);
399
575
  });
400
576
  api.get("/channel/:id", protect, async (req, res) => {
401
577
  const channel = await db.retrieveChannel(getParam(req, "id"));
402
578
  if (channel) {
579
+ const permissions = await db.retrievePermissions(getUser(req).userID, "server");
580
+ if (!hasAnyPermission(permissions, channel.serverID)) {
581
+ return res.sendStatus(403);
582
+ }
403
583
  return res.send(msgpack.encode(channel));
404
584
  }
405
585
  else {
406
586
  return res.sendStatus(404);
407
587
  }
408
588
  });
589
+ api.patch("/permission/:permissionID", protect, async (req, res) => {
590
+ const parsed = permissionRolePayload.safeParse(req.body);
591
+ if (!parsed.success) {
592
+ res.status(400).json({
593
+ error: "Invalid role",
594
+ issues: parsed.error.issues,
595
+ });
596
+ return;
597
+ }
598
+ const target = await db.retrievePermission(getParam(req, "permissionID"));
599
+ if (!target) {
600
+ res.sendStatus(404);
601
+ return;
602
+ }
603
+ if (target.resourceType !== "server") {
604
+ res.sendStatus(400);
605
+ return;
606
+ }
607
+ const actor = getUser(req);
608
+ if (target.userID === actor.userID) {
609
+ res.status(400).json({
610
+ error: "Use another owner to change your role.",
611
+ });
612
+ return;
613
+ }
614
+ const actorPermissions = await db.retrievePermissions(actor.userID, "server");
615
+ if (!hasPermission(actorPermissions, target.resourceID, 100)) {
616
+ res.sendStatus(403);
617
+ return;
618
+ }
619
+ const updated = await db.updatePermissionPowerLevel(target.permissionID, parsed.data.powerLevel);
620
+ if (!updated) {
621
+ res.sendStatus(404);
622
+ return;
623
+ }
624
+ await notifyServerChange(target.resourceID);
625
+ res.send(msgpack.encode(updated));
626
+ });
409
627
  api.delete("/permission/:permissionID", protect, async (req, res) => {
410
628
  const permissionID = getParam(req, "permissionID");
411
629
  const userDetails = getUser(req);
@@ -415,17 +633,29 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
415
633
  return;
416
634
  }
417
635
  const permissions = await db.retrievePermissions(userDetails.userID, permToDelete.resourceType);
418
- for (const perm of permissions) {
419
- if (perm.resourceID === permToDelete.resourceID &&
420
- (perm.userID === userDetails.userID ||
421
- (perm.powerLevel > POWER_LEVELS.DELETE &&
422
- perm.powerLevel > permToDelete.powerLevel))) {
423
- await db.deletePermission(permToDelete.permissionID);
424
- res.sendStatus(200);
425
- return;
636
+ if (canDeletePermission(permissions, userDetails.userID, permToDelete, POWER_LEVELS.DELETE)) {
637
+ if (permToDelete.resourceType === "server" &&
638
+ permToDelete.powerLevel >= 100) {
639
+ const resourcePermissions = await db.retrievePermissionsByResourceID(permToDelete.resourceID);
640
+ const hasAnotherOwner = resourcePermissions.some((permission) => permission.permissionID !== permToDelete.permissionID &&
641
+ permission.powerLevel >= 100);
642
+ if (!hasAnotherOwner) {
643
+ res.status(409).json({
644
+ error: "Delete the server instead of leaving it without an owner.",
645
+ });
646
+ return;
647
+ }
648
+ }
649
+ await db.deletePermission(permToDelete.permissionID);
650
+ if (permToDelete.resourceType === "server") {
651
+ await notifyServerChange(permToDelete.resourceID, [
652
+ permToDelete.userID,
653
+ ]);
426
654
  }
655
+ res.sendStatus(200);
656
+ return;
427
657
  }
428
- res.sendStatus(401);
658
+ res.sendStatus(403);
429
659
  });
430
660
  api.post("/userList/:channelID", protect, async (req, res) => {
431
661
  const userDetails = getUser(req);
@@ -486,6 +716,10 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
486
716
  res.sendStatus(401);
487
717
  return;
488
718
  }
719
+ if (deviceDetails.deviceID !== getParam(req, "id")) {
720
+ res.sendStatus(403);
721
+ return;
722
+ }
489
723
  const inbox = await db.retrieveMail(deviceDetails.deviceID);
490
724
  res.send(msgpack.encode(inbox));
491
725
  });
@@ -511,10 +745,7 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
511
745
  const regKey = await spireXSignOpenAsync(signed, XUtils.decodeHex(device.signKey));
512
746
  if (regKey &&
513
747
  tokenValidator(uuidStringify(regKey), TokenScopes.Connect)) {
514
- const token = jwt.sign({ device }, getJwtSecret(), {
515
- expiresIn: JWT_EXPIRY,
516
- });
517
- jwt.verify(token, getJwtSecret());
748
+ const token = signAuthJwt({ device, scope: "device" }, JWT_EXPIRY);
518
749
  res.send(msgpack.encode({ deviceToken: token }));
519
750
  }
520
751
  else {
@@ -527,6 +758,10 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
527
758
  res.sendStatus(401);
528
759
  return;
529
760
  }
761
+ if (deviceDetails.deviceID !== getParam(req, "id")) {
762
+ res.sendStatus(403);
763
+ return;
764
+ }
530
765
  const count = await db.getOTKCount(deviceDetails.deviceID);
531
766
  res.send(msgpack.encode({ count }));
532
767
  });
@@ -560,7 +795,7 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
560
795
  res.status(400).send({ error: "Prekey deviceID mismatch." });
561
796
  return;
562
797
  }
563
- if (!(await verifyPreKeyWsSignature(preKey, device.signKey))) {
798
+ if (!(await verifyPreKeyWsSignature(preKey, device.signKey, "signed"))) {
564
799
  res.status(401).send({ error: "Prekey signature invalid." });
565
800
  return;
566
801
  }
@@ -601,7 +836,7 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
601
836
  res.status(400).send({ error: "OTK deviceID mismatch." });
602
837
  return;
603
838
  }
604
- if (!(await verifyPreKeyWsSignature(submittedOTK, device.signKey))) {
839
+ if (!(await verifyPreKeyWsSignature(submittedOTK, device.signKey, "one-time"))) {
605
840
  res.status(401).send({ error: "OTK signature invalid." });
606
841
  return;
607
842
  }
@@ -611,6 +846,15 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
611
846
  });
612
847
  api.get("/emoji/:emojiID/details", protect, async (req, res) => {
613
848
  const emoji = await db.retrieveEmoji(getParam(req, "emojiID"));
849
+ if (!emoji) {
850
+ res.sendStatus(404);
851
+ return;
852
+ }
853
+ const permissions = await db.retrievePermissions(getUser(req).userID, "server");
854
+ if (!hasAnyPermission(permissions, emoji.owner)) {
855
+ res.sendStatus(403);
856
+ return;
857
+ }
614
858
  res.send(msgpack.encode(emoji));
615
859
  });
616
860
  api.get("/emoji/:emojiID", protect, async (req, res) => {
@@ -619,6 +863,16 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
619
863
  res.sendStatus(400);
620
864
  return;
621
865
  }
866
+ const emoji = await db.retrieveEmoji(safeId.data);
867
+ if (!emoji) {
868
+ res.sendStatus(404);
869
+ return;
870
+ }
871
+ const permissions = await db.retrievePermissions(getUser(req).userID, "server");
872
+ if (!hasAnyPermission(permissions, emoji.owner)) {
873
+ res.sendStatus(403);
874
+ return;
875
+ }
622
876
  const filePath = "./emoji/" + safeId.data;
623
877
  const typeDetails = await fileTypeFromFile(filePath).catch(() => null);
624
878
  if (!typeDetails) {
@@ -634,7 +888,7 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
634
888
  });
635
889
  stream.pipe(res);
636
890
  });
637
- api.post("/emoji/:serverID/json", protect, async (req, res) => {
891
+ api.post("/emoji/:serverID/json", uploadLimiter, protect, async (req, res) => {
638
892
  const parsedPayload = emojiPayload.safeParse(req.body);
639
893
  if (!parsedPayload.success) {
640
894
  res.status(400).json({
@@ -654,7 +908,14 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
654
908
  res.sendStatus(400);
655
909
  return;
656
910
  }
657
- const buf = Buffer.from(XUtils.decodeBase64(payload.file));
911
+ let buf;
912
+ try {
913
+ buf = Buffer.from(XUtils.decodeBase64(payload.file));
914
+ }
915
+ catch {
916
+ res.status(400).send({ error: "Emoji must be valid base64." });
917
+ return;
918
+ }
658
919
  const serverEntry = await db.retrieveServer(getParam(req, "serverID"));
659
920
  const permissionList = await db.retrievePermissionsByResourceID(getParam(req, "serverID"));
660
921
  if (!userHasPermission(permissionList, userDetails.userID, POWER_LEVELS.EMOJI)) {
@@ -697,7 +958,7 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
697
958
  res.sendStatus(500);
698
959
  }
699
960
  });
700
- api.post("/emoji/:serverID", protect, multer().single("emoji"), async (req, res) => {
961
+ api.post("/emoji/:serverID", uploadLimiter, protect, emojiUpload.single("emoji"), async (req, res) => {
701
962
  const parsedPayload = emojiPayload.safeParse(req.body);
702
963
  if (!parsedPayload.success) {
703
964
  res.status(400).json({
@@ -774,9 +1035,11 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
774
1035
  api.use(entitlementRouter);
775
1036
  api.use(passkeyRouter);
776
1037
  api.use(passkeyDeviceRouter);
1038
+ api.use(passwordRouter);
777
1039
  api.use("/user", userRouter);
778
1040
  api.use("/file", fileRouter);
779
1041
  api.use("/avatar", avatarRouter);
1042
+ api.use("/server-icon", serverIconRouter);
780
1043
  api.use("/invite", inviteRouter);
781
1044
  setupDocs(api);
782
1045
  // Central error handler MUST be last. Handles both thrown AppErrors