@bhooai/nexus-core 2.0.10 → 2.0.12
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/package.json +1 -1
- package/src/app/createNexusApp.ts +313 -20
- package/src/app/discover.ts +39 -8
- package/src/config/defaults.ts +4 -0
- package/src/config/schema.ts +4 -0
- package/src/config/types.ts +8 -0
package/package.json
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* await app.listen();
|
|
14
14
|
*/
|
|
15
15
|
import { existsSync } from 'node:fs';
|
|
16
|
-
import { resolve, dirname } from 'node:path';
|
|
16
|
+
import { resolve, dirname, join } from 'node:path';
|
|
17
17
|
import { readFile } from 'node:fs/promises';
|
|
18
18
|
import { loadConfigAuto, type NexusConfig } from '../config/index.js';
|
|
19
19
|
import { Container } from '../di/Container.js';
|
|
@@ -29,13 +29,14 @@ import { eventBus, type EventBus, type Listener } from './events.js';
|
|
|
29
29
|
import { configureQueue, InMemoryQueueAdapter, type JobQueueAdapter } from './Job.js';
|
|
30
30
|
import { configureMailDriver, configureMailRenderer, logMailDriver } from './Mailable.js';
|
|
31
31
|
import { policyRegistry } from './policies.js';
|
|
32
|
-
import { configureStorageFromEnv } from './Storage.js';
|
|
32
|
+
import { configureStorageFromEnv, storage } from './Storage.js';
|
|
33
33
|
import { maintenanceMiddleware } from './maintenance.js';
|
|
34
|
+
import { serveStatic } from '../http/static.js';
|
|
34
35
|
import { createLazyDb, registerAdminRoutes, RequestLogBuffer } from './adminModule.js';
|
|
35
36
|
import { registerAuthRoutes } from './authModule.js';
|
|
36
|
-
import { issueCsrfToken, csrf, authToken, requireRole } from '@bhooai/nexus-auth';
|
|
37
|
+
import { issueCsrfToken, getCsrfToken, csrf, authToken, requireRole } from '@bhooai/nexus-auth';
|
|
37
38
|
import { connect } from '@bhooai/nexus-data';
|
|
38
|
-
import { createGateway, graphqlHttpHandler } from '@bhooai/nexus-graphql';
|
|
39
|
+
import { createGateway, createFederatedGateway, graphqlHttpHandler, getExplorerHtml, helloSubgraph } from '@bhooai/nexus-graphql';
|
|
39
40
|
import type { Subgraph } from '@bhooai/nexus-graphql';
|
|
40
41
|
|
|
41
42
|
export interface CreateNexusAppOptions {
|
|
@@ -109,6 +110,15 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
109
110
|
// ------------------------------------------------------------------
|
|
110
111
|
const discovery = await discoverBackend(srcRoot);
|
|
111
112
|
|
|
113
|
+
// ------------------------------------------------------------------
|
|
114
|
+
// DB must be connected before importing routes/graphql that define models
|
|
115
|
+
// (blog-crud Post/Author/Comment call model() at import time — previously
|
|
116
|
+
// connect was only inside `if (admin.enabled)` after routes were mounted,
|
|
117
|
+
// causing "Not connected — call connect(uri) first." and missing /api/posts)
|
|
118
|
+
// ------------------------------------------------------------------
|
|
119
|
+
console.log(`[db] early connect ${config.db.uri} autoIndex=${config.db.autoIndex}`);
|
|
120
|
+
try { connect(config.db.uri, { autoIndex: config.db.autoIndex }); console.log(`[db] early connect ok`); } catch (e) { console.error(`[db] early connect failed`, e); }
|
|
121
|
+
|
|
112
122
|
// ------------------------------------------------------------------
|
|
113
123
|
// Storage facade + queue + mail — always configured, even if minimal
|
|
114
124
|
// ------------------------------------------------------------------
|
|
@@ -236,37 +246,118 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
236
246
|
});
|
|
237
247
|
|
|
238
248
|
// ------------------------------------------------------------------
|
|
239
|
-
// GraphQL gateway —
|
|
249
|
+
// GraphQL gateway — builtin hello + discovered subgraphs (federated)
|
|
240
250
|
// ------------------------------------------------------------------
|
|
241
251
|
let graphqlMounted = false;
|
|
242
|
-
|
|
252
|
+
{
|
|
253
|
+
const explorerEnabled = (config.graphql as unknown as { explorer?: boolean })?.explorer ?? true;
|
|
254
|
+
const helloEnabled = (config.graphql as unknown as { hello?: boolean })?.hello !== false;
|
|
243
255
|
try {
|
|
244
|
-
const
|
|
256
|
+
const discovered: Subgraph[] = [];
|
|
245
257
|
for (const file of discovery.graphql) {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
258
|
+
try {
|
|
259
|
+
const sg = await importDefault<Subgraph>(file);
|
|
260
|
+
if (sg && typeof sg === 'object' && 'sdl' in sg && 'resolvers' in sg) {
|
|
261
|
+
// Avoid duplicate hello if user created a hello subgraph
|
|
262
|
+
if (helloEnabled && (sg as unknown as { name?: string }).name === 'hello') {
|
|
263
|
+
console.warn(`[${name}] graphql file ${file.path} defines a 'hello' subgraph — overriding builtin hello`);
|
|
264
|
+
}
|
|
265
|
+
discovered.push(sg);
|
|
266
|
+
} else {
|
|
267
|
+
console.warn(`[${name}] graphql file ${file.path} does not export a subgraph — skipped`);
|
|
268
|
+
}
|
|
269
|
+
} catch (e) {
|
|
270
|
+
console.warn(`[${name}] failed to import graphql ${file.path}:`, e);
|
|
251
271
|
}
|
|
252
272
|
}
|
|
253
|
-
|
|
254
|
-
|
|
273
|
+
|
|
274
|
+
// Builtin hello is always first unless disabled or user already provides hello
|
|
275
|
+
const hasUserHello = discovered.some((s) => s.name === 'hello');
|
|
276
|
+
const subgraphs: Subgraph[] = [];
|
|
277
|
+
if (helloEnabled && !hasUserHello) subgraphs.push(helloSubgraph);
|
|
278
|
+
subgraphs.push(...discovered);
|
|
279
|
+
|
|
280
|
+
if (subgraphs.length > 0) {
|
|
255
281
|
const graphqlPath = config.graphql?.path ?? '/graphql';
|
|
282
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
283
|
+
let gateway: any;
|
|
284
|
+
let publicSdl: string;
|
|
285
|
+
if (subgraphs.length === 1) {
|
|
286
|
+
gateway = createGateway({ subgraph: subgraphs[0]! });
|
|
287
|
+
publicSdl = (subgraphs[0] as unknown as { sdl?: string })?.sdl ?? '';
|
|
288
|
+
} else {
|
|
289
|
+
gateway = createFederatedGateway(subgraphs);
|
|
290
|
+
// mergedSdl is client-facing SDL (no federation directives)
|
|
291
|
+
publicSdl = (gateway as unknown as { supergraph?: { mergedSdl?: string } })?.supergraph?.mergedSdl ?? subgraphs.map((s) => s.sdl).join('\n\n');
|
|
292
|
+
console.log(`[${name}] GraphQL federated gateway: ${subgraphs.map((s) => s.name).join(', ')} (hello builtin ${helloEnabled ? 'included' : 'disabled'})`);
|
|
293
|
+
}
|
|
256
294
|
const handler = graphqlHttpHandler({
|
|
257
295
|
gateway,
|
|
258
296
|
introspection: config.graphql?.introspection ?? true,
|
|
297
|
+
requireMutationCsrf: config.graphql?.requireMutationCsrf ?? true,
|
|
259
298
|
});
|
|
260
|
-
|
|
299
|
+
if (explorerEnabled) {
|
|
300
|
+
const explorerHtml = getExplorerHtml({
|
|
301
|
+
endpoint: graphqlPath,
|
|
302
|
+
appName: name,
|
|
303
|
+
sdl: publicSdl,
|
|
304
|
+
introspection: config.graphql?.introspection ?? true,
|
|
305
|
+
});
|
|
306
|
+
const explorerHandler: Handler = async (ctx) => {
|
|
307
|
+
ctx.setHeader('access-control-allow-origin', '*');
|
|
308
|
+
ctx.html(explorerHtml);
|
|
309
|
+
};
|
|
310
|
+
router.add('GET', '/graphiql', explorerHandler);
|
|
311
|
+
router.add('GET', graphqlPath, handler);
|
|
312
|
+
} else {
|
|
313
|
+
router.add('GET', graphqlPath, handler);
|
|
314
|
+
}
|
|
261
315
|
router.add('POST', graphqlPath, handler);
|
|
262
316
|
graphqlMounted = true;
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
317
|
+
if (subgraphs.length === 1) {
|
|
318
|
+
console.log(`[${name}] GraphQL gateway mounted at ${graphqlPath} (${subgraphs[0]!.name} subgraph${explorerEnabled ? ' + explorer at /graphiql' : ''})`);
|
|
319
|
+
} else {
|
|
320
|
+
console.log(`[${name}] GraphQL gateway mounted at ${graphqlPath}${explorerEnabled ? ' (+ explorer at /graphiql)' : ''}`);
|
|
321
|
+
}
|
|
266
322
|
}
|
|
267
323
|
} catch (err) {
|
|
268
324
|
console.warn(`[${name}] failed to mount GraphQL gateway:`, err);
|
|
269
325
|
}
|
|
326
|
+
// Fallback — should not happen because builtin hello guarantees a subgraph, but keep for hello:false
|
|
327
|
+
if (!graphqlMounted && explorerEnabled) {
|
|
328
|
+
const graphqlPath = config.graphql?.path ?? '/graphql';
|
|
329
|
+
const placeholderHtml = getExplorerHtml({
|
|
330
|
+
endpoint: graphqlPath,
|
|
331
|
+
appName: name,
|
|
332
|
+
sdl: helloEnabled ? helloSubgraph.sdl : '',
|
|
333
|
+
introspection: config.graphql?.introspection ?? true,
|
|
334
|
+
});
|
|
335
|
+
const placeholderHandler: Handler = async (ctx) => {
|
|
336
|
+
ctx.setHeader('access-control-allow-origin', '*');
|
|
337
|
+
ctx.html(placeholderHtml);
|
|
338
|
+
};
|
|
339
|
+
const graphqlPlaceholder: Handler = async (ctx) => {
|
|
340
|
+
if (helloEnabled) {
|
|
341
|
+
// hello is available even in fallback — route through its gateway
|
|
342
|
+
const gw = createGateway({ subgraph: helloSubgraph });
|
|
343
|
+
const h = graphqlHttpHandler({ gateway: gw, introspection: config.graphql?.introspection ?? true, requireMutationCsrf: config.graphql?.requireMutationCsrf ?? true });
|
|
344
|
+
return h(ctx);
|
|
345
|
+
}
|
|
346
|
+
ctx.json({ errors: [{ message: 'No GraphQL subgraph found. Add one with: npx nexus make:subgraph <name> (then restart)' }] }, 404);
|
|
347
|
+
};
|
|
348
|
+
try { router.add('GET', '/graphiql', placeholderHandler); } catch {}
|
|
349
|
+
try { router.add('GET', graphqlPath, graphqlPlaceholder); } catch {}
|
|
350
|
+
try {
|
|
351
|
+
if (helloEnabled) {
|
|
352
|
+
const gw = createGateway({ subgraph: helloSubgraph });
|
|
353
|
+
router.add('POST', graphqlPath, graphqlHttpHandler({ gateway: gw, introspection: config.graphql?.introspection ?? true, requireMutationCsrf: config.graphql?.requireMutationCsrf ?? true }));
|
|
354
|
+
} else {
|
|
355
|
+
router.add('POST', graphqlPath, async (ctx) => ctx.json({ errors: [{ message: 'No GraphQL subgraph found. Add one with: npx nexus make:subgraph <name>' }] }, 404));
|
|
356
|
+
}
|
|
357
|
+
} catch {}
|
|
358
|
+
console.log(`[${name}] GraphQL explorer mounted at /graphiql${helloEnabled ? ' (hello builtin)' : ' (no schema — add a subgraph)'}`);
|
|
359
|
+
if (helloEnabled) graphqlMounted = true;
|
|
360
|
+
}
|
|
270
361
|
}
|
|
271
362
|
|
|
272
363
|
// ------------------------------------------------------------------
|
|
@@ -285,9 +376,12 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
285
376
|
// AuthService used to build the admin guard.
|
|
286
377
|
const authService = registerAuthRoutes(router, config);
|
|
287
378
|
|
|
288
|
-
// CSRF token endpoint —
|
|
379
|
+
// CSRF token endpoint — returns the double-submit token. Reuses the existing
|
|
380
|
+
// cookie token when present (no rotation) so previously-issued tokens (e.g. a
|
|
381
|
+
// copied urlbar URL) keep matching; only mints a fresh one when none exists.
|
|
289
382
|
router.get('/csrf-token', (ctx) => {
|
|
290
|
-
const
|
|
383
|
+
const existing = getCsrfToken(ctx);
|
|
384
|
+
const token = existing ?? issueCsrfToken(ctx);
|
|
291
385
|
ctx.json({ token });
|
|
292
386
|
});
|
|
293
387
|
|
|
@@ -297,6 +391,13 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
297
391
|
requireRole('admin'),
|
|
298
392
|
];
|
|
299
393
|
|
|
394
|
+
// When enabled, gate the GraphiQL explorer with the same admin guard used for
|
|
395
|
+
// /admin/* and plugin admin pages. Only /graphiql is protected; the /graphql
|
|
396
|
+
// JSON endpoint stays public.
|
|
397
|
+
if (config.graphql?.explorerRequireAuth) {
|
|
398
|
+
for (const mw of guard) router.use('/graphiql', mw);
|
|
399
|
+
}
|
|
400
|
+
|
|
300
401
|
// CSRF protection (double-submit cookie) on the auth + admin surfaces.
|
|
301
402
|
router.use('/auth', csrf());
|
|
302
403
|
router.use('/admin', csrf());
|
|
@@ -320,6 +421,198 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
320
421
|
onError: (err, ctx) => errorHandler.toApiError(err),
|
|
321
422
|
});
|
|
322
423
|
|
|
424
|
+
// ------------------------------------------------------------------
|
|
425
|
+
// Realtime WS server — mount discovered ws/*.room.ts handlers (chat lobby)
|
|
426
|
+
// ------------------------------------------------------------------
|
|
427
|
+
let realtime: any = null;
|
|
428
|
+
try {
|
|
429
|
+
const { RealtimeServer } = await import('@bhooai/nexus-realtime');
|
|
430
|
+
realtime = new RealtimeServer({ httpServer: server.httpServer, path: '/ws' });
|
|
431
|
+
// Load custom ws rooms (e.g. chat) for persistence + presence bridging
|
|
432
|
+
const roomHandlers = new Map<string, any>();
|
|
433
|
+
for (const file of discovery.rooms) {
|
|
434
|
+
try {
|
|
435
|
+
const mod: any = await importDefault(file);
|
|
436
|
+
const def = mod?.default ?? mod;
|
|
437
|
+
if (def?.name) roomHandlers.set(def.name, def);
|
|
438
|
+
} catch (e) {
|
|
439
|
+
console.warn(`[realtime] failed to load ws room ${file.path}:`, e);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// Bridge custom chat protocol (roomId/msg/typing) to generic realtime (room/broadcast)
|
|
443
|
+
// so lobby works with both old clients (roomId) and new (room/broadcast).
|
|
444
|
+
const chatRoom = roomHandlers.get('chat');
|
|
445
|
+
if (chatRoom) {
|
|
446
|
+
const origOnMessage = (realtime as any).onMessage.bind(realtime);
|
|
447
|
+
(realtime as any).onMessage = async (conn: any, raw: string) => {
|
|
448
|
+
let msg: any;
|
|
449
|
+
try { msg = JSON.parse(raw); } catch { return origOnMessage(conn, raw); }
|
|
450
|
+
// Normalize join with roomId/username (chat) → generic join with room
|
|
451
|
+
if (msg.type === 'join' && (msg.roomId || msg.room)) {
|
|
452
|
+
const room = msg.room ?? msg.roomId;
|
|
453
|
+
(conn as any).data = (conn as any).data ?? {};
|
|
454
|
+
if (msg.username) (conn as any).data.username = msg.username;
|
|
455
|
+
// Persist room for later msg attribution
|
|
456
|
+
(conn as any).username = msg.username ?? (conn as any).username;
|
|
457
|
+
// Only call custom onJoin for side-effects, but suppress its broadcast (generic will handle presence)
|
|
458
|
+
try {
|
|
459
|
+
const sockLike: any = { data: (conn as any).data, join: () => {}, to: () => ({ emit: () => {} }) };
|
|
460
|
+
const ctxNoop: any = { server: { to: () => ({ emit: () => {} }), broadcast: () => {} } };
|
|
461
|
+
await chatRoom.onJoin?.(sockLike, { roomId: room, room, username: msg.username }, ctxNoop);
|
|
462
|
+
} catch {}
|
|
463
|
+
msg.room = room;
|
|
464
|
+
return origOnMessage(conn, JSON.stringify(msg));
|
|
465
|
+
}
|
|
466
|
+
// Custom msg → persist + single broadcast as 'msg' (legacy) — also visible to generic broadcast listeners
|
|
467
|
+
if (msg.type === 'msg' && (msg.roomId || msg.room) && msg.text) {
|
|
468
|
+
const room = msg.roomId ?? msg.room;
|
|
469
|
+
const from = (conn as any).data?.username ?? msg.username ?? (conn as any).username ?? 'anon';
|
|
470
|
+
const at = new Date().toISOString();
|
|
471
|
+
// Persist via chat handler (Message.create) without double-broadcast
|
|
472
|
+
if (chatRoom?.onMessage?.['msg']) {
|
|
473
|
+
try {
|
|
474
|
+
const sockLike: any = { data: (conn as any).data ?? { username: from } };
|
|
475
|
+
const ctxNoop: any = { server: { to: () => ({ emit: () => {} }), broadcast: () => {} } };
|
|
476
|
+
await chatRoom.onMessage['msg'](sockLike, { roomId: room, room, text: msg.text }, ctxNoop);
|
|
477
|
+
} catch {}
|
|
478
|
+
}
|
|
479
|
+
// Single publish as 'msg' — include room so deliverRoom can route to all members (including sender, since origin undefined)
|
|
480
|
+
const payload: any = { type: 'msg', room, from, text: msg.text, at, payload: { from, text: msg.text, at } };
|
|
481
|
+
try { (realtime as any).adapter.publish(`room:${room}`, JSON.stringify(payload)); } catch {}
|
|
482
|
+
try { (realtime as any).adapter.publish(`room:chat:${room}`, JSON.stringify(payload)); } catch {}
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (msg.type === 'typing' && (msg.roomId || msg.room)) {
|
|
486
|
+
const room = msg.roomId ?? msg.room;
|
|
487
|
+
const username = (conn as any).data?.username ?? msg.username ?? 'anon';
|
|
488
|
+
const payload: any = { type: 'typing', room, username, payload: { username } };
|
|
489
|
+
try { (realtime as any).adapter.publish(`room:${room}`, JSON.stringify(payload)); } catch {}
|
|
490
|
+
try { (realtime as any).adapter.publish(`room:chat:${room}`, JSON.stringify(payload)); } catch {}
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
// Handle generic broadcast with event:msg (from new clients) → persist + re-broadcast as msg
|
|
494
|
+
if (msg.type === 'broadcast' && msg.event === 'msg' && msg.data?.text) {
|
|
495
|
+
const room = msg.room;
|
|
496
|
+
const from = msg.data.from ?? (conn as any).data?.username ?? 'anon';
|
|
497
|
+
const text = msg.data.text;
|
|
498
|
+
const at = msg.data.at ?? new Date().toISOString();
|
|
499
|
+
if (chatRoom?.onMessage?.['msg']) {
|
|
500
|
+
try {
|
|
501
|
+
const sockLike: any = { data: (conn as any).data ?? { username: from } };
|
|
502
|
+
const ctxNoop: any = { server: { to: () => ({ emit: () => {} }), broadcast: () => {} } };
|
|
503
|
+
await chatRoom.onMessage['msg'](sockLike, { roomId: room, room, text }, ctxNoop);
|
|
504
|
+
} catch {}
|
|
505
|
+
}
|
|
506
|
+
const payload: any = { type: 'msg', room, from, text, at, payload: { from, text, at } };
|
|
507
|
+
try { (realtime as any).adapter.publish(`room:${room}`, JSON.stringify(payload)); } catch {}
|
|
508
|
+
try { (realtime as any).adapter.publish(`room:chat:${room}`, JSON.stringify(payload)); } catch {}
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (msg.type === 'broadcast' && msg.event === 'typing') {
|
|
512
|
+
const room = msg.room;
|
|
513
|
+
const username = msg.data?.username ?? (conn as any).data?.username ?? 'anon';
|
|
514
|
+
const payload: any = { type: 'typing', room, username, payload: { username } };
|
|
515
|
+
try { (realtime as any).adapter.publish(`room:${room}`, JSON.stringify(payload)); } catch {}
|
|
516
|
+
try { (realtime as any).adapter.publish(`room:chat:${room}`, JSON.stringify(payload)); } catch {}
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
// Normalize roomId → room for generic handling
|
|
520
|
+
if (msg.roomId && !msg.room) msg.room = msg.roomId;
|
|
521
|
+
return origOnMessage(conn, JSON.stringify(msg));
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
} catch (e) {
|
|
525
|
+
// realtime optional — if not installed, ws lobby falls back to no-op
|
|
526
|
+
if ((e as any)?.code !== 'ERR_MODULE_NOT_FOUND') console.warn('[realtime] ws init failed:', e);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Public uploads: serve GET /uploads/* from storage/uploads (file-storage example).
|
|
530
|
+
// This was missing — 404 `No route for GET /uploads/media/...`.
|
|
531
|
+
server.use(serveStatic(join(projectRoot, 'storage', 'uploads'), { prefix: '/uploads' }));
|
|
532
|
+
|
|
533
|
+
// Signed private files: GET /files/<encodedPath>?expires=&sig=
|
|
534
|
+
// Handles LocalDisk.signedUrl() URLs (HMAC, 5min TTL). Streams from whichever local disk holds the file.
|
|
535
|
+
server.use(async (ctx, next) => {
|
|
536
|
+
if ((ctx.method !== 'GET' && ctx.method !== 'HEAD') || (!ctx.path.startsWith('/files/') && ctx.path !== '/files')) {
|
|
537
|
+
await next();
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
const encoded = ctx.path.slice('/files/'.length);
|
|
541
|
+
if (!encoded) {
|
|
542
|
+
ctx.json({ error: { code: 'NOT_FOUND', message: 'missing file path' } }, 404);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
let decoded: string;
|
|
546
|
+
try {
|
|
547
|
+
decoded = decodeURIComponent(encoded);
|
|
548
|
+
} catch {
|
|
549
|
+
ctx.json({ error: { code: 'BAD_REQUEST', message: 'invalid file path' } }, 400);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
// Block traversal
|
|
553
|
+
if (decoded.includes('..') || decoded.includes('\\') || decoded.startsWith('/')) {
|
|
554
|
+
ctx.json({ error: { code: 'BAD_REQUEST', message: 'invalid file path' } }, 400);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
const expiresRaw = ctx.query.expires;
|
|
558
|
+
const sigRaw = ctx.query.sig;
|
|
559
|
+
const expiresStr = Array.isArray(expiresRaw) ? expiresRaw[0] : (expiresRaw as string | undefined);
|
|
560
|
+
const sig = Array.isArray(sigRaw) ? sigRaw[0] : (sigRaw as string | undefined);
|
|
561
|
+
if (!expiresStr || !sig) {
|
|
562
|
+
ctx.json({ error: { code: 'UNAUTHORIZED', message: 'missing signature' } }, 401);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
const expires = parseInt(String(expiresStr), 10);
|
|
566
|
+
if (!Number.isFinite(expires)) {
|
|
567
|
+
ctx.json({ error: { code: 'BAD_REQUEST', message: 'invalid expires' } }, 400);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
const localUploads = storage.local('uploads');
|
|
571
|
+
const localPrivate = storage.local('private');
|
|
572
|
+
const verifier = localUploads ?? localPrivate;
|
|
573
|
+
const isValid = verifier ? verifier.verifySignedUrl(decoded, expires, String(sig)) : false;
|
|
574
|
+
// Fallback: also accept signature valid for private disk if uploads verifier fails (same secret, so same result — but check both)
|
|
575
|
+
const isValidAlt = !isValid && localPrivate && localUploads ? localPrivate.verifySignedUrl(decoded, expires, String(sig)) : false;
|
|
576
|
+
if (!isValid && !isValidAlt) {
|
|
577
|
+
ctx.json({ error: { code: 'FORBIDDEN', message: 'invalid or expired signature' } }, 403);
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
// Find disk holding the file (private first, then uploads)
|
|
581
|
+
const candidates = [localPrivate, localUploads].filter(Boolean) as Array<NonNullable<typeof localPrivate>>;
|
|
582
|
+
let disk: (typeof candidates)[number] | null = null;
|
|
583
|
+
for (const d of candidates) {
|
|
584
|
+
try {
|
|
585
|
+
if (await d.exists(decoded)) { disk = d; break; }
|
|
586
|
+
} catch {}
|
|
587
|
+
}
|
|
588
|
+
if (!disk) {
|
|
589
|
+
ctx.json({ error: { code: 'NOT_FOUND', message: 'file not found' } }, 404);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
try {
|
|
593
|
+
if (ctx.method === 'HEAD') {
|
|
594
|
+
ctx.status(200);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
const stream = disk.stream(decoded);
|
|
598
|
+
const ext = decoded.includes('.') ? `.${decoded.split('.').pop()!.toLowerCase()}` : '';
|
|
599
|
+
const MIME: Record<string, string> = {
|
|
600
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif',
|
|
601
|
+
'.webp': 'image/webp', '.svg': 'image/svg+xml', '.pdf': 'application/pdf',
|
|
602
|
+
'.txt': 'text/plain; charset=utf-8', '.json': 'application/json; charset=utf-8',
|
|
603
|
+
};
|
|
604
|
+
ctx.setHeader('content-type', MIME[ext] ?? 'application/octet-stream');
|
|
605
|
+
await new Promise<void>((resolveStream, rejectStream) => {
|
|
606
|
+
stream.on('error', rejectStream);
|
|
607
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
608
|
+
(stream as any).pipe(ctx.res);
|
|
609
|
+
stream.on('end', resolveStream);
|
|
610
|
+
});
|
|
611
|
+
} catch {
|
|
612
|
+
ctx.json({ error: { code: 'NOT_FOUND', message: 'file not found' } }, 404);
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
|
|
323
616
|
// Body parsing (JSON / urlencoded / multipart) — before everything else so
|
|
324
617
|
// POST/PUT/PATCH handlers see ctx.body.
|
|
325
618
|
server.use(bodyParser(config.server.bodyLimit));
|
package/src/app/discover.ts
CHANGED
|
@@ -76,16 +76,16 @@ export async function discoverBackend(srcRoot: string): Promise<DiscoveryResult>
|
|
|
76
76
|
|
|
77
77
|
const [routes, graphql, rooms, mailables, errorPages, events, listeners, jobs, policies, providers, seeds, migrations, plugins, config] =
|
|
78
78
|
await Promise.all([
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
collectWithModules(abs, 'routes', /\.(ts|js)$/),
|
|
80
|
+
collectWithModules(abs, 'graphql', /\.graph\.(ts|js)$/),
|
|
81
|
+
collectWithModules(abs, 'ws', /\.room\.(ts|js)$/),
|
|
82
82
|
collect(join(abs, 'mail', 'mailables'), /\.(ts|js)$/),
|
|
83
83
|
collect(join(abs, 'errors', 'pages'), /\.html$/),
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
84
|
+
collectWithModules(abs, 'events', /\.(ts|js)$/),
|
|
85
|
+
collectWithModules(abs, 'listeners', /^On.*\.(ts|js)$/),
|
|
86
|
+
collectWithModules(abs, 'jobs', /Job\.(ts|js)$/),
|
|
87
|
+
collectWithModules(abs, 'policies', /Policy\.(ts|js)$/),
|
|
88
|
+
collectWithModules(abs, 'providers', /ServiceProvider\.(ts|js)$/),
|
|
89
89
|
collect(join(abs, 'database', 'seeds'), /Seeder\.(ts|js)$/),
|
|
90
90
|
collect(join(abs, 'database', 'migrations'), /^\d{4}_\d{2}_\d{2}_\d{6}_.*\.(ts|js)$/),
|
|
91
91
|
collectPluginDirs(join(abs, 'plugins')),
|
|
@@ -112,6 +112,37 @@ async function collect(dir: string, pattern: RegExp): Promise<DiscoveredFile[]>
|
|
|
112
112
|
return out;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/** Collect from flat root + modules/<domain>/<sub> — keeps modular workflow discoverable. */
|
|
116
|
+
async function collectWithModules(abs: string, sub: string, pattern: RegExp): Promise<DiscoveredFile[]> {
|
|
117
|
+
const flat = await collect(join(abs, sub), pattern);
|
|
118
|
+
const modulesRoot = join(abs, 'modules');
|
|
119
|
+
if (!existsSync(modulesRoot)) return dedupeByPath(flat);
|
|
120
|
+
const mods = await readdir(modulesRoot, { withFileTypes: true });
|
|
121
|
+
const moduleFiles: DiscoveredFile[] = [];
|
|
122
|
+
for (const m of mods) {
|
|
123
|
+
if (!m.isDirectory()) continue;
|
|
124
|
+
const modSub = join(modulesRoot, m.name, sub);
|
|
125
|
+
const files = await collect(modSub, pattern);
|
|
126
|
+
for (const f of files) {
|
|
127
|
+
moduleFiles.push({ ...f, subdir: f.subdir ? `${m.name}/${f.subdir}` : m.name });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Prefer modular file when same `name` exists in both flat and modular (flat is deprecated shim)
|
|
131
|
+
if (moduleFiles.length > 0) {
|
|
132
|
+
const moduleNames = new Set(moduleFiles.map((f) => f.name));
|
|
133
|
+
const filteredFlat = flat.filter((f) => !moduleNames.has(f.name));
|
|
134
|
+
return dedupeByPath([...filteredFlat, ...moduleFiles]);
|
|
135
|
+
}
|
|
136
|
+
return dedupeByPath([...flat, ...moduleFiles]);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function dedupeByPath(files: DiscoveredFile[]): DiscoveredFile[] {
|
|
140
|
+
const seen = new Set<string>();
|
|
141
|
+
const out: DiscoveredFile[] = [];
|
|
142
|
+
for (const f of files) if (!seen.has(f.path)) { seen.add(f.path); out.push(f); }
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
115
146
|
async function collectPluginDirs(dir: string): Promise<DiscoveredFile[]> {
|
|
116
147
|
if (!existsSync(dir)) return [];
|
|
117
148
|
const out: DiscoveredFile[] = [];
|
package/src/config/defaults.ts
CHANGED
package/src/config/schema.ts
CHANGED
|
@@ -39,6 +39,10 @@ export const nexusConfigSchema = z.object({
|
|
|
39
39
|
federation: z.enum(['in-process', 'distributed']),
|
|
40
40
|
subscriptions: z.boolean(),
|
|
41
41
|
introspection: z.boolean(),
|
|
42
|
+
explorer: z.boolean().optional(),
|
|
43
|
+
hello: z.boolean().optional(),
|
|
44
|
+
requireMutationCsrf: z.boolean(),
|
|
45
|
+
explorerRequireAuth: z.boolean().optional(),
|
|
42
46
|
}),
|
|
43
47
|
ws: z.object({
|
|
44
48
|
path: z.string().min(1),
|
package/src/config/types.ts
CHANGED
|
@@ -154,6 +154,14 @@ export interface GraphqlConfig {
|
|
|
154
154
|
subscriptions: boolean;
|
|
155
155
|
/** Introspection enabled (disable in production). */
|
|
156
156
|
introspection: boolean;
|
|
157
|
+
/** Serve the interactive sandbox at GET /graphiql. */
|
|
158
|
+
explorer?: boolean;
|
|
159
|
+
/** Include builtin hello/ping subgraph in every app (default true). Disable with `hello: false`. */
|
|
160
|
+
hello?: boolean;
|
|
161
|
+
/** Require a matching CSRF token to execute mutation operations on /graphql (default true). */
|
|
162
|
+
requireMutationCsrf?: boolean;
|
|
163
|
+
/** Require an admin access token (JWT) to open the GraphiQL explorer at /graphiql (default false). */
|
|
164
|
+
explorerRequireAuth?: boolean;
|
|
157
165
|
}
|
|
158
166
|
|
|
159
167
|
export interface WsConfig {
|