@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
@@ -58,6 +58,7 @@ export const getPasskeyDeviceRouter = (db, notify, disconnectDevices) => {
58
58
  // "I lost my phone, sign in with the passkey, wipe the
59
59
  // old device, then recover onto a new one."
60
60
  await db.deleteDevice(deviceID);
61
+ disconnectDevices?.([deviceID]);
61
62
  // Tell whoever's online that the device-list shape
62
63
  // changed; clients use this to refresh the Settings →
63
64
  // Devices view in real time.
@@ -1 +1 @@
1
- {"version":3,"file":"passkeyDevices.js","sourceRoot":"","sources":["../../src/server/passkeyDevices.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"passkeyDevices.js","sourceRoot":"","sources":["../../src/server/passkeyDevices.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,7 @@
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
+ import type { Database } from "../Database.ts";
7
+ export declare const getPasswordRouter: (db: Database) => import("express-serve-static-core").Router;
@@ -0,0 +1,58 @@
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
+ import express from "express";
7
+ import { PasswordUpdatePayloadSchema } from "@vex-chat/types";
8
+ import { hashPasswordArgon2, validateAccountPassword, verifyPassword, } from "../Database.js";
9
+ import { AppError } from "./errors.js";
10
+ import { passwordUpdateLimiter } from "./rateLimit.js";
11
+ import { getParam, getUser } from "./utils.js";
12
+ import { protectAnyAuth } from "./index.js";
13
+ export const getPasswordRouter = (db) => {
14
+ const router = express.Router();
15
+ router.patch("/user/:id/password", protectAnyAuth, passwordUpdateLimiter, async (req, res) => {
16
+ const authenticatedUser = getUser(req);
17
+ const userID = getParam(req, "id");
18
+ if (authenticatedUser.userID !== userID) {
19
+ throw new AppError(403, "Not authorized for this account");
20
+ }
21
+ const payload = PasswordUpdatePayloadSchema.parse(req.body);
22
+ const user = await db.retrieveUser(userID);
23
+ if (!user) {
24
+ throw new AppError(404, "Account not found");
25
+ }
26
+ const passwordError = validateAccountPassword(payload.newPassword, user.username);
27
+ if (passwordError) {
28
+ throw new AppError(400, passwordError);
29
+ }
30
+ if (req.passkey) {
31
+ const passkey = await db.retrievePasskeyInternal(req.passkey.passkeyID);
32
+ if (!passkey || passkey.userID !== userID) {
33
+ throw new AppError(401, "Passkey authentication required");
34
+ }
35
+ }
36
+ else {
37
+ if (!req.device ||
38
+ req.device.owner !== userID ||
39
+ !payload.currentPassword) {
40
+ throw new AppError(401, "Current-password authentication required");
41
+ }
42
+ const current = await verifyPassword(payload.currentPassword, user);
43
+ if (!current.valid) {
44
+ throw new AppError(401, "Current password is incorrect");
45
+ }
46
+ }
47
+ const reused = await verifyPassword(payload.newPassword, user);
48
+ if (reused.valid) {
49
+ throw new AppError(409, "New password must be different from the current password");
50
+ }
51
+ const passwordHash = await hashPasswordArgon2(payload.newPassword);
52
+ await db.rehashPassword(userID, passwordHash);
53
+ res.setHeader("Cache-Control", "no-store");
54
+ res.sendStatus(204);
55
+ });
56
+ return router;
57
+ };
58
+ //# sourceMappingURL=password.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"password.js","sourceRoot":"","sources":["../../src/server/password.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,OAAO,OAAO,MAAM,SAAS,CAAC;AAE9B,OAAO,EAAE,2BAA2B,EAAE,MAAM,iBAAiB,CAAC;AAE9D,OAAO,EACH,kBAAkB,EAClB,uBAAuB,EACvB,cAAc,GACjB,MAAM,gBAAgB,CAAC;AAExB,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AACvC,OAAO,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AACvD,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAE/C,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAE5C,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,EAAY,EAAE,EAAE;IAC9C,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAEhC,MAAM,CAAC,KAAK,CACR,oBAAoB,EACpB,cAAc,EACd,qBAAqB,EACrB,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,EAAE;QACf,MAAM,iBAAiB,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;QACvC,MAAM,MAAM,GAAG,QAAQ,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;QACnC,IAAI,iBAAiB,CAAC,MAAM,KAAK,MAAM,EAAE,CAAC;YACtC,MAAM,IAAI,QAAQ,CAAC,GAAG,EAAE,iCAAiC,CAAC,CAAC;QAC/D,CAAC;QAED,MAAM,OAAO,GAAG,2BAA2B,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAC5D,MAAM,IAAI,GAAG,MAAM,EAAE,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC;QAC3C,IAAI,CAAC,IAAI,EAAE,CAAC;YACR,MAAM,IAAI,QAAQ,CAAC,GAAG,EAAE,mBAAmB,CAAC,CAAC;QACjD,CAAC;QAED,MAAM,aAAa,GAAG,uBAAuB,CACzC,OAAO,CAAC,WAAW,EACnB,IAAI,CAAC,QAAQ,CAChB,CAAC;QACF,IAAI,aAAa,EAAE,CAAC;YAChB,MAAM,IAAI,QAAQ,CAAC,GAAG,EAAE,aAAa,CAAC,CAAC;QAC3C,CAAC;QAED,IAAI,GAAG,CAAC,OAAO,EAAE,CAAC;YACd,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,uBAAuB,CAC5C,GAAG,CAAC,OAAO,CAAC,SAAS,CACxB,CAAC;YACF,IAAI,CAAC,OAAO,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM,EAAE,CAAC;gBACxC,MAAM,IAAI,QAAQ,CAAC,GAAG,EAAE,iCAAiC,CAAC,CAAC;YAC/D,CAAC;QACL,CAAC;aAAM,CAAC;YACJ,IACI,CAAC,GAAG,CAAC,MAAM;gBACX,GAAG,CAAC,MAAM,CAAC,KAAK,KAAK,MAAM;gBAC3B,CAAC,OAAO,CAAC,eAAe,EAC1B,CAAC;gBACC,MAAM,IAAI,QAAQ,CACd,GAAG,EACH,0CAA0C,CAC7C,CAAC;YACN,CAAC;YACD,MAAM,OAAO,GAAG,MAAM,cAAc,CAChC,OAAO,CAAC,eAAe,EACvB,IAAI,CACP,CAAC;YACF,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;gBACjB,MAAM,IAAI,QAAQ,CAAC,GAAG,EAAE,+BAA+B,CAAC,CAAC;YAC7D,CAAC;QACL,CAAC;QAED,MAAM,MAAM,GAAG,MAAM,cAAc,CAAC,OAAO,CAAC,WAAW,EAAE,IAAI,CAAC,CAAC;QAC/D,IAAI,MAAM,CAAC,KAAK,EAAE,CAAC;YACf,MAAM,IAAI,QAAQ,CACd,GAAG,EACH,0DAA0D,CAC7D,CAAC;QACN,CAAC;QAED,MAAM,YAAY,GAAG,MAAM,kBAAkB,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;QACnE,MAAM,EAAE,CAAC,cAAc,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;QAC9C,GAAG,CAAC,SAAS,CAAC,eAAe,EAAE,UAAU,CAAC,CAAC;QAC3C,GAAG,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC;IACxB,CAAC,CACJ,CAAC;IAEF,OAAO,MAAM,CAAC;AAClB,CAAC,CAAC"}
@@ -4,6 +4,7 @@
4
4
  * Commercial licenses available at vex.wtf
5
5
  */
6
6
  import type { Permission } from "@vex-chat/types";
7
+ export declare function canDeletePermission(permissions: Permission[], actorUserID: string, target: Permission, adminPowerLevel: number): boolean;
7
8
  /**
8
9
  * Check whether any permission in the list covers the given resource
9
10
  * (any power level).
@@ -3,6 +3,15 @@
3
3
  * Licensed under AGPL-3.0. See LICENSE for details.
4
4
  * Commercial licenses available at vex.wtf
5
5
  */
6
+ export function canDeletePermission(permissions, actorUserID, target, adminPowerLevel) {
7
+ if (target.userID === actorUserID) {
8
+ return true;
9
+ }
10
+ return permissions.some((permission) => permission.userID === actorUserID &&
11
+ permission.resourceID === target.resourceID &&
12
+ permission.powerLevel >= adminPowerLevel &&
13
+ permission.powerLevel > target.powerLevel);
14
+ }
6
15
  /**
7
16
  * Check whether any permission in the list covers the given resource
8
17
  * (any power level).
@@ -15,13 +24,13 @@ export function hasAnyPermission(permissions, resourceID) {
15
24
  * on the given resource.
16
25
  */
17
26
  export function hasPermission(permissions, resourceID, minPowerLevel) {
18
- return permissions.some((p) => p.resourceID === resourceID && p.powerLevel > minPowerLevel);
27
+ return permissions.some((p) => p.resourceID === resourceID && p.powerLevel >= minPowerLevel);
19
28
  }
20
29
  /**
21
30
  * Check whether any permission in the list grants at least `minPowerLevel`
22
31
  * on the given resource AND belongs to the specified user.
23
32
  */
24
33
  export function userHasPermission(permissions, userID, minPowerLevel) {
25
- return permissions.some((p) => p.userID === userID && p.powerLevel > minPowerLevel);
34
+ return permissions.some((p) => p.userID === userID && p.powerLevel >= minPowerLevel);
26
35
  }
27
36
  //# sourceMappingURL=permissions.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"permissions.js","sourceRoot":"","sources":["../../src/server/permissions.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAC5B,WAAyB,EACzB,UAAkB;IAElB,OAAO,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,UAAU,KAAK,UAAU,CAAC,CAAC;AAChE,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,aAAa,CACzB,WAAyB,EACzB,UAAkB,EAClB,aAAqB;IAErB,OAAO,WAAW,CAAC,IAAI,CACnB,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,UAAU,KAAK,UAAU,IAAI,CAAC,CAAC,UAAU,GAAG,aAAa,CACrE,CAAC;AACN,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,iBAAiB,CAC7B,WAAyB,EACzB,MAAc,EACd,aAAqB;IAErB,OAAO,WAAW,CAAC,IAAI,CACnB,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,KAAK,MAAM,IAAI,CAAC,CAAC,UAAU,GAAG,aAAa,CAC7D,CAAC;AACN,CAAC"}
1
+ {"version":3,"file":"permissions.js","sourceRoot":"","sources":["../../src/server/permissions.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,MAAM,UAAU,mBAAmB,CAC/B,WAAyB,EACzB,WAAmB,EACnB,MAAkB,EAClB,eAAuB;IAEvB,IAAI,MAAM,CAAC,MAAM,KAAK,WAAW,EAAE,CAAC;QAChC,OAAO,IAAI,CAAC;IAChB,CAAC;IACD,OAAO,WAAW,CAAC,IAAI,CACnB,CAAC,UAAU,EAAE,EAAE,CACX,UAAU,CAAC,MAAM,KAAK,WAAW;QACjC,UAAU,CAAC,UAAU,KAAK,MAAM,CAAC,UAAU;QAC3C,UAAU,CAAC,UAAU,IAAI,eAAe;QACxC,UAAU,CAAC,UAAU,GAAG,MAAM,CAAC,UAAU,CAChD,CAAC;AACN,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAC5B,WAAyB,EACzB,UAAkB;IAElB,OAAO,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,UAAU,KAAK,UAAU,CAAC,CAAC;AAChE,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,aAAa,CACzB,WAAyB,EACzB,UAAkB,EAClB,aAAqB;IAErB,OAAO,WAAW,CAAC,IAAI,CACnB,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,UAAU,KAAK,UAAU,IAAI,CAAC,CAAC,UAAU,IAAI,aAAa,CACtE,CAAC;AACN,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,iBAAiB,CAC7B,WAAyB,EACzB,MAAc,EACd,aAAqB;IAErB,OAAO,WAAW,CAAC,IAAI,CACnB,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,KAAK,MAAM,IAAI,CAAC,CAAC,UAAU,IAAI,aAAa,CAC9D,CAAC;AACN,CAAC"}
@@ -34,6 +34,17 @@ export declare const globalLimiter: import("express-rate-limit").RateLimitReques
34
34
  * (CWE-307) without harming UX.
35
35
  */
36
36
  export declare const authLimiter: import("express-rate-limit").RateLimitRequestHandler;
37
+ /**
38
+ * Cross-IP password defense. This account bucket complements the IP bucket so
39
+ * a distributed password spray cannot make unlimited guesses at one account.
40
+ */
41
+ export declare const accountAuthLimiter: import("express-rate-limit").RateLimitRequestHandler;
42
+ /**
43
+ * Limit repeated password replacement attempts per authenticated account.
44
+ * This is intentionally tighter than sign-in because each failed request has
45
+ * already passed bearer/device validation and still consumes an Argon2 check.
46
+ */
47
+ export declare const passwordUpdateLimiter: import("express-rate-limit").RateLimitRequestHandler;
37
48
  /**
38
49
  * Upload endpoint limiter. Applied per-route to /file and /avatar
39
50
  * POSTs, BEFORE multer parses the multipart body. Caps the number of
@@ -59,14 +59,13 @@ function disableRateLimitsByEnv() {
59
59
  * versions silently collapsed all IPv4-mapped IPv6 addresses into
60
60
  * one bucket, which let attackers bypass the limiter).
61
61
  *
62
- * `trust proxy` must be set on the Express app (see Spire.ts) so
63
- * `req.ip` returns the real client address, not the immediate proxy.
62
+ * `trust proxy` must match the real deployment topology (see Spire.ts) so
63
+ * `req.ip` cannot be spoofed and does not collapse all traffic onto a proxy.
64
64
  */
65
65
  /**
66
66
  * Bucket requests by the real client IP, IPv6-safe.
67
67
  *
68
- * `req.ip` is already populated correctly because `trust proxy` is
69
- * set to `1` in Spire's constructor. We still run it through
68
+ * `req.ip` reflects the explicitly configured proxy hop count. We still run it through
70
69
  * `ipKeyGenerator` so IPv4-mapped IPv6 (`::ffff:1.2.3.4`) doesn't
71
70
  * collide with unrelated IPv6 addresses in the same /56.
72
71
  */
@@ -104,6 +103,46 @@ export const authLimiter = rateLimit({
104
103
  standardHeaders: "draft-7",
105
104
  windowMs: 15 * 60 * 1000,
106
105
  });
106
+ /**
107
+ * Cross-IP password defense. This account bucket complements the IP bucket so
108
+ * a distributed password spray cannot make unlimited guesses at one account.
109
+ */
110
+ export const accountAuthLimiter = rateLimit({
111
+ keyGenerator: (req) => {
112
+ const body = req.body;
113
+ const username = typeof body === "object" &&
114
+ body !== null &&
115
+ "username" in body &&
116
+ typeof body.username === "string"
117
+ ? body.username.trim().toLowerCase().slice(0, 64)
118
+ : "";
119
+ return username.length > 0
120
+ ? `account:${username}`
121
+ : `invalid:${keyByIp(req)}`;
122
+ },
123
+ legacyHeaders: false,
124
+ limit: 100,
125
+ skip: devApiKeySkipsRateLimits,
126
+ skipSuccessfulRequests: true,
127
+ standardHeaders: "draft-7",
128
+ windowMs: 15 * 60 * 1000,
129
+ });
130
+ /**
131
+ * Limit repeated password replacement attempts per authenticated account.
132
+ * This is intentionally tighter than sign-in because each failed request has
133
+ * already passed bearer/device validation and still consumes an Argon2 check.
134
+ */
135
+ export const passwordUpdateLimiter = rateLimit({
136
+ keyGenerator: (req) => req.user?.userID
137
+ ? `password:${req.user.userID}`
138
+ : `invalid:${keyByIp(req)}`,
139
+ legacyHeaders: false,
140
+ limit: 10,
141
+ skip: devApiKeySkipsRateLimits,
142
+ skipSuccessfulRequests: true,
143
+ standardHeaders: "draft-7",
144
+ windowMs: 15 * 60 * 1000,
145
+ });
107
146
  /**
108
147
  * Upload endpoint limiter. Applied per-route to /file and /avatar
109
148
  * POSTs, BEFORE multer parses the multipart body. Caps the number of
@@ -1 +1 @@
1
- {"version":3,"file":"rateLimit.js","sourceRoot":"","sources":["../../src/server/rateLimit.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"rateLimit.js","sourceRoot":"","sources":["../../src/server/rateLimit.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,10 @@
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
+ import type { Database } from "../Database.ts";
7
+ type NotifyServerChange = (serverID: string) => Promise<void>;
8
+ export declare function deleteServerIconFile(iconID: string): Promise<void>;
9
+ export declare const getServerIconRouter: (db: Database, notifyServerChange: NotifyServerChange) => import("express-serve-static-core").Router;
10
+ export {};
@@ -0,0 +1,158 @@
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
+ import * as fs from "node:fs";
7
+ import * as fsp from "node:fs/promises";
8
+ import express from "express";
9
+ import { XUtils } from "@vex-chat/crypto";
10
+ import { fileTypeFromBuffer, fileTypeFromFile } from "file-type";
11
+ import multer from "multer";
12
+ import { z } from "zod/v4";
13
+ import { POWER_LEVELS } from "../ClientManager.js";
14
+ import { msgpack } from "../utils/msgpack.js";
15
+ import { hasPermission } from "./permissions.js";
16
+ import { uploadLimiter } from "./rateLimit.js";
17
+ import { getParam, getUser } from "./utils.js";
18
+ import { ALLOWED_IMAGE_TYPES, protect } from "./index.js";
19
+ const SERVER_ICON_DIR = "server-icons";
20
+ const MAX_SERVER_ICON_BYTES = 5 * 1024 * 1024;
21
+ const safePathParam = z.string().regex(/^[a-zA-Z0-9._-]+$/);
22
+ const serverIconUpload = multer({
23
+ limits: { fields: 1, files: 1, fileSize: MAX_SERVER_ICON_BYTES, parts: 2 },
24
+ });
25
+ const serverIconJsonPayload = z.object({
26
+ file: z
27
+ .string()
28
+ .min(1)
29
+ .max(Math.ceil((MAX_SERVER_ICON_BYTES * 4) / 3) + 4),
30
+ });
31
+ export async function deleteServerIconFile(iconID) {
32
+ const safeID = safePathParam.safeParse(iconID);
33
+ if (!safeID.success)
34
+ return;
35
+ await fsp
36
+ .unlink(`${SERVER_ICON_DIR}/${safeID.data}`)
37
+ .catch(() => undefined);
38
+ }
39
+ export const getServerIconRouter = (db, notifyServerChange) => {
40
+ const router = express.Router();
41
+ router.get("/:iconID", async (req, res) => {
42
+ const safeID = safePathParam.safeParse(getParam(req, "iconID"));
43
+ if (!safeID.success) {
44
+ res.sendStatus(400);
45
+ return;
46
+ }
47
+ const filePath = `${SERVER_ICON_DIR}/${safeID.data}`;
48
+ const typeDetails = await fileTypeFromFile(filePath).catch(() => null);
49
+ if (!typeDetails) {
50
+ res.sendStatus(404);
51
+ return;
52
+ }
53
+ res.set("Content-Type", typeDetails.mime);
54
+ res.set("Cache-Control", "public, max-age=31536000, immutable");
55
+ res.set("Cross-Origin-Resource-Policy", "cross-origin");
56
+ const stream = fs.createReadStream(filePath);
57
+ stream.on("error", () => {
58
+ if (!res.headersSent)
59
+ res.sendStatus(500);
60
+ else
61
+ res.destroy();
62
+ });
63
+ stream.pipe(res);
64
+ });
65
+ router.post("/:serverID/json", uploadLimiter, protect, async (req, res) => {
66
+ const parsed = serverIconJsonPayload.safeParse(req.body);
67
+ if (!parsed.success) {
68
+ res.status(400).json({
69
+ error: "Invalid server icon payload",
70
+ issues: parsed.error.issues,
71
+ });
72
+ return;
73
+ }
74
+ let buffer;
75
+ try {
76
+ buffer = Buffer.from(XUtils.decodeBase64(parsed.data.file));
77
+ }
78
+ catch {
79
+ res.status(400).json({ error: "Icon must be valid base64." });
80
+ return;
81
+ }
82
+ await saveIcon(req, res, db, notifyServerChange, buffer);
83
+ });
84
+ router.post("/:serverID", uploadLimiter, protect, serverIconUpload.single("icon"), async (req, res) => {
85
+ if (!req.file) {
86
+ res.sendStatus(400);
87
+ return;
88
+ }
89
+ await saveIcon(req, res, db, notifyServerChange, req.file.buffer);
90
+ });
91
+ router.delete("/:serverID", protect, async (req, res) => {
92
+ const serverID = getParam(req, "serverID");
93
+ if (!(await canManageServer(db, getUser(req).userID, serverID))) {
94
+ res.sendStatus(403);
95
+ return;
96
+ }
97
+ const existing = await db.retrieveServer(serverID);
98
+ if (!existing) {
99
+ res.sendStatus(404);
100
+ return;
101
+ }
102
+ const updated = await db.updateServer(serverID, { icon: null });
103
+ if (!updated) {
104
+ res.sendStatus(404);
105
+ return;
106
+ }
107
+ if (existing.icon)
108
+ await deleteServerIconFile(existing.icon);
109
+ await notifyServerChange(serverID);
110
+ res.send(msgpack.encode(updated));
111
+ });
112
+ return router;
113
+ };
114
+ async function canManageServer(db, userID, serverID) {
115
+ const permissions = await db.retrievePermissions(userID, "server");
116
+ return hasPermission(permissions, serverID, POWER_LEVELS.CREATE);
117
+ }
118
+ async function saveIcon(req, res, db, notifyServerChange, buffer) {
119
+ const serverID = getParam(req, "serverID");
120
+ if (!(await canManageServer(db, getUser(req).userID, serverID))) {
121
+ res.sendStatus(403);
122
+ return;
123
+ }
124
+ if (buffer.byteLength > MAX_SERVER_ICON_BYTES) {
125
+ res.sendStatus(413);
126
+ return;
127
+ }
128
+ const typeDetails = await fileTypeFromBuffer(buffer);
129
+ if (!ALLOWED_IMAGE_TYPES.includes(typeDetails?.mime ?? "")) {
130
+ res.status(400).json({
131
+ error: "Unsupported icon type. Use JPEG, PNG, GIF, APNG, AVIF, or WebP.",
132
+ });
133
+ return;
134
+ }
135
+ const existing = await db.retrieveServer(serverID);
136
+ if (!existing) {
137
+ res.sendStatus(404);
138
+ return;
139
+ }
140
+ const iconID = crypto.randomUUID();
141
+ const filePath = `${SERVER_ICON_DIR}/${iconID}`;
142
+ try {
143
+ await fsp.writeFile(filePath, buffer, { flag: "wx" });
144
+ const updated = await db.updateServer(serverID, { icon: iconID });
145
+ if (!updated) {
146
+ throw new Error("Server disappeared while its icon was updating.");
147
+ }
148
+ if (existing.icon)
149
+ await deleteServerIconFile(existing.icon);
150
+ await notifyServerChange(serverID);
151
+ res.send(msgpack.encode(updated));
152
+ }
153
+ catch (error) {
154
+ await fsp.unlink(filePath).catch(() => undefined);
155
+ throw error;
156
+ }
157
+ }
158
+ //# sourceMappingURL=serverIcon.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serverIcon.js","sourceRoot":"","sources":["../../src/server/serverIcon.ts"],"names":[],"mappings":"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"}
@@ -56,4 +56,4 @@ export declare function resolveDeviceEnrollmentRequest(args: {
56
56
  kind: "err";
57
57
  status: number;
58
58
  }>;
59
- export declare const getUserRouter: (db: Database, tokenValidator: (key: string, scope: TokenScopes) => boolean, notify: (userID: string, event: string, transmissionID: string, data?: unknown, deviceID?: string) => void) => import("express-serve-static-core").Router;
59
+ export declare const getUserRouter: (db: Database, tokenValidator: (key: string, scope: TokenScopes) => boolean, notify: (userID: string, event: string, transmissionID: string, data?: unknown, deviceID?: string) => void, disconnectDevices?: (deviceIDs: string[]) => void) => import("express-serve-static-core").Router;
@@ -10,7 +10,9 @@ import { DevicePayloadSchema, TokenScopes } from "@vex-chat/types";
10
10
  import { generateRegistrationOptions, verifyRegistrationResponse, } from "@simplewebauthn/server";
11
11
  import { stringify } from "uuid";
12
12
  import { z } from "zod/v4";
13
+ import { MAX_ACTIVE_DEVICES_PER_USER } from "../Database.js";
13
14
  import { msgpack } from "../utils/msgpack.js";
15
+ import { verifyDevicePayloadPreKeySignature } from "../utils/preKeySignature.js";
14
16
  import { spireXSignOpenAsync } from "../utils/spireXSignOpenAsync.js";
15
17
  import { AppError } from "./errors.js";
16
18
  import { censorUser, getParam, getUser } from "./utils.js";
@@ -312,7 +314,7 @@ async function tryGetVerifiedPendingEnrollmentForSigned(req, signed) {
312
314
  }
313
315
  return { ok: true, pending };
314
316
  }
315
- export const getUserRouter = (db, tokenValidator, notify) => {
317
+ export const getUserRouter = (db, tokenValidator, notify, disconnectDevices) => {
316
318
  const router = express.Router();
317
319
  // Unauthenticated status poll for the *requesting* device on a pending
318
320
  // device-enrollment request. The new device cannot use the protected
@@ -390,7 +392,7 @@ export const getUserRouter = (db, tokenValidator, notify) => {
390
392
  authenticatorSelection: {
391
393
  requireResidentKey: false,
392
394
  residentKey: "preferred",
393
- userVerification: "preferred",
395
+ userVerification: "required",
394
396
  },
395
397
  excludeCredentials: [],
396
398
  rpID,
@@ -467,16 +469,18 @@ export const getUserRouter = (db, tokenValidator, notify) => {
467
469
  expectedChallenge: passkeyRegistration.challenge,
468
470
  expectedOrigin,
469
471
  expectedRPID: rpID,
470
- requireUserVerification: false,
472
+ requireUserVerification: true,
471
473
  // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- structurally validated by simplewebauthn below
472
474
  response: parsed.data
473
475
  .response,
474
476
  });
475
477
  }
476
478
  catch (err) {
479
+ const requestId = crypto.randomUUID();
477
480
  const message = err instanceof Error ? err.message : String(err);
481
+ console.warn(`[spire] pending-device WebAuthn registration failed requestId=${requestId} message=${message}`);
478
482
  res.status(400).send({
479
- error: "Passkey attestation invalid: " + message,
483
+ error: "Passkey attestation could not be verified.",
480
484
  });
481
485
  return;
482
486
  }
@@ -589,16 +593,28 @@ export const getUserRouter = (db, tokenValidator, notify) => {
589
593
  });
590
594
  router.get("/:id/permissions", protect, async (req, res) => {
591
595
  const userDetails = getUser(req);
596
+ if (getParam(req, "id") !== userDetails.userID) {
597
+ res.sendStatus(403);
598
+ return;
599
+ }
592
600
  const permissions = await db.retrievePermissions(userDetails.userID, "all");
593
601
  res.send(msgpack.encode(permissions));
594
602
  });
595
603
  router.get("/:id/servers", protect, async (req, res) => {
596
604
  const userDetails = getUser(req);
605
+ if (getParam(req, "id") !== userDetails.userID) {
606
+ res.sendStatus(403);
607
+ return;
608
+ }
597
609
  const servers = await db.retrieveServers(userDetails.userID);
598
610
  res.send(msgpack.encode(servers));
599
611
  });
600
612
  router.get("/:id/servers/bootstrap", protect, async (req, res) => {
601
613
  const userDetails = getUser(req);
614
+ if (getParam(req, "id") !== userDetails.userID) {
615
+ res.sendStatus(403);
616
+ return;
617
+ }
602
618
  const payload = await db.retrieveServerChannelBootstrap(userDetails.userID);
603
619
  res.send(msgpack.encode(payload));
604
620
  });
@@ -609,8 +625,12 @@ export const getUserRouter = (db, tokenValidator, notify) => {
609
625
  return;
610
626
  }
611
627
  const userDetails = getUser(req);
612
- if (userDetails.userID !== device.owner) {
613
- res.sendStatus(401);
628
+ const currentDevice = req.device;
629
+ if (getParam(req, "userID") !== userDetails.userID ||
630
+ userDetails.userID !== device.owner ||
631
+ !currentDevice ||
632
+ currentDevice.owner !== userDetails.userID) {
633
+ res.sendStatus(403);
614
634
  return;
615
635
  }
616
636
  const deviceList = await db.retrieveUserDeviceList([
@@ -623,6 +643,7 @@ export const getUserRouter = (db, tokenValidator, notify) => {
623
643
  return;
624
644
  }
625
645
  await db.deleteDevice(device.deviceID);
646
+ disconnectDevices?.([device.deviceID]);
626
647
  res.sendStatus(200);
627
648
  });
628
649
  router.post("/:id/devices", protect, async (req, res) => {
@@ -652,9 +673,21 @@ export const getUserRouter = (db, tokenValidator, notify) => {
652
673
  return;
653
674
  }
654
675
  if (tokenValidator(stringify(token), TokenScopes.Device)) {
676
+ if (!(await verifyDevicePayloadPreKeySignature(deviceData))) {
677
+ res.status(400).send({
678
+ error: "Signed prekey signature is invalid.",
679
+ });
680
+ return;
681
+ }
655
682
  const userDevices = await db.retrieveUserDeviceList([
656
683
  userDetails.userID,
657
684
  ]);
685
+ if (userDevices.length >= MAX_ACTIVE_DEVICES_PER_USER) {
686
+ res.status(409).send({
687
+ error: `Each account is limited to ${String(MAX_ACTIVE_DEVICES_PER_USER)} active devices. Remove an old device before adding another.`,
688
+ });
689
+ return;
690
+ }
658
691
  if (userDevices.length === 0) {
659
692
  try {
660
693
  const device = await db.createDevice(userDetails.userID, deviceData);
@@ -765,9 +798,7 @@ export const getUserRouter = (db, tokenValidator, notify) => {
765
798
  });
766
799
  return;
767
800
  }
768
- // New clients put the passkey proof on the requesting device.
769
- // Older clients may still satisfy this with an approval-side passkey.
770
- const approvedByPasskeyID = pending.requesterPasskeyID ?? req.passkey?.passkeyID;
801
+ const approvedByPasskeyID = pending.requesterPasskeyID;
771
802
  if (approvedByPasskeyID) {
772
803
  const passkey = await db.retrievePasskeyInternal(approvedByPasskeyID);
773
804
  if (!passkey || passkey.userID !== userID) {