@bhooai/nexus-core 2.0.11 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bhooai/nexus-core",
3
- "version": "2.0.11",
3
+ "version": "2.0.12",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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 {
@@ -245,37 +246,118 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
245
246
  });
246
247
 
247
248
  // ------------------------------------------------------------------
248
- // GraphQL gateway — compose discovered subgraphs into a /graphql endpoint
249
+ // GraphQL gateway — builtin hello + discovered subgraphs (federated)
249
250
  // ------------------------------------------------------------------
250
251
  let graphqlMounted = false;
251
- if (discovery.graphql.length > 0) {
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;
252
255
  try {
253
- const subgraphs: Subgraph[] = [];
256
+ const discovered: Subgraph[] = [];
254
257
  for (const file of discovery.graphql) {
255
- const sg = await importDefault<Subgraph>(file);
256
- if (sg && typeof sg === 'object' && 'sdl' in sg && 'resolvers' in sg) {
257
- subgraphs.push(sg);
258
- } else {
259
- console.warn(`[${name}] graphql file ${file.path} does not export a subgraph skipped`);
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);
260
271
  }
261
272
  }
262
- if (subgraphs.length === 1) {
263
- const gateway = createGateway({ subgraph: subgraphs[0]! });
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) {
264
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
+ }
265
294
  const handler = graphqlHttpHandler({
266
295
  gateway,
267
296
  introspection: config.graphql?.introspection ?? true,
297
+ requireMutationCsrf: config.graphql?.requireMutationCsrf ?? true,
268
298
  });
269
- router.add('GET', graphqlPath, handler);
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
+ }
270
315
  router.add('POST', graphqlPath, handler);
271
316
  graphqlMounted = true;
272
- console.log(`[${name}] GraphQL gateway mounted at ${graphqlPath}`);
273
- } else if (subgraphs.length > 1) {
274
- console.warn(`[${name}] found ${subgraphs.length} subgraphs — multi-subgraph federation is Phase 6; GraphQL not mounted`);
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
+ }
275
322
  }
276
323
  } catch (err) {
277
324
  console.warn(`[${name}] failed to mount GraphQL gateway:`, err);
278
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
+ }
279
361
  }
280
362
 
281
363
  // ------------------------------------------------------------------
@@ -294,9 +376,12 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
294
376
  // AuthService used to build the admin guard.
295
377
  const authService = registerAuthRoutes(router, config);
296
378
 
297
- // CSRF token endpoint — mints a double-submit token cookie + returns it.
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.
298
382
  router.get('/csrf-token', (ctx) => {
299
- const token = issueCsrfToken(ctx);
383
+ const existing = getCsrfToken(ctx);
384
+ const token = existing ?? issueCsrfToken(ctx);
300
385
  ctx.json({ token });
301
386
  });
302
387
 
@@ -306,6 +391,13 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
306
391
  requireRole('admin'),
307
392
  ];
308
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
+
309
401
  // CSRF protection (double-submit cookie) on the auth + admin surfaces.
310
402
  router.use('/auth', csrf());
311
403
  router.use('/admin', csrf());
@@ -434,6 +526,93 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
434
526
  if ((e as any)?.code !== 'ERR_MODULE_NOT_FOUND') console.warn('[realtime] ws init failed:', e);
435
527
  }
436
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
+
437
616
  // Body parsing (JSON / urlencoded / multipart) — before everything else so
438
617
  // POST/PUT/PATCH handlers see ctx.body.
439
618
  server.use(bodyParser(config.server.bodyLimit));
@@ -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
- collect(join(abs, 'routes'), /\.(ts|js)$/),
80
- collect(join(abs, 'graphql'), /\.graph\.(ts|js)$/),
81
- collect(join(abs, 'ws'), /\.room\.(ts|js)$/),
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
- collect(join(abs, 'events'), /\.(ts|js)$/),
85
- collect(join(abs, 'listeners'), /^On.*\.(ts|js)$/),
86
- collect(join(abs, 'jobs'), /Job\.(ts|js)$/),
87
- collect(join(abs, 'policies'), /Policy\.(ts|js)$/),
88
- collect(join(abs, 'providers'), /ServiceProvider\.(ts|js)$/),
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[] = [];
@@ -34,6 +34,10 @@ export const defaults: NexusConfig = {
34
34
  federation: 'in-process',
35
35
  subscriptions: true,
36
36
  introspection: true,
37
+ explorer: true,
38
+ hello: true,
39
+ requireMutationCsrf: true,
40
+ explorerRequireAuth: false,
37
41
  },
38
42
  ws: {
39
43
  path: '/ws',
@@ -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),
@@ -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 {