@bhooai/nexus-core 2.0.11 → 2.0.13
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 +224 -21
- package/src/app/discover.ts +39 -8
- package/src/config/defaults.ts +4 -0
- package/src/config/runtimeJson.ts +6 -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,14 +29,15 @@ 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 type { Subgraph } from '@bhooai/nexus-graphql';
|
|
39
|
+
import { createGateway, createFederatedGateway, graphqlHttpHandler, getExplorerHtml, helloSubgraph, SubscriptionServer } from '@bhooai/nexus-graphql';
|
|
40
|
+
import type { Subgraph, GraphQLContext } from '@bhooai/nexus-graphql';
|
|
40
41
|
|
|
41
42
|
export interface CreateNexusAppOptions {
|
|
42
43
|
/** Identifier for this backend, used in admin, telemetry, logs. */
|
|
@@ -58,6 +59,11 @@ export interface CreateNexusAppOptions {
|
|
|
58
59
|
/** Hooks for extending boot. */
|
|
59
60
|
beforeStart?: (app: NexusApp) => Promise<void> | void;
|
|
60
61
|
afterStart?: (app: NexusApp) => Promise<void> | void;
|
|
62
|
+
/**
|
|
63
|
+
* Extra values injected into every GraphQL resolver context (e.g. a payments
|
|
64
|
+
* service). Merged with the default `{ request, user }` context.
|
|
65
|
+
*/
|
|
66
|
+
graphqlContext?: Record<string, unknown>;
|
|
61
67
|
}
|
|
62
68
|
|
|
63
69
|
export interface NexusApp {
|
|
@@ -245,37 +251,122 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
245
251
|
});
|
|
246
252
|
|
|
247
253
|
// ------------------------------------------------------------------
|
|
248
|
-
// GraphQL gateway —
|
|
254
|
+
// GraphQL gateway — builtin hello + discovered subgraphs (federated)
|
|
249
255
|
// ------------------------------------------------------------------
|
|
250
256
|
let graphqlMounted = false;
|
|
251
|
-
|
|
257
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
258
|
+
let graphqlGateway: any = null;
|
|
259
|
+
{
|
|
260
|
+
const explorerEnabled = (config.graphql as unknown as { explorer?: boolean })?.explorer ?? true;
|
|
261
|
+
const helloEnabled = (config.graphql as unknown as { hello?: boolean })?.hello !== false;
|
|
252
262
|
try {
|
|
253
|
-
const
|
|
263
|
+
const discovered: Subgraph[] = [];
|
|
254
264
|
for (const file of discovery.graphql) {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
265
|
+
try {
|
|
266
|
+
const sg = await importDefault<Subgraph>(file);
|
|
267
|
+
if (sg && typeof sg === 'object' && 'sdl' in sg && 'resolvers' in sg) {
|
|
268
|
+
// Avoid duplicate hello if user created a hello subgraph
|
|
269
|
+
if (helloEnabled && (sg as unknown as { name?: string }).name === 'hello') {
|
|
270
|
+
console.warn(`[${name}] graphql file ${file.path} defines a 'hello' subgraph — overriding builtin hello`);
|
|
271
|
+
}
|
|
272
|
+
discovered.push(sg);
|
|
273
|
+
} else {
|
|
274
|
+
console.warn(`[${name}] graphql file ${file.path} does not export a subgraph — skipped`);
|
|
275
|
+
}
|
|
276
|
+
} catch (e) {
|
|
277
|
+
console.warn(`[${name}] failed to import graphql ${file.path}:`, e);
|
|
260
278
|
}
|
|
261
279
|
}
|
|
262
|
-
|
|
263
|
-
|
|
280
|
+
|
|
281
|
+
// Builtin hello is always first unless disabled or user already provides hello
|
|
282
|
+
const hasUserHello = discovered.some((s) => s.name === 'hello');
|
|
283
|
+
const subgraphs: Subgraph[] = [];
|
|
284
|
+
if (helloEnabled && !hasUserHello) subgraphs.push(helloSubgraph);
|
|
285
|
+
subgraphs.push(...discovered);
|
|
286
|
+
|
|
287
|
+
if (subgraphs.length > 0) {
|
|
264
288
|
const graphqlPath = config.graphql?.path ?? '/graphql';
|
|
289
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
290
|
+
let gateway: any;
|
|
291
|
+
let publicSdl: string;
|
|
292
|
+
if (subgraphs.length === 1) {
|
|
293
|
+
gateway = createGateway({ subgraph: subgraphs[0]! });
|
|
294
|
+
publicSdl = (subgraphs[0] as unknown as { sdl?: string })?.sdl ?? '';
|
|
295
|
+
} else {
|
|
296
|
+
gateway = createFederatedGateway(subgraphs);
|
|
297
|
+
// mergedSdl is client-facing SDL (no federation directives)
|
|
298
|
+
publicSdl = (gateway as unknown as { supergraph?: { mergedSdl?: string } })?.supergraph?.mergedSdl ?? subgraphs.map((s) => s.sdl).join('\n\n');
|
|
299
|
+
console.log(`[${name}] GraphQL federated gateway: ${subgraphs.map((s) => s.name).join(', ')} (hello builtin ${helloEnabled ? 'included' : 'disabled'})`);
|
|
300
|
+
}
|
|
265
301
|
const handler = graphqlHttpHandler({
|
|
266
302
|
gateway,
|
|
267
303
|
introspection: config.graphql?.introspection ?? true,
|
|
304
|
+
requireMutationCsrf: config.graphql?.requireMutationCsrf ?? true,
|
|
305
|
+
context: (ctx) => ({ request: ctx, user: (ctx.state.user as GraphQLContext['user']), ...opts.graphqlContext }),
|
|
268
306
|
});
|
|
269
|
-
|
|
307
|
+
if (explorerEnabled) {
|
|
308
|
+
const explorerHtml = getExplorerHtml({
|
|
309
|
+
endpoint: graphqlPath,
|
|
310
|
+
appName: name,
|
|
311
|
+
sdl: publicSdl,
|
|
312
|
+
introspection: config.graphql?.introspection ?? true,
|
|
313
|
+
});
|
|
314
|
+
const explorerHandler: Handler = async (ctx) => {
|
|
315
|
+
ctx.setHeader('access-control-allow-origin', '*');
|
|
316
|
+
ctx.html(explorerHtml);
|
|
317
|
+
};
|
|
318
|
+
router.add('GET', '/graphiql', explorerHandler);
|
|
319
|
+
router.add('GET', graphqlPath, handler);
|
|
320
|
+
} else {
|
|
321
|
+
router.add('GET', graphqlPath, handler);
|
|
322
|
+
}
|
|
270
323
|
router.add('POST', graphqlPath, handler);
|
|
271
324
|
graphqlMounted = true;
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
325
|
+
graphqlGateway = gateway;
|
|
326
|
+
if (subgraphs.length === 1) {
|
|
327
|
+
console.log(`[${name}] GraphQL gateway mounted at ${graphqlPath} (${subgraphs[0]!.name} subgraph${explorerEnabled ? ' + explorer at /graphiql' : ''})`);
|
|
328
|
+
} else {
|
|
329
|
+
console.log(`[${name}] GraphQL gateway mounted at ${graphqlPath}${explorerEnabled ? ' (+ explorer at /graphiql)' : ''}`);
|
|
330
|
+
}
|
|
275
331
|
}
|
|
276
332
|
} catch (err) {
|
|
277
333
|
console.warn(`[${name}] failed to mount GraphQL gateway:`, err);
|
|
278
334
|
}
|
|
335
|
+
// Fallback — should not happen because builtin hello guarantees a subgraph, but keep for hello:false
|
|
336
|
+
if (!graphqlMounted && explorerEnabled) {
|
|
337
|
+
const graphqlPath = config.graphql?.path ?? '/graphql';
|
|
338
|
+
const placeholderHtml = getExplorerHtml({
|
|
339
|
+
endpoint: graphqlPath,
|
|
340
|
+
appName: name,
|
|
341
|
+
sdl: helloEnabled ? helloSubgraph.sdl : '',
|
|
342
|
+
introspection: config.graphql?.introspection ?? true,
|
|
343
|
+
});
|
|
344
|
+
const placeholderHandler: Handler = async (ctx) => {
|
|
345
|
+
ctx.setHeader('access-control-allow-origin', '*');
|
|
346
|
+
ctx.html(placeholderHtml);
|
|
347
|
+
};
|
|
348
|
+
const graphqlPlaceholder: Handler = async (ctx) => {
|
|
349
|
+
if (helloEnabled) {
|
|
350
|
+
// hello is available even in fallback — route through its gateway
|
|
351
|
+
const gw = createGateway({ subgraph: helloSubgraph });
|
|
352
|
+
const h = graphqlHttpHandler({ gateway: gw, introspection: config.graphql?.introspection ?? true, requireMutationCsrf: config.graphql?.requireMutationCsrf ?? true, context: (ctx) => ({ request: ctx, user: (ctx.state.user as GraphQLContext['user']), ...opts.graphqlContext }) });
|
|
353
|
+
return h(ctx);
|
|
354
|
+
}
|
|
355
|
+
ctx.json({ errors: [{ message: 'No GraphQL subgraph found. Add one with: npx nexus make:subgraph <name> (then restart)' }] }, 404);
|
|
356
|
+
};
|
|
357
|
+
try { router.add('GET', '/graphiql', placeholderHandler); } catch {}
|
|
358
|
+
try { router.add('GET', graphqlPath, graphqlPlaceholder); } catch {}
|
|
359
|
+
try {
|
|
360
|
+
if (helloEnabled) {
|
|
361
|
+
const gw = createGateway({ subgraph: helloSubgraph });
|
|
362
|
+
router.add('POST', graphqlPath, graphqlHttpHandler({ gateway: gw, introspection: config.graphql?.introspection ?? true, requireMutationCsrf: config.graphql?.requireMutationCsrf ?? true, context: (ctx) => ({ request: ctx, user: (ctx.state.user as GraphQLContext['user']), ...opts.graphqlContext }) }));
|
|
363
|
+
} else {
|
|
364
|
+
router.add('POST', graphqlPath, async (ctx) => ctx.json({ errors: [{ message: 'No GraphQL subgraph found. Add one with: npx nexus make:subgraph <name>' }] }, 404));
|
|
365
|
+
}
|
|
366
|
+
} catch {}
|
|
367
|
+
console.log(`[${name}] GraphQL explorer mounted at /graphiql${helloEnabled ? ' (hello builtin)' : ' (no schema — add a subgraph)'}`);
|
|
368
|
+
if (helloEnabled) graphqlMounted = true;
|
|
369
|
+
}
|
|
279
370
|
}
|
|
280
371
|
|
|
281
372
|
// ------------------------------------------------------------------
|
|
@@ -294,9 +385,12 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
294
385
|
// AuthService used to build the admin guard.
|
|
295
386
|
const authService = registerAuthRoutes(router, config);
|
|
296
387
|
|
|
297
|
-
// CSRF token endpoint —
|
|
388
|
+
// CSRF token endpoint — returns the double-submit token. Reuses the existing
|
|
389
|
+
// cookie token when present (no rotation) so previously-issued tokens (e.g. a
|
|
390
|
+
// copied urlbar URL) keep matching; only mints a fresh one when none exists.
|
|
298
391
|
router.get('/csrf-token', (ctx) => {
|
|
299
|
-
const
|
|
392
|
+
const existing = getCsrfToken(ctx);
|
|
393
|
+
const token = existing ?? issueCsrfToken(ctx);
|
|
300
394
|
ctx.json({ token });
|
|
301
395
|
});
|
|
302
396
|
|
|
@@ -306,6 +400,13 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
306
400
|
requireRole('admin'),
|
|
307
401
|
];
|
|
308
402
|
|
|
403
|
+
// When enabled, gate the GraphiQL explorer with the same admin guard used for
|
|
404
|
+
// /admin/* and plugin admin pages. Only /graphiql is protected; the /graphql
|
|
405
|
+
// JSON endpoint stays public.
|
|
406
|
+
if (config.graphql?.explorerRequireAuth) {
|
|
407
|
+
for (const mw of guard) router.use('/graphiql', mw);
|
|
408
|
+
}
|
|
409
|
+
|
|
309
410
|
// CSRF protection (double-submit cookie) on the auth + admin surfaces.
|
|
310
411
|
router.use('/auth', csrf());
|
|
311
412
|
router.use('/admin', csrf());
|
|
@@ -434,6 +535,108 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
434
535
|
if ((e as any)?.code !== 'ERR_MODULE_NOT_FOUND') console.warn('[realtime] ws init failed:', e);
|
|
435
536
|
}
|
|
436
537
|
|
|
538
|
+
// ------------------------------------------------------------------
|
|
539
|
+
// GraphQL subscriptions over WebSocket — mount when a gateway exists
|
|
540
|
+
// and config.graphql.subscriptions is enabled.
|
|
541
|
+
// ------------------------------------------------------------------
|
|
542
|
+
const graphqlSubscriptions = (config.graphql as unknown as { subscriptions?: boolean })?.subscriptions ?? true;
|
|
543
|
+
if (graphqlGateway && graphqlSubscriptions && typeof graphqlGateway.subscribe === 'function') {
|
|
544
|
+
try {
|
|
545
|
+
const subPath = `${config.graphql?.path ?? '/graphql'}/ws`;
|
|
546
|
+
new SubscriptionServer({ httpServer: server.httpServer, gateway: graphqlGateway, path: subPath });
|
|
547
|
+
console.log(`[${name}] GraphQL subscriptions mounted at ${subPath}`);
|
|
548
|
+
} catch (err) {
|
|
549
|
+
console.warn(`[${name}] failed to mount GraphQL subscriptions:`, err);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Public uploads: serve GET /uploads/* from storage/uploads (file-storage example).
|
|
554
|
+
// This was missing — 404 `No route for GET /uploads/media/...`.
|
|
555
|
+
server.use(serveStatic(join(projectRoot, 'storage', 'uploads'), { prefix: '/uploads' }));
|
|
556
|
+
|
|
557
|
+
// Signed private files: GET /files/<encodedPath>?expires=&sig=
|
|
558
|
+
// Handles LocalDisk.signedUrl() URLs (HMAC, 5min TTL). Streams from whichever local disk holds the file.
|
|
559
|
+
server.use(async (ctx, next) => {
|
|
560
|
+
if ((ctx.method !== 'GET' && ctx.method !== 'HEAD') || (!ctx.path.startsWith('/files/') && ctx.path !== '/files')) {
|
|
561
|
+
await next();
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
const encoded = ctx.path.slice('/files/'.length);
|
|
565
|
+
if (!encoded) {
|
|
566
|
+
ctx.json({ error: { code: 'NOT_FOUND', message: 'missing file path' } }, 404);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
let decoded: string;
|
|
570
|
+
try {
|
|
571
|
+
decoded = decodeURIComponent(encoded);
|
|
572
|
+
} catch {
|
|
573
|
+
ctx.json({ error: { code: 'BAD_REQUEST', message: 'invalid file path' } }, 400);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
// Block traversal
|
|
577
|
+
if (decoded.includes('..') || decoded.includes('\\') || decoded.startsWith('/')) {
|
|
578
|
+
ctx.json({ error: { code: 'BAD_REQUEST', message: 'invalid file path' } }, 400);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
const expiresRaw = ctx.query.expires;
|
|
582
|
+
const sigRaw = ctx.query.sig;
|
|
583
|
+
const expiresStr = Array.isArray(expiresRaw) ? expiresRaw[0] : (expiresRaw as string | undefined);
|
|
584
|
+
const sig = Array.isArray(sigRaw) ? sigRaw[0] : (sigRaw as string | undefined);
|
|
585
|
+
if (!expiresStr || !sig) {
|
|
586
|
+
ctx.json({ error: { code: 'UNAUTHORIZED', message: 'missing signature' } }, 401);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
const expires = parseInt(String(expiresStr), 10);
|
|
590
|
+
if (!Number.isFinite(expires)) {
|
|
591
|
+
ctx.json({ error: { code: 'BAD_REQUEST', message: 'invalid expires' } }, 400);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
const localUploads = storage.local('uploads');
|
|
595
|
+
const localPrivate = storage.local('private');
|
|
596
|
+
const verifier = localUploads ?? localPrivate;
|
|
597
|
+
const isValid = verifier ? verifier.verifySignedUrl(decoded, expires, String(sig)) : false;
|
|
598
|
+
// Fallback: also accept signature valid for private disk if uploads verifier fails (same secret, so same result — but check both)
|
|
599
|
+
const isValidAlt = !isValid && localPrivate && localUploads ? localPrivate.verifySignedUrl(decoded, expires, String(sig)) : false;
|
|
600
|
+
if (!isValid && !isValidAlt) {
|
|
601
|
+
ctx.json({ error: { code: 'FORBIDDEN', message: 'invalid or expired signature' } }, 403);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
// Find disk holding the file (private first, then uploads)
|
|
605
|
+
const candidates = [localPrivate, localUploads].filter(Boolean) as Array<NonNullable<typeof localPrivate>>;
|
|
606
|
+
let disk: (typeof candidates)[number] | null = null;
|
|
607
|
+
for (const d of candidates) {
|
|
608
|
+
try {
|
|
609
|
+
if (await d.exists(decoded)) { disk = d; break; }
|
|
610
|
+
} catch {}
|
|
611
|
+
}
|
|
612
|
+
if (!disk) {
|
|
613
|
+
ctx.json({ error: { code: 'NOT_FOUND', message: 'file not found' } }, 404);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
try {
|
|
617
|
+
if (ctx.method === 'HEAD') {
|
|
618
|
+
ctx.status(200);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const stream = disk.stream(decoded);
|
|
622
|
+
const ext = decoded.includes('.') ? `.${decoded.split('.').pop()!.toLowerCase()}` : '';
|
|
623
|
+
const MIME: Record<string, string> = {
|
|
624
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif',
|
|
625
|
+
'.webp': 'image/webp', '.svg': 'image/svg+xml', '.pdf': 'application/pdf',
|
|
626
|
+
'.txt': 'text/plain; charset=utf-8', '.json': 'application/json; charset=utf-8',
|
|
627
|
+
};
|
|
628
|
+
ctx.setHeader('content-type', MIME[ext] ?? 'application/octet-stream');
|
|
629
|
+
await new Promise<void>((resolveStream, rejectStream) => {
|
|
630
|
+
stream.on('error', rejectStream);
|
|
631
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
632
|
+
(stream as any).pipe(ctx.res);
|
|
633
|
+
stream.on('end', resolveStream);
|
|
634
|
+
});
|
|
635
|
+
} catch {
|
|
636
|
+
ctx.json({ error: { code: 'NOT_FOUND', message: 'file not found' } }, 404);
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
|
|
437
640
|
// Body parsing (JSON / urlencoded / multipart) — before everything else so
|
|
438
641
|
// POST/PUT/PATCH handlers see ctx.body.
|
|
439
642
|
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
|
@@ -74,5 +74,11 @@ export async function mergeRuntimeJson(projectRoot: string, patch: Record<string
|
|
|
74
74
|
if (patch.ai && typeof patch.ai === 'object' && existing.ai && typeof existing.ai === 'object') {
|
|
75
75
|
merged.ai = { ...(existing as { ai: Record<string, unknown> }).ai, ...(patch.ai as Record<string, unknown>) };
|
|
76
76
|
}
|
|
77
|
+
// Deep-merge `payments` so toggling one provider's enabled/sandbox state does
|
|
78
|
+
// NOT wipe the other providers' persisted state (shallow merge would replace
|
|
79
|
+
// the whole payments object).
|
|
80
|
+
if (patch.payments && typeof patch.payments === 'object' && existing.payments && typeof existing.payments === 'object') {
|
|
81
|
+
merged.payments = { ...(existing as { payments: Record<string, unknown> }).payments, ...(patch.payments as Record<string, unknown>) };
|
|
82
|
+
}
|
|
77
83
|
await writeRuntimeJson(projectRoot, stripRedactionMask(merged) as Record<string, unknown>);
|
|
78
84
|
}
|
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 {
|