@vex-chat/spire 1.0.0 → 1.0.1

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 (65) hide show
  1. package/README.md +37 -38
  2. package/dist/ClientManager.d.ts +1 -3
  3. package/dist/ClientManager.js +6 -48
  4. package/dist/ClientManager.js.map +1 -1
  5. package/dist/Database.d.ts +25 -5
  6. package/dist/Database.js +54 -16
  7. package/dist/Database.js.map +1 -1
  8. package/dist/Spire.d.ts +0 -3
  9. package/dist/Spire.js +35 -73
  10. package/dist/Spire.js.map +1 -1
  11. package/dist/__tests__/Database.spec.js +0 -14
  12. package/dist/__tests__/Database.spec.js.map +1 -1
  13. package/dist/db/schema.d.ts +1 -0
  14. package/dist/migrations/2026-04-14_argon2id-password-hashing.d.ts +3 -0
  15. package/dist/migrations/2026-04-14_argon2id-password-hashing.js +12 -0
  16. package/dist/migrations/2026-04-14_argon2id-password-hashing.js.map +1 -0
  17. package/dist/run.js +0 -1
  18. package/dist/run.js.map +1 -1
  19. package/dist/server/avatar.d.ts +1 -3
  20. package/dist/server/avatar.js +7 -11
  21. package/dist/server/avatar.js.map +1 -1
  22. package/dist/server/errors.d.ts +3 -6
  23. package/dist/server/errors.js +2 -28
  24. package/dist/server/errors.js.map +1 -1
  25. package/dist/server/file.d.ts +1 -2
  26. package/dist/server/file.js +5 -7
  27. package/dist/server/file.js.map +1 -1
  28. package/dist/server/index.d.ts +1 -2
  29. package/dist/server/index.js +22 -33
  30. package/dist/server/index.js.map +1 -1
  31. package/dist/server/invite.d.ts +1 -2
  32. package/dist/server/invite.js +1 -1
  33. package/dist/server/invite.js.map +1 -1
  34. package/dist/server/rateLimit.d.ts +3 -3
  35. package/dist/server/rateLimit.js +6 -6
  36. package/dist/server/rateLimit.js.map +1 -1
  37. package/dist/server/user.d.ts +1 -2
  38. package/dist/server/user.js +3 -5
  39. package/dist/server/user.js.map +1 -1
  40. package/dist/utils/jwtSecret.d.ts +3 -3
  41. package/dist/utils/jwtSecret.js +5 -6
  42. package/dist/utils/jwtSecret.js.map +1 -1
  43. package/dist/utils/loadEnv.js +1 -1
  44. package/dist/utils/loadEnv.js.map +1 -1
  45. package/package.json +6 -4
  46. package/src/ClientManager.ts +6 -58
  47. package/src/Database.ts +83 -21
  48. package/src/Spire.ts +37 -108
  49. package/src/__tests__/Database.spec.ts +0 -17
  50. package/src/db/schema.ts +1 -0
  51. package/src/migrations/2026-04-14_argon2id-password-hashing.ts +18 -0
  52. package/src/run.ts +0 -1
  53. package/src/server/avatar.ts +7 -14
  54. package/src/server/errors.ts +3 -32
  55. package/src/server/file.ts +5 -10
  56. package/src/server/index.ts +23 -37
  57. package/src/server/invite.ts +0 -2
  58. package/src/server/rateLimit.ts +6 -6
  59. package/src/server/user.ts +2 -6
  60. package/src/utils/jwtSecret.ts +5 -6
  61. package/src/utils/loadEnv.ts +1 -1
  62. package/dist/utils/createLogger.d.ts +0 -5
  63. package/dist/utils/createLogger.js +0 -35
  64. package/dist/utils/createLogger.js.map +0 -1
  65. package/src/utils/createLogger.ts +0 -47
@@ -1,6 +1,5 @@
1
1
  import type { Database } from "../Database.ts";
2
2
  import type { Emoji } from "@vex-chat/types";
3
- import type winston from "winston";
4
3
 
5
4
  import * as fs from "node:fs";
6
5
  import * as fsp from "node:fs/promises";
@@ -15,7 +14,6 @@ import cors from "cors";
15
14
  import { fileTypeFromBuffer, fileTypeFromFile } from "file-type";
16
15
  import helmet from "helmet";
17
16
  import jwt from "jsonwebtoken";
18
- import morgan from "morgan";
19
17
  import multer from "multer";
20
18
  import parseDuration from "parse-duration";
21
19
  import { stringify as uuidStringify } from "uuid";
@@ -156,8 +154,6 @@ export const msgpackParser: express.RequestHandler = (req, res, next) => {
156
154
  next();
157
155
  };
158
156
 
159
- const isProduction = process.env["NODE_ENV"] === "production";
160
-
161
157
  const directories = ["files", "avatars"];
162
158
  for (const dir of directories) {
163
159
  if (!fs.existsSync(dir)) {
@@ -168,7 +164,6 @@ for (const dir of directories) {
168
164
  export const initApp = (
169
165
  api: express.Application,
170
166
  db: Database,
171
- log: winston.Logger,
172
167
  tokenValidator: (key: string, scope: TokenScopes) => boolean,
173
168
  signKeys: KeyPair,
174
169
  notify: (
@@ -180,10 +175,10 @@ export const initApp = (
180
175
  ) => void,
181
176
  ) => {
182
177
  // INIT ROUTERS
183
- const userRouter = getUserRouter(db, log, tokenValidator);
184
- const fileRouter = getFileRouter(db, log);
185
- const avatarRouter = getAvatarRouter(db, log);
186
- const inviteRouter = getInviteRouter(db, log, tokenValidator, notify);
178
+ const userRouter = getUserRouter(db, tokenValidator);
179
+ const fileRouter = getFileRouter(db);
180
+ const avatarRouter = getAvatarRouter();
181
+ const inviteRouter = getInviteRouter(db, tokenValidator, notify);
187
182
 
188
183
  // MIDDLEWARE
189
184
  // Global per-IP rate limit is the FIRST middleware so a flooded
@@ -197,18 +192,18 @@ export const initApp = (
197
192
  type: "application/msgpack",
198
193
  }),
199
194
  );
200
- if (isProduction) {
201
- api.use(helmet());
202
- }
195
+ api.use(helmet());
203
196
  api.use(msgpackParser);
204
197
  api.use(checkAuth);
205
198
  api.use(checkDevice);
206
199
 
207
- if (!jestRun()) {
208
- api.use(morgan("dev", { stream: process.stdout }));
209
- }
210
-
211
- api.use(cors({ credentials: true }));
200
+ const allowedOrigins = process.env["CORS_ORIGINS"]?.split(",") ?? [];
201
+ api.use(
202
+ cors({
203
+ credentials: true,
204
+ origin: allowedOrigins.length > 0 ? allowedOrigins : false,
205
+ }),
206
+ );
212
207
 
213
208
  api.get("/server/:id", protect, async (req, res) => {
214
209
  const server = await db.retrieveServer(getParam(req, "id"));
@@ -259,7 +254,6 @@ export const initApp = (
259
254
  POWER_LEVELS.INVITE,
260
255
  )
261
256
  ) {
262
- log.warn("No permission!");
263
257
  res.sendStatus(401);
264
258
  return;
265
259
  }
@@ -649,8 +643,8 @@ export const initApp = (
649
643
  res.set("Cache-control", "public, max-age=31536000");
650
644
 
651
645
  const stream = fs.createReadStream(filePath);
652
- stream.on("error", (err) => {
653
- log.error(err.toString());
646
+ stream.on("error", (_err) => {
647
+ // debugger: emoji stream read error
654
648
  res.sendStatus(500);
655
649
  });
656
650
  stream.pipe(res);
@@ -703,10 +697,11 @@ export const initApp = (
703
697
  }
704
698
  if (!payload.name) {
705
699
  res.sendStatus(400);
700
+ return;
706
701
  }
707
702
  if (Buffer.byteLength(buf) > 256000) {
708
- log.warn("File too big.");
709
703
  res.sendStatus(413);
704
+ return;
710
705
  }
711
706
 
712
707
  const mimeType = await fileTypeFromBuffer(buf);
@@ -730,10 +725,9 @@ export const initApp = (
730
725
  try {
731
726
  // write the file to disk
732
727
  await fsp.writeFile("emoji/" + emoji.emojiID, buf);
733
- log.info("Wrote new emoji " + emoji.emojiID);
734
728
  res.send(msgpack.encode(emoji));
735
- } catch (err: unknown) {
736
- log.warn(String(err));
729
+ } catch (_err: unknown) {
730
+ // debugger: emoji write failed
737
731
  res.sendStatus(500);
738
732
  }
739
733
  });
@@ -786,17 +780,17 @@ export const initApp = (
786
780
 
787
781
  if (!payload.name) {
788
782
  res.sendStatus(400);
783
+ return;
789
784
  }
790
785
 
791
786
  if (!req.file) {
792
- log.warn("MISSING FILE");
793
787
  res.sendStatus(400);
794
788
  return;
795
789
  }
796
790
 
797
791
  if (Buffer.byteLength(req.file.buffer) > 256000) {
798
- log.warn("File too big.");
799
792
  res.sendStatus(413);
793
+ return;
800
794
  }
801
795
 
802
796
  const mimeType = await fileTypeFromBuffer(req.file.buffer);
@@ -820,10 +814,9 @@ export const initApp = (
820
814
  try {
821
815
  // write the file to disk
822
816
  await fsp.writeFile("emoji/" + emoji.emojiID, req.file.buffer);
823
- log.info("Wrote new emoji " + emoji.emojiID);
824
817
  res.send(msgpack.encode(emoji));
825
- } catch (err: unknown) {
826
- log.warn(String(err));
818
+ } catch (_err: unknown) {
819
+ // debugger: emoji write failed
827
820
  res.sendStatus(500);
828
821
  }
829
822
  },
@@ -844,12 +837,5 @@ export const initApp = (
844
837
  // (client-safe status + message) and programmer errors (generic 500
845
838
  // with full details logged server-side). See src/server/errors.ts
846
839
  // for the CWE mapping.
847
- api.use(errorHandler(log));
848
- };
849
-
850
- /**
851
- * @ignore
852
- */
853
- const jestRun = () => {
854
- return process.env["JEST_WORKER_ID"] !== undefined;
840
+ api.use(errorHandler());
855
841
  };
@@ -1,6 +1,5 @@
1
1
  import type { Database } from "../Database.ts";
2
2
  import type { TokenScopes } from "@vex-chat/types";
3
- import type winston from "winston";
4
3
 
5
4
  import express from "express";
6
5
 
@@ -12,7 +11,6 @@ import { protect } from "./index.ts";
12
11
 
13
12
  export const getInviteRouter = (
14
13
  db: Database,
15
- log: winston.Logger,
16
14
  tokenValidator: (key: string, scope: TokenScopes) => boolean,
17
15
  notify: (
18
16
  userID: string,
@@ -38,14 +38,14 @@ const keyByIp = (req: Request): string => ipKeyGenerator(req.ip ?? "");
38
38
  /**
39
39
  * Global per-IP limiter. Applied app-wide via `api.use(globalLimiter)`.
40
40
  *
41
- * 300 requests per 15 minutes per client IP. A human chatting via a
41
+ * 3000 requests per 15 minutes per client IP. A human chatting via a
42
42
  * browser or the libvex client won't come close; a single-host DoS
43
43
  * gets throttled quickly.
44
44
  */
45
45
  export const globalLimiter = rateLimit({
46
46
  keyGenerator: keyByIp,
47
47
  legacyHeaders: false,
48
- limit: 300,
48
+ limit: 3000,
49
49
  standardHeaders: "draft-7",
50
50
  windowMs: 15 * 60 * 1000,
51
51
  });
@@ -54,7 +54,7 @@ export const globalLimiter = rateLimit({
54
54
  * Strict auth endpoint limiter. Applied per-route to /auth, /register,
55
55
  * and /auth/device.
56
56
  *
57
- * 5 failed attempts per 15 minutes per IP. Successful logins don't
57
+ * 50 failed attempts per 15 minutes per IP. Successful logins don't
58
58
  * count (`skipSuccessfulRequests`), so a normal user doesn't lock
59
59
  * themselves out by fat-fingering a password once. Blocks brute force
60
60
  * (CWE-307) without harming UX.
@@ -62,7 +62,7 @@ export const globalLimiter = rateLimit({
62
62
  export const authLimiter = rateLimit({
63
63
  keyGenerator: keyByIp,
64
64
  legacyHeaders: false,
65
- limit: 5,
65
+ limit: 50,
66
66
  skipSuccessfulRequests: true,
67
67
  standardHeaders: "draft-7",
68
68
  windowMs: 15 * 60 * 1000,
@@ -74,13 +74,13 @@ export const authLimiter = rateLimit({
74
74
  * upload attempts per minute so an attacker can't force spire to
75
75
  * spend CPU/IO on repeated large-body parses.
76
76
  *
77
- * 20 uploads per minute per IP — generous for a chat client (rapid-
77
+ * 200 uploads per minute per IP — generous for a chat client (rapid-
78
78
  * fire image attachments) but tight enough to shield the disk.
79
79
  */
80
80
  export const uploadLimiter = rateLimit({
81
81
  keyGenerator: keyByIp,
82
82
  legacyHeaders: false,
83
- limit: 20,
83
+ limit: 200,
84
84
  standardHeaders: "draft-7",
85
85
  windowMs: 60 * 1000,
86
86
  });
@@ -1,5 +1,4 @@
1
1
  import type { Database } from "../Database.ts";
2
- import type winston from "winston";
3
2
 
4
3
  import express from "express";
5
4
 
@@ -17,7 +16,6 @@ import { protect } from "./index.ts";
17
16
 
18
17
  export const getUserRouter = (
19
18
  db: Database,
20
- log: winston.Logger,
21
19
  tokenValidator: (key: string, scope: TokenScopes) => boolean,
22
20
  ) => {
23
21
  const router = express.Router();
@@ -98,7 +96,6 @@ export const getUserRouter = (
98
96
  );
99
97
 
100
98
  if (!token) {
101
- log.warn("Invalid signature on token.");
102
99
  res.sendStatus(400);
103
100
  return;
104
101
  }
@@ -110,9 +107,8 @@ export const getUserRouter = (
110
107
  deviceData,
111
108
  );
112
109
  res.send(msgpack.encode(device));
113
- } catch (err: unknown) {
114
- log.warn(String(err));
115
- // failed registration due to signkey being taken
110
+ } catch (_err: unknown) {
111
+ // signkey already taken
116
112
  res.sendStatus(470);
117
113
  return;
118
114
  }
@@ -1,15 +1,14 @@
1
1
  /**
2
- * Returns the JWT signing secret.
2
+ * Returns the dedicated JWT HMAC signing secret.
3
3
  *
4
- * Prefers JWT_SECRET (dedicated HMAC key) over SPK (NaCl server signing key).
5
- * Falls back to SPK for backward compat with existing deployments.
4
+ * This MUST be a separate key from SPK (the Ed25519 server signing key)
5
+ * so that compromise of one does not affect the other.
6
6
  */
7
7
  export function getJwtSecret(): string {
8
- const secret = process.env["JWT_SECRET"] ?? process.env["SPK"];
8
+ const secret = process.env["JWT_SECRET"];
9
9
  if (!secret) {
10
10
  throw new Error(
11
- "Neither JWT_SECRET nor SPK is set. " +
12
- "Set JWT_SECRET (preferred) or SPK in your environment.",
11
+ "JWT_SECRET is not set. Generate one with: node scripts/gen-spk.js",
13
12
  );
14
13
  }
15
14
  return secret;
@@ -3,7 +3,7 @@ import { config } from "dotenv";
3
3
  /* Populate process.env with vars from .env and verify required vars are present. */
4
4
  export function loadEnv(): void {
5
5
  config();
6
- const requiredEnvVars: string[] = ["CANARY", "DB_TYPE", "SPK"];
6
+ const requiredEnvVars: string[] = ["DB_TYPE", "JWT_SECRET", "SPK"];
7
7
  for (const required of requiredEnvVars) {
8
8
  if (process.env[required] === undefined) {
9
9
  process.stderr.write(
@@ -1,5 +0,0 @@
1
- import winston from "winston";
2
- /**
3
- * @ignore
4
- */
5
- export declare function createLogger(logName: string, logLevel?: string): winston.Logger;
@@ -1,35 +0,0 @@
1
- import winston from "winston";
2
- /**
3
- * @ignore
4
- */
5
- export function createLogger(logName, logLevel) {
6
- const logger = winston.createLogger({
7
- defaultMeta: { service: "vex-" + logName },
8
- format: winston.format.combine(winston.format.timestamp({
9
- format: "YYYY-MM-DD HH:mm:ss",
10
- }), winston.format.errors({ stack: true }), winston.format.splat(), winston.format.json()),
11
- level: logLevel || "error",
12
- transports: [
13
- //
14
- // - Write all logs with level `error` and below to `error.log`
15
- // - Write all logs with level `info` and below to `combined.log`
16
- //
17
- new winston.transports.File({
18
- filename: "vex:" + logName + ".log",
19
- level: "error",
20
- }),
21
- ],
22
- });
23
- //
24
- // If we're not in production then log to the `console` with the format:
25
- // `${info.level}: ${info.message} JSON.stringify({ ...rest }) `
26
- //
27
- if (process.env["NODE_ENV"] !== "production" &&
28
- process.env["NODE_ENV"] !== "test") {
29
- logger.add(new winston.transports.Console({
30
- format: winston.format.combine(winston.format.colorize(), winston.format.simple()),
31
- }));
32
- }
33
- return logger;
34
- }
35
- //# sourceMappingURL=createLogger.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"createLogger.js","sourceRoot":"","sources":["../../src/utils/createLogger.ts"],"names":[],"mappings":"AAAA,OAAO,OAAO,MAAM,SAAS,CAAC;AAE9B;;GAEG;AACH,MAAM,UAAU,YAAY,CAAC,OAAe,EAAE,QAAiB;IAC3D,MAAM,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;QAChC,WAAW,EAAE,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,EAAE;QAC1C,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,OAAO,CAC1B,OAAO,CAAC,MAAM,CAAC,SAAS,CAAC;YACrB,MAAM,EAAE,qBAAqB;SAChC,CAAC,EACF,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,EACtC,OAAO,CAAC,MAAM,CAAC,KAAK,EAAE,EACtB,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,CACxB;QACD,KAAK,EAAE,QAAQ,IAAI,OAAO;QAC1B,UAAU,EAAE;YACR,EAAE;YACF,+DAA+D;YAC/D,iEAAiE;YACjE,EAAE;YACF,IAAI,OAAO,CAAC,UAAU,CAAC,IAAI,CAAC;gBACxB,QAAQ,EAAE,MAAM,GAAG,OAAO,GAAG,MAAM;gBACnC,KAAK,EAAE,OAAO;aACjB,CAAC;SACL;KACJ,CAAC,CAAC;IACH,EAAE;IACF,wEAAwE;IACxE,gEAAgE;IAChE,EAAE;IACF,IACI,OAAO,CAAC,GAAG,CAAC,UAAU,CAAC,KAAK,YAAY;QACxC,OAAO,CAAC,GAAG,CAAC,UAAU,CAAC,KAAK,MAAM,EACpC,CAAC;QACC,MAAM,CAAC,GAAG,CACN,IAAI,OAAO,CAAC,UAAU,CAAC,OAAO,CAAC;YAC3B,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,OAAO,CAC1B,OAAO,CAAC,MAAM,CAAC,QAAQ,EAAE,EACzB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,CAC1B;SACJ,CAAC,CACL,CAAC;IACN,CAAC;IACD,OAAO,MAAM,CAAC;AAClB,CAAC"}
@@ -1,47 +0,0 @@
1
- import winston from "winston";
2
-
3
- /**
4
- * @ignore
5
- */
6
- export function createLogger(logName: string, logLevel?: string) {
7
- const logger = winston.createLogger({
8
- defaultMeta: { service: "vex-" + logName },
9
- format: winston.format.combine(
10
- winston.format.timestamp({
11
- format: "YYYY-MM-DD HH:mm:ss",
12
- }),
13
- winston.format.errors({ stack: true }),
14
- winston.format.splat(),
15
- winston.format.json(),
16
- ),
17
- level: logLevel || "error",
18
- transports: [
19
- //
20
- // - Write all logs with level `error` and below to `error.log`
21
- // - Write all logs with level `info` and below to `combined.log`
22
- //
23
- new winston.transports.File({
24
- filename: "vex:" + logName + ".log",
25
- level: "error",
26
- }),
27
- ],
28
- });
29
- //
30
- // If we're not in production then log to the `console` with the format:
31
- // `${info.level}: ${info.message} JSON.stringify({ ...rest }) `
32
- //
33
- if (
34
- process.env["NODE_ENV"] !== "production" &&
35
- process.env["NODE_ENV"] !== "test"
36
- ) {
37
- logger.add(
38
- new winston.transports.Console({
39
- format: winston.format.combine(
40
- winston.format.colorize(),
41
- winston.format.simple(),
42
- ),
43
- }),
44
- );
45
- }
46
- return logger;
47
- }