@bhooai/nexus-core 2.0.12 → 2.0.15

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.
@@ -44,11 +44,18 @@ import {
44
44
  type FacebookOAuthConfig,
45
45
  } from '@bhooai/nexus-auth';
46
46
  import { ConflictError, AuthenticationError, ValidationError } from '../index.js';
47
- import { ObjectId } from '@bhooai/nexus-data';
48
- import { initUserModel, getUserModel, findUserForLogin, upsertOAuthUser, type UserInstance } from './userModel.js';
47
+ import { getUserStore, type StoredUser } from './userStore.js';
49
48
 
50
- const sessions = new MemorySessionStore();
51
- const oauthState = new MemoryOAuthStateStore();
49
+ let sessions: InstanceType<typeof MemorySessionStore> | undefined;
50
+ let oauthState: InstanceType<typeof MemoryOAuthStateStore> | undefined;
51
+ function getSessions() {
52
+ if (!sessions) sessions = new MemorySessionStore();
53
+ return sessions;
54
+ }
55
+ function getOauthState() {
56
+ if (!oauthState) oauthState = new MemoryOAuthStateStore();
57
+ return oauthState;
58
+ }
52
59
 
53
60
  function origin(config: NexusConfig): string {
54
61
  const proto = config.server.https ? 'https' : 'http';
@@ -67,12 +74,13 @@ function jwtOptions(config: NexusConfig) {
67
74
  }
68
75
 
69
76
  function authService(config: NexusConfig): AuthService {
70
- return new AuthService(jwtOptions(config), sessions);
77
+ return new AuthService(jwtOptions(config), getSessions());
71
78
  }
72
79
 
73
- /** Register /auth/* routes onto the given router. Call after `connect()` + `initUserModel()`. */
80
+ /** Register /auth/* routes onto the given router. The user store is initialized
81
+ * by `createNexusApp()` (or lazily falls back to Mongo on first use). */
74
82
  export function registerAuthRoutes(router: Router, config: NexusConfig, onUserCreated?: () => void): AuthService {
75
- initUserModel();
83
+ const users = getUserStore();
76
84
  const service = authService(config);
77
85
 
78
86
  // POST /auth/register
@@ -81,18 +89,17 @@ export function registerAuthRoutes(router: Router, config: NexusConfig, onUserCr
81
89
  if (!body.email || !body.password) throw new ValidationError('email and password are required');
82
90
  if (body.password.length < 8) throw new ValidationError('password must be at least 8 characters');
83
91
 
84
- const User = getUserModel();
85
- const existing = await User.findOne({ email: body.email.toLowerCase() }).lean();
92
+ const existing = await users.findByEmail(body.email);
86
93
  if (existing) throw new ConflictError('A user with that email already exists');
87
94
 
88
95
  const passwordHash = await hashPassword(body.password);
89
96
  // Bootstrap: the very first registered user becomes an admin.
90
- const userCount = await User.countDocuments();
97
+ const userCount = await users.count();
91
98
  const roles = userCount === 0 ? ['admin'] : ['user'];
92
- const [user] = await User.create({ email: body.email, name: body.name, passwordHash, roles });
99
+ const user = await users.create({ email: body.email, name: body.name, passwordHash, roles });
93
100
  // Notify subscribers (e.g. a GraphQL `userCount` subscription) if wired.
94
101
  onUserCreated?.();
95
- const pair = await service.login({ userId: String((user as UserInstance)._id), roles: (user as UserInstance).roles, meta: { ip: ctx.req.socket.remoteAddress } });
102
+ const pair = await service.login({ userId: String(user._id), roles: user.roles, meta: { ip: ctx.req.socket.remoteAddress } });
96
103
  setAuthCookies(ctx, pair, { accessTokenName: config.auth.cookieName, refreshTokenName: config.auth.refreshCookieName });
97
104
  ctx.json({ user: publicUser(user), accessToken: pair.accessToken, refreshToken: pair.refreshToken });
98
105
  });
@@ -101,7 +108,7 @@ export function registerAuthRoutes(router: Router, config: NexusConfig, onUserCr
101
108
  router.post('/auth/login', async (ctx) => {
102
109
  const body = (ctx.body ?? {}) as { email?: string; password?: string };
103
110
  if (!body.email || !body.password) throw new ValidationError('email and password are required');
104
- const user = await findUserForLogin(body.email);
111
+ const user = await users.findForLogin(body.email);
105
112
  if (!user || !user.passwordHash) throw new AuthenticationError('Invalid email or password');
106
113
  const ok = await verifyPassword(body.password, user.passwordHash);
107
114
  if (!ok) throw new AuthenticationError('Invalid email or password');
@@ -132,9 +139,8 @@ export function registerAuthRoutes(router: Router, config: NexusConfig, onUserCr
132
139
 
133
140
  // GET /auth/me
134
141
  router.get('/auth/me', async (ctx) => {
135
- const User = getUserModel();
136
142
  const id = (ctx.state.user as { id: string }).id;
137
- const user = await User.findById(id).lean();
143
+ const user = await users.findById(id);
138
144
  ctx.json({ user: publicUser(user) });
139
145
  }, [authToken(service, { cookieName: config.auth.cookieName }), requireAuth()]);
140
146
 
@@ -144,9 +150,7 @@ export function registerAuthRoutes(router: Router, config: NexusConfig, onUserCr
144
150
  const name = typeof body.name === 'string' ? body.name.trim() : '';
145
151
  if (!name) throw new ValidationError('name is required');
146
152
  const id = (ctx.state.user as { id: string }).id;
147
- const User = getUserModel();
148
- await User.updateOne({ _id: id }, { $set: { name } });
149
- const user = await User.findById(id).lean();
153
+ const user = await users.setName(id, name);
150
154
  ctx.json({ ok: true, user: publicUser(user) });
151
155
  }, [authToken(service, { cookieName: config.auth.cookieName }), requireAuth()]);
152
156
 
@@ -157,16 +161,12 @@ export function registerAuthRoutes(router: Router, config: NexusConfig, onUserCr
157
161
  if (!body.newPassword || body.newPassword.length < 8) throw new ValidationError('password must be at least 8 characters');
158
162
 
159
163
  const id = (ctx.state.user as { id: string }).id;
160
- const User = getUserModel();
161
- const coll = await User.collection;
162
- const raw = await coll.findOne({ _id: new ObjectId(id) });
163
- if (!raw) throw new AuthenticationError('User not found');
164
- const passwordHash = raw.passwordHash as string | undefined;
164
+ const passwordHash = await users.findPasswordHash(id);
165
165
  if (!passwordHash) throw new AuthenticationError('This account has no password (OAuth account)');
166
166
  const ok = await verifyPassword(body.currentPassword, passwordHash);
167
167
  if (!ok) throw new AuthenticationError('Current password is incorrect');
168
168
  const nextHash = await hashPassword(body.newPassword);
169
- await User.updateOne({ _id: id }, { $set: { passwordHash: nextHash } });
169
+ await users.setPassword(id, nextHash);
170
170
  ctx.json({ ok: true });
171
171
  }, [authToken(service, { cookieName: config.auth.cookieName }), requireAuth()]);
172
172
 
@@ -181,18 +181,18 @@ export function registerAuthRoutes(router: Router, config: NexusConfig, onUserCr
181
181
  router.get('/auth/google', async (ctx) => {
182
182
  const state = generateState();
183
183
  const verifier = generatePkceVerifier();
184
- await oauthState.set(state, { provider: 'google', verifier }, 5 * 60_000);
184
+ await getOauthState().set(state, { provider: 'google', verifier }, 5 * 60_000);
185
185
  ctx.redirect(buildGoogleAuthUrl(google, { state, verifier }));
186
186
  });
187
187
  router.get(config.auth.google.callbackPath, async (ctx) => {
188
188
  const code = ctx.query.code as string | undefined;
189
189
  const state = ctx.query.state as string | undefined;
190
190
  if (!code || !state) throw new AuthenticationError('Missing OAuth code/state');
191
- const data = await oauthState.consume(state);
191
+ const data = await getOauthState().consume(state);
192
192
  if (!data || data.provider !== 'google') throw new AuthenticationError('Invalid OAuth state');
193
193
  const tokens = await exchangeGoogleCode(code, google, data.verifier as string);
194
194
  const profile = await fetchGoogleProfile(tokens.accessToken);
195
- const user = await upsertOAuthUser({ provider: 'google', providerUserId: profile.providerUserId, email: profile.email, name: profile.name });
195
+ const user = await upsertOAuthUser(users, { provider: 'google', providerUserId: profile.providerUserId, email: profile.email, name: profile.name });
196
196
  const pair = await service.login({ userId: String(user._id), roles: user.roles });
197
197
  setAuthCookies(ctx, pair, { accessTokenName: config.auth.cookieName, refreshTokenName: config.auth.refreshCookieName });
198
198
  ctx.json({ user: publicUser(user), accessToken: pair.accessToken });
@@ -209,18 +209,18 @@ export function registerAuthRoutes(router: Router, config: NexusConfig, onUserCr
209
209
  };
210
210
  router.get('/auth/facebook', async (ctx) => {
211
211
  const state = generateState();
212
- await oauthState.set(state, { provider: 'facebook' }, 5 * 60_000);
212
+ await getOauthState().set(state, { provider: 'facebook' }, 5 * 60_000);
213
213
  ctx.redirect(buildFacebookAuthUrl(facebook, state));
214
214
  });
215
215
  router.get(config.auth.facebook.callbackPath, async (ctx) => {
216
216
  const code = ctx.query.code as string | undefined;
217
217
  const state = ctx.query.state as string | undefined;
218
218
  if (!code || !state) throw new AuthenticationError('Missing OAuth code/state');
219
- const data = await oauthState.consume(state);
219
+ const data = await getOauthState().consume(state);
220
220
  if (!data || data.provider !== 'facebook') throw new AuthenticationError('Invalid OAuth state');
221
221
  const tokens = await exchangeFacebookCode(code, facebook);
222
222
  const profile = await fetchFacebookProfile(tokens.accessToken);
223
- const user = await upsertOAuthUser({ provider: 'facebook', providerUserId: profile.providerUserId, email: profile.email, name: profile.name });
223
+ const user = await upsertOAuthUser(users, { provider: 'facebook', providerUserId: profile.providerUserId, email: profile.email, name: profile.name });
224
224
  const pair = await service.login({ userId: String(user._id), roles: user.roles });
225
225
  setAuthCookies(ctx, pair, { accessTokenName: config.auth.cookieName, refreshTokenName: config.auth.refreshCookieName });
226
226
  ctx.json({ user: publicUser(user), accessToken: pair.accessToken });
@@ -230,6 +230,32 @@ export function registerAuthRoutes(router: Router, config: NexusConfig, onUserCr
230
230
  return service;
231
231
  }
232
232
 
233
+ /** Find-or-create a user from an OAuth profile (account linking by provider+id). */
234
+ async function upsertOAuthUser(
235
+ users: ReturnType<typeof getUserStore>,
236
+ profile: { provider: string; providerUserId: string; email?: string; name?: string },
237
+ ): Promise<StoredUser> {
238
+ const existing = await users.findOAuthUser(profile.provider, profile.providerUserId);
239
+ if (existing) return existing;
240
+
241
+ // Link to an existing email account if present, else create a new one.
242
+ if (profile.email) {
243
+ const byEmail = await users.findByEmail(profile.email);
244
+ if (byEmail) {
245
+ const linked = await users.linkOAuth(String(byEmail._id), profile.provider, profile.providerUserId);
246
+ if (linked) return linked;
247
+ }
248
+ }
249
+
250
+ return users.create({
251
+ email: profile.email ?? `${profile.provider}-${profile.providerUserId}@oauth.local`,
252
+ name: profile.name,
253
+ emailVerified: true,
254
+ oauthAccounts: [{ provider: profile.provider, providerUserId: profile.providerUserId }],
255
+ roles: ['user'],
256
+ });
257
+ }
258
+
233
259
  function publicUser(user: unknown): Record<string, unknown> | null {
234
260
  if (!user) return null;
235
261
  // DocumentInstance → use toObject() (avoids spreading the Proxy and its circular _model).
@@ -15,7 +15,7 @@
15
15
  import { existsSync } from 'node:fs';
16
16
  import { resolve, dirname, join } from 'node:path';
17
17
  import { readFile } from 'node:fs/promises';
18
- import { loadConfigAuto, type NexusConfig } from '../config/index.js';
18
+ import { loadConfigAuto, activeDatabase, mongoEnabled, mongoUri, fusionEnabled, type NexusConfig } from '../config/index.js';
19
19
  import { Container } from '../di/Container.js';
20
20
  import { Router } from '../http/Router.js';
21
21
  import { NexusServer } from '../http/Server.js';
@@ -25,6 +25,10 @@ import { discoverBackend, importDefault, type DiscoveryResult } from './discover
25
25
  import type { RoutesFile, RouteDef } from './defineRoutes.js';
26
26
  import { DefaultErrorHandler, ErrorHandler } from './ErrorHandler.js';
27
27
  import { ErrorPages } from './errorPages.js';
28
+ import { ConfigError, NotFoundError, toNexusError } from '../errors.js';
29
+ import { initUserModel } from './userModel.js';
30
+ import { initUserStore, MongoUserStore, FusionUserStore } from './userStore.js';
31
+ import { openAppFusion, closeAppFusion } from './fusionEngine.js';
28
32
  import { eventBus, type EventBus, type Listener } from './events.js';
29
33
  import { configureQueue, InMemoryQueueAdapter, type JobQueueAdapter } from './Job.js';
30
34
  import { configureMailDriver, configureMailRenderer, logMailDriver } from './Mailable.js';
@@ -36,12 +40,14 @@ import { createLazyDb, registerAdminRoutes, RequestLogBuffer } from './adminModu
36
40
  import { registerAuthRoutes } from './authModule.js';
37
41
  import { issueCsrfToken, getCsrfToken, csrf, authToken, requireRole } from '@bhooai/nexus-auth';
38
42
  import { connect } from '@bhooai/nexus-data';
39
- import { createGateway, createFederatedGateway, graphqlHttpHandler, getExplorerHtml, helloSubgraph } from '@bhooai/nexus-graphql';
40
- import type { Subgraph } from '@bhooai/nexus-graphql';
43
+ import { createGateway, createFederatedGateway, graphqlHttpHandler, getExplorerHtml, helloSubgraph, SubscriptionServer } from '@bhooai/nexus-graphql';
44
+ import type { Subgraph, GraphQLContext } from '@bhooai/nexus-graphql';
41
45
 
42
46
  export interface CreateNexusAppOptions {
43
47
  /** Identifier for this backend, used in admin, telemetry, logs. */
44
48
  name?: string;
49
+ /** Stack: react = http+ws+graphql, future = live+canvas+ai+autoui. Auto-detected from src/live if omitted. */
50
+ tech?: 'react' | 'future';
45
51
  /** Path to the backend src/ folder. Defaults to `<cwd>/src`. */
46
52
  srcRoot?: string;
47
53
  /** Project root (where .nexus-down and storage/ live). Defaults to srcRoot/.. */
@@ -59,6 +65,11 @@ export interface CreateNexusAppOptions {
59
65
  /** Hooks for extending boot. */
60
66
  beforeStart?: (app: NexusApp) => Promise<void> | void;
61
67
  afterStart?: (app: NexusApp) => Promise<void> | void;
68
+ /**
69
+ * Extra values injected into every GraphQL resolver context (e.g. a payments
70
+ * service). Merged with the default `{ request, user }` context.
71
+ */
72
+ graphqlContext?: Record<string, unknown>;
62
73
  }
63
74
 
64
75
  export interface NexusApp {
@@ -93,13 +104,23 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
93
104
  let config = opts.config ?? (await loadConfigAuto({ root: projectRoot }));
94
105
 
95
106
  // ------------------------------------------------------------------
96
- // Per-app ports: NEXUS_PORT env var (set by `nexus dev` per service) wins.
97
- // Otherwise, config port is used. For multi-app backends this is the only
98
- // way they don't all collide on 4000.
107
+ // Per-app ports: nexus.config.ts `apps[]` is authoritative. The app's own
108
+ // entry (matched by `opts.name`) wins over `server.port`. NEXUS_PORT (set
109
+ // by `nexus dev`, which resolves from the same config) is honored only as
110
+ // the ephemeral run-time value — e.g. after a coordinated step-up — and a
111
+ // mismatch is logged so silent drift is impossible to miss.
99
112
  // ------------------------------------------------------------------
113
+ const configuredPort = config.apps?.find((a) => a.name === name)?.port ?? config.server?.port;
100
114
  const envPort = process.env.NEXUS_PORT ? parseInt(process.env.NEXUS_PORT, 10) : null;
101
- if (envPort && config.server) {
102
- config = Object.freeze({ ...config, server: { ...config.server, port: envPort } }) as NexusConfig;
115
+ const finalPort = envPort && Number.isFinite(envPort) ? envPort : configuredPort;
116
+ if (envPort && Number.isFinite(envPort) && configuredPort != null && envPort !== configuredPort) {
117
+ console.warn(
118
+ `[${name}] NEXUS_PORT=${envPort} differs from configured port ${configuredPort} ` +
119
+ `(ephemeral step-up for this run — nexus.config.ts unchanged).`,
120
+ );
121
+ }
122
+ if (finalPort != null && config.server && finalPort !== config.server.port) {
123
+ config = Object.freeze({ ...config, server: { ...config.server, port: finalPort } }) as NexusConfig;
103
124
  }
104
125
 
105
126
  const container = new Container();
@@ -109,6 +130,8 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
109
130
  // Discover convention folders
110
131
  // ------------------------------------------------------------------
111
132
  const discovery = await discoverBackend(srcRoot);
133
+ const tech = opts.tech ?? (discovery.live.length > 0 ? 'future' : 'react');
134
+ console.log(`[${name}] stack: ${tech} (${tech === 'future' ? 'live+canvas+ai+autoui' : 'http+ws+graphql'})`);
112
135
 
113
136
  // ------------------------------------------------------------------
114
137
  // DB must be connected before importing routes/graphql that define models
@@ -116,8 +139,28 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
116
139
  // connect was only inside `if (admin.enabled)` after routes were mounted,
117
140
  // causing "Not connected — call connect(uri) first." and missing /api/posts)
118
141
  // ------------------------------------------------------------------
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); }
142
+ console.log(`[db] early connect ${mongoUri(config)} autoIndex=${config.db.mongodb.autoIndex} enabled=${mongoEnabled(config)} active=${activeDatabase(config)}`);
143
+ if (mongoEnabled(config)) {
144
+ try { connect(mongoUri(config), { autoIndex: config.db.mongodb.autoIndex }); console.log(`[db] early connect ok`); } catch (e) { console.error(`[db] early connect failed`, e); }
145
+ } else {
146
+ console.log(`[db] mongodb disabled — skipping early connect`);
147
+ }
148
+
149
+ // ------------------------------------------------------------------
150
+ // User store — Mongo (default) or Fusion (db.active: 'fusion').
151
+ // ------------------------------------------------------------------
152
+ if (activeDatabase(config) === 'fusion') {
153
+ if (!fusionEnabled(config)) {
154
+ throw new ConfigError(
155
+ "db.active is 'fusion' but db.fusion.enabled is false — enable it in nexus.config.ts",
156
+ );
157
+ }
158
+ const fusionDb = await openAppFusion(projectRoot, config);
159
+ initUserStore(new FusionUserStore(fusionDb));
160
+ } else {
161
+ if (mongoEnabled(config)) initUserModel();
162
+ initUserStore(new MongoUserStore());
163
+ }
121
164
 
122
165
  // ------------------------------------------------------------------
123
166
  // Storage facade + queue + mail — always configured, even if minimal
@@ -130,7 +173,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
130
173
  // ------------------------------------------------------------------
131
174
  // Error pages + handler
132
175
  // ------------------------------------------------------------------
133
- const errorPages = new ErrorPages(projectRoot);
176
+ const errorPages = new ErrorPages(projectRoot, srcRoot);
134
177
  let errorHandler: ErrorHandler = opts.errorHandler ?? new DefaultErrorHandler();
135
178
  if (discovery.errorHandler) {
136
179
  try {
@@ -214,6 +257,44 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
214
257
 
215
258
  for (const def of opts.additionalRoutes ?? []) registerRoute(router, def, '');
216
259
 
260
+ // ------------------------------------------------------------------
261
+ // Placeholder upload route for empty projects — keeps POST /api/files alive
262
+ // Ensures file upload works even before file-storage example is installed.
263
+ // ------------------------------------------------------------------
264
+ const hasUploadRoute = router.routes().some((r) => r.method === 'POST' && r.path === '/api/files');
265
+ if (!hasUploadRoute) {
266
+ router.add('POST', '/api/files', async (ctx) => {
267
+ try {
268
+ const { uploadedFiles } = await import('../http/uploads.js');
269
+ const files = uploadedFiles(ctx as any);
270
+ if (!files.length) {
271
+ (ctx as any).json({ error: { code: 'VALIDATION_ERROR', message: 'file is required' } }, 400);
272
+ return;
273
+ }
274
+ const f = files[0]!;
275
+ const stored = await storage.disk('uploads').put('media', { buffer: f.data, originalName: f.filename, mime: f.contentType });
276
+ (ctx as any).json({ file: stored }, 201);
277
+ } catch (e: any) {
278
+ (ctx as any).json({ error: { code: 'VALIDATION_ERROR', message: e.message ?? 'upload failed' } }, 400);
279
+ }
280
+ });
281
+ router.add('POST', '/api/files/private', async (ctx) => {
282
+ try {
283
+ const { uploadedFiles } = await import('../http/uploads.js');
284
+ const files = uploadedFiles(ctx as any);
285
+ if (!files.length) {
286
+ (ctx as any).json({ error: { code: 'VALIDATION_ERROR', message: 'file is required' } }, 400);
287
+ return;
288
+ }
289
+ const f = files[0]!;
290
+ const stored = await storage.disk('private').put('secure', { buffer: f.data, originalName: f.filename, mime: f.contentType });
291
+ (ctx as any).json({ file: stored }, 201);
292
+ } catch (e: any) {
293
+ (ctx as any).json({ error: { code: 'VALIDATION_ERROR', message: e.message ?? 'upload failed' } }, 400);
294
+ }
295
+ });
296
+ }
297
+
217
298
  // ------------------------------------------------------------------
218
299
  // Built-in: /health + /admin/_registry
219
300
  // ------------------------------------------------------------------
@@ -221,34 +302,30 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
221
302
  ctx.json({ ok: true, name, time: new Date().toISOString() });
222
303
  });
223
304
 
224
- // Apps registry — used by the admin SPA's Apps tab. Reads the CLI's
225
- // .nexus-ports.json (when present) and reports per-app info. Apps that
226
- // aren't running show healthy: false.
305
+ // Apps registry — used by the admin SPA's Apps tab. Ports come from
306
+ // nexus.config.ts `apps[]` (single source of truth). Apps that aren't
307
+ // running show healthy: false.
227
308
  router.get('/admin/_registry', async (ctx) => {
228
- const apps: Array<{ name: string; port: number; source: 'registry' }> = [];
229
- try {
230
- const regPath = resolve(projectRoot, '.nexus-ports.json');
231
- if (existsSync(regPath)) {
232
- const raw = await readFile(regPath, 'utf-8');
233
- const reg = JSON.parse(raw) as Record<string, number>;
234
- for (const [appName, port] of Object.entries(reg)) {
235
- apps.push({ name: appName, port, source: 'registry' });
236
- }
237
- }
238
- } catch (err) {
239
- void err; // fall through with empty list
309
+ const apps: Array<{ name: string; port: number; source: 'config' }> = [];
310
+ for (const entry of config.apps ?? []) {
311
+ if (entry.enabled === false) continue;
312
+ apps.push({ name: entry.name, port: entry.port, source: 'config' });
240
313
  }
241
314
  // Always include the current app if not listed
242
315
  if (!apps.some((a) => a.name === name)) {
243
- apps.unshift({ name, port: config.server.port, source: 'registry' });
316
+ apps.unshift({ name, port: config.server.port, source: 'config' });
244
317
  }
245
318
  ctx.json({ apps });
246
319
  });
247
320
 
248
321
  // ------------------------------------------------------------------
249
322
  // GraphQL gateway — builtin hello + discovered subgraphs (federated)
323
+ // React stack only — future uses live+canvas
250
324
  // ------------------------------------------------------------------
251
325
  let graphqlMounted = false;
326
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
327
+ let graphqlGateway: any = null;
328
+ if (tech === 'react') {
252
329
  {
253
330
  const explorerEnabled = (config.graphql as unknown as { explorer?: boolean })?.explorer ?? true;
254
331
  const helloEnabled = (config.graphql as unknown as { hello?: boolean })?.hello !== false;
@@ -295,6 +372,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
295
372
  gateway,
296
373
  introspection: config.graphql?.introspection ?? true,
297
374
  requireMutationCsrf: config.graphql?.requireMutationCsrf ?? true,
375
+ context: (ctx) => ({ request: ctx, user: (ctx.state.user as GraphQLContext['user']), ...opts.graphqlContext }),
298
376
  });
299
377
  if (explorerEnabled) {
300
378
  const explorerHtml = getExplorerHtml({
@@ -314,6 +392,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
314
392
  }
315
393
  router.add('POST', graphqlPath, handler);
316
394
  graphqlMounted = true;
395
+ graphqlGateway = gateway;
317
396
  if (subgraphs.length === 1) {
318
397
  console.log(`[${name}] GraphQL gateway mounted at ${graphqlPath} (${subgraphs[0]!.name} subgraph${explorerEnabled ? ' + explorer at /graphiql' : ''})`);
319
398
  } else {
@@ -340,7 +419,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
340
419
  if (helloEnabled) {
341
420
  // hello is available even in fallback — route through its gateway
342
421
  const gw = createGateway({ subgraph: helloSubgraph });
343
- const h = graphqlHttpHandler({ gateway: gw, introspection: config.graphql?.introspection ?? true, requireMutationCsrf: config.graphql?.requireMutationCsrf ?? true });
422
+ 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 }) });
344
423
  return h(ctx);
345
424
  }
346
425
  ctx.json({ errors: [{ message: 'No GraphQL subgraph found. Add one with: npx nexus make:subgraph <name> (then restart)' }] }, 404);
@@ -350,7 +429,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
350
429
  try {
351
430
  if (helloEnabled) {
352
431
  const gw = createGateway({ subgraph: helloSubgraph });
353
- router.add('POST', graphqlPath, graphqlHttpHandler({ gateway: gw, introspection: config.graphql?.introspection ?? true, requireMutationCsrf: config.graphql?.requireMutationCsrf ?? true }));
432
+ 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 }) }));
354
433
  } else {
355
434
  router.add('POST', graphqlPath, async (ctx) => ctx.json({ errors: [{ message: 'No GraphQL subgraph found. Add one with: npx nexus make:subgraph <name>' }] }, 404));
356
435
  }
@@ -359,6 +438,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
359
438
  if (helloEnabled) graphqlMounted = true;
360
439
  }
361
440
  }
441
+ }
362
442
 
363
443
  // ------------------------------------------------------------------
364
444
  // Admin module — request log buffer, lazy DB, admin + AI proxy routes
@@ -368,9 +448,12 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
368
448
 
369
449
  if (config.admin.enabled) {
370
450
  // Connect to MongoDB + register the User model so /auth/* can work.
371
- try {
372
- connect(config.db.uri, { autoIndex: config.db.autoIndex });
373
- } catch { /* Mongo unreachable — auth routes will fail lazily */ }
451
+ // Skipped when mongodb is disabled (fusion-active backends).
452
+ if (mongoEnabled(config)) {
453
+ try {
454
+ connect(mongoUri(config), { autoIndex: config.db.mongodb.autoIndex });
455
+ } catch { /* Mongo unreachable — auth routes will fail lazily */ }
456
+ }
374
457
 
375
458
  // Auth routes (register/login/refresh/logout/me + OAuth). Returns the
376
459
  // AuthService used to build the admin guard.
@@ -419,12 +502,26 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
419
502
  ? { certFile: config.server.certFile, keyFile: config.server.keyFile }
420
503
  : {}),
421
504
  onError: (err, ctx) => errorHandler.toApiError(err),
505
+ renderError: (err, ctx) => {
506
+ const ne = toNexusError(err);
507
+ const lookup = (status: number) =>
508
+ errorPages.render(status, { requestId: ctx.requestId, message: ne.message });
509
+ return errorHandler.render(err, ctx, lookup);
510
+ },
511
+ renderNotFound: (ctx) => {
512
+ const notFound = new NotFoundError(`No route for ${ctx.method} ${ctx.path}`);
513
+ const lookup = (status: number) =>
514
+ errorPages.render(status, { requestId: ctx.requestId, message: notFound.message });
515
+ return errorHandler.render(notFound, ctx, lookup);
516
+ },
422
517
  });
423
518
 
424
519
  // ------------------------------------------------------------------
425
520
  // Realtime WS server — mount discovered ws/*.room.ts handlers (chat lobby)
521
+ // React stack only — future uses live canvas
426
522
  // ------------------------------------------------------------------
427
523
  let realtime: any = null;
524
+ if (tech === 'react') {
428
525
  try {
429
526
  const { RealtimeServer } = await import('@bhooai/nexus-realtime');
430
527
  realtime = new RealtimeServer({ httpServer: server.httpServer, path: '/ws' });
@@ -525,6 +622,75 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
525
622
  // realtime optional — if not installed, ws lobby falls back to no-op
526
623
  if ((e as any)?.code !== 'ERR_MODULE_NOT_FOUND') console.warn('[realtime] ws init failed:', e);
527
624
  }
625
+ } // tech === react
626
+
627
+ // ------------------------------------------------------------------
628
+ // Live pages — mount discovered src/live/*.live.js (future runtime)
629
+ // Self-contained: state travels with definition, auto-subscribed.
630
+ // Shares the same httpServer/port as the existing backend.
631
+ // Future stack only — react uses http+ws+graphql
632
+ // ------------------------------------------------------------------
633
+ let liveServer: any = null;
634
+ if (tech === 'future') {
635
+ try {
636
+ // @ts-ignore — nexus-future is JS/JSDoc, no .d.ts (v3)
637
+ const { LiveServer } = await import('@bhooai/nexus-future/live');
638
+ liveServer = new LiveServer({ httpServer: server.httpServer, onLog: (m: string) => console.log(`[live] ${m}`) });
639
+ // Global sense() — adaptive vibe (time/weather/mood) broadcast to every live page
640
+ try {
641
+ // @ts-ignore — nexus-future is JS/JSDoc, no .d.ts (v3)
642
+ const { sense } = await import('@bhooai/nexus-future/ui');
643
+ const feel = sense();
644
+ (liveServer as any).attachSense(feel);
645
+ console.log(`[${name}] sense attached: ${JSON.stringify((feel.vibe() as any)?.period)}/${JSON.stringify((feel.vibe() as any)?.weather)} — vibe broadcasts live`);
646
+ } catch (e) {
647
+ console.warn('[live] sense not attached:', e);
648
+ }
649
+ for (const file of discovery.live) {
650
+ try {
651
+ const mod: any = await importDefault(file);
652
+ const def = mod?.default ?? mod;
653
+ if (def?.path && typeof def.render === 'function') {
654
+ liveServer.addPage(def); // state on def auto-subscribes
655
+ console.log(`[${name}] live page mounted: ${def.path} (${file.name})`);
656
+ } else {
657
+ console.warn(`[${name}] live file ${file.path} does not export live() — skipped`);
658
+ }
659
+ } catch (e) {
660
+ console.warn(`[${name}] failed to load live page ${file.path}:`, e);
661
+ }
662
+ }
663
+ // Wire live pages into the NexusServer pipeline — live pages share the
664
+ // same port as the backend. Check before other handlers.
665
+ if (liveServer) {
666
+ server.use(async (ctx, next) => {
667
+ try {
668
+ const handled = liveServer.handleRequest(ctx.req as any, ctx.res as any);
669
+ if (handled) return;
670
+ } catch {}
671
+ await next();
672
+ });
673
+ }
674
+ } catch (e) {
675
+ if ((e as any)?.code !== 'ERR_MODULE_NOT_FOUND') console.warn('[live] init failed:', e);
676
+ }
677
+ } // tech === future
678
+ void liveServer;
679
+
680
+ // ------------------------------------------------------------------
681
+ // GraphQL subscriptions over WebSocket — mount when a gateway exists
682
+ // and config.graphql.subscriptions is enabled.
683
+ // ------------------------------------------------------------------
684
+ const graphqlSubscriptions = (config.graphql as unknown as { subscriptions?: boolean })?.subscriptions ?? true;
685
+ if (graphqlGateway && graphqlSubscriptions && typeof graphqlGateway.subscribe === 'function') {
686
+ try {
687
+ const subPath = `${config.graphql?.path ?? '/graphql'}/ws`;
688
+ new SubscriptionServer({ httpServer: server.httpServer, gateway: graphqlGateway, path: subPath });
689
+ console.log(`[${name}] GraphQL subscriptions mounted at ${subPath}`);
690
+ } catch (err) {
691
+ console.warn(`[${name}] failed to mount GraphQL subscriptions:`, err);
692
+ }
693
+ }
528
694
 
529
695
  // Public uploads: serve GET /uploads/* from storage/uploads (file-storage example).
530
696
  // This was missing — 404 `No route for GET /uploads/media/...`.
@@ -671,6 +837,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
671
837
  },
672
838
  async close() {
673
839
  await server.close();
840
+ await closeAppFusion();
674
841
  },
675
842
  };
676
843