@vex-chat/spire 1.0.1 → 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/src/Database.ts CHANGED
@@ -21,6 +21,7 @@ import type { Migration, MigrationProvider } from "kysely";
21
21
 
22
22
  import { EventEmitter } from "events";
23
23
  import { pbkdf2Sync } from "node:crypto";
24
+ import { statSync } from "node:fs";
24
25
  import * as fs from "node:fs/promises";
25
26
  import path from "node:path";
26
27
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -119,6 +120,34 @@ export interface InternalUserRecord extends UserRecord {
119
120
  export class Database extends EventEmitter {
120
121
  private db: Kysely<ServerDatabase>;
121
122
 
123
+ /** Underlying better-sqlite3 handle (file or :memory:). */
124
+ private readonly rawSqlite: InstanceType<typeof BetterSqlite3>;
125
+
126
+ /**
127
+ * In-process cache for {@link retrieveUserDeviceList} when queried for a
128
+ * single owner. Libvex calls GET /user/:id/devices on every outgoing
129
+ * `forward()`; stress runs otherwise hammer SQLite with identical reads.
130
+ * Cleared on device create/delete and when the DB is closed.
131
+ */
132
+ private readonly userDeviceListCache = new Map<
133
+ string,
134
+ { devices: Device[]; until: number }
135
+ >();
136
+
137
+ /**
138
+ * Short TTL cache for {@link retrieveEmojiList} (key = server id, stored as
139
+ * `emojis.owner`). The noise harness fires `emoji.retrieveList` as a
140
+ * follow-up on ~1/25 successful ops, so this path can contend on SQLite
141
+ * before heavier surfaces. Invalidated on emoji create/delete.
142
+ */
143
+ private readonly emojiListByServerCache = new Map<
144
+ string,
145
+ { rows: Emoji[]; until: number }
146
+ >();
147
+
148
+ private static readonly USER_DEVICE_LIST_CACHE_TTL_MS = 10_000;
149
+ private static readonly EMOJI_LIST_CACHE_TTL_MS = 10_000;
150
+
122
151
  constructor(options?: SpireOptions) {
123
152
  super();
124
153
 
@@ -138,22 +167,86 @@ export class Database extends EventEmitter {
138
167
  break;
139
168
  }
140
169
 
141
- const sqliteDb = new BetterSqlite3(filename);
142
- sqliteDb.pragma("journal_mode = WAL");
143
- sqliteDb.pragma("synchronous = NORMAL");
144
- sqliteDb.pragma("busy_timeout = 5000");
145
- sqliteDb.pragma("cache_size = -64000");
146
- sqliteDb.pragma("temp_store = memory");
147
- sqliteDb.pragma("foreign_keys = ON");
170
+ this.rawSqlite = new BetterSqlite3(filename);
171
+ this.rawSqlite.pragma("journal_mode = WAL");
172
+ this.rawSqlite.pragma("synchronous = NORMAL");
173
+ this.rawSqlite.pragma("busy_timeout = 5000");
174
+ this.rawSqlite.pragma("cache_size = -64000");
175
+ this.rawSqlite.pragma("temp_store = memory");
176
+ this.rawSqlite.pragma("foreign_keys = ON");
148
177
 
149
178
  this.db = new Kysely<ServerDatabase>({
150
- dialect: new SqliteDialect({ database: sqliteDb }),
179
+ dialect: new SqliteDialect({ database: this.rawSqlite }),
151
180
  });
152
181
 
153
182
  void this.init();
154
183
  }
155
184
 
185
+ /**
186
+ * Dev-only snapshot of SQLite files + pragmas (same process as Spire).
187
+ * Not for production callers.
188
+ */
189
+ public getDevSqliteMonitor(): Record<string, unknown> {
190
+ const openedAs = this.rawSqlite.name;
191
+ const absPath =
192
+ openedAs === ":memory:"
193
+ ? ":memory:"
194
+ : path.isAbsolute(openedAs)
195
+ ? openedAs
196
+ : path.resolve(process.cwd(), openedAs);
197
+
198
+ const journalMode = this.rawSqlite.pragma("journal_mode", {
199
+ simple: true,
200
+ });
201
+ const synchronous = this.rawSqlite.pragma("synchronous", {
202
+ simple: true,
203
+ });
204
+ const busyTimeout = this.rawSqlite.pragma("busy_timeout", {
205
+ simple: true,
206
+ });
207
+ const cacheSize = this.rawSqlite.pragma("cache_size", { simple: true });
208
+ const pageCount = this.rawSqlite.pragma("page_count", { simple: true });
209
+ const pageSize = this.rawSqlite.pragma("page_size", { simple: true });
210
+ const freelistCount = this.rawSqlite.pragma("freelist_count", {
211
+ simple: true,
212
+ });
213
+
214
+ const out: Record<string, unknown> = {
215
+ absPath,
216
+ busyTimeout,
217
+ cacheSize,
218
+ freelistCount,
219
+ journalMode,
220
+ openedAs,
221
+ pageCount,
222
+ pageSize,
223
+ synchronous,
224
+ };
225
+
226
+ if (openedAs !== ":memory:") {
227
+ const filePaths = {
228
+ main: absPath,
229
+ shm: `${absPath}-shm`,
230
+ wal: `${absPath}-wal`,
231
+ };
232
+ out["filePaths"] = filePaths;
233
+ const sizes: Record<string, number> = {};
234
+ for (const [label, fp] of Object.entries(filePaths)) {
235
+ try {
236
+ sizes[label] = statSync(fp).size;
237
+ } catch {
238
+ sizes[label] = 0;
239
+ }
240
+ }
241
+ out["fileBytes"] = sizes;
242
+ }
243
+
244
+ return out;
245
+ }
246
+
156
247
  public async close(): Promise<void> {
248
+ this.userDeviceListCache.clear();
249
+ this.emojiListByServerCache.clear();
157
250
  await this.db.destroy();
158
251
  }
159
252
 
@@ -184,6 +277,7 @@ export class Database extends EventEmitter {
184
277
  };
185
278
 
186
279
  await this.db.insertInto("devices").values(device).execute();
280
+ this.userDeviceListCache.delete(owner);
187
281
 
188
282
  const medPreKeys = {
189
283
  deviceID: device.deviceID,
@@ -201,6 +295,7 @@ export class Database extends EventEmitter {
201
295
 
202
296
  public async createEmoji(emoji: Emoji): Promise<void> {
203
297
  await this.db.insertInto("emojis").values(emoji).execute();
298
+ this.emojiListByServerCache.delete(emoji.owner);
204
299
  }
205
300
 
206
301
  public async createFile(file: FileSQL): Promise<void> {
@@ -230,30 +325,31 @@ export class Database extends EventEmitter {
230
325
  resourceID: string,
231
326
  powerLevel: number,
232
327
  ): Promise<Permission> {
233
- const permissionID = crypto.randomUUID();
234
-
235
- // check if it already exists
236
- const checkPermission = await this.db
237
- .selectFrom("permissions")
238
- .selectAll()
239
- .where("userID", "=", userID)
240
- .where("resourceID", "=", resourceID)
241
- .execute();
242
- const existing = checkPermission[0];
243
- if (existing) {
244
- return existing;
245
- }
328
+ // Atomic check-then-insert inside a transaction so two concurrent
329
+ // callers cannot both find "no existing" and insert duplicates.
330
+ return this.db.transaction().execute(async (trx) => {
331
+ const checkPermission = await trx
332
+ .selectFrom("permissions")
333
+ .selectAll()
334
+ .where("userID", "=", userID)
335
+ .where("resourceID", "=", resourceID)
336
+ .execute();
337
+ const existing = checkPermission[0];
338
+ if (existing) {
339
+ return existing;
340
+ }
246
341
 
247
- const permission: Permission = {
248
- permissionID,
249
- powerLevel,
250
- resourceID,
251
- resourceType,
252
- userID,
253
- };
342
+ const permission: Permission = {
343
+ permissionID: crypto.randomUUID(),
344
+ powerLevel,
345
+ resourceID,
346
+ resourceType,
347
+ userID,
348
+ };
254
349
 
255
- await this.db.insertInto("permissions").values(permission).execute();
256
- return permission;
350
+ await trx.insertInto("permissions").values(permission).execute();
351
+ return permission;
352
+ });
257
353
  }
258
354
 
259
355
  public async createServer(name: string, ownerID: string): Promise<Server> {
@@ -321,6 +417,12 @@ export class Database extends EventEmitter {
321
417
  }
322
418
 
323
419
  public async deleteDevice(deviceID: string): Promise<void> {
420
+ const ownerRow = await this.db
421
+ .selectFrom("devices")
422
+ .select("owner")
423
+ .where("deviceID", "=", deviceID)
424
+ .executeTakeFirst();
425
+
324
426
  await this.db
325
427
  .deleteFrom("preKeys")
326
428
  .where("deviceID", "=", deviceID)
@@ -336,13 +438,21 @@ export class Database extends EventEmitter {
336
438
  .set({ deleted: 1 })
337
439
  .where("deviceID", "=", deviceID)
338
440
  .execute();
441
+
442
+ if (ownerRow?.owner) {
443
+ this.userDeviceListCache.delete(ownerRow.owner);
444
+ }
339
445
  }
340
446
 
341
447
  public async deleteEmoji(emojiID: string): Promise<void> {
448
+ const existing = await this.retrieveEmoji(emojiID);
342
449
  await this.db
343
450
  .deleteFrom("emojis")
344
451
  .where("emojiID", "=", emojiID)
345
452
  .execute();
453
+ if (existing) {
454
+ this.emojiListByServerCache.delete(existing.owner);
455
+ }
346
456
  }
347
457
 
348
458
  public async deleteInvite(inviteID: string): Promise<void> {
@@ -405,31 +515,34 @@ export class Database extends EventEmitter {
405
515
  }
406
516
 
407
517
  public async getOTK(deviceID: string): Promise<null | PreKeysWS> {
408
- const rows: PreKeysSQL[] = await this.db
409
- .selectFrom("oneTimeKeys")
410
- .selectAll()
411
- .where("deviceID", "=", deviceID)
412
- .orderBy("index")
413
- .limit(1)
414
- .execute();
415
- const otkInfo = rows[0];
416
- if (!otkInfo) {
417
- return null;
418
- }
419
- const otk: PreKeysWS = {
420
- deviceID: otkInfo.deviceID,
421
- index: otkInfo.index,
422
- publicKey: XUtils.decodeHex(otkInfo.publicKey),
423
- signature: XUtils.decodeHex(otkInfo.signature),
424
- };
518
+ // Atomic select-then-delete inside a transaction so two concurrent
519
+ // callers cannot dispense the same one-time key.
520
+ return this.db.transaction().execute(async (trx) => {
521
+ const rows: PreKeysSQL[] = await trx
522
+ .selectFrom("oneTimeKeys")
523
+ .selectAll()
524
+ .where("deviceID", "=", deviceID)
525
+ .orderBy("index")
526
+ .limit(1)
527
+ .execute();
528
+ const otkInfo = rows[0];
529
+ if (!otkInfo) {
530
+ return null;
531
+ }
425
532
 
426
- // delete the otk
427
- await this.db
428
- .deleteFrom("oneTimeKeys")
429
- .where("deviceID", "=", deviceID)
430
- .where("index", "=", otk.index)
431
- .execute();
432
- return otk;
533
+ await trx
534
+ .deleteFrom("oneTimeKeys")
535
+ .where("deviceID", "=", deviceID)
536
+ .where("index", "=", otkInfo.index)
537
+ .execute();
538
+
539
+ return {
540
+ deviceID: otkInfo.deviceID,
541
+ index: otkInfo.index,
542
+ publicKey: XUtils.decodeHex(otkInfo.publicKey),
543
+ signature: XUtils.decodeHex(otkInfo.signature),
544
+ };
545
+ });
433
546
  }
434
547
 
435
548
  public async getOTKCount(deviceID: string): Promise<number> {
@@ -605,12 +718,25 @@ export class Database extends EventEmitter {
605
718
  return rows[0] ?? null;
606
719
  }
607
720
 
608
- public async retrieveEmojiList(userID: string): Promise<Emoji[]> {
609
- return this.db
721
+ public async retrieveEmojiList(serverID: string): Promise<Emoji[]> {
722
+ const now = Date.now();
723
+ const hit = this.emojiListByServerCache.get(serverID);
724
+ if (hit && hit.until > now) {
725
+ return hit.rows.map((r) => ({ ...r }));
726
+ }
727
+
728
+ const rows: Emoji[] = await this.db
610
729
  .selectFrom("emojis")
611
730
  .selectAll()
612
- .where("owner", "=", userID)
731
+ .where("owner", "=", serverID)
613
732
  .execute();
733
+
734
+ this.emojiListByServerCache.set(serverID, {
735
+ rows: rows.map((r) => ({ ...r })),
736
+ until: now + Database.EMOJI_LIST_CACHE_TTL_MS,
737
+ });
738
+
739
+ return rows.map((r) => ({ ...r }));
614
740
  }
615
741
 
616
742
  public async retrieveFile(fileID: string): Promise<FileSQL | null> {
@@ -752,17 +878,25 @@ export class Database extends EventEmitter {
752
878
  .where("serverID", "=", serverID)
753
879
  .execute();
754
880
 
755
- return rows.filter((invite: Invite) => {
756
- const valid =
757
- new Date(Date.now()).getTime() <
758
- new Date(invite.expiration).getTime();
759
-
760
- if (!valid) {
761
- void this.deleteInvite(invite.inviteID);
881
+ const nowMs = Date.now();
882
+ const expiredIds: string[] = [];
883
+ const valid: Invite[] = [];
884
+ for (const row of rows) {
885
+ const expMs = new Date(row.expiration).getTime();
886
+ if (!Number.isFinite(expMs) || expMs <= nowMs) {
887
+ expiredIds.push(row.inviteID);
888
+ continue;
762
889
  }
763
-
764
- return valid;
765
- });
890
+ valid.push(row);
891
+ }
892
+ if (expiredIds.length > 0) {
893
+ await this.db
894
+ .deleteFrom("invites")
895
+ .where("serverID", "=", serverID)
896
+ .where("inviteID", "in", expiredIds)
897
+ .execute();
898
+ }
899
+ return valid;
766
900
  }
767
901
 
768
902
  public async retrieveServers(userID: string): Promise<Server[]> {
@@ -803,13 +937,36 @@ export class Database extends EventEmitter {
803
937
  }
804
938
 
805
939
  public async retrieveUserDeviceList(userIDs: string[]): Promise<Device[]> {
940
+ const now = Date.now();
941
+ if (userIDs.length === 1) {
942
+ const owner = userIDs[0];
943
+ if (typeof owner === "string") {
944
+ const hit = this.userDeviceListCache.get(owner);
945
+ if (hit && hit.until > now) {
946
+ return hit.devices.map((d) => ({ ...d }));
947
+ }
948
+ }
949
+ }
950
+
806
951
  const rows = await this.db
807
952
  .selectFrom("devices")
808
953
  .selectAll()
809
954
  .where("owner", "in", userIDs)
810
955
  .where("deleted", "=", 0)
811
956
  .execute();
812
- return rows.map(toDevice);
957
+ const devices = rows.map(toDevice);
958
+
959
+ if (userIDs.length === 1) {
960
+ const owner = userIDs[0];
961
+ if (typeof owner === "string") {
962
+ this.userDeviceListCache.set(owner, {
963
+ devices: devices.map((d) => ({ ...d })),
964
+ until: now + Database.USER_DEVICE_LIST_CACHE_TTL_MS,
965
+ });
966
+ }
967
+ }
968
+
969
+ return devices;
813
970
  }
814
971
 
815
972
  public async retrieveUsers(): Promise<InternalUserRecord[]> {
package/src/Spire.ts CHANGED
@@ -4,6 +4,7 @@ import type { Server } from "http";
4
4
  import { EventEmitter } from "events";
5
5
  import { execSync } from "node:child_process";
6
6
  import * as fs from "node:fs";
7
+ import { freemem, loadavg, totalmem } from "node:os";
7
8
  import path from "node:path";
8
9
  import { fileURLToPath } from "node:url";
9
10
 
@@ -31,7 +32,7 @@ import { z } from "zod/v4";
31
32
  import { ClientManager } from "./ClientManager.ts";
32
33
  import { Database, hashPasswordArgon2, verifyPassword } from "./Database.ts";
33
34
  import { initApp, protect } from "./server/index.ts";
34
- import { authLimiter } from "./server/rateLimit.ts";
35
+ import { authLimiter, devApiKeySkipsRateLimits } from "./server/rateLimit.ts";
35
36
  import { censorUser, getParam, getUser } from "./server/utils.ts";
36
37
  import { getJwtSecret } from "./utils/jwtSecret.ts";
37
38
  import { msgpack } from "./utils/msgpack.ts";
@@ -190,13 +191,22 @@ export class Spire extends EventEmitter {
190
191
 
191
192
  private async bootstrapRequestCounter(): Promise<void> {
192
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).
193
199
  const startupIncrements = this.queuedRequestIncrements;
194
200
  this.queuedRequestIncrements = 0;
195
- this.requestsTotal = persistedTotal + startupIncrements;
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
+
196
207
  if (startupIncrements > 0) {
197
208
  await this.db.incrementRequestsTotal(startupIncrements);
198
209
  }
199
- this.requestsTotalLoaded = true;
200
210
  }
201
211
 
202
212
  private createActionToken(scope: TokenScopes): ActionToken {
@@ -210,8 +220,9 @@ export class Spire extends EventEmitter {
210
220
  }
211
221
 
212
222
  private deleteActionToken(key: ActionToken) {
213
- if (this.actionTokens.includes(key)) {
214
- this.actionTokens.splice(this.actionTokens.indexOf(key), 1);
223
+ const idx = this.actionTokens.indexOf(key);
224
+ if (idx !== -1) {
225
+ this.actionTokens.splice(idx, 1);
215
226
  }
216
227
  }
217
228
 
@@ -286,11 +297,9 @@ export class Spire extends EventEmitter {
286
297
  );
287
298
 
288
299
  client.on("fail", () => {
289
- if (this.clients.includes(client)) {
290
- this.clients.splice(
291
- this.clients.indexOf(client),
292
- 1,
293
- );
300
+ const idx = this.clients.indexOf(client);
301
+ if (idx !== -1) {
302
+ this.clients.splice(idx, 1);
294
303
  }
295
304
  });
296
305
 
@@ -448,6 +457,68 @@ export class Spire extends EventEmitter {
448
457
  });
449
458
  });
450
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
+
451
522
  this.api.post("/goodbye", protect, (req, res) => {
452
523
  jwt.sign({ user: req.user }, getJwtSecret(), { expiresIn: -1 });
453
524
  res.sendStatus(200);
@@ -763,7 +834,10 @@ export class Spire extends EventEmitter {
763
834
  data?: unknown,
764
835
  deviceID?: string,
765
836
  ): void {
766
- for (const client of this.clients) {
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) {
767
841
  if (deviceID) {
768
842
  if (client.getDevice().deviceID === deviceID) {
769
843
  const msg: NotifyMsg = {
@@ -197,11 +197,25 @@ export const initApp = (
197
197
  api.use(checkAuth);
198
198
  api.use(checkDevice);
199
199
 
200
- const allowedOrigins = process.env["CORS_ORIGINS"]?.split(",") ?? [];
200
+ // Browser clients (web, Tauri, Capacitor, etc.) hit Spire from arbitrary
201
+ // origins when self-hosted or embedded in third-party apps. libvex uses
202
+ // `Authorization: Bearer` only (no cookies), so reflecting `Origin` is the
203
+ // usual bearer-API pattern. Set `CORS_ORIGINS` to a comma-separated allowlist
204
+ // when an operator wants to restrict which frontends may call the API.
205
+ const corsRaw = process.env["CORS_ORIGINS"];
206
+ const corsOrigins = corsRaw
207
+ ? corsRaw
208
+ .split(",")
209
+ .map((o) => o.trim())
210
+ .filter((o) => o.length > 0)
211
+ : [];
201
212
  api.use(
202
213
  cors({
203
214
  credentials: true,
204
- origin: allowedOrigins.length > 0 ? allowedOrigins : false,
215
+ origin:
216
+ corsOrigins.length > 0
217
+ ? corsOrigins
218
+ : true /* reflect request Origin */,
205
219
  }),
206
220
  );
207
221
 
@@ -1,3 +1,37 @@
1
+ import type { Request } from "express";
2
+
3
+ import { timingSafeEqual } from "node:crypto";
4
+
5
+ import rateLimit, { ipKeyGenerator } from "express-rate-limit";
6
+
7
+ /** HTTP header carrying the dev API key (must match {@link process.env.DEV_API_KEY}). */
8
+ export const DEV_API_KEY_HEADER = "x-dev-api-key";
9
+
10
+ /**
11
+ * When `DEV_API_KEY` is set in the environment, any request whose
12
+ * `x-dev-api-key` header matches (constant-time) skips all in-process rate
13
+ * limiters. Dev / load-testing escape hatch only — never set in production.
14
+ * (Future: first-class API keys with scopes may reuse this header name.)
15
+ */
16
+ export function devApiKeySkipsRateLimits(req: Request): boolean {
17
+ const configured = process.env["DEV_API_KEY"]?.trim() ?? "";
18
+ if (configured.length === 0) {
19
+ return false;
20
+ }
21
+ const presented = req.get(DEV_API_KEY_HEADER);
22
+ if (!presented || presented.length !== configured.length) {
23
+ return false;
24
+ }
25
+ try {
26
+ return timingSafeEqual(
27
+ Buffer.from(presented, "utf8"),
28
+ Buffer.from(configured, "utf8"),
29
+ );
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
1
35
  /**
2
36
  * Rate limiting middleware.
3
37
  *
@@ -21,9 +55,6 @@
21
55
  * `trust proxy` must be set on the Express app (see Spire.ts) so
22
56
  * `req.ip` returns the real client address, not the immediate proxy.
23
57
  */
24
- import type { Request } from "express";
25
-
26
- import rateLimit, { ipKeyGenerator } from "express-rate-limit";
27
58
 
28
59
  /**
29
60
  * Bucket requests by the real client IP, IPv6-safe.
@@ -46,6 +77,7 @@ export const globalLimiter = rateLimit({
46
77
  keyGenerator: keyByIp,
47
78
  legacyHeaders: false,
48
79
  limit: 3000,
80
+ skip: devApiKeySkipsRateLimits,
49
81
  standardHeaders: "draft-7",
50
82
  windowMs: 15 * 60 * 1000,
51
83
  });
@@ -63,6 +95,7 @@ export const authLimiter = rateLimit({
63
95
  keyGenerator: keyByIp,
64
96
  legacyHeaders: false,
65
97
  limit: 50,
98
+ skip: devApiKeySkipsRateLimits,
66
99
  skipSuccessfulRequests: true,
67
100
  standardHeaders: "draft-7",
68
101
  windowMs: 15 * 60 * 1000,
@@ -81,6 +114,7 @@ export const uploadLimiter = rateLimit({
81
114
  keyGenerator: keyByIp,
82
115
  legacyHeaders: false,
83
116
  limit: 200,
117
+ skip: devApiKeySkipsRateLimits,
84
118
  standardHeaders: "draft-7",
85
119
  windowMs: 60 * 1000,
86
120
  });
@@ -31,9 +31,13 @@ export const getUserRouter = (
31
31
  });
32
32
 
33
33
  router.get("/:id/devices", protect, async (req, res) => {
34
- const deviceList = await db.retrieveUserDeviceList([
35
- getParam(req, "id"),
36
- ]);
34
+ const id = getParam(req, "id");
35
+ const user = await db.retrieveUser(id);
36
+ if (!user) {
37
+ res.sendStatus(404);
38
+ return;
39
+ }
40
+ const deviceList = await db.retrieveUserDeviceList([id]);
37
41
  return res.send(msgpack.encode(deviceList));
38
42
  });
39
43
 
@@ -1 +0,0 @@
1
- export {};