@vex-chat/spire 1.0.0 → 1.0.2
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.
- package/README.md +38 -38
- package/dist/ClientManager.d.ts +1 -3
- package/dist/ClientManager.js +7 -50
- package/dist/ClientManager.js.map +1 -1
- package/dist/Database.d.ts +49 -6
- package/dist/Database.js +255 -80
- package/dist/Database.js.map +1 -1
- package/dist/Spire.d.ts +0 -3
- package/dist/Spire.js +116 -81
- package/dist/Spire.js.map +1 -1
- package/dist/db/schema.d.ts +1 -0
- package/dist/migrations/2026-04-14_argon2id-password-hashing.d.ts +3 -0
- package/dist/migrations/2026-04-14_argon2id-password-hashing.js +12 -0
- package/dist/migrations/2026-04-14_argon2id-password-hashing.js.map +1 -0
- package/dist/run.js +0 -1
- package/dist/run.js.map +1 -1
- package/dist/server/avatar.d.ts +1 -3
- package/dist/server/avatar.js +7 -11
- package/dist/server/avatar.js.map +1 -1
- package/dist/server/errors.d.ts +3 -6
- package/dist/server/errors.js +2 -28
- package/dist/server/errors.js.map +1 -1
- package/dist/server/file.d.ts +1 -2
- package/dist/server/file.js +5 -7
- package/dist/server/file.js.map +1 -1
- package/dist/server/index.d.ts +1 -2
- package/dist/server/index.js +35 -33
- package/dist/server/index.js.map +1 -1
- package/dist/server/invite.d.ts +1 -2
- package/dist/server/invite.js +1 -1
- package/dist/server/invite.js.map +1 -1
- package/dist/server/rateLimit.d.ts +13 -3
- package/dist/server/rateLimit.js +57 -6
- package/dist/server/rateLimit.js.map +1 -1
- package/dist/server/user.d.ts +1 -2
- package/dist/server/user.js +10 -8
- package/dist/server/user.js.map +1 -1
- package/dist/utils/jwtSecret.d.ts +3 -3
- package/dist/utils/jwtSecret.js +5 -6
- package/dist/utils/jwtSecret.js.map +1 -1
- package/dist/utils/loadEnv.js +1 -1
- package/dist/utils/loadEnv.js.map +1 -1
- package/package.json +17 -8
- package/src/ClientManager.ts +7 -60
- package/src/Database.ts +308 -89
- package/src/Spire.ts +122 -119
- package/src/__tests__/Database.spec.ts +0 -17
- package/src/db/schema.ts +1 -0
- package/src/migrations/2026-04-14_argon2id-password-hashing.ts +18 -0
- package/src/run.ts +0 -1
- package/src/server/avatar.ts +7 -14
- package/src/server/errors.ts +3 -32
- package/src/server/file.ts +5 -10
- package/src/server/index.ts +37 -37
- package/src/server/invite.ts +0 -2
- package/src/server/rateLimit.ts +43 -9
- package/src/server/user.ts +9 -9
- package/src/utils/jwtSecret.ts +5 -6
- package/src/utils/loadEnv.ts +1 -1
- package/dist/__tests__/Database.spec.d.ts +0 -1
- package/dist/__tests__/Database.spec.js +0 -136
- package/dist/__tests__/Database.spec.js.map +0 -1
- package/dist/utils/createLogger.d.ts +0 -5
- package/dist/utils/createLogger.js +0 -35
- package/dist/utils/createLogger.js.map +0 -1
- package/src/utils/createLogger.ts +0 -47
package/src/Spire.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { ActionToken, BaseMsg, NotifyMsg, User } from "@vex-chat/types";
|
|
2
2
|
import type { Server } from "http";
|
|
3
|
-
import type winston from "winston";
|
|
4
3
|
|
|
5
4
|
import { EventEmitter } from "events";
|
|
6
5
|
import { execSync } from "node:child_process";
|
|
7
6
|
import * as fs from "node:fs";
|
|
7
|
+
import { freemem, loadavg, totalmem } from "node:os";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
|
|
@@ -30,11 +30,10 @@ import { WebSocketServer } from "ws";
|
|
|
30
30
|
import { z } from "zod/v4";
|
|
31
31
|
|
|
32
32
|
import { ClientManager } from "./ClientManager.ts";
|
|
33
|
-
import { Database,
|
|
33
|
+
import { Database, hashPasswordArgon2, verifyPassword } from "./Database.ts";
|
|
34
34
|
import { initApp, protect } from "./server/index.ts";
|
|
35
|
-
import { authLimiter } from "./server/rateLimit.ts";
|
|
35
|
+
import { authLimiter, devApiKeySkipsRateLimits } from "./server/rateLimit.ts";
|
|
36
36
|
import { censorUser, getParam, getUser } from "./server/utils.ts";
|
|
37
|
-
import { createLogger } from "./utils/createLogger.ts";
|
|
38
37
|
import { getJwtSecret } from "./utils/jwtSecret.ts";
|
|
39
38
|
import { msgpack } from "./utils/msgpack.ts";
|
|
40
39
|
|
|
@@ -130,14 +129,6 @@ const getCommitSha = (): string => {
|
|
|
130
129
|
export interface SpireOptions {
|
|
131
130
|
apiPort?: number;
|
|
132
131
|
dbType?: "mysql" | "sqlite3" | "sqlite3mem" | "sqlite";
|
|
133
|
-
logLevel?:
|
|
134
|
-
| "debug"
|
|
135
|
-
| "error"
|
|
136
|
-
| "http"
|
|
137
|
-
| "info"
|
|
138
|
-
| "silly"
|
|
139
|
-
| "verbose"
|
|
140
|
-
| "warn";
|
|
141
132
|
}
|
|
142
133
|
|
|
143
134
|
export class Spire extends EventEmitter {
|
|
@@ -151,9 +142,6 @@ export class Spire extends EventEmitter {
|
|
|
151
142
|
string,
|
|
152
143
|
{ deviceID: string; nonce: string; time: number }
|
|
153
144
|
>();
|
|
154
|
-
private log: winston.Logger;
|
|
155
|
-
private options: SpireOptions | undefined;
|
|
156
|
-
|
|
157
145
|
private queuedRequestIncrements = 0;
|
|
158
146
|
private requestsTotal = 0;
|
|
159
147
|
|
|
@@ -164,7 +152,10 @@ export class Spire extends EventEmitter {
|
|
|
164
152
|
|
|
165
153
|
private readonly startedAt = new Date();
|
|
166
154
|
private readonly version = getAppVersion();
|
|
167
|
-
private wss: WebSocketServer = new WebSocketServer({
|
|
155
|
+
private wss: WebSocketServer = new WebSocketServer({
|
|
156
|
+
maxPayload: 4096,
|
|
157
|
+
noServer: true,
|
|
158
|
+
});
|
|
168
159
|
|
|
169
160
|
constructor(SK: string, options?: SpireOptions) {
|
|
170
161
|
super();
|
|
@@ -180,17 +171,12 @@ export class Spire extends EventEmitter {
|
|
|
180
171
|
this.db = new Database(options);
|
|
181
172
|
this.db.on("ready", () => {
|
|
182
173
|
this.dbReady = true;
|
|
183
|
-
this.bootstrapRequestCounter().catch((
|
|
184
|
-
|
|
185
|
-
"Failed to load persisted request counter: " + String(err),
|
|
186
|
-
);
|
|
174
|
+
this.bootstrapRequestCounter().catch((_err: unknown) => {
|
|
175
|
+
// debugger: bootstrap request counter failed
|
|
187
176
|
});
|
|
188
177
|
});
|
|
189
178
|
|
|
190
|
-
this.log = createLogger("spire", options?.logLevel || "error");
|
|
191
179
|
this.init(options?.apiPort || 16777);
|
|
192
|
-
|
|
193
|
-
this.options = options;
|
|
194
180
|
}
|
|
195
181
|
|
|
196
182
|
public async close(): Promise<void> {
|
|
@@ -198,29 +184,29 @@ export class Spire extends EventEmitter {
|
|
|
198
184
|
ws.terminate();
|
|
199
185
|
});
|
|
200
186
|
|
|
201
|
-
this.wss.on("close", () => {
|
|
202
|
-
this.log.info("ws: closed.");
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
this.server?.on("close", () => {
|
|
206
|
-
this.log.info("http: closed.");
|
|
207
|
-
});
|
|
208
|
-
|
|
209
187
|
this.server?.close();
|
|
210
188
|
this.wss.close();
|
|
211
189
|
await this.db.close();
|
|
212
|
-
return;
|
|
213
190
|
}
|
|
214
191
|
|
|
215
192
|
private async bootstrapRequestCounter(): Promise<void> {
|
|
216
193
|
const persistedTotal = await this.db.getRequestsTotal();
|
|
194
|
+
|
|
195
|
+
// Between the await above and this synchronous block, requests may
|
|
196
|
+
// have incremented both `requestsTotal` and `queuedRequestIncrements`.
|
|
197
|
+
// Capture the queue, mark loaded, then merge — never overwrite
|
|
198
|
+
// `requestsTotal` (which already includes in-flight increments).
|
|
217
199
|
const startupIncrements = this.queuedRequestIncrements;
|
|
218
200
|
this.queuedRequestIncrements = 0;
|
|
219
|
-
this.
|
|
201
|
+
this.requestsTotalLoaded = true;
|
|
202
|
+
|
|
203
|
+
// Add the persisted baseline on top of whatever the middleware
|
|
204
|
+
// already counted in-memory, instead of overwriting it.
|
|
205
|
+
this.requestsTotal += persistedTotal;
|
|
206
|
+
|
|
220
207
|
if (startupIncrements > 0) {
|
|
221
208
|
await this.db.incrementRequestsTotal(startupIncrements);
|
|
222
209
|
}
|
|
223
|
-
this.requestsTotalLoaded = true;
|
|
224
210
|
}
|
|
225
211
|
|
|
226
212
|
private createActionToken(scope: TokenScopes): ActionToken {
|
|
@@ -234,8 +220,9 @@ export class Spire extends EventEmitter {
|
|
|
234
220
|
}
|
|
235
221
|
|
|
236
222
|
private deleteActionToken(key: ActionToken) {
|
|
237
|
-
|
|
238
|
-
|
|
223
|
+
const idx = this.actionTokens.indexOf(key);
|
|
224
|
+
if (idx !== -1) {
|
|
225
|
+
this.actionTokens.splice(idx, 1);
|
|
239
226
|
}
|
|
240
227
|
}
|
|
241
228
|
|
|
@@ -246,11 +233,8 @@ export class Spire extends EventEmitter {
|
|
|
246
233
|
if (!this.requestsTotalLoaded) {
|
|
247
234
|
this.queuedRequestIncrements += 1;
|
|
248
235
|
} else {
|
|
249
|
-
this.db.incrementRequestsTotal(1).catch((
|
|
250
|
-
|
|
251
|
-
"Failed to persist request counter increment: " +
|
|
252
|
-
String(err),
|
|
253
|
-
);
|
|
236
|
+
this.db.incrementRequestsTotal(1).catch((_err: unknown) => {
|
|
237
|
+
// debugger: failed to persist request counter
|
|
254
238
|
});
|
|
255
239
|
}
|
|
256
240
|
|
|
@@ -261,7 +245,6 @@ export class Spire extends EventEmitter {
|
|
|
261
245
|
initApp(
|
|
262
246
|
this.api,
|
|
263
247
|
this.db,
|
|
264
|
-
this.log,
|
|
265
248
|
this.validateToken.bind(this),
|
|
266
249
|
this.signKeys,
|
|
267
250
|
this.notify.bind(this),
|
|
@@ -269,11 +252,9 @@ export class Spire extends EventEmitter {
|
|
|
269
252
|
|
|
270
253
|
// WS auth: client sends { type: "auth", token } as first message
|
|
271
254
|
this.wss.on("connection", (ws) => {
|
|
272
|
-
this.log.info("WS connection established, waiting for auth...");
|
|
273
255
|
const AUTH_TIMEOUT = 10_000;
|
|
274
256
|
|
|
275
257
|
const timer = setTimeout(() => {
|
|
276
|
-
this.log.warn("WS auth timeout — closing.");
|
|
277
258
|
ws.close();
|
|
278
259
|
}, AUTH_TIMEOUT);
|
|
279
260
|
|
|
@@ -308,47 +289,25 @@ export class Spire extends EventEmitter {
|
|
|
308
289
|
}
|
|
309
290
|
const userDetails: User = jwtResult.data.user;
|
|
310
291
|
|
|
311
|
-
this.log.info(
|
|
312
|
-
"WS auth succeeded for " + userDetails.username,
|
|
313
|
-
);
|
|
314
|
-
|
|
315
292
|
const client = new ClientManager(
|
|
316
293
|
ws,
|
|
317
294
|
this.db,
|
|
318
295
|
this.notify.bind(this),
|
|
319
296
|
userDetails,
|
|
320
|
-
this.options,
|
|
321
297
|
);
|
|
322
298
|
|
|
323
299
|
client.on("fail", () => {
|
|
324
|
-
this.
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
);
|
|
328
|
-
if (this.clients.includes(client)) {
|
|
329
|
-
this.clients.splice(
|
|
330
|
-
this.clients.indexOf(client),
|
|
331
|
-
1,
|
|
332
|
-
);
|
|
300
|
+
const idx = this.clients.indexOf(client);
|
|
301
|
+
if (idx !== -1) {
|
|
302
|
+
this.clients.splice(idx, 1);
|
|
333
303
|
}
|
|
334
|
-
this.log.info(
|
|
335
|
-
"Current authorized clients: " +
|
|
336
|
-
String(this.clients.length),
|
|
337
|
-
);
|
|
338
304
|
});
|
|
339
305
|
|
|
340
306
|
client.on("authed", () => {
|
|
341
|
-
this.log.info(
|
|
342
|
-
"New client authorized: " + client.toString(),
|
|
343
|
-
);
|
|
344
307
|
this.clients.push(client);
|
|
345
|
-
this.log.info(
|
|
346
|
-
"Current authorized clients: " +
|
|
347
|
-
String(this.clients.length),
|
|
348
|
-
);
|
|
349
308
|
});
|
|
350
|
-
} catch (
|
|
351
|
-
|
|
309
|
+
} catch (_err: unknown) {
|
|
310
|
+
// debugger: WS auth failed
|
|
352
311
|
const errMsg: BaseMsg = {
|
|
353
312
|
transmissionID: crypto.randomUUID(),
|
|
354
313
|
type: "unauthorized",
|
|
@@ -421,9 +380,7 @@ export class Spire extends EventEmitter {
|
|
|
421
380
|
}
|
|
422
381
|
|
|
423
382
|
try {
|
|
424
|
-
this.log.info("New token requested of type " + tokenType);
|
|
425
383
|
const token = this.createActionToken(scope);
|
|
426
|
-
this.log.info("New token created: " + token.key);
|
|
427
384
|
|
|
428
385
|
setTimeout(() => {
|
|
429
386
|
this.deleteActionToken(token);
|
|
@@ -441,8 +398,8 @@ export class Spire extends EventEmitter {
|
|
|
441
398
|
|
|
442
399
|
res.set("Content-Type", "application/msgpack");
|
|
443
400
|
return res.send(msgpack.encode(token));
|
|
444
|
-
} catch (
|
|
445
|
-
|
|
401
|
+
} catch (_err: unknown) {
|
|
402
|
+
// debugger: token creation failed
|
|
446
403
|
return res.sendStatus(500);
|
|
447
404
|
}
|
|
448
405
|
},
|
|
@@ -457,7 +414,6 @@ export class Spire extends EventEmitter {
|
|
|
457
414
|
res.send(
|
|
458
415
|
msgpack.encode({
|
|
459
416
|
exp: req.exp,
|
|
460
|
-
token: req.bearerToken,
|
|
461
417
|
user: req.user,
|
|
462
418
|
}),
|
|
463
419
|
);
|
|
@@ -477,7 +433,12 @@ export class Spire extends EventEmitter {
|
|
|
477
433
|
const checkDurationMs = Date.now() - started;
|
|
478
434
|
|
|
479
435
|
const ok = dbHealthy;
|
|
436
|
+
const canaryEnv = process.env["CANARY"]?.trim().toLowerCase();
|
|
480
437
|
res.json({
|
|
438
|
+
canary:
|
|
439
|
+
canaryEnv === "1" ||
|
|
440
|
+
canaryEnv === "true" ||
|
|
441
|
+
canaryEnv === "yes",
|
|
481
442
|
checkDurationMs,
|
|
482
443
|
commitSha: this.commitSha,
|
|
483
444
|
dbHealthy,
|
|
@@ -496,6 +457,68 @@ export class Spire extends EventEmitter {
|
|
|
496
457
|
});
|
|
497
458
|
});
|
|
498
459
|
|
|
460
|
+
/**
|
|
461
|
+
* Dev-only process snapshot (same gate as rate-limit bypass: `DEV_API_KEY`
|
|
462
|
+
* env + `x-dev-api-key` header). Returns 404 when not enabled or key wrong
|
|
463
|
+
* so the route is not advertised to anonymous callers.
|
|
464
|
+
* Lets local stress / `sample <pid>` workflows see RSS, WS count, etc.
|
|
465
|
+
*/
|
|
466
|
+
this.api.get("/status/process", (req, res) => {
|
|
467
|
+
if (!devApiKeySkipsRateLimits(req)) {
|
|
468
|
+
res.sendStatus(404);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const mu = process.memoryUsage();
|
|
472
|
+
const ru = process.resourceUsage();
|
|
473
|
+
res.json({
|
|
474
|
+
activeRequestsApprox: this.requestsTotal,
|
|
475
|
+
dbReady: this.dbReady,
|
|
476
|
+
hostOs: {
|
|
477
|
+
freemem: freemem(),
|
|
478
|
+
loadavg: loadavg(),
|
|
479
|
+
totalmem: totalmem(),
|
|
480
|
+
},
|
|
481
|
+
memory: {
|
|
482
|
+
arrayBuffers: mu.arrayBuffers,
|
|
483
|
+
external: mu.external,
|
|
484
|
+
heapTotal: mu.heapTotal,
|
|
485
|
+
heapUsed: mu.heapUsed,
|
|
486
|
+
rss: mu.rss,
|
|
487
|
+
},
|
|
488
|
+
pid: process.pid,
|
|
489
|
+
resourceUsage: {
|
|
490
|
+
fsRead: ru.fsRead,
|
|
491
|
+
fsWrite: ru.fsWrite,
|
|
492
|
+
maxRSS: ru.maxRSS,
|
|
493
|
+
systemMicros: ru.systemCPUTime,
|
|
494
|
+
userMicros: ru.userCPUTime,
|
|
495
|
+
},
|
|
496
|
+
uptimeSeconds: Math.floor(process.uptime()),
|
|
497
|
+
websocketClients: this.wss.clients.size,
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Dev-only SQLite file + pragma snapshot (same gate as `/status/process`).
|
|
503
|
+
*/
|
|
504
|
+
this.api.get("/status/sqlite", (req, res) => {
|
|
505
|
+
if (!devApiKeySkipsRateLimits(req)) {
|
|
506
|
+
res.sendStatus(404);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (!this.dbReady) {
|
|
510
|
+
res.status(503).json({ dbReady: false, ok: false });
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
try {
|
|
514
|
+
const sqlite = this.db.getDevSqliteMonitor();
|
|
515
|
+
res.json({ ok: true, sqlite });
|
|
516
|
+
} catch (err: unknown) {
|
|
517
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
518
|
+
res.status(500).json({ error: msg, ok: false });
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
|
|
499
522
|
this.api.post("/goodbye", protect, (req, res) => {
|
|
500
523
|
jwt.sign({ user: req.user }, getJwtSecret(), { expiresIn: -1 });
|
|
501
524
|
res.sendStatus(200);
|
|
@@ -532,12 +555,11 @@ export class Spire extends EventEmitter {
|
|
|
532
555
|
this.deviceChallenges.delete(challengeID);
|
|
533
556
|
}, DEVICE_CHALLENGE_EXPIRY);
|
|
534
557
|
|
|
535
|
-
this.log.info("Device challenge issued for " + deviceID);
|
|
536
558
|
return res.send(
|
|
537
559
|
msgpack.encode({ challenge: nonce, challengeID }),
|
|
538
560
|
);
|
|
539
|
-
} catch (
|
|
540
|
-
|
|
561
|
+
} catch (_err: unknown) {
|
|
562
|
+
// debugger: device challenge error
|
|
541
563
|
return res.sendStatus(500);
|
|
542
564
|
}
|
|
543
565
|
});
|
|
@@ -608,18 +630,11 @@ export class Spire extends EventEmitter {
|
|
|
608
630
|
getJwtSecret(),
|
|
609
631
|
{ expiresIn: DEVICE_AUTH_JWT_EXPIRY },
|
|
610
632
|
);
|
|
611
|
-
this.log.info(
|
|
612
|
-
"Device-key auth succeeded for " +
|
|
613
|
-
user.username +
|
|
614
|
-
" (device " +
|
|
615
|
-
device.deviceID +
|
|
616
|
-
")",
|
|
617
|
-
);
|
|
618
633
|
return res.send(
|
|
619
634
|
msgpack.encode({ token, user: censorUser(user) }),
|
|
620
635
|
);
|
|
621
|
-
} catch (
|
|
622
|
-
|
|
636
|
+
} catch (_err: unknown) {
|
|
637
|
+
// debugger: device verify error
|
|
623
638
|
return res.sendStatus(500);
|
|
624
639
|
}
|
|
625
640
|
});
|
|
@@ -648,8 +663,6 @@ export class Spire extends EventEmitter {
|
|
|
648
663
|
senderDeviceDetails.deviceID,
|
|
649
664
|
authorUserDetails.userID,
|
|
650
665
|
);
|
|
651
|
-
this.log.info("Received mail for " + mail.recipient);
|
|
652
|
-
|
|
653
666
|
const recipientDeviceDetails = await this.db.retrieveDevice(
|
|
654
667
|
mail.recipient,
|
|
655
668
|
);
|
|
@@ -681,21 +694,25 @@ export class Spire extends EventEmitter {
|
|
|
681
694
|
try {
|
|
682
695
|
const userEntry = await this.db.retrieveUser(username);
|
|
683
696
|
if (!userEntry) {
|
|
684
|
-
res.sendStatus(
|
|
685
|
-
this.log.warn("User does not exist.");
|
|
697
|
+
res.sendStatus(401);
|
|
686
698
|
return;
|
|
687
699
|
}
|
|
688
700
|
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
|
|
701
|
+
const { needsRehash, valid } = await verifyPassword(
|
|
702
|
+
password,
|
|
703
|
+
userEntry,
|
|
692
704
|
);
|
|
693
705
|
|
|
694
|
-
if (
|
|
706
|
+
if (!valid) {
|
|
695
707
|
res.sendStatus(401);
|
|
696
708
|
return;
|
|
697
709
|
}
|
|
698
710
|
|
|
711
|
+
if (needsRehash) {
|
|
712
|
+
const newHash = await hashPasswordArgon2(password);
|
|
713
|
+
await this.db.rehashPassword(userEntry.userID, newHash);
|
|
714
|
+
}
|
|
715
|
+
|
|
699
716
|
const token = jwt.sign(
|
|
700
717
|
{ user: censorUser(userEntry) },
|
|
701
718
|
getJwtSecret(),
|
|
@@ -708,8 +725,8 @@ export class Spire extends EventEmitter {
|
|
|
708
725
|
res.send(
|
|
709
726
|
msgpack.encode({ token, user: censorUser(userEntry) }),
|
|
710
727
|
);
|
|
711
|
-
} catch (
|
|
712
|
-
|
|
728
|
+
} catch (_err: unknown) {
|
|
729
|
+
// debugger: auth error
|
|
713
730
|
res.sendStatus(500);
|
|
714
731
|
}
|
|
715
732
|
});
|
|
@@ -762,9 +779,6 @@ export class Spire extends EventEmitter {
|
|
|
762
779
|
"users_signkey_unique",
|
|
763
780
|
);
|
|
764
781
|
|
|
765
|
-
this.log.warn(
|
|
766
|
-
"User attempted to register duplicate account.",
|
|
767
|
-
);
|
|
768
782
|
if (usernameConflict) {
|
|
769
783
|
res.status(400).send({
|
|
770
784
|
error: "Username is already registered.",
|
|
@@ -782,16 +796,10 @@ export class Spire extends EventEmitter {
|
|
|
782
796
|
});
|
|
783
797
|
break;
|
|
784
798
|
default:
|
|
785
|
-
this.log.info(
|
|
786
|
-
"Unsupported sql error type: " +
|
|
787
|
-
String(errCode),
|
|
788
|
-
);
|
|
789
|
-
this.log.error(String(err));
|
|
790
799
|
res.sendStatus(500);
|
|
791
800
|
break;
|
|
792
801
|
}
|
|
793
802
|
} else {
|
|
794
|
-
this.log.info("Registration success.");
|
|
795
803
|
if (!user) {
|
|
796
804
|
res.sendStatus(500);
|
|
797
805
|
return;
|
|
@@ -803,15 +811,13 @@ export class Spire extends EventEmitter {
|
|
|
803
811
|
error: "Invalid or no token supplied.",
|
|
804
812
|
});
|
|
805
813
|
}
|
|
806
|
-
} catch (
|
|
807
|
-
|
|
814
|
+
} catch (_err: unknown) {
|
|
815
|
+
// debugger: registration error
|
|
808
816
|
res.sendStatus(500);
|
|
809
817
|
}
|
|
810
818
|
});
|
|
811
819
|
|
|
812
|
-
this.server = this.api.listen(apiPort
|
|
813
|
-
this.log.info("API started on port " + String(apiPort));
|
|
814
|
-
});
|
|
820
|
+
this.server = this.api.listen(apiPort);
|
|
815
821
|
|
|
816
822
|
// Accept all WS upgrades — auth happens post-connection.
|
|
817
823
|
this.server.on("upgrade", (req, socket, head) => {
|
|
@@ -828,7 +834,10 @@ export class Spire extends EventEmitter {
|
|
|
828
834
|
data?: unknown,
|
|
829
835
|
deviceID?: string,
|
|
830
836
|
): void {
|
|
831
|
-
|
|
837
|
+
// Snapshot the array so that a synchronous `fail` → splice inside
|
|
838
|
+
// client.send() doesn't corrupt the iteration.
|
|
839
|
+
const snapshot = this.clients.slice();
|
|
840
|
+
for (const client of snapshot) {
|
|
832
841
|
if (deviceID) {
|
|
833
842
|
if (client.getDevice().deviceID === deviceID) {
|
|
834
843
|
const msg: NotifyMsg = {
|
|
@@ -854,7 +863,6 @@ export class Spire extends EventEmitter {
|
|
|
854
863
|
}
|
|
855
864
|
|
|
856
865
|
private validateToken(key: string, scope: TokenScopes): boolean {
|
|
857
|
-
this.log.info("Validating token: " + key);
|
|
858
866
|
for (const rKey of this.actionTokens) {
|
|
859
867
|
if (rKey.key === key) {
|
|
860
868
|
if (rKey.scope !== scope) {
|
|
@@ -862,17 +870,12 @@ export class Spire extends EventEmitter {
|
|
|
862
870
|
}
|
|
863
871
|
|
|
864
872
|
const age = Date.now() - new Date(rKey.time).getTime();
|
|
865
|
-
this.log.info("Token found, " + String(age) + " ms old.");
|
|
866
873
|
if (age < TOKEN_EXPIRY) {
|
|
867
|
-
this.log.info("Token is valid.");
|
|
868
874
|
this.deleteActionToken(rKey);
|
|
869
875
|
return true;
|
|
870
|
-
} else {
|
|
871
|
-
this.log.info("Token is expired.");
|
|
872
876
|
}
|
|
873
877
|
}
|
|
874
878
|
}
|
|
875
|
-
this.log.info("Token not found.");
|
|
876
879
|
return false;
|
|
877
880
|
}
|
|
878
881
|
}
|
|
@@ -5,7 +5,6 @@ import { XUtils } from "@vex-chat/crypto";
|
|
|
5
5
|
|
|
6
6
|
import * as uuid from "uuid";
|
|
7
7
|
import { describe, expect, it, vi } from "vitest";
|
|
8
|
-
import winston from "winston";
|
|
9
8
|
|
|
10
9
|
import { Database } from "../Database.ts";
|
|
11
10
|
|
|
@@ -27,19 +26,6 @@ vi.mock("uuid", () => ({
|
|
|
27
26
|
validate: () => true,
|
|
28
27
|
}));
|
|
29
28
|
|
|
30
|
-
/** Winston logger stub — Database.close() calls `.info`, and `{}` breaks that. */
|
|
31
|
-
function silentLogger(): winston.Logger {
|
|
32
|
-
const noop = vi.fn();
|
|
33
|
-
return {
|
|
34
|
-
debug: noop,
|
|
35
|
-
error: noop,
|
|
36
|
-
info: noop,
|
|
37
|
-
log: noop,
|
|
38
|
-
verbose: noop,
|
|
39
|
-
warn: noop,
|
|
40
|
-
} as unknown as winston.Logger;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
29
|
describe("Database", () => {
|
|
44
30
|
// Reusable test data
|
|
45
31
|
const keyID = "de459e05-aa63-4dfa-97b4-ed43d5c7a5f7";
|
|
@@ -80,9 +66,6 @@ describe("Database", () => {
|
|
|
80
66
|
expect.assertions(1);
|
|
81
67
|
|
|
82
68
|
vi.mocked(uuid.v4).mockReturnValueOnce(keyID);
|
|
83
|
-
vi.spyOn(winston, "createLogger").mockReturnValueOnce(
|
|
84
|
-
silentLogger(),
|
|
85
|
-
);
|
|
86
69
|
|
|
87
70
|
const provider = new Database(options);
|
|
88
71
|
await new Promise<void>((resolve, reject) => {
|
package/src/db/schema.ts
CHANGED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Kysely, sql } from "kysely";
|
|
2
|
+
|
|
3
|
+
export async function down(db: Kysely<unknown>): Promise<void> {
|
|
4
|
+
await db.schema.alterTable("users").dropColumn("hashAlgo").execute();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export async function up(db: Kysely<unknown>): Promise<void> {
|
|
8
|
+
await db.schema
|
|
9
|
+
.alterTable("users")
|
|
10
|
+
.addColumn("hashAlgo", "varchar(16)", (cb) =>
|
|
11
|
+
cb.defaultTo("pbkdf2").notNull(),
|
|
12
|
+
)
|
|
13
|
+
.execute();
|
|
14
|
+
|
|
15
|
+
await sql`UPDATE users SET hashAlgo = 'pbkdf2' WHERE hashAlgo = 'pbkdf2'`.execute(
|
|
16
|
+
db,
|
|
17
|
+
);
|
|
18
|
+
}
|
package/src/run.ts
CHANGED
package/src/server/avatar.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import type { Database } from "../Database.ts";
|
|
2
|
-
import type winston from "winston";
|
|
3
|
-
|
|
4
1
|
import * as fs from "node:fs";
|
|
5
2
|
import * as fsp from "node:fs/promises";
|
|
6
3
|
|
|
@@ -20,7 +17,7 @@ import { ALLOWED_IMAGE_TYPES, protect } from "./index.ts";
|
|
|
20
17
|
|
|
21
18
|
const safePathParam = z.string().regex(/^[a-zA-Z0-9._-]+$/);
|
|
22
19
|
|
|
23
|
-
export const getAvatarRouter = (
|
|
20
|
+
export const getAvatarRouter = () => {
|
|
24
21
|
const router = express.Router();
|
|
25
22
|
|
|
26
23
|
router.get("/:userID", async (req, res) => {
|
|
@@ -39,8 +36,8 @@ export const getAvatarRouter = (db: Database, log: winston.Logger) => {
|
|
|
39
36
|
res.set("Cache-control", "public, max-age=31536000");
|
|
40
37
|
|
|
41
38
|
const stream = fs.createReadStream(filePath);
|
|
42
|
-
stream.on("error", (
|
|
43
|
-
|
|
39
|
+
stream.on("error", (_err) => {
|
|
40
|
+
// debugger: avatar stream read error
|
|
44
41
|
res.sendStatus(500);
|
|
45
42
|
});
|
|
46
43
|
stream.pipe(res);
|
|
@@ -65,7 +62,6 @@ export const getAvatarRouter = (db: Database, log: winston.Logger) => {
|
|
|
65
62
|
}
|
|
66
63
|
|
|
67
64
|
if (!payload.file) {
|
|
68
|
-
log.warn("MISSING FILE");
|
|
69
65
|
res.sendStatus(400);
|
|
70
66
|
return;
|
|
71
67
|
}
|
|
@@ -84,10 +80,9 @@ export const getAvatarRouter = (db: Database, log: winston.Logger) => {
|
|
|
84
80
|
try {
|
|
85
81
|
// write the file to disk
|
|
86
82
|
await fsp.writeFile("avatars/" + userDetails.userID, buf);
|
|
87
|
-
log.info("Wrote new avatar " + userDetails.userID);
|
|
88
83
|
res.sendStatus(200);
|
|
89
|
-
} catch (
|
|
90
|
-
|
|
84
|
+
} catch (_err: unknown) {
|
|
85
|
+
// debugger: avatar write failed
|
|
91
86
|
res.sendStatus(500);
|
|
92
87
|
}
|
|
93
88
|
});
|
|
@@ -107,7 +102,6 @@ export const getAvatarRouter = (db: Database, log: winston.Logger) => {
|
|
|
107
102
|
}
|
|
108
103
|
|
|
109
104
|
if (!req.file) {
|
|
110
|
-
log.warn("MISSING FILE");
|
|
111
105
|
res.sendStatus(400);
|
|
112
106
|
return;
|
|
113
107
|
}
|
|
@@ -128,10 +122,9 @@ export const getAvatarRouter = (db: Database, log: winston.Logger) => {
|
|
|
128
122
|
"avatars/" + userDetails.userID,
|
|
129
123
|
req.file.buffer,
|
|
130
124
|
);
|
|
131
|
-
log.info("Wrote new avatar " + userDetails.userID);
|
|
132
125
|
res.sendStatus(200);
|
|
133
|
-
} catch (
|
|
134
|
-
|
|
126
|
+
} catch (_err: unknown) {
|
|
127
|
+
// debugger: avatar write failed
|
|
135
128
|
res.sendStatus(500);
|
|
136
129
|
}
|
|
137
130
|
},
|