@fitzzero/quickdraw-core 0.1.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 +300 -0
- package/dist/client/index.d.ts +349 -0
- package/dist/client/index.js +690 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/testing.d.ts +73 -0
- package/dist/client/testing.js +78 -0
- package/dist/client/testing.js.map +1 -0
- package/dist/server/index.d.ts +336 -0
- package/dist/server/index.js +707 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/testing.d.ts +70 -0
- package/dist/server/testing.js +399 -0
- package/dist/server/testing.js.map +1 -0
- package/dist/shared/index.d.ts +158 -0
- package/dist/shared/index.js +12 -0
- package/dist/shared/index.js.map +1 -0
- package/dist/types-CUExGF7F.d.ts +109 -0
- package/dist/types-OhRxDBID.d.ts +118 -0
- package/package.json +111 -0
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { createServer } from 'http';
|
|
3
|
+
import { Server } from 'socket.io';
|
|
4
|
+
import * as jose from 'jose';
|
|
5
|
+
|
|
6
|
+
// src/shared/types.ts
|
|
7
|
+
var consoleLogger = {
|
|
8
|
+
info: (message, meta) => console.log(`[INFO] ${message}`, meta ?? ""),
|
|
9
|
+
warn: (message, meta) => console.warn(`[WARN] ${message}`, meta ?? ""),
|
|
10
|
+
error: (message, meta) => console.error(`[ERROR] ${message}`, meta ?? ""),
|
|
11
|
+
debug: (message, meta) => console.debug(`[DEBUG] ${message}`, meta ?? ""),
|
|
12
|
+
child: () => consoleLogger
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// src/server/BaseService.ts
|
|
16
|
+
var BaseService = class {
|
|
17
|
+
serviceName;
|
|
18
|
+
hasEntryACL;
|
|
19
|
+
defaultACL;
|
|
20
|
+
logger;
|
|
21
|
+
// Subscription tracking: entryId -> Set of sockets
|
|
22
|
+
subscribers = /* @__PURE__ */ new Map();
|
|
23
|
+
// Prisma delegate for DB operations
|
|
24
|
+
delegate;
|
|
25
|
+
// Collection of public methods for registry discovery
|
|
26
|
+
publicMethods = /* @__PURE__ */ new Map();
|
|
27
|
+
constructor(options) {
|
|
28
|
+
this.serviceName = options.serviceName;
|
|
29
|
+
this.hasEntryACL = options.hasEntryACL ?? false;
|
|
30
|
+
this.defaultACL = options.defaultACL ?? [];
|
|
31
|
+
this.logger = options.logger?.child({ service: this.serviceName }) ?? consoleLogger.child({ service: this.serviceName });
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Set the Prisma delegate for this service.
|
|
35
|
+
* Must be called in the constructor of derived classes.
|
|
36
|
+
*/
|
|
37
|
+
setDelegate(delegate) {
|
|
38
|
+
this.delegate = delegate;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Get the delegate, throwing if not set.
|
|
42
|
+
*/
|
|
43
|
+
getDelegate() {
|
|
44
|
+
if (!this.delegate) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`Delegate not set for service ${this.serviceName}. Call setDelegate() in constructor.`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return this.delegate;
|
|
50
|
+
}
|
|
51
|
+
// ===========================================================================
|
|
52
|
+
// Subscription Management
|
|
53
|
+
// ===========================================================================
|
|
54
|
+
/**
|
|
55
|
+
* Subscribe a socket to an entity's updates.
|
|
56
|
+
* Returns the current entity data if access is granted, null otherwise.
|
|
57
|
+
*/
|
|
58
|
+
async subscribe(entryId, socket, requiredLevel = "Read") {
|
|
59
|
+
if (!socket.userId) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const allowed = await this.checkSubscriptionAccess(
|
|
63
|
+
socket.userId,
|
|
64
|
+
entryId,
|
|
65
|
+
requiredLevel,
|
|
66
|
+
socket
|
|
67
|
+
);
|
|
68
|
+
if (!allowed) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
if (!this.subscribers.has(entryId)) {
|
|
72
|
+
this.subscribers.set(entryId, /* @__PURE__ */ new Set());
|
|
73
|
+
}
|
|
74
|
+
this.subscribers.get(entryId).add(socket);
|
|
75
|
+
this.logger.debug(`User ${socket.userId} subscribed to ${entryId}`);
|
|
76
|
+
const entity = await this.findById(entryId);
|
|
77
|
+
return entity;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Unsubscribe a socket from an entity's updates.
|
|
81
|
+
*/
|
|
82
|
+
unsubscribe(entryId, socket) {
|
|
83
|
+
const subs = this.subscribers.get(entryId);
|
|
84
|
+
if (subs) {
|
|
85
|
+
subs.delete(socket);
|
|
86
|
+
if (subs.size === 0) {
|
|
87
|
+
this.subscribers.delete(entryId);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Remove a socket from all subscriptions (called on disconnect).
|
|
93
|
+
*/
|
|
94
|
+
unsubscribeSocket(socket) {
|
|
95
|
+
for (const [entryId, sockets] of this.subscribers.entries()) {
|
|
96
|
+
if (sockets.has(socket)) {
|
|
97
|
+
sockets.delete(socket);
|
|
98
|
+
if (sockets.size === 0) {
|
|
99
|
+
this.subscribers.delete(entryId);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Emit an update to all subscribers of an entity.
|
|
106
|
+
*/
|
|
107
|
+
emitUpdate(entryId, data) {
|
|
108
|
+
const eventName = `${this.serviceName}:update:${entryId}`;
|
|
109
|
+
const subs = this.subscribers.get(entryId);
|
|
110
|
+
if (subs) {
|
|
111
|
+
for (const socket of subs) {
|
|
112
|
+
socket.emit(eventName, data);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// ===========================================================================
|
|
117
|
+
// Access Control
|
|
118
|
+
// ===========================================================================
|
|
119
|
+
/**
|
|
120
|
+
* Check if a user has access to subscribe to an entity.
|
|
121
|
+
* Override in derived classes for custom logic.
|
|
122
|
+
*/
|
|
123
|
+
async checkSubscriptionAccess(userId, entryId, requiredLevel, socket) {
|
|
124
|
+
if (this.hasServiceAccess(socket, requiredLevel)) {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
if (this.checkAccess(userId, entryId, requiredLevel, socket)) {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
if (this.hasEntryACL) {
|
|
131
|
+
return await this.checkEntryACL(userId, entryId, requiredLevel);
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Check if a socket has service-level access.
|
|
137
|
+
*/
|
|
138
|
+
hasServiceAccess(socket, requiredLevel) {
|
|
139
|
+
const userLevel = socket.serviceAccess?.[this.serviceName];
|
|
140
|
+
if (!userLevel) return false;
|
|
141
|
+
return this.isLevelSufficient(userLevel, requiredLevel);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Check if a user has access to an entity.
|
|
145
|
+
* Override in derived classes for custom logic (e.g., self-access).
|
|
146
|
+
*/
|
|
147
|
+
checkAccess(_userId, _entryId, _requiredLevel, _socket) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Check entry-level ACL stored on the entity.
|
|
152
|
+
*/
|
|
153
|
+
async checkEntryACL(userId, entryId, requiredLevel) {
|
|
154
|
+
try {
|
|
155
|
+
const entity = await this.getDelegate().findUnique({
|
|
156
|
+
where: { id: entryId },
|
|
157
|
+
select: { acl: true }
|
|
158
|
+
});
|
|
159
|
+
const acl = entity?.acl;
|
|
160
|
+
if (!acl || !Array.isArray(acl)) return false;
|
|
161
|
+
const ace = acl.find((a) => a.userId === userId);
|
|
162
|
+
if (!ace) return false;
|
|
163
|
+
return this.isLevelSufficient(ace.level, requiredLevel);
|
|
164
|
+
} catch {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Compare access levels.
|
|
170
|
+
*/
|
|
171
|
+
isLevelSufficient(userLevel, requiredLevel) {
|
|
172
|
+
const order = {
|
|
173
|
+
Public: 0,
|
|
174
|
+
Read: 1,
|
|
175
|
+
Moderate: 2,
|
|
176
|
+
Admin: 3
|
|
177
|
+
};
|
|
178
|
+
return (order[userLevel] ?? 0) >= (order[requiredLevel] ?? 0);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Ensure access for a public method.
|
|
182
|
+
* Throws if access is denied.
|
|
183
|
+
*/
|
|
184
|
+
async ensureAccessForMethod(requiredLevel, socket, entryId) {
|
|
185
|
+
if (requiredLevel === "Public") {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (!socket.userId) {
|
|
189
|
+
throw new Error("Authentication required");
|
|
190
|
+
}
|
|
191
|
+
if (this.hasServiceAccess(socket, requiredLevel)) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (entryId) {
|
|
195
|
+
if (this.checkAccess(socket.userId, entryId, requiredLevel, socket)) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (this.hasEntryACL && await this.checkEntryACL(socket.userId, entryId, requiredLevel)) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
throw new Error("Insufficient permissions");
|
|
202
|
+
}
|
|
203
|
+
if (requiredLevel === "Read") {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
throw new Error("Insufficient permissions");
|
|
207
|
+
}
|
|
208
|
+
// ===========================================================================
|
|
209
|
+
// CRUD Operations
|
|
210
|
+
// ===========================================================================
|
|
211
|
+
/**
|
|
212
|
+
* Find an entity by ID.
|
|
213
|
+
*/
|
|
214
|
+
async findById(id) {
|
|
215
|
+
return await this.getDelegate().findUnique({
|
|
216
|
+
where: { id }
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Create an entity and emit to subscribers.
|
|
221
|
+
*/
|
|
222
|
+
async create(data) {
|
|
223
|
+
const entity = await this.getDelegate().create({ data });
|
|
224
|
+
this.emitUpdate(entity.id, entity);
|
|
225
|
+
this.logger.info(`Created entity ${entity.id}`);
|
|
226
|
+
return entity;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Update an entity and emit to subscribers.
|
|
230
|
+
*/
|
|
231
|
+
async update(id, data) {
|
|
232
|
+
try {
|
|
233
|
+
const entity = await this.getDelegate().update({
|
|
234
|
+
where: { id },
|
|
235
|
+
data
|
|
236
|
+
});
|
|
237
|
+
this.emitUpdate(id, entity);
|
|
238
|
+
this.logger.info(`Updated entity ${id}`);
|
|
239
|
+
return entity;
|
|
240
|
+
} catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Delete an entity and emit deletion event to subscribers.
|
|
246
|
+
*/
|
|
247
|
+
async delete(id) {
|
|
248
|
+
try {
|
|
249
|
+
await this.getDelegate().delete({
|
|
250
|
+
where: { id }
|
|
251
|
+
});
|
|
252
|
+
this.emitUpdate(id, { id, deleted: true });
|
|
253
|
+
this.logger.info(`Deleted entity ${id}`);
|
|
254
|
+
return true;
|
|
255
|
+
} catch {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// ===========================================================================
|
|
260
|
+
// Public Method Definition
|
|
261
|
+
// ===========================================================================
|
|
262
|
+
/**
|
|
263
|
+
* Define a public method that will be exposed via Socket.io.
|
|
264
|
+
*
|
|
265
|
+
* @typeParam K - Method name from the service methods map
|
|
266
|
+
*/
|
|
267
|
+
defineMethod(name, access, handler, options) {
|
|
268
|
+
const definition = {
|
|
269
|
+
name,
|
|
270
|
+
access,
|
|
271
|
+
handler,
|
|
272
|
+
schema: options?.schema,
|
|
273
|
+
resolveEntryId: options?.resolveEntryId
|
|
274
|
+
};
|
|
275
|
+
this.publicMethods.set(
|
|
276
|
+
name,
|
|
277
|
+
definition
|
|
278
|
+
);
|
|
279
|
+
return definition;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Get all public methods for registry discovery.
|
|
283
|
+
*/
|
|
284
|
+
getPublicMethods() {
|
|
285
|
+
return Array.from(this.publicMethods.values());
|
|
286
|
+
}
|
|
287
|
+
// ===========================================================================
|
|
288
|
+
// Admin Methods (Scaffolding)
|
|
289
|
+
// ===========================================================================
|
|
290
|
+
/**
|
|
291
|
+
* Install standard admin CRUD methods.
|
|
292
|
+
* Call this in derived class constructor to expose admin endpoints.
|
|
293
|
+
*/
|
|
294
|
+
installAdminMethods(_options) {
|
|
295
|
+
this.logger.debug("Admin methods installation placeholder");
|
|
296
|
+
}
|
|
297
|
+
// ===========================================================================
|
|
298
|
+
// Utility Methods
|
|
299
|
+
// ===========================================================================
|
|
300
|
+
/**
|
|
301
|
+
* Fields that should never be exposed or editable via admin methods.
|
|
302
|
+
*/
|
|
303
|
+
getDefaultDeniedFields() {
|
|
304
|
+
return ["id", "createdAt", "updatedAt", "acl", "serviceAccess"];
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// src/server/ServiceRegistry.ts
|
|
309
|
+
var ServiceRegistry = class {
|
|
310
|
+
io;
|
|
311
|
+
services = /* @__PURE__ */ new Map();
|
|
312
|
+
logger;
|
|
313
|
+
constructor(io, options) {
|
|
314
|
+
this.io = io;
|
|
315
|
+
this.logger = options?.logger?.child({ service: "ServiceRegistry" }) ?? consoleLogger.child({ service: "ServiceRegistry" });
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Register a service and auto-discover its public methods.
|
|
319
|
+
*/
|
|
320
|
+
registerService(serviceName, service) {
|
|
321
|
+
this.services.set(serviceName, service);
|
|
322
|
+
this.logger.info(`Registered service: ${serviceName}`);
|
|
323
|
+
this.discoverServiceMethods(serviceName, service);
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Discover and register all public methods from a service.
|
|
327
|
+
*/
|
|
328
|
+
discoverServiceMethods(serviceName, service) {
|
|
329
|
+
const methods = service.getPublicMethods();
|
|
330
|
+
for (const method of methods) {
|
|
331
|
+
const eventName = `${serviceName}:${method.name}`;
|
|
332
|
+
this.logger.info(`Registering socket event: ${eventName}`);
|
|
333
|
+
this.io.on("connection", (socket) => {
|
|
334
|
+
this.registerMethodListener(
|
|
335
|
+
socket,
|
|
336
|
+
eventName,
|
|
337
|
+
method,
|
|
338
|
+
service
|
|
339
|
+
);
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
this.io.on("connection", (socket) => {
|
|
343
|
+
this.registerSubscriptionListener(
|
|
344
|
+
socket,
|
|
345
|
+
`${serviceName}:subscribe`,
|
|
346
|
+
service
|
|
347
|
+
);
|
|
348
|
+
this.registerUnsubscriptionListener(
|
|
349
|
+
socket,
|
|
350
|
+
`${serviceName}:unsubscribe`,
|
|
351
|
+
service
|
|
352
|
+
);
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Register a method as a socket event listener.
|
|
357
|
+
*/
|
|
358
|
+
registerMethodListener(socket, eventName, method, service) {
|
|
359
|
+
socket.on(
|
|
360
|
+
eventName,
|
|
361
|
+
async (payload, callback) => {
|
|
362
|
+
const startedAt = Date.now();
|
|
363
|
+
const [serviceName, methodName] = eventName.split(":");
|
|
364
|
+
const userIdShort = socket.userId?.slice(0, 8) ?? "anon";
|
|
365
|
+
try {
|
|
366
|
+
if (!socket.userId && method.access !== "Public") {
|
|
367
|
+
const errorResponse = {
|
|
368
|
+
success: false,
|
|
369
|
+
error: "Authentication required",
|
|
370
|
+
code: 401
|
|
371
|
+
};
|
|
372
|
+
callback?.(errorResponse);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
let validatedPayload = payload;
|
|
376
|
+
if (method.schema) {
|
|
377
|
+
const result2 = method.schema.safeParse(payload);
|
|
378
|
+
if (!result2.success) {
|
|
379
|
+
const errorResponse = {
|
|
380
|
+
success: false,
|
|
381
|
+
error: `Validation error: ${result2.error.message}`,
|
|
382
|
+
code: 400
|
|
383
|
+
};
|
|
384
|
+
callback?.(errorResponse);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
validatedPayload = result2.data;
|
|
388
|
+
}
|
|
389
|
+
let entryId;
|
|
390
|
+
if (method.resolveEntryId) {
|
|
391
|
+
entryId = method.resolveEntryId(validatedPayload) ?? void 0;
|
|
392
|
+
} else if (validatedPayload && typeof validatedPayload === "object" && "id" in validatedPayload) {
|
|
393
|
+
const idValue = validatedPayload.id;
|
|
394
|
+
if (typeof idValue === "string") {
|
|
395
|
+
entryId = idValue;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
await service.ensureAccessForMethod(method.access, socket, entryId);
|
|
399
|
+
this.logger.info(
|
|
400
|
+
`User ${userIdShort} ${serviceName}.${methodName} -> start`,
|
|
401
|
+
{
|
|
402
|
+
category: "request_processing",
|
|
403
|
+
serviceName,
|
|
404
|
+
methodName,
|
|
405
|
+
userId: socket.userId,
|
|
406
|
+
socketId: socket.id
|
|
407
|
+
}
|
|
408
|
+
);
|
|
409
|
+
const result = await method.handler(validatedPayload, {
|
|
410
|
+
userId: socket.userId,
|
|
411
|
+
socketId: socket.id,
|
|
412
|
+
serviceAccess: socket.serviceAccess ?? {}
|
|
413
|
+
});
|
|
414
|
+
const durationMs = Date.now() - startedAt;
|
|
415
|
+
this.logger.info(
|
|
416
|
+
`User ${userIdShort} ${serviceName}.${methodName} -> success`,
|
|
417
|
+
{
|
|
418
|
+
category: "request_processing",
|
|
419
|
+
outcome: "success",
|
|
420
|
+
durationMs,
|
|
421
|
+
serviceName,
|
|
422
|
+
methodName,
|
|
423
|
+
userId: socket.userId
|
|
424
|
+
}
|
|
425
|
+
);
|
|
426
|
+
const successResponse = {
|
|
427
|
+
success: true,
|
|
428
|
+
data: result
|
|
429
|
+
};
|
|
430
|
+
callback?.(successResponse);
|
|
431
|
+
} catch (error) {
|
|
432
|
+
const durationMs = Date.now() - startedAt;
|
|
433
|
+
this.logger.error(
|
|
434
|
+
`User ${userIdShort} ${serviceName}.${methodName} -> fail`,
|
|
435
|
+
{
|
|
436
|
+
category: "request_processing",
|
|
437
|
+
outcome: "fail",
|
|
438
|
+
durationMs,
|
|
439
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
440
|
+
}
|
|
441
|
+
);
|
|
442
|
+
const errorResponse = {
|
|
443
|
+
success: false,
|
|
444
|
+
error: error instanceof Error ? error.message : "Internal error",
|
|
445
|
+
code: 500
|
|
446
|
+
};
|
|
447
|
+
callback?.(errorResponse);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Register subscription listener for a service.
|
|
454
|
+
*/
|
|
455
|
+
registerSubscriptionListener(socket, eventName, service) {
|
|
456
|
+
socket.on(
|
|
457
|
+
eventName,
|
|
458
|
+
async (payload, callback) => {
|
|
459
|
+
try {
|
|
460
|
+
if (!socket.userId) {
|
|
461
|
+
callback?.({
|
|
462
|
+
success: false,
|
|
463
|
+
error: "Authentication required",
|
|
464
|
+
code: 401
|
|
465
|
+
});
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const data = await service.subscribe(
|
|
469
|
+
payload.entryId,
|
|
470
|
+
socket,
|
|
471
|
+
payload.requiredLevel ?? "Read"
|
|
472
|
+
);
|
|
473
|
+
if (data === null) {
|
|
474
|
+
callback?.({
|
|
475
|
+
success: false,
|
|
476
|
+
error: "Access denied or entry not found",
|
|
477
|
+
code: 403
|
|
478
|
+
});
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
callback?.({ success: true, data });
|
|
482
|
+
} catch (error) {
|
|
483
|
+
this.logger.error(`Error in subscription ${eventName}:`, {
|
|
484
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
485
|
+
});
|
|
486
|
+
callback?.({
|
|
487
|
+
success: false,
|
|
488
|
+
error: error instanceof Error ? error.message : "Subscription failed"
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Register unsubscription listener for a service.
|
|
496
|
+
*/
|
|
497
|
+
registerUnsubscriptionListener(socket, eventName, service) {
|
|
498
|
+
socket.on(
|
|
499
|
+
eventName,
|
|
500
|
+
(payload, callback) => {
|
|
501
|
+
try {
|
|
502
|
+
service.unsubscribe(payload.entryId, socket);
|
|
503
|
+
callback?.({
|
|
504
|
+
success: true,
|
|
505
|
+
data: { unsubscribed: true, entryId: payload.entryId }
|
|
506
|
+
});
|
|
507
|
+
} catch (error) {
|
|
508
|
+
this.logger.error(`Error in unsubscription ${eventName}:`, {
|
|
509
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
510
|
+
});
|
|
511
|
+
callback?.({
|
|
512
|
+
success: false,
|
|
513
|
+
error: error instanceof Error ? error.message : "Unsubscription failed"
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Get list of registered service names.
|
|
521
|
+
*/
|
|
522
|
+
getServices() {
|
|
523
|
+
return Array.from(this.services.keys());
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Get all service instances for lifecycle management.
|
|
527
|
+
*/
|
|
528
|
+
getServiceInstances() {
|
|
529
|
+
return Array.from(this.services.values());
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
function createQuickdrawServer(options) {
|
|
533
|
+
const logger = options.logger ?? consoleLogger;
|
|
534
|
+
const serverLogger = logger.child({ service: "QuickdrawServer" });
|
|
535
|
+
const app = express();
|
|
536
|
+
app.use(express.json());
|
|
537
|
+
app.get("/health", (_req, res) => {
|
|
538
|
+
res.json({ status: "ok", timestamp: (/* @__PURE__ */ new Date()).toISOString() });
|
|
539
|
+
});
|
|
540
|
+
const httpServer = createServer(app);
|
|
541
|
+
const io = new Server(httpServer, {
|
|
542
|
+
cors: {
|
|
543
|
+
origin: options.cors?.origin ?? "*",
|
|
544
|
+
methods: options.cors?.methods ?? ["GET", "POST"],
|
|
545
|
+
credentials: options.cors?.credentials ?? true
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
const registry = new ServiceRegistry(io, { logger });
|
|
549
|
+
io.use(async (socket, next) => {
|
|
550
|
+
const quickdrawSocket = socket;
|
|
551
|
+
try {
|
|
552
|
+
if (options.auth?.authenticate) {
|
|
553
|
+
const auth = socket.handshake.auth;
|
|
554
|
+
const userId = await options.auth.authenticate(quickdrawSocket, auth);
|
|
555
|
+
quickdrawSocket.userId = userId;
|
|
556
|
+
quickdrawSocket.serviceAccess = {};
|
|
557
|
+
}
|
|
558
|
+
next();
|
|
559
|
+
} catch (error) {
|
|
560
|
+
serverLogger.error("Authentication failed", {
|
|
561
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
562
|
+
socketId: socket.id
|
|
563
|
+
});
|
|
564
|
+
next(new Error("Authentication failed"));
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
for (const [serviceName, service] of Object.entries(options.services)) {
|
|
568
|
+
registry.registerService(serviceName, service);
|
|
569
|
+
}
|
|
570
|
+
io.on("connection", (socket) => {
|
|
571
|
+
const quickdrawSocket = socket;
|
|
572
|
+
serverLogger.info("Socket connected", {
|
|
573
|
+
socketId: quickdrawSocket.id,
|
|
574
|
+
userId: quickdrawSocket.userId
|
|
575
|
+
});
|
|
576
|
+
quickdrawSocket.on("disconnect", () => {
|
|
577
|
+
serverLogger.info("Socket disconnected", {
|
|
578
|
+
socketId: quickdrawSocket.id,
|
|
579
|
+
userId: quickdrawSocket.userId
|
|
580
|
+
});
|
|
581
|
+
for (const service of registry.getServiceInstances()) {
|
|
582
|
+
try {
|
|
583
|
+
service.unsubscribeSocket(quickdrawSocket);
|
|
584
|
+
} catch {
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
httpServer.listen(options.port, "0.0.0.0", () => {
|
|
590
|
+
serverLogger.info(`Server listening on port ${options.port}`);
|
|
591
|
+
serverLogger.info(
|
|
592
|
+
`Registered services: ${registry.getServices().join(", ")}`
|
|
593
|
+
);
|
|
594
|
+
});
|
|
595
|
+
return { io, httpServer, registry };
|
|
596
|
+
}
|
|
597
|
+
var DEFAULT_EXPIRATION = "7d";
|
|
598
|
+
async function createJWT(payload, secret, expiresIn = DEFAULT_EXPIRATION) {
|
|
599
|
+
const secretKey = new TextEncoder().encode(secret);
|
|
600
|
+
const jwt = await new jose.SignJWT(payload).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(expiresIn).sign(secretKey);
|
|
601
|
+
return jwt;
|
|
602
|
+
}
|
|
603
|
+
async function verifyJWT(token, secret) {
|
|
604
|
+
try {
|
|
605
|
+
const secretKey = new TextEncoder().encode(secret);
|
|
606
|
+
const { payload } = await jose.jwtVerify(token, secretKey);
|
|
607
|
+
return {
|
|
608
|
+
userId: payload.userId,
|
|
609
|
+
email: payload.email,
|
|
610
|
+
exp: payload.exp,
|
|
611
|
+
iat: payload.iat
|
|
612
|
+
};
|
|
613
|
+
} catch {
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// src/server/auth/oauth.ts
|
|
619
|
+
function createOAuthURL(provider, config, state) {
|
|
620
|
+
const params = new URLSearchParams({
|
|
621
|
+
client_id: config.clientId,
|
|
622
|
+
redirect_uri: config.redirectUri,
|
|
623
|
+
response_type: "code",
|
|
624
|
+
scope: provider.scopes.join(" ")
|
|
625
|
+
});
|
|
626
|
+
if (state) {
|
|
627
|
+
params.set("state", state);
|
|
628
|
+
}
|
|
629
|
+
return `${provider.authorizationUrl}?${params.toString()}`;
|
|
630
|
+
}
|
|
631
|
+
async function exchangeOAuthCode(provider, config, code) {
|
|
632
|
+
const tokenResponse = await fetch(provider.tokenUrl, {
|
|
633
|
+
method: "POST",
|
|
634
|
+
headers: {
|
|
635
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
636
|
+
Accept: "application/json"
|
|
637
|
+
},
|
|
638
|
+
body: new URLSearchParams({
|
|
639
|
+
client_id: config.clientId,
|
|
640
|
+
client_secret: config.clientSecret,
|
|
641
|
+
code,
|
|
642
|
+
grant_type: "authorization_code",
|
|
643
|
+
redirect_uri: config.redirectUri
|
|
644
|
+
})
|
|
645
|
+
});
|
|
646
|
+
if (!tokenResponse.ok) {
|
|
647
|
+
const error = await tokenResponse.text();
|
|
648
|
+
throw new Error(`Token exchange failed: ${error}`);
|
|
649
|
+
}
|
|
650
|
+
const tokens = await tokenResponse.json();
|
|
651
|
+
const userResponse = await fetch(provider.userInfoUrl, {
|
|
652
|
+
headers: {
|
|
653
|
+
Authorization: `Bearer ${tokens.access_token}`,
|
|
654
|
+
Accept: "application/json"
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
if (!userResponse.ok) {
|
|
658
|
+
const error = await userResponse.text();
|
|
659
|
+
throw new Error(`User info fetch failed: ${error}`);
|
|
660
|
+
}
|
|
661
|
+
const userData = await userResponse.json();
|
|
662
|
+
const user = provider.parseUserInfo(userData);
|
|
663
|
+
return { tokens, user };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// src/server/auth/discord.ts
|
|
667
|
+
var discordProvider = {
|
|
668
|
+
name: "discord",
|
|
669
|
+
authorizationUrl: "https://discord.com/api/oauth2/authorize",
|
|
670
|
+
tokenUrl: "https://discord.com/api/oauth2/token",
|
|
671
|
+
userInfoUrl: "https://discord.com/api/users/@me",
|
|
672
|
+
scopes: ["identify", "email"],
|
|
673
|
+
parseUserInfo: (data) => {
|
|
674
|
+
const d = data;
|
|
675
|
+
return {
|
|
676
|
+
id: String(d.id),
|
|
677
|
+
username: String(d.username),
|
|
678
|
+
discriminator: String(d.discriminator ?? "0"),
|
|
679
|
+
email: d.email ? String(d.email) : null,
|
|
680
|
+
avatar: d.avatar ? String(d.avatar) : null,
|
|
681
|
+
verified: Boolean(d.verified)
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
// src/server/auth/google.ts
|
|
687
|
+
var googleProvider = {
|
|
688
|
+
name: "google",
|
|
689
|
+
authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
690
|
+
tokenUrl: "https://oauth2.googleapis.com/token",
|
|
691
|
+
userInfoUrl: "https://www.googleapis.com/oauth2/v2/userinfo",
|
|
692
|
+
scopes: ["openid", "email", "profile"],
|
|
693
|
+
parseUserInfo: (data) => {
|
|
694
|
+
const d = data;
|
|
695
|
+
return {
|
|
696
|
+
id: String(d.id),
|
|
697
|
+
email: String(d.email),
|
|
698
|
+
name: String(d.name),
|
|
699
|
+
picture: d.picture ? String(d.picture) : null,
|
|
700
|
+
verified_email: Boolean(d.verified_email)
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
export { BaseService, ServiceRegistry, createJWT, createOAuthURL, createQuickdrawServer, discordProvider, exchangeOAuthCode, googleProvider, verifyJWT };
|
|
706
|
+
//# sourceMappingURL=index.js.map
|
|
707
|
+
//# sourceMappingURL=index.js.map
|