@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/README.md +12 -11
- package/dist/ClientManager.js +1 -2
- package/dist/ClientManager.js.map +1 -1
- package/dist/Database.d.ts +24 -1
- package/dist/Database.js +201 -64
- package/dist/Database.js.map +1 -1
- package/dist/Spire.js +81 -8
- package/dist/Spire.js.map +1 -1
- package/dist/server/index.js +15 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/rateLimit.d.ts +10 -0
- package/dist/server/rateLimit.js +51 -0
- package/dist/server/rateLimit.js.map +1 -1
- package/dist/server/user.js +7 -3
- package/dist/server/user.js.map +1 -1
- package/package.json +12 -5
- package/src/ClientManager.ts +1 -2
- package/src/Database.ts +225 -68
- package/src/Spire.ts +85 -11
- package/src/server/index.ts +16 -2
- package/src/server/rateLimit.ts +37 -3
- package/src/server/user.ts +7 -3
- package/dist/__tests__/Database.spec.d.ts +0 -1
- package/dist/__tests__/Database.spec.js +0 -122
- package/dist/__tests__/Database.spec.js.map +0 -1
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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:
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
342
|
+
const permission: Permission = {
|
|
343
|
+
permissionID: crypto.randomUUID(),
|
|
344
|
+
powerLevel,
|
|
345
|
+
resourceID,
|
|
346
|
+
resourceType,
|
|
347
|
+
userID,
|
|
348
|
+
};
|
|
254
349
|
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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(
|
|
609
|
-
|
|
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", "=",
|
|
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
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
if (!
|
|
761
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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 = {
|
package/src/server/index.ts
CHANGED
|
@@ -197,11 +197,25 @@ export const initApp = (
|
|
|
197
197
|
api.use(checkAuth);
|
|
198
198
|
api.use(checkDevice);
|
|
199
199
|
|
|
200
|
-
|
|
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:
|
|
215
|
+
origin:
|
|
216
|
+
corsOrigins.length > 0
|
|
217
|
+
? corsOrigins
|
|
218
|
+
: true /* reflect request Origin */,
|
|
205
219
|
}),
|
|
206
220
|
);
|
|
207
221
|
|
package/src/server/rateLimit.ts
CHANGED
|
@@ -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
|
});
|
package/src/server/user.ts
CHANGED
|
@@ -31,9 +31,13 @@ export const getUserRouter = (
|
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
router.get("/:id/devices", protect, async (req, res) => {
|
|
34
|
-
const
|
|
35
|
-
|
|
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 {};
|