@fitzzero/quickdraw-core 3.6.0 → 3.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +218 -64
- package/dist/client/index.d.ts +34 -4
- package/dist/client/index.js +57 -65
- package/dist/client/index.js.map +1 -1
- package/dist/client/testing.d.ts +1 -1
- package/dist/client/testing.js.map +1 -1
- package/dist/server/express/index.d.ts +37 -0
- package/dist/server/express/index.js +60 -0
- package/dist/server/express/index.js.map +1 -0
- package/dist/server/index.d.ts +330 -5
- package/dist/server/index.js +593 -144
- package/dist/server/index.js.map +1 -1
- package/dist/server/testing-prisma.d.ts +101 -0
- package/dist/server/testing-prisma.js +172 -0
- package/dist/server/testing-prisma.js.map +1 -0
- package/dist/server/testing.d.ts +1 -1
- package/dist/server/testing.js +87 -22
- package/dist/server/testing.js.map +1 -1
- package/dist/shared/index.d.ts +77 -1
- package/dist/shared/index.js +5 -1
- package/dist/shared/index.js.map +1 -1
- package/dist/{types-DniMxkQs.d.ts → types-ClgSr90l.d.ts} +5 -1
- package/dist/{types-B1z5aXdR.d.ts → types-DUHPIy_5.d.ts} +20 -1
- package/eslint-config/index.mjs +1 -2
- package/eslint-plugin-quickdraw/index.mjs +15 -7
- package/eslint-plugin-quickdraw/rules/no-cross-service-mutations.mjs +31 -32
- package/eslint-plugin-quickdraw/rules/no-direct-prisma-mutations.mjs +17 -18
- package/eslint-plugin-quickdraw/rules/no-raw-button-strings.mjs +52 -0
- package/eslint-plugin-quickdraw/rules/no-raw-service-room-string.mjs +140 -0
- package/eslint-plugin-quickdraw/rules/no-raw-socket-emit.mjs +20 -22
- package/eslint-plugin-quickdraw/rules/no-raw-socket-on.mjs +17 -17
- package/eslint-plugin-quickdraw/rules/no-raw-tooltip-strings.mjs +59 -0
- package/eslint-plugin-quickdraw/rules/no-raw-typography-strings.mjs +52 -0
- package/eslint-plugin-quickdraw/rules/no-service-method-record.mjs +3 -3
- package/eslint-plugin-quickdraw/rules/no-unsafe-payload-cast.mjs +8 -8
- package/eslint-plugin-quickdraw/rules/require-zod-schema.mjs +15 -20
- package/package.json +74 -50
package/dist/server/index.js
CHANGED
|
@@ -2,11 +2,15 @@ import express from 'express';
|
|
|
2
2
|
import { createServer } from 'http';
|
|
3
3
|
import { Server } from 'socket.io';
|
|
4
4
|
import * as jose from 'jose';
|
|
5
|
+
import { randomBytes, createCipheriv, createDecipheriv } from 'crypto';
|
|
5
6
|
import * as readline from 'readline';
|
|
6
7
|
import * as fs from 'fs';
|
|
7
8
|
import * as path from 'path';
|
|
8
9
|
|
|
9
10
|
// src/shared/types.ts
|
|
11
|
+
function channelEventName(serviceName, channelName) {
|
|
12
|
+
return `channel:${serviceName}:${channelName}`;
|
|
13
|
+
}
|
|
10
14
|
var consoleLogger = {
|
|
11
15
|
info: (message, meta) => console.log(`[INFO] ${message}`, meta ?? ""),
|
|
12
16
|
warn: (message, meta) => console.warn(`[WARN] ${message}`, meta ?? ""),
|
|
@@ -16,18 +20,8 @@ var consoleLogger = {
|
|
|
16
20
|
};
|
|
17
21
|
|
|
18
22
|
// src/server/utils/zodToAdminFields.ts
|
|
19
|
-
var NON_EDITABLE_FIELDS = /* @__PURE__ */ new Set([
|
|
20
|
-
|
|
21
|
-
"createdAt",
|
|
22
|
-
"updatedAt",
|
|
23
|
-
"created_at",
|
|
24
|
-
"updated_at"
|
|
25
|
-
]);
|
|
26
|
-
var DEFAULT_HIDDEN_FIELDS = /* @__PURE__ */ new Set([
|
|
27
|
-
"acl",
|
|
28
|
-
"serviceAccess",
|
|
29
|
-
"service_access"
|
|
30
|
-
]);
|
|
23
|
+
var NON_EDITABLE_FIELDS = /* @__PURE__ */ new Set(["id", "createdAt", "updatedAt", "created_at", "updated_at"]);
|
|
24
|
+
var DEFAULT_HIDDEN_FIELDS = /* @__PURE__ */ new Set(["acl", "serviceAccess", "service_access"]);
|
|
31
25
|
function toLabel(fieldName) {
|
|
32
26
|
return fieldName.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
33
27
|
}
|
|
@@ -68,9 +62,7 @@ function getEnumValues(zodType) {
|
|
|
68
62
|
}
|
|
69
63
|
if (typeName === "ZodNativeEnum") {
|
|
70
64
|
const enumObj = zodType._def.values;
|
|
71
|
-
return Object.values(enumObj).filter(
|
|
72
|
-
(v) => typeof v === "string"
|
|
73
|
-
);
|
|
65
|
+
return Object.values(enumObj).filter((v) => typeof v === "string");
|
|
74
66
|
}
|
|
75
67
|
return void 0;
|
|
76
68
|
}
|
|
@@ -167,13 +159,9 @@ function getDefaultEntityFields() {
|
|
|
167
159
|
}
|
|
168
160
|
function mergeWithDefaultFields(schemaFields, options = {}) {
|
|
169
161
|
const { includeDefaults = ["id", "createdAt"] } = options;
|
|
170
|
-
const defaultFields = getDefaultEntityFields().filter(
|
|
171
|
-
(f) => includeDefaults.includes(f.name)
|
|
172
|
-
);
|
|
162
|
+
const defaultFields = getDefaultEntityFields().filter((f) => includeDefaults.includes(f.name));
|
|
173
163
|
const defaultNames = new Set(defaultFields.map((f) => f.name));
|
|
174
|
-
const filteredSchemaFields = schemaFields.filter(
|
|
175
|
-
(f) => !defaultNames.has(f.name)
|
|
176
|
-
);
|
|
164
|
+
const filteredSchemaFields = schemaFields.filter((f) => !defaultNames.has(f.name));
|
|
177
165
|
return [...defaultFields, ...filteredSchemaFields];
|
|
178
166
|
}
|
|
179
167
|
|
|
@@ -191,6 +179,8 @@ var BaseService = class {
|
|
|
191
179
|
io = null;
|
|
192
180
|
// Collection of public methods for registry discovery
|
|
193
181
|
publicMethods = /* @__PURE__ */ new Map();
|
|
182
|
+
// Collection of channels for registry discovery
|
|
183
|
+
publicChannels = /* @__PURE__ */ new Map();
|
|
194
184
|
// Admin metadata configuration (set by installAdminMethods)
|
|
195
185
|
adminMeta = null;
|
|
196
186
|
constructor(options) {
|
|
@@ -258,9 +248,7 @@ var BaseService = class {
|
|
|
258
248
|
this.subscribers.get(entryId).add(socket);
|
|
259
249
|
const roomName = this.getRoomName(entryId);
|
|
260
250
|
void socket.join(roomName);
|
|
261
|
-
this.logger.debug(
|
|
262
|
-
`User ${socket.userId} subscribed to ${entryId} (room: ${roomName})`
|
|
263
|
-
);
|
|
251
|
+
this.logger.debug(`User ${socket.userId} subscribed to ${entryId} (room: ${roomName})`);
|
|
264
252
|
const entity = await this.findById(entryId);
|
|
265
253
|
if (!entity) return null;
|
|
266
254
|
return this.filterEntityForSubscriber(entity, socket, entryId);
|
|
@@ -303,11 +291,7 @@ var BaseService = class {
|
|
|
303
291
|
results[entryId] = null;
|
|
304
292
|
continue;
|
|
305
293
|
}
|
|
306
|
-
results[entryId] = this.filterEntityForSubscriber(
|
|
307
|
-
entity,
|
|
308
|
-
socket,
|
|
309
|
-
entryId
|
|
310
|
-
);
|
|
294
|
+
results[entryId] = this.filterEntityForSubscriber(entity, socket, entryId);
|
|
311
295
|
}
|
|
312
296
|
this.logger.debug(
|
|
313
297
|
`User ${socket.userId} batch-subscribed to ${allowedIds.length}/${entryIds.length} entities`
|
|
@@ -325,12 +309,7 @@ var BaseService = class {
|
|
|
325
309
|
const results = /* @__PURE__ */ new Map();
|
|
326
310
|
await Promise.all(
|
|
327
311
|
entryIds.map(async (id) => {
|
|
328
|
-
const allowed = await this.checkSubscriptionAccess(
|
|
329
|
-
userId,
|
|
330
|
-
id,
|
|
331
|
-
requiredLevel,
|
|
332
|
-
socket
|
|
333
|
-
);
|
|
312
|
+
const allowed = await this.checkSubscriptionAccess(userId, id, requiredLevel, socket);
|
|
334
313
|
results.set(id, allowed);
|
|
335
314
|
})
|
|
336
315
|
);
|
|
@@ -344,9 +323,7 @@ var BaseService = class {
|
|
|
344
323
|
* Default: fetches each entity individually in parallel.
|
|
345
324
|
*/
|
|
346
325
|
async findByIds(ids) {
|
|
347
|
-
const results = await Promise.all(
|
|
348
|
-
ids.map((id) => this.findById(id))
|
|
349
|
-
);
|
|
326
|
+
const results = await Promise.all(ids.map((id) => this.findById(id)));
|
|
350
327
|
return results.filter((e) => e !== null);
|
|
351
328
|
}
|
|
352
329
|
/**
|
|
@@ -410,9 +387,37 @@ var BaseService = class {
|
|
|
410
387
|
return;
|
|
411
388
|
}
|
|
412
389
|
anySocket.to(roomName).emit(eventName, data);
|
|
413
|
-
this.logger.debug(
|
|
414
|
-
|
|
415
|
-
|
|
390
|
+
this.logger.debug(`Emitted ${eventName} to room ${roomName} (via socket fallback)`);
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Emit a custom event to a room using Socket.io's volatile flag.
|
|
394
|
+
*
|
|
395
|
+
* Volatile events are dropped for clients whose connection is backpressured
|
|
396
|
+
* instead of being buffered — exactly what you want for high-frequency state
|
|
397
|
+
* that is superseded by the next emit (game snapshots, cursor positions).
|
|
398
|
+
* Never use this for events a client must not miss.
|
|
399
|
+
*
|
|
400
|
+
* Intentionally does no per-call logging: this is a hot path, typically
|
|
401
|
+
* called at tick rate (10–60Hz).
|
|
402
|
+
*
|
|
403
|
+
* @example
|
|
404
|
+
* ```typescript
|
|
405
|
+
* // In a 20Hz game loop
|
|
406
|
+
* this.emitToRoomVolatile(
|
|
407
|
+
* this.getRoomName(worldId),
|
|
408
|
+
* 'game:snapshot',
|
|
409
|
+
* snapshot
|
|
410
|
+
* );
|
|
411
|
+
* ```
|
|
412
|
+
*/
|
|
413
|
+
emitToRoomVolatile(roomName, eventName, data) {
|
|
414
|
+
if (this.io) {
|
|
415
|
+
this.io.to(roomName).volatile.emit(eventName, data);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const anySocket = this.getAnySubscriberSocket();
|
|
419
|
+
if (!anySocket) return;
|
|
420
|
+
anySocket.to(roomName).volatile.emit(eventName, data);
|
|
416
421
|
}
|
|
417
422
|
/**
|
|
418
423
|
* Emit a custom event to a specific user's room.
|
|
@@ -689,10 +694,7 @@ var BaseService = class {
|
|
|
689
694
|
schema: options?.schema,
|
|
690
695
|
resolveEntryId: options?.resolveEntryId
|
|
691
696
|
};
|
|
692
|
-
this.publicMethods.set(
|
|
693
|
-
name,
|
|
694
|
-
definition
|
|
695
|
-
);
|
|
697
|
+
this.publicMethods.set(name, definition);
|
|
696
698
|
return definition;
|
|
697
699
|
}
|
|
698
700
|
/**
|
|
@@ -701,6 +703,78 @@ var BaseService = class {
|
|
|
701
703
|
getPublicMethods() {
|
|
702
704
|
return Array.from(this.publicMethods.values());
|
|
703
705
|
}
|
|
706
|
+
// ===========================================================================
|
|
707
|
+
// Channel Definition (high-frequency, fire-and-forget events)
|
|
708
|
+
// ===========================================================================
|
|
709
|
+
/**
|
|
710
|
+
* Define a channel: a fire-and-forget event for high-frequency traffic
|
|
711
|
+
* (game input, cursor positions, typing indicators).
|
|
712
|
+
*
|
|
713
|
+
* Channels differ from methods:
|
|
714
|
+
* - No ack, no response — the handler returns void and errors are logged,
|
|
715
|
+
* never sent to the client.
|
|
716
|
+
* - Exempt from the global rate limiter; each channel enforces its own
|
|
717
|
+
* per-socket token bucket (`ratePerSecond`/`burst`). Excess messages are
|
|
718
|
+
* silently dropped; sustained extreme abuse disconnects the socket.
|
|
719
|
+
* - Access checks are fully synchronous and in-memory: authentication is
|
|
720
|
+
* always required; "Moderate"/"Admin" check the socket's serviceAccess;
|
|
721
|
+
* entry-level access is expressed via `requireRoom` (the socket must
|
|
722
|
+
* already be in that room, which was ACL-gated at subscribe time).
|
|
723
|
+
* - `schema` is required — channel payloads are untrusted input arriving
|
|
724
|
+
* at tick rate.
|
|
725
|
+
*
|
|
726
|
+
* Routes as the Socket.io event `channel:<serviceName>:<name>`.
|
|
727
|
+
*
|
|
728
|
+
* @example
|
|
729
|
+
* ```typescript
|
|
730
|
+
* this.defineChannel(
|
|
731
|
+
* "input",
|
|
732
|
+
* "Read",
|
|
733
|
+
* (payload, ctx) => this.sim.applyInput(ctx.userId, payload),
|
|
734
|
+
* {
|
|
735
|
+
* schema: gameInputSchema,
|
|
736
|
+
* ratePerSecond: 30,
|
|
737
|
+
* requireRoom: () => this.getRoomName(GLOBAL_WORLD_ID),
|
|
738
|
+
* },
|
|
739
|
+
* );
|
|
740
|
+
* ```
|
|
741
|
+
*/
|
|
742
|
+
defineChannel(name, access, handler, options) {
|
|
743
|
+
const ratePerSecond = options.ratePerSecond ?? 30;
|
|
744
|
+
const definition = {
|
|
745
|
+
name,
|
|
746
|
+
access,
|
|
747
|
+
handler,
|
|
748
|
+
schema: options.schema,
|
|
749
|
+
ratePerSecond,
|
|
750
|
+
burst: options.burst ?? ratePerSecond * 2,
|
|
751
|
+
requireRoom: options.requireRoom
|
|
752
|
+
};
|
|
753
|
+
this.publicChannels.set(name, definition);
|
|
754
|
+
return definition;
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Get all channels for registry discovery.
|
|
758
|
+
*/
|
|
759
|
+
getPublicChannels() {
|
|
760
|
+
return Array.from(this.publicChannels.values());
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Synchronous, in-memory access check for a channel message.
|
|
764
|
+
* Called by the registry on the hot path — must not touch the database.
|
|
765
|
+
* Returns false to silently drop the message.
|
|
766
|
+
*/
|
|
767
|
+
checkChannelAccess(channel, socket, payload) {
|
|
768
|
+
if (!socket.userId) return false;
|
|
769
|
+
if (channel.access !== "Public" && channel.access !== "Read" && !this.hasServiceAccess(socket, channel.access)) {
|
|
770
|
+
return false;
|
|
771
|
+
}
|
|
772
|
+
if (channel.requireRoom) {
|
|
773
|
+
const roomName = channel.requireRoom(payload);
|
|
774
|
+
if (roomName && !socket.rooms.has(roomName)) return false;
|
|
775
|
+
}
|
|
776
|
+
return true;
|
|
777
|
+
}
|
|
704
778
|
/**
|
|
705
779
|
* Verify that all expected service methods have been defined via defineMethod().
|
|
706
780
|
* Call at the end of initMethods() to catch missing implementations at boot time.
|
|
@@ -725,9 +799,7 @@ var BaseService = class {
|
|
|
725
799
|
}
|
|
726
800
|
}
|
|
727
801
|
if (missing.length > 0) {
|
|
728
|
-
throw new Error(
|
|
729
|
-
`${this.serviceName}: Missing method implementations: ${missing.join(", ")}`
|
|
730
|
-
);
|
|
802
|
+
throw new Error(`${this.serviceName}: Missing method implementations: ${missing.join(", ")}`);
|
|
731
803
|
}
|
|
732
804
|
}
|
|
733
805
|
// ===========================================================================
|
|
@@ -758,15 +830,7 @@ var BaseService = class {
|
|
|
758
830
|
* ```
|
|
759
831
|
*/
|
|
760
832
|
installAdminMethods(options) {
|
|
761
|
-
const {
|
|
762
|
-
expose,
|
|
763
|
-
access,
|
|
764
|
-
schema,
|
|
765
|
-
displayName,
|
|
766
|
-
tableColumns,
|
|
767
|
-
hiddenFields,
|
|
768
|
-
fieldOverrides
|
|
769
|
-
} = options;
|
|
833
|
+
const { expose, access, schema, displayName, tableColumns, hiddenFields, fieldOverrides } = options;
|
|
770
834
|
if (schema) {
|
|
771
835
|
const schemaFields = zodToAdminFields(schema, {
|
|
772
836
|
hiddenFields,
|
|
@@ -820,9 +884,7 @@ var BaseService = class {
|
|
|
820
884
|
name: "adminCreate",
|
|
821
885
|
access: access.create,
|
|
822
886
|
handler: async (payload, _context) => {
|
|
823
|
-
return await this.adminCreate(
|
|
824
|
-
payload.data
|
|
825
|
-
);
|
|
887
|
+
return await this.adminCreate(payload.data);
|
|
826
888
|
}
|
|
827
889
|
});
|
|
828
890
|
}
|
|
@@ -862,9 +924,7 @@ var BaseService = class {
|
|
|
862
924
|
name: "adminGetSubscribers",
|
|
863
925
|
access: access.getSubscribers,
|
|
864
926
|
handler: async (payload, _context) => {
|
|
865
|
-
return this.adminGetSubscribers(
|
|
866
|
-
payload.entryId
|
|
867
|
-
);
|
|
927
|
+
return this.adminGetSubscribers(payload.entryId);
|
|
868
928
|
},
|
|
869
929
|
resolveEntryId: (payload) => payload.entryId
|
|
870
930
|
});
|
|
@@ -874,9 +934,7 @@ var BaseService = class {
|
|
|
874
934
|
name: "adminReemit",
|
|
875
935
|
access: access.reemit,
|
|
876
936
|
handler: async (payload, _context) => {
|
|
877
|
-
return await this.adminReemit(
|
|
878
|
-
payload.entryId
|
|
879
|
-
);
|
|
937
|
+
return await this.adminReemit(payload.entryId);
|
|
880
938
|
},
|
|
881
939
|
resolveEntryId: (payload) => payload.entryId
|
|
882
940
|
});
|
|
@@ -886,16 +944,12 @@ var BaseService = class {
|
|
|
886
944
|
name: "adminUnsubscribeAll",
|
|
887
945
|
access: access.unsubscribeAll,
|
|
888
946
|
handler: async (payload, _context) => {
|
|
889
|
-
return this.adminUnsubscribeAll(
|
|
890
|
-
payload.entryId
|
|
891
|
-
);
|
|
947
|
+
return this.adminUnsubscribeAll(payload.entryId);
|
|
892
948
|
},
|
|
893
949
|
resolveEntryId: (payload) => payload.entryId
|
|
894
950
|
});
|
|
895
951
|
}
|
|
896
|
-
const installedMethods = Object.keys(expose).filter(
|
|
897
|
-
(k) => expose[k]
|
|
898
|
-
);
|
|
952
|
+
const installedMethods = Object.keys(expose).filter((k) => expose[k]);
|
|
899
953
|
if (shouldExposeMeta) installedMethods.push("meta");
|
|
900
954
|
this.logger.info(`Installed admin methods: ${installedMethods.join(", ")}`);
|
|
901
955
|
}
|
|
@@ -1047,6 +1101,8 @@ var BaseService = class {
|
|
|
1047
1101
|
};
|
|
1048
1102
|
|
|
1049
1103
|
// src/server/ServiceRegistry.ts
|
|
1104
|
+
var CHANNEL_ABUSE_WINDOW_MS = 1e4;
|
|
1105
|
+
var CHANNEL_ABUSE_MULTIPLIER = 100;
|
|
1050
1106
|
var ServiceRegistry = class {
|
|
1051
1107
|
io;
|
|
1052
1108
|
services = /* @__PURE__ */ new Map();
|
|
@@ -1057,6 +1113,8 @@ var ServiceRegistry = class {
|
|
|
1057
1113
|
connectionHandlerInstalled = false;
|
|
1058
1114
|
// Store method metadata for deferred registration
|
|
1059
1115
|
methodRegistry = /* @__PURE__ */ new Map();
|
|
1116
|
+
// Store channel metadata for deferred registration
|
|
1117
|
+
channelRegistry = /* @__PURE__ */ new Map();
|
|
1060
1118
|
constructor(io, options) {
|
|
1061
1119
|
this.io = io;
|
|
1062
1120
|
this.logger = options?.logger?.child({ service: "ServiceRegistry" }) ?? consoleLogger.child({ service: "ServiceRegistry" });
|
|
@@ -1090,22 +1148,13 @@ var ServiceRegistry = class {
|
|
|
1090
1148
|
for (const [eventName, { method, service }] of this.methodRegistry) {
|
|
1091
1149
|
this.registerMethodListener(socket, eventName, method, service);
|
|
1092
1150
|
}
|
|
1151
|
+
for (const [eventName, { channel, service }] of this.channelRegistry) {
|
|
1152
|
+
this.registerChannelListener(socket, eventName, channel, service);
|
|
1153
|
+
}
|
|
1093
1154
|
for (const [serviceName, service] of this.services) {
|
|
1094
|
-
this.registerSubscriptionListener(
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
service
|
|
1098
|
-
);
|
|
1099
|
-
this.registerBatchSubscriptionListener(
|
|
1100
|
-
socket,
|
|
1101
|
-
`${serviceName}:batchSubscribe`,
|
|
1102
|
-
service
|
|
1103
|
-
);
|
|
1104
|
-
this.registerUnsubscriptionListener(
|
|
1105
|
-
socket,
|
|
1106
|
-
`${serviceName}:unsubscribe`,
|
|
1107
|
-
service
|
|
1108
|
-
);
|
|
1155
|
+
this.registerSubscriptionListener(socket, `${serviceName}:subscribe`, service);
|
|
1156
|
+
this.registerBatchSubscriptionListener(socket, `${serviceName}:batchSubscribe`, service);
|
|
1157
|
+
this.registerUnsubscriptionListener(socket, `${serviceName}:unsubscribe`, service);
|
|
1109
1158
|
}
|
|
1110
1159
|
}
|
|
1111
1160
|
/**
|
|
@@ -1118,6 +1167,7 @@ var ServiceRegistry = class {
|
|
|
1118
1167
|
}
|
|
1119
1168
|
this.logger.info(`Registered service: ${serviceName}`);
|
|
1120
1169
|
this.discoverServiceMethods(serviceName, service);
|
|
1170
|
+
this.discoverServiceChannels(serviceName, service);
|
|
1121
1171
|
this.installConnectionHandler();
|
|
1122
1172
|
}
|
|
1123
1173
|
/**
|
|
@@ -1135,6 +1185,77 @@ var ServiceRegistry = class {
|
|
|
1135
1185
|
});
|
|
1136
1186
|
}
|
|
1137
1187
|
}
|
|
1188
|
+
/**
|
|
1189
|
+
* Discover and store all channels from a service.
|
|
1190
|
+
*/
|
|
1191
|
+
discoverServiceChannels(serviceName, service) {
|
|
1192
|
+
const channels = service.getPublicChannels?.() ?? [];
|
|
1193
|
+
for (const channel of channels) {
|
|
1194
|
+
const eventName = channelEventName(serviceName, channel.name);
|
|
1195
|
+
this.logger.info(
|
|
1196
|
+
`Registering channel event: ${eventName} (${channel.ratePerSecond}/s, burst ${channel.burst})`
|
|
1197
|
+
);
|
|
1198
|
+
this.channelRegistry.set(eventName, {
|
|
1199
|
+
serviceName,
|
|
1200
|
+
channel,
|
|
1201
|
+
service
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
/**
|
|
1206
|
+
* Register a channel as a socket event listener.
|
|
1207
|
+
*
|
|
1208
|
+
* This is the hot path for high-frequency traffic: no ack, no async access
|
|
1209
|
+
* checks, no per-message logging. Order of operations per message:
|
|
1210
|
+
* token bucket → schema validation → in-memory access check → handler.
|
|
1211
|
+
* Any failure silently drops the message; sustained extreme flooding
|
|
1212
|
+
* disconnects the socket.
|
|
1213
|
+
*/
|
|
1214
|
+
registerChannelListener(socket, eventName, channel, service) {
|
|
1215
|
+
let tokens = channel.burst;
|
|
1216
|
+
let lastRefillAt = Date.now();
|
|
1217
|
+
let abuseWindowStart = lastRefillAt;
|
|
1218
|
+
let droppedInWindow = 0;
|
|
1219
|
+
socket.on(eventName, (payload) => {
|
|
1220
|
+
const now = Date.now();
|
|
1221
|
+
tokens = Math.min(
|
|
1222
|
+
channel.burst,
|
|
1223
|
+
tokens + (now - lastRefillAt) / 1e3 * channel.ratePerSecond
|
|
1224
|
+
);
|
|
1225
|
+
lastRefillAt = now;
|
|
1226
|
+
if (tokens < 1) {
|
|
1227
|
+
if (now - abuseWindowStart > CHANNEL_ABUSE_WINDOW_MS) {
|
|
1228
|
+
abuseWindowStart = now;
|
|
1229
|
+
droppedInWindow = 0;
|
|
1230
|
+
}
|
|
1231
|
+
droppedInWindow++;
|
|
1232
|
+
if (droppedInWindow > channel.ratePerSecond * CHANNEL_ABUSE_MULTIPLIER) {
|
|
1233
|
+
this.logger.warn(
|
|
1234
|
+
`Disconnecting socket ${socket.id} for sustained channel flooding on ${eventName}`,
|
|
1235
|
+
{ userId: socket.userId, droppedInWindow }
|
|
1236
|
+
);
|
|
1237
|
+
socket.disconnect(true);
|
|
1238
|
+
}
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
tokens -= 1;
|
|
1242
|
+
const parsed = channel.schema.safeParse(payload);
|
|
1243
|
+
if (!parsed.success) return;
|
|
1244
|
+
if (!service.checkChannelAccess?.(channel, socket, parsed.data)) return;
|
|
1245
|
+
try {
|
|
1246
|
+
channel.handler(parsed.data, {
|
|
1247
|
+
userId: socket.userId,
|
|
1248
|
+
socketId: socket.id,
|
|
1249
|
+
serviceAccess: socket.serviceAccess ?? {}
|
|
1250
|
+
});
|
|
1251
|
+
} catch (error) {
|
|
1252
|
+
this.logger.error(`Channel handler error for ${eventName}`, {
|
|
1253
|
+
userId: socket.userId,
|
|
1254
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1138
1259
|
/**
|
|
1139
1260
|
* Register a method as a socket event listener.
|
|
1140
1261
|
*/
|
|
@@ -1238,10 +1359,7 @@ var ServiceRegistry = class {
|
|
|
1238
1359
|
if (this.logPayloads) {
|
|
1239
1360
|
logContext.payload = payload;
|
|
1240
1361
|
}
|
|
1241
|
-
this.logger.error(
|
|
1242
|
-
`User ${userIdShort} ${serviceName}.${methodName} -> fail`,
|
|
1243
|
-
logContext
|
|
1244
|
-
);
|
|
1362
|
+
this.logger.error(`User ${userIdShort} ${serviceName}.${methodName} -> fail`, logContext);
|
|
1245
1363
|
const errorResponse = {
|
|
1246
1364
|
success: false,
|
|
1247
1365
|
error: error instanceof Error ? error.message : "Internal error",
|
|
@@ -1437,9 +1555,7 @@ function createQuickdrawServer(options) {
|
|
|
1437
1555
|
});
|
|
1438
1556
|
httpServer.listen(options.port, "0.0.0.0", () => {
|
|
1439
1557
|
serverLogger.info(`Server listening on port ${options.port}`);
|
|
1440
|
-
serverLogger.info(
|
|
1441
|
-
`Registered services: ${registry.getServices().join(", ")}`
|
|
1442
|
-
);
|
|
1558
|
+
serverLogger.info(`Registered services: ${registry.getServices().join(", ")}`);
|
|
1443
1559
|
});
|
|
1444
1560
|
const shutdown = async (signal) => {
|
|
1445
1561
|
serverLogger.info(`Received ${signal}, starting graceful shutdown...`);
|
|
@@ -1475,13 +1591,7 @@ function createQuickdrawServer(options) {
|
|
|
1475
1591
|
// src/server/redis.ts
|
|
1476
1592
|
async function setupRedisAdapter(io, options = {}) {
|
|
1477
1593
|
const logger = options.logger?.child({ service: "RedisAdapter" }) ?? consoleLogger.child({ service: "RedisAdapter" });
|
|
1478
|
-
const {
|
|
1479
|
-
host = "localhost",
|
|
1480
|
-
port = 6379,
|
|
1481
|
-
password,
|
|
1482
|
-
db = 0,
|
|
1483
|
-
keyPrefix = "socket.io"
|
|
1484
|
-
} = options;
|
|
1594
|
+
const { host = "localhost", port = 6379, password, db = 0, keyPrefix = "socket.io" } = options;
|
|
1485
1595
|
try {
|
|
1486
1596
|
const dynamicImport = new Function("specifier", "return import(specifier)");
|
|
1487
1597
|
const [redisAdapterModule, redisModule] = await Promise.all([
|
|
@@ -1497,7 +1607,9 @@ async function setupRedisAdapter(io, options = {}) {
|
|
|
1497
1607
|
});
|
|
1498
1608
|
const subClient = pubClient.duplicate();
|
|
1499
1609
|
await Promise.all([pubClient.connect(), subClient.connect()]);
|
|
1500
|
-
io.adapter(
|
|
1610
|
+
io.adapter(
|
|
1611
|
+
createAdapter(pubClient, subClient, { key: keyPrefix })
|
|
1612
|
+
);
|
|
1501
1613
|
logger.info(`Redis adapter connected to ${host}:${port}`);
|
|
1502
1614
|
const cleanup = async () => {
|
|
1503
1615
|
try {
|
|
@@ -1529,10 +1641,7 @@ async function setupRedisAdapter(io, options = {}) {
|
|
|
1529
1641
|
async function isRedisAdapterAvailable() {
|
|
1530
1642
|
try {
|
|
1531
1643
|
const dynamicImport = new Function("specifier", "return import(specifier)");
|
|
1532
|
-
await Promise.all([
|
|
1533
|
-
dynamicImport("@socket.io/redis-adapter"),
|
|
1534
|
-
dynamicImport("redis")
|
|
1535
|
-
]);
|
|
1644
|
+
await Promise.all([dynamicImport("@socket.io/redis-adapter"), dynamicImport("redis")]);
|
|
1536
1645
|
return true;
|
|
1537
1646
|
} catch {
|
|
1538
1647
|
return false;
|
|
@@ -1541,11 +1650,7 @@ async function isRedisAdapterAvailable() {
|
|
|
1541
1650
|
|
|
1542
1651
|
// src/server/rateLimit.ts
|
|
1543
1652
|
function createRateLimiter(options = {}) {
|
|
1544
|
-
const {
|
|
1545
|
-
windowMs = 6e4,
|
|
1546
|
-
maxRequests = 100,
|
|
1547
|
-
excludeEvents = []
|
|
1548
|
-
} = options;
|
|
1653
|
+
const { windowMs = 6e4, maxRequests = 100, excludeEvents = [], excludePrefixes = [] } = options;
|
|
1549
1654
|
const entries = /* @__PURE__ */ new Map();
|
|
1550
1655
|
const cleanupInterval = setInterval(() => {
|
|
1551
1656
|
const now = Date.now();
|
|
@@ -1597,7 +1702,8 @@ function createRateLimiter(options = {}) {
|
|
|
1597
1702
|
options: {
|
|
1598
1703
|
windowMs,
|
|
1599
1704
|
maxRequests,
|
|
1600
|
-
excludeEvents
|
|
1705
|
+
excludeEvents,
|
|
1706
|
+
excludePrefixes
|
|
1601
1707
|
}
|
|
1602
1708
|
};
|
|
1603
1709
|
}
|
|
@@ -1611,13 +1717,17 @@ function applyRateLimitMiddleware(io, rateLimiter, options = {}) {
|
|
|
1611
1717
|
retryAfter: rateLimiter.getResetTime(keyGenerator(socket, eventName))
|
|
1612
1718
|
});
|
|
1613
1719
|
});
|
|
1614
|
-
const { excludeEvents } = rateLimiter.options;
|
|
1720
|
+
const { excludeEvents, excludePrefixes } = rateLimiter.options;
|
|
1615
1721
|
io.on("connection", (socket) => {
|
|
1616
1722
|
socket.use(([eventName, ...args], next) => {
|
|
1617
1723
|
if (excludeEvents.includes(eventName)) {
|
|
1618
1724
|
next();
|
|
1619
1725
|
return;
|
|
1620
1726
|
}
|
|
1727
|
+
if (excludePrefixes.some((prefix) => eventName.startsWith(prefix))) {
|
|
1728
|
+
next();
|
|
1729
|
+
return;
|
|
1730
|
+
}
|
|
1621
1731
|
if (eventName.startsWith("$")) {
|
|
1622
1732
|
next();
|
|
1623
1733
|
return;
|
|
@@ -1769,6 +1879,13 @@ var discordProvider = {
|
|
|
1769
1879
|
};
|
|
1770
1880
|
}
|
|
1771
1881
|
};
|
|
1882
|
+
function getDiscordAvatarUrl(user) {
|
|
1883
|
+
if (user.avatar) {
|
|
1884
|
+
return `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`;
|
|
1885
|
+
}
|
|
1886
|
+
const index = user.discriminator !== "0" ? parseInt(user.discriminator, 10) % 5 : (BigInt(user.id) >> BigInt(22)) % BigInt(6);
|
|
1887
|
+
return `https://cdn.discordapp.com/embed/avatars/${index}.png`;
|
|
1888
|
+
}
|
|
1772
1889
|
|
|
1773
1890
|
// src/server/auth/google.ts
|
|
1774
1891
|
var googleProvider = {
|
|
@@ -1789,6 +1906,306 @@ var googleProvider = {
|
|
|
1789
1906
|
}
|
|
1790
1907
|
};
|
|
1791
1908
|
|
|
1909
|
+
// src/server/auth/validateOrigin.ts
|
|
1910
|
+
var OAUTH_RETURN_ORIGIN_COOKIE = "oauth_return_origin";
|
|
1911
|
+
var CODESPACE_REGEX = /^https:\/\/[a-z0-9-]+-[a-z0-9-]+-\d+\.app\.github\.dev$/;
|
|
1912
|
+
var LOCALHOST_REGEX = /^http:\/\/localhost:\d+$/;
|
|
1913
|
+
function envExtraOrigins() {
|
|
1914
|
+
const raw = process.env.EXTRA_ALLOWED_ORIGINS;
|
|
1915
|
+
if (!raw) return [];
|
|
1916
|
+
return raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1917
|
+
}
|
|
1918
|
+
function validateRedirectOrigin(origin, options = {}) {
|
|
1919
|
+
if (!origin) return null;
|
|
1920
|
+
const clientUrl = options.clientUrl ?? process.env.CLIENT_URL;
|
|
1921
|
+
let parsedOrigin;
|
|
1922
|
+
try {
|
|
1923
|
+
parsedOrigin = new URL(origin);
|
|
1924
|
+
} catch {
|
|
1925
|
+
return null;
|
|
1926
|
+
}
|
|
1927
|
+
const cleanOrigin = parsedOrigin.origin;
|
|
1928
|
+
if (clientUrl && cleanOrigin === clientUrl) {
|
|
1929
|
+
return cleanOrigin;
|
|
1930
|
+
}
|
|
1931
|
+
const extra = options.extraAllowedOrigins ?? envExtraOrigins();
|
|
1932
|
+
if (extra.includes(cleanOrigin)) {
|
|
1933
|
+
return cleanOrigin;
|
|
1934
|
+
}
|
|
1935
|
+
if (options.allowedPatterns?.some((pattern) => pattern.test(cleanOrigin))) {
|
|
1936
|
+
return cleanOrigin;
|
|
1937
|
+
}
|
|
1938
|
+
if ((options.allowCodespaces ?? true) && CODESPACE_REGEX.test(cleanOrigin)) {
|
|
1939
|
+
return cleanOrigin;
|
|
1940
|
+
}
|
|
1941
|
+
if ((options.allowLocalhostInDev ?? true) && process.env.NODE_ENV !== "production" && LOCALHOST_REGEX.test(cleanOrigin)) {
|
|
1942
|
+
return cleanOrigin;
|
|
1943
|
+
}
|
|
1944
|
+
return null;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
// src/server/auth/mock.ts
|
|
1948
|
+
var DEFAULT_MOCK_PATH_PREFIX = "/auth/mock/provider";
|
|
1949
|
+
var CODE_TTL_MS = 5 * 60 * 1e3;
|
|
1950
|
+
var TOKEN_TTL_MS = 60 * 60 * 1e3;
|
|
1951
|
+
var DEV_PRIVATE_ORIGIN_REGEX = /^http:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}):\d+$/;
|
|
1952
|
+
function isMockOAuthEnabled() {
|
|
1953
|
+
return process.env.ENABLE_MOCK_OAUTH === "true" && process.env.NODE_ENV !== "production";
|
|
1954
|
+
}
|
|
1955
|
+
function createMockOAuthProvider(apiBaseUrl, options = {}) {
|
|
1956
|
+
const prefix = options.pathPrefix ?? DEFAULT_MOCK_PATH_PREFIX;
|
|
1957
|
+
const publicBase = apiBaseUrl.replace(/\/$/, "");
|
|
1958
|
+
const internalBase = (options.internalBaseUrl ?? apiBaseUrl).replace(/\/$/, "");
|
|
1959
|
+
return {
|
|
1960
|
+
name: "mock",
|
|
1961
|
+
authorizationUrl: `${publicBase}${prefix}/authorize`,
|
|
1962
|
+
tokenUrl: `${internalBase}${prefix}/token`,
|
|
1963
|
+
userInfoUrl: `${internalBase}${prefix}/userinfo`,
|
|
1964
|
+
scopes: ["openid", "email", "profile"],
|
|
1965
|
+
parseUserInfo: (data) => {
|
|
1966
|
+
const d = data;
|
|
1967
|
+
return {
|
|
1968
|
+
id: String(d.id),
|
|
1969
|
+
email: String(d.email),
|
|
1970
|
+
name: String(d.name),
|
|
1971
|
+
picture: d.picture ? String(d.picture) : null,
|
|
1972
|
+
verified_email: true
|
|
1973
|
+
};
|
|
1974
|
+
}
|
|
1975
|
+
};
|
|
1976
|
+
}
|
|
1977
|
+
function getQueryParam(req, name) {
|
|
1978
|
+
const fromQuery = req.query?.[name];
|
|
1979
|
+
if (typeof fromQuery === "string") return fromQuery;
|
|
1980
|
+
const url = req.originalUrl ?? req.url;
|
|
1981
|
+
if (!url) return null;
|
|
1982
|
+
const idx = url.indexOf("?");
|
|
1983
|
+
if (idx === -1) return null;
|
|
1984
|
+
return new URLSearchParams(url.slice(idx + 1)).get(name);
|
|
1985
|
+
}
|
|
1986
|
+
async function readBodyParams(req) {
|
|
1987
|
+
if (typeof req.body === "string") return new URLSearchParams(req.body);
|
|
1988
|
+
if (req.body && typeof req.body === "object" && Object.keys(req.body).length > 0) {
|
|
1989
|
+
return new URLSearchParams(req.body);
|
|
1990
|
+
}
|
|
1991
|
+
const chunks = [];
|
|
1992
|
+
for await (const chunk of req) {
|
|
1993
|
+
chunks.push(Buffer.from(chunk));
|
|
1994
|
+
}
|
|
1995
|
+
return new URLSearchParams(Buffer.concat(chunks).toString("utf8"));
|
|
1996
|
+
}
|
|
1997
|
+
function escapeHtml(value) {
|
|
1998
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1999
|
+
}
|
|
2000
|
+
function isAllowedRedirectUri(uri, allowedOrigins) {
|
|
2001
|
+
let parsed;
|
|
2002
|
+
try {
|
|
2003
|
+
parsed = new URL(uri);
|
|
2004
|
+
} catch {
|
|
2005
|
+
return false;
|
|
2006
|
+
}
|
|
2007
|
+
const origin = parsed.origin;
|
|
2008
|
+
if (allowedOrigins.includes(origin)) return true;
|
|
2009
|
+
if (DEV_PRIVATE_ORIGIN_REGEX.test(origin)) return true;
|
|
2010
|
+
return validateRedirectOrigin(origin) !== null;
|
|
2011
|
+
}
|
|
2012
|
+
function renderPickerPage(users, authorizePath, redirectUri, state) {
|
|
2013
|
+
const links = users.map((user) => {
|
|
2014
|
+
const params = new URLSearchParams({ redirect_uri: redirectUri, email: user.email });
|
|
2015
|
+
if (state) params.set("state", state);
|
|
2016
|
+
const href = `${authorizePath}?${params.toString()}`;
|
|
2017
|
+
return `<li><a href="${escapeHtml(href)}"><strong>${escapeHtml(user.name)}</strong><span>${escapeHtml(user.email)}</span></a></li>`;
|
|
2018
|
+
}).join("\n");
|
|
2019
|
+
return `<!doctype html>
|
|
2020
|
+
<html lang="en">
|
|
2021
|
+
<head>
|
|
2022
|
+
<meta charset="utf-8" />
|
|
2023
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
2024
|
+
<title>Mock sign-in</title>
|
|
2025
|
+
<style>
|
|
2026
|
+
body { font-family: system-ui, sans-serif; background: #111; color: #eee; display: flex; min-height: 100vh; align-items: center; justify-content: center; margin: 0; }
|
|
2027
|
+
main { max-width: 420px; width: 100%; padding: 2rem; }
|
|
2028
|
+
h1 { font-size: 1.25rem; }
|
|
2029
|
+
p { color: #999; font-size: 0.875rem; }
|
|
2030
|
+
ul { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; }
|
|
2031
|
+
a { display: flex; flex-direction: column; gap: 0.125rem; padding: 0.75rem 1rem; border: 1px solid #333; border-radius: 8px; text-decoration: none; color: inherit; }
|
|
2032
|
+
a:hover { border-color: #888; background: #1a1a1a; }
|
|
2033
|
+
a span { color: #999; font-size: 0.8125rem; }
|
|
2034
|
+
</style>
|
|
2035
|
+
</head>
|
|
2036
|
+
<body>
|
|
2037
|
+
<main>
|
|
2038
|
+
<h1>Mock sign-in</h1>
|
|
2039
|
+
<p>Development only \u2014 pick an account to continue. Run your seed script if this list is empty.</p>
|
|
2040
|
+
<ul>
|
|
2041
|
+
${links || "<li><em>No users found.</em></li>"}
|
|
2042
|
+
</ul>
|
|
2043
|
+
</main>
|
|
2044
|
+
</body>
|
|
2045
|
+
</html>`;
|
|
2046
|
+
}
|
|
2047
|
+
function registerMockOAuthProvider(router, options) {
|
|
2048
|
+
const logger = options.logger ?? consoleLogger;
|
|
2049
|
+
if (!isMockOAuthEnabled()) {
|
|
2050
|
+
if (process.env.ENABLE_MOCK_OAUTH === "true") {
|
|
2051
|
+
logger.error("Mock OAuth is enabled but NODE_ENV is production \u2014 refusing to mount routes");
|
|
2052
|
+
}
|
|
2053
|
+
return false;
|
|
2054
|
+
}
|
|
2055
|
+
const prefix = options.pathPrefix ?? DEFAULT_MOCK_PATH_PREFIX;
|
|
2056
|
+
const allowedOrigins = options.allowedRedirectOrigins ?? [];
|
|
2057
|
+
const codes = /* @__PURE__ */ new Map();
|
|
2058
|
+
const tokens = /* @__PURE__ */ new Map();
|
|
2059
|
+
function sweep(store) {
|
|
2060
|
+
const now = Date.now();
|
|
2061
|
+
for (const [key, entry] of store) {
|
|
2062
|
+
if (entry.expiresAt < now) store.delete(key);
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
function guard(res) {
|
|
2066
|
+
if (process.env.NODE_ENV === "production") {
|
|
2067
|
+
res.status(404).json({ error: "Not found" });
|
|
2068
|
+
return false;
|
|
2069
|
+
}
|
|
2070
|
+
return true;
|
|
2071
|
+
}
|
|
2072
|
+
router.get(`${prefix}/authorize`, async (req, res) => {
|
|
2073
|
+
if (!guard(res)) return;
|
|
2074
|
+
const redirectUri = getQueryParam(req, "redirect_uri");
|
|
2075
|
+
const state = getQueryParam(req, "state");
|
|
2076
|
+
const email = getQueryParam(req, "email");
|
|
2077
|
+
if (!redirectUri || !isAllowedRedirectUri(redirectUri, allowedOrigins)) {
|
|
2078
|
+
res.status(400).json({ error: "Invalid redirect_uri" });
|
|
2079
|
+
return;
|
|
2080
|
+
}
|
|
2081
|
+
const users = await options.listUsers();
|
|
2082
|
+
if (!email) {
|
|
2083
|
+
res.status(200).send(renderPickerPage(users, `${prefix}/authorize`, redirectUri, state));
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
2086
|
+
const user = users.find((u) => u.email === email);
|
|
2087
|
+
if (!user) {
|
|
2088
|
+
res.status(400).json({ error: "Unknown mock user" });
|
|
2089
|
+
return;
|
|
2090
|
+
}
|
|
2091
|
+
sweep(codes);
|
|
2092
|
+
const code = randomBytes(24).toString("hex");
|
|
2093
|
+
codes.set(code, { email: user.email, expiresAt: Date.now() + CODE_TTL_MS });
|
|
2094
|
+
const params = new URLSearchParams({ code });
|
|
2095
|
+
if (state) params.set("state", state);
|
|
2096
|
+
res.redirect(`${redirectUri}${redirectUri.includes("?") ? "&" : "?"}${params.toString()}`);
|
|
2097
|
+
});
|
|
2098
|
+
router.post(`${prefix}/token`, async (req, res) => {
|
|
2099
|
+
if (!guard(res)) return;
|
|
2100
|
+
const body = await readBodyParams(req);
|
|
2101
|
+
const code = body.get("code");
|
|
2102
|
+
const entry = code ? codes.get(code) : void 0;
|
|
2103
|
+
if (code) codes.delete(code);
|
|
2104
|
+
if (!entry || entry.expiresAt < Date.now()) {
|
|
2105
|
+
res.status(400).json({ error: "invalid_grant" });
|
|
2106
|
+
return;
|
|
2107
|
+
}
|
|
2108
|
+
sweep(tokens);
|
|
2109
|
+
const accessToken = randomBytes(24).toString("hex");
|
|
2110
|
+
tokens.set(accessToken, { email: entry.email, expiresAt: Date.now() + TOKEN_TTL_MS });
|
|
2111
|
+
res.status(200).json({
|
|
2112
|
+
access_token: accessToken,
|
|
2113
|
+
token_type: "Bearer",
|
|
2114
|
+
expires_in: Math.floor(TOKEN_TTL_MS / 1e3),
|
|
2115
|
+
scope: "openid email profile"
|
|
2116
|
+
});
|
|
2117
|
+
});
|
|
2118
|
+
router.get(`${prefix}/userinfo`, async (req, res) => {
|
|
2119
|
+
if (!guard(res)) return;
|
|
2120
|
+
const auth = req.headers.authorization;
|
|
2121
|
+
const accessToken = typeof auth === "string" && auth.startsWith("Bearer ") ? auth.slice(7) : null;
|
|
2122
|
+
const entry = accessToken ? tokens.get(accessToken) : void 0;
|
|
2123
|
+
if (!entry || entry.expiresAt < Date.now()) {
|
|
2124
|
+
res.status(401).json({ error: "invalid_token" });
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
const users = await options.listUsers();
|
|
2128
|
+
const user = users.find((u) => u.email === entry.email);
|
|
2129
|
+
if (!user) {
|
|
2130
|
+
res.status(401).json({ error: "invalid_token" });
|
|
2131
|
+
return;
|
|
2132
|
+
}
|
|
2133
|
+
res.status(200).json({
|
|
2134
|
+
// providerAccountId = email: stable across re-seeds, unlike row ids.
|
|
2135
|
+
id: user.email,
|
|
2136
|
+
email: user.email,
|
|
2137
|
+
name: user.name,
|
|
2138
|
+
picture: user.picture ?? null,
|
|
2139
|
+
verified_email: true
|
|
2140
|
+
});
|
|
2141
|
+
});
|
|
2142
|
+
logger.info(`Mock OAuth provider mounted at ${prefix} (development only)`);
|
|
2143
|
+
return true;
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
// src/server/auth/sessionCookie.ts
|
|
2147
|
+
var SESSION_COOKIE = "session";
|
|
2148
|
+
var DEFAULT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
2149
|
+
function cookieOptions(options) {
|
|
2150
|
+
const isProd = process.env.NODE_ENV === "production";
|
|
2151
|
+
const domain = options.domain ?? process.env.COOKIE_DOMAIN;
|
|
2152
|
+
return {
|
|
2153
|
+
httpOnly: true,
|
|
2154
|
+
secure: isProd,
|
|
2155
|
+
// SameSite=None (with Secure) lets the session cookie ride cross-site
|
|
2156
|
+
// fetches, so a secondary web origin can hit the primary API host. The
|
|
2157
|
+
// CORS allowlist (validateRedirectOrigin) is the actual origin gate. Dev
|
|
2158
|
+
// keeps Lax — localhost is http-only and SameSite=None requires Secure.
|
|
2159
|
+
sameSite: isProd ? "none" : "lax",
|
|
2160
|
+
path: "/",
|
|
2161
|
+
...domain && { domain }
|
|
2162
|
+
};
|
|
2163
|
+
}
|
|
2164
|
+
function setSessionCookie(res, jwt, options = {}) {
|
|
2165
|
+
res.cookie(options.cookieName ?? SESSION_COOKIE, jwt, {
|
|
2166
|
+
...cookieOptions(options),
|
|
2167
|
+
maxAge: options.maxAgeMs ?? DEFAULT_MAX_AGE_MS
|
|
2168
|
+
});
|
|
2169
|
+
}
|
|
2170
|
+
function clearSessionCookie(res, options = {}) {
|
|
2171
|
+
res.clearCookie(options.cookieName ?? SESSION_COOKIE, cookieOptions(options));
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
// src/server/auth/restMiddleware.ts
|
|
2175
|
+
function extractBearerOrCookieToken(req, cookieName = SESSION_COOKIE) {
|
|
2176
|
+
const cookieToken = req.cookies?.[cookieName];
|
|
2177
|
+
if (cookieToken) return cookieToken;
|
|
2178
|
+
const authHeader = req.headers.authorization;
|
|
2179
|
+
if (authHeader?.startsWith("Bearer ")) return authHeader.slice(7);
|
|
2180
|
+
return null;
|
|
2181
|
+
}
|
|
2182
|
+
function createRequireAuth(options) {
|
|
2183
|
+
return async (req, res, next) => {
|
|
2184
|
+
const token = extractBearerOrCookieToken(req, options.cookieName);
|
|
2185
|
+
if (!token) {
|
|
2186
|
+
res.status(401).json({ error: "Missing or invalid authorization" });
|
|
2187
|
+
return;
|
|
2188
|
+
}
|
|
2189
|
+
const secret = options.jwtSecret ?? process.env.JWT_SECRET;
|
|
2190
|
+
if (!secret) {
|
|
2191
|
+
res.status(401).json({ error: "Authentication is not configured" });
|
|
2192
|
+
return;
|
|
2193
|
+
}
|
|
2194
|
+
const payload = await verifyJWT(token, secret);
|
|
2195
|
+
if (!payload?.userId) {
|
|
2196
|
+
res.status(401).json({ error: "Invalid or expired token" });
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2199
|
+
const session = await options.getSession(token);
|
|
2200
|
+
if (!session || session.expiresAt < /* @__PURE__ */ new Date()) {
|
|
2201
|
+
res.status(401).json({ error: "Session expired or revoked" });
|
|
2202
|
+
return;
|
|
2203
|
+
}
|
|
2204
|
+
req.userId = payload.userId;
|
|
2205
|
+
next();
|
|
2206
|
+
};
|
|
2207
|
+
}
|
|
2208
|
+
|
|
1792
2209
|
// src/server/utils/env.ts
|
|
1793
2210
|
function validateEnv(options) {
|
|
1794
2211
|
const result = checkEnv(options);
|
|
@@ -1797,7 +2214,11 @@ function validateEnv(options) {
|
|
|
1797
2214
|
}
|
|
1798
2215
|
}
|
|
1799
2216
|
function checkEnv(options) {
|
|
1800
|
-
const {
|
|
2217
|
+
const {
|
|
2218
|
+
required,
|
|
2219
|
+
productionOnly = true,
|
|
2220
|
+
errorPrefix = "Missing required environment variables"
|
|
2221
|
+
} = options;
|
|
1801
2222
|
if (productionOnly && process.env.NODE_ENV !== "production") {
|
|
1802
2223
|
return { success: true, missing: [] };
|
|
1803
2224
|
}
|
|
@@ -1827,6 +2248,54 @@ function requireEnv(name, defaultValue) {
|
|
|
1827
2248
|
}
|
|
1828
2249
|
return value;
|
|
1829
2250
|
}
|
|
2251
|
+
var ALGORITHM = "aes-256-gcm";
|
|
2252
|
+
var IV_LENGTH = 12;
|
|
2253
|
+
var AUTH_TAG_LENGTH = 16;
|
|
2254
|
+
function getKey() {
|
|
2255
|
+
const hex = process.env.ENCRYPTION_KEY;
|
|
2256
|
+
if (!hex || hex.length !== 64) {
|
|
2257
|
+
throw new Error(
|
|
2258
|
+
"ENCRYPTION_KEY must be set as a 64-character hex string (32 bytes). Check your environment configuration."
|
|
2259
|
+
);
|
|
2260
|
+
}
|
|
2261
|
+
return Buffer.from(hex, "hex");
|
|
2262
|
+
}
|
|
2263
|
+
function encrypt(plaintext) {
|
|
2264
|
+
const key = getKey();
|
|
2265
|
+
const iv = randomBytes(IV_LENGTH);
|
|
2266
|
+
const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
|
2267
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
2268
|
+
const authTag = cipher.getAuthTag();
|
|
2269
|
+
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`;
|
|
2270
|
+
}
|
|
2271
|
+
function decrypt(ciphertext) {
|
|
2272
|
+
const key = getKey();
|
|
2273
|
+
const [ivHex, authTagHex, encryptedHex] = ciphertext.split(":");
|
|
2274
|
+
if (!ivHex || !authTagHex || !encryptedHex) {
|
|
2275
|
+
throw new Error("Invalid ciphertext format");
|
|
2276
|
+
}
|
|
2277
|
+
const iv = Buffer.from(ivHex, "hex");
|
|
2278
|
+
const authTag = Buffer.from(authTagHex, "hex");
|
|
2279
|
+
const encrypted = Buffer.from(encryptedHex, "hex");
|
|
2280
|
+
const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
|
2281
|
+
decipher.setAuthTag(authTag);
|
|
2282
|
+
return decipher.update(encrypted) + decipher.final("utf8");
|
|
2283
|
+
}
|
|
2284
|
+
function isEncrypted(value) {
|
|
2285
|
+
const parts = value.split(":");
|
|
2286
|
+
return parts.length === 3 && parts.every((p) => /^[0-9a-f]+$/.test(p));
|
|
2287
|
+
}
|
|
2288
|
+
function decryptIfEncrypted(value) {
|
|
2289
|
+
return isEncrypted(value) ? decrypt(value) : value;
|
|
2290
|
+
}
|
|
2291
|
+
function tryDecrypt(ciphertext, logger = consoleLogger) {
|
|
2292
|
+
try {
|
|
2293
|
+
return decryptIfEncrypted(ciphertext);
|
|
2294
|
+
} catch {
|
|
2295
|
+
logger.warn("Failed to decrypt stored token \u2014 likely stale or re-keyed");
|
|
2296
|
+
return null;
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
1830
2299
|
|
|
1831
2300
|
// src/server/mcp/McpRegistry.ts
|
|
1832
2301
|
var McpRegistry = class {
|
|
@@ -1863,7 +2332,9 @@ var McpRegistry = class {
|
|
|
1863
2332
|
listTools(options) {
|
|
1864
2333
|
const hasDefaultUser = !!options?.defaultUserId;
|
|
1865
2334
|
if (this.customToolSpecs) {
|
|
1866
|
-
return this.customToolSpecs.filter((s) => this.services.has(s.name)).map(
|
|
2335
|
+
return this.customToolSpecs.filter((s) => this.services.has(s.name)).map(
|
|
2336
|
+
(svc) => this.buildToolDefinition(svc.name, svc.description, svc.methodNames, hasDefaultUser)
|
|
2337
|
+
);
|
|
1867
2338
|
}
|
|
1868
2339
|
const tools = [];
|
|
1869
2340
|
for (const [serviceName] of this.services) {
|
|
@@ -1898,11 +2369,7 @@ var McpRegistry = class {
|
|
|
1898
2369
|
const startTime = Date.now();
|
|
1899
2370
|
try {
|
|
1900
2371
|
const validatedPayload = this.validatePayload(method, payload);
|
|
1901
|
-
await instance.ensureAccessForMethod(
|
|
1902
|
-
method.access,
|
|
1903
|
-
ctx,
|
|
1904
|
-
entryId
|
|
1905
|
-
);
|
|
2372
|
+
await instance.ensureAccessForMethod(method.access, ctx, entryId);
|
|
1906
2373
|
const result = await method.handler(validatedPayload, {
|
|
1907
2374
|
userId,
|
|
1908
2375
|
socketId: ctx.id,
|
|
@@ -1954,9 +2421,7 @@ var McpRegistry = class {
|
|
|
1954
2421
|
if (method.schema) {
|
|
1955
2422
|
const result = method.schema.safeParse(payload);
|
|
1956
2423
|
if (!result.success) {
|
|
1957
|
-
throw new Error(
|
|
1958
|
-
`Validation error: ${result.error?.message ?? "invalid payload"}`
|
|
1959
|
-
);
|
|
2424
|
+
throw new Error(`Validation error: ${result.error?.message ?? "invalid payload"}`);
|
|
1960
2425
|
}
|
|
1961
2426
|
return result.data;
|
|
1962
2427
|
}
|
|
@@ -2053,12 +2518,7 @@ function createMcpStdioServer(options) {
|
|
|
2053
2518
|
}
|
|
2054
2519
|
const userId = args?.userId ?? defaultUserId ?? process.env.MCP_USER_ID ?? "mcp-anonymous";
|
|
2055
2520
|
const payload = args?.payload;
|
|
2056
|
-
const result = await registry.invoke(
|
|
2057
|
-
serviceName,
|
|
2058
|
-
methodName,
|
|
2059
|
-
payload,
|
|
2060
|
-
userId
|
|
2061
|
-
);
|
|
2521
|
+
const result = await registry.invoke(serviceName, methodName, payload, userId);
|
|
2062
2522
|
return {
|
|
2063
2523
|
jsonrpc: "2.0",
|
|
2064
2524
|
id,
|
|
@@ -2181,12 +2641,7 @@ function createMcpRoutes(router, options) {
|
|
|
2181
2641
|
res.status(400).json({ error: "Missing service or method" });
|
|
2182
2642
|
return;
|
|
2183
2643
|
}
|
|
2184
|
-
const result = await registry.invoke(
|
|
2185
|
-
service,
|
|
2186
|
-
method,
|
|
2187
|
-
toolPayload,
|
|
2188
|
-
tokenPayload.userId
|
|
2189
|
-
);
|
|
2644
|
+
const result = await registry.invoke(service, method, toolPayload, tokenPayload.userId);
|
|
2190
2645
|
res.json({ success: true, data: result });
|
|
2191
2646
|
} catch (error) {
|
|
2192
2647
|
const message = error instanceof Error ? error.message : "Internal error";
|
|
@@ -2464,10 +2919,7 @@ function generateToolMetadata(options) {
|
|
|
2464
2919
|
allServices.push(serviceInfo);
|
|
2465
2920
|
if (docsOutputDir) {
|
|
2466
2921
|
const doc = generateServiceDoc(serviceInfo);
|
|
2467
|
-
const outputPath = path.join(
|
|
2468
|
-
docsOutputDir,
|
|
2469
|
-
`${serviceInfo.className}.md`
|
|
2470
|
-
);
|
|
2922
|
+
const outputPath = path.join(docsOutputDir, `${serviceInfo.className}.md`);
|
|
2471
2923
|
fs.writeFileSync(outputPath, doc);
|
|
2472
2924
|
}
|
|
2473
2925
|
}
|
|
@@ -2488,15 +2940,12 @@ Auto-generated documentation for all services.
|
|
|
2488
2940
|
}
|
|
2489
2941
|
const toolsContent = generateToolsFileContent(allServices);
|
|
2490
2942
|
fs.writeFileSync(toolsOutputPath, toolsContent);
|
|
2491
|
-
const totalMethods = allServices.reduce(
|
|
2492
|
-
(sum, s) => sum + s.methods.length,
|
|
2493
|
-
0
|
|
2494
|
-
);
|
|
2943
|
+
const totalMethods = allServices.reduce((sum, s) => sum + s.methods.length, 0);
|
|
2495
2944
|
console.log(
|
|
2496
2945
|
`Generated ${allServices.length} service tools (${totalMethods} methods) -> ${toolsOutputPath}`
|
|
2497
2946
|
);
|
|
2498
2947
|
}
|
|
2499
2948
|
|
|
2500
|
-
export { BaseService, McpRegistry, ServiceRegistry, applyRateLimitMiddleware, bootstrapMcpServer, checkEnv, createJWT, createMcpRoutes, createMcpStdioServer, createOAuthURL, createQuickdrawServer, createRateLimiter, createTieredRateLimiter, discordProvider, exchangeOAuthCode, generateToolMetadata, getDefaultEntityFields, googleProvider, isRedisAdapterAvailable, mergeWithDefaultFields, requireEnv, setupRedisAdapter, validateEnv, verifyJWT, zodToAdminFields };
|
|
2949
|
+
export { BaseService, DEFAULT_MOCK_PATH_PREFIX, McpRegistry, OAUTH_RETURN_ORIGIN_COOKIE, SESSION_COOKIE, ServiceRegistry, applyRateLimitMiddleware, bootstrapMcpServer, checkEnv, clearSessionCookie, createJWT, createMcpRoutes, createMcpStdioServer, createMockOAuthProvider, createOAuthURL, createQuickdrawServer, createRateLimiter, createRequireAuth, createTieredRateLimiter, decrypt, decryptIfEncrypted, discordProvider, encrypt, exchangeOAuthCode, extractBearerOrCookieToken, generateToolMetadata, getDefaultEntityFields, getDiscordAvatarUrl, googleProvider, isEncrypted, isMockOAuthEnabled, isRedisAdapterAvailable, mergeWithDefaultFields, registerMockOAuthProvider, requireEnv, setSessionCookie, setupRedisAdapter, tryDecrypt, validateEnv, validateRedirectOrigin, verifyJWT, zodToAdminFields };
|
|
2501
2950
|
//# sourceMappingURL=index.js.map
|
|
2502
2951
|
//# sourceMappingURL=index.js.map
|