@bhooai/nexus-core 2.0.13 → 2.0.16

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,15 @@ 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';
43
+ import { ensureLicense } from '@bhooai/nexus-crypto';
39
44
  import { createGateway, createFederatedGateway, graphqlHttpHandler, getExplorerHtml, helloSubgraph, SubscriptionServer } from '@bhooai/nexus-graphql';
40
45
  import type { Subgraph, GraphQLContext } from '@bhooai/nexus-graphql';
41
46
 
42
47
  export interface CreateNexusAppOptions {
43
48
  /** Identifier for this backend, used in admin, telemetry, logs. */
44
49
  name?: string;
50
+ /** Stack: react = http+ws+graphql, future = live+canvas+ai+autoui. Auto-detected from src/live if omitted. */
51
+ tech?: 'react' | 'future';
45
52
  /** Path to the backend src/ folder. Defaults to `<cwd>/src`. */
46
53
  srcRoot?: string;
47
54
  /** Project root (where .nexus-down and storage/ live). Defaults to srcRoot/.. */
@@ -97,14 +104,37 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
97
104
  const projectRoot = resolve(opts.projectRoot ?? dirname(srcRoot));
98
105
  let config = opts.config ?? (await loadConfigAuto({ root: projectRoot }));
99
106
 
107
+ // License check at project start — verify the token (signature via fetched
108
+ // JWKS + online status) with the authority. On failure the app still boots,
109
+ // but every request is answered with 402 LICENSE_REQUIRED (middleware below)
110
+ // instead of crashing — a crashed backend surfaces as an opaque HTTP 500
111
+ // through dev proxies.
112
+ let licenseError: string | null = null;
113
+ try {
114
+ await ensureLicense({ projectDir: projectRoot });
115
+ } catch (err) {
116
+ licenseError = (err as Error).message;
117
+ console.error(`[${name}] license check failed: ${licenseError}`);
118
+ }
119
+
100
120
  // ------------------------------------------------------------------
101
- // Per-app ports: NEXUS_PORT env var (set by `nexus dev` per service) wins.
102
- // Otherwise, config port is used. For multi-app backends this is the only
103
- // way they don't all collide on 4000.
121
+ // Per-app ports: nexus.config.ts `apps[]` is authoritative. The app's own
122
+ // entry (matched by `opts.name`) wins over `server.port`. NEXUS_PORT (set
123
+ // by `nexus dev`, which resolves from the same config) is honored only as
124
+ // the ephemeral run-time value — e.g. after a coordinated step-up — and a
125
+ // mismatch is logged so silent drift is impossible to miss.
104
126
  // ------------------------------------------------------------------
127
+ const configuredPort = config.apps?.find((a) => a.name === name)?.port ?? config.server?.port;
105
128
  const envPort = process.env.NEXUS_PORT ? parseInt(process.env.NEXUS_PORT, 10) : null;
106
- if (envPort && config.server) {
107
- config = Object.freeze({ ...config, server: { ...config.server, port: envPort } }) as NexusConfig;
129
+ const finalPort = envPort && Number.isFinite(envPort) ? envPort : configuredPort;
130
+ if (envPort && Number.isFinite(envPort) && configuredPort != null && envPort !== configuredPort) {
131
+ console.warn(
132
+ `[${name}] NEXUS_PORT=${envPort} differs from configured port ${configuredPort} ` +
133
+ `(ephemeral step-up for this run — nexus.config.ts unchanged).`,
134
+ );
135
+ }
136
+ if (finalPort != null && config.server && finalPort !== config.server.port) {
137
+ config = Object.freeze({ ...config, server: { ...config.server, port: finalPort } }) as NexusConfig;
108
138
  }
109
139
 
110
140
  const container = new Container();
@@ -114,6 +144,8 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
114
144
  // Discover convention folders
115
145
  // ------------------------------------------------------------------
116
146
  const discovery = await discoverBackend(srcRoot);
147
+ const tech = opts.tech ?? (discovery.live.length > 0 ? 'future' : 'react');
148
+ console.log(`[${name}] stack: ${tech} (${tech === 'future' ? 'live+canvas+ai+autoui' : 'http+ws+graphql'})`);
117
149
 
118
150
  // ------------------------------------------------------------------
119
151
  // DB must be connected before importing routes/graphql that define models
@@ -121,8 +153,28 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
121
153
  // connect was only inside `if (admin.enabled)` after routes were mounted,
122
154
  // causing "Not connected — call connect(uri) first." and missing /api/posts)
123
155
  // ------------------------------------------------------------------
124
- console.log(`[db] early connect ${config.db.uri} autoIndex=${config.db.autoIndex}`);
125
- try { connect(config.db.uri, { autoIndex: config.db.autoIndex }); console.log(`[db] early connect ok`); } catch (e) { console.error(`[db] early connect failed`, e); }
156
+ console.log(`[db] early connect ${mongoUri(config)} autoIndex=${config.db.mongodb.autoIndex} enabled=${mongoEnabled(config)} active=${activeDatabase(config)}`);
157
+ if (mongoEnabled(config)) {
158
+ try { connect(mongoUri(config), { autoIndex: config.db.mongodb.autoIndex }); console.log(`[db] early connect ok`); } catch (e) { console.error(`[db] early connect failed`, e); }
159
+ } else {
160
+ console.log(`[db] mongodb disabled — skipping early connect`);
161
+ }
162
+
163
+ // ------------------------------------------------------------------
164
+ // User store — Mongo (default) or Fusion (db.active: 'fusion').
165
+ // ------------------------------------------------------------------
166
+ if (activeDatabase(config) === 'fusion') {
167
+ if (!fusionEnabled(config)) {
168
+ throw new ConfigError(
169
+ "db.active is 'fusion' but db.fusion.enabled is false — enable it in nexus.config.ts",
170
+ );
171
+ }
172
+ const fusionDb = await openAppFusion(projectRoot, config);
173
+ initUserStore(new FusionUserStore(fusionDb));
174
+ } else {
175
+ if (mongoEnabled(config)) initUserModel();
176
+ initUserStore(new MongoUserStore());
177
+ }
126
178
 
127
179
  // ------------------------------------------------------------------
128
180
  // Storage facade + queue + mail — always configured, even if minimal
@@ -135,7 +187,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
135
187
  // ------------------------------------------------------------------
136
188
  // Error pages + handler
137
189
  // ------------------------------------------------------------------
138
- const errorPages = new ErrorPages(projectRoot);
190
+ const errorPages = new ErrorPages(projectRoot, srcRoot);
139
191
  let errorHandler: ErrorHandler = opts.errorHandler ?? new DefaultErrorHandler();
140
192
  if (discovery.errorHandler) {
141
193
  try {
@@ -219,6 +271,44 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
219
271
 
220
272
  for (const def of opts.additionalRoutes ?? []) registerRoute(router, def, '');
221
273
 
274
+ // ------------------------------------------------------------------
275
+ // Placeholder upload route for empty projects — keeps POST /api/files alive
276
+ // Ensures file upload works even before file-storage example is installed.
277
+ // ------------------------------------------------------------------
278
+ const hasUploadRoute = router.routes().some((r) => r.method === 'POST' && r.path === '/api/files');
279
+ if (!hasUploadRoute) {
280
+ router.add('POST', '/api/files', async (ctx) => {
281
+ try {
282
+ const { uploadedFiles } = await import('../http/uploads.js');
283
+ const files = uploadedFiles(ctx as any);
284
+ if (!files.length) {
285
+ (ctx as any).json({ error: { code: 'VALIDATION_ERROR', message: 'file is required' } }, 400);
286
+ return;
287
+ }
288
+ const f = files[0]!;
289
+ const stored = await storage.disk('uploads').put('media', { buffer: f.data, originalName: f.filename, mime: f.contentType });
290
+ (ctx as any).json({ file: stored }, 201);
291
+ } catch (e: any) {
292
+ (ctx as any).json({ error: { code: 'VALIDATION_ERROR', message: e.message ?? 'upload failed' } }, 400);
293
+ }
294
+ });
295
+ router.add('POST', '/api/files/private', async (ctx) => {
296
+ try {
297
+ const { uploadedFiles } = await import('../http/uploads.js');
298
+ const files = uploadedFiles(ctx as any);
299
+ if (!files.length) {
300
+ (ctx as any).json({ error: { code: 'VALIDATION_ERROR', message: 'file is required' } }, 400);
301
+ return;
302
+ }
303
+ const f = files[0]!;
304
+ const stored = await storage.disk('private').put('secure', { buffer: f.data, originalName: f.filename, mime: f.contentType });
305
+ (ctx as any).json({ file: stored }, 201);
306
+ } catch (e: any) {
307
+ (ctx as any).json({ error: { code: 'VALIDATION_ERROR', message: e.message ?? 'upload failed' } }, 400);
308
+ }
309
+ });
310
+ }
311
+
222
312
  // ------------------------------------------------------------------
223
313
  // Built-in: /health + /admin/_registry
224
314
  // ------------------------------------------------------------------
@@ -226,36 +316,30 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
226
316
  ctx.json({ ok: true, name, time: new Date().toISOString() });
227
317
  });
228
318
 
229
- // Apps registry — used by the admin SPA's Apps tab. Reads the CLI's
230
- // .nexus-ports.json (when present) and reports per-app info. Apps that
231
- // aren't running show healthy: false.
319
+ // Apps registry — used by the admin SPA's Apps tab. Ports come from
320
+ // nexus.config.ts `apps[]` (single source of truth). Apps that aren't
321
+ // running show healthy: false.
232
322
  router.get('/admin/_registry', async (ctx) => {
233
- const apps: Array<{ name: string; port: number; source: 'registry' }> = [];
234
- try {
235
- const regPath = resolve(projectRoot, '.nexus-ports.json');
236
- if (existsSync(regPath)) {
237
- const raw = await readFile(regPath, 'utf-8');
238
- const reg = JSON.parse(raw) as Record<string, number>;
239
- for (const [appName, port] of Object.entries(reg)) {
240
- apps.push({ name: appName, port, source: 'registry' });
241
- }
242
- }
243
- } catch (err) {
244
- void err; // fall through with empty list
323
+ const apps: Array<{ name: string; port: number; source: 'config' }> = [];
324
+ for (const entry of config.apps ?? []) {
325
+ if (entry.enabled === false) continue;
326
+ apps.push({ name: entry.name, port: entry.port, source: 'config' });
245
327
  }
246
328
  // Always include the current app if not listed
247
329
  if (!apps.some((a) => a.name === name)) {
248
- apps.unshift({ name, port: config.server.port, source: 'registry' });
330
+ apps.unshift({ name, port: config.server.port, source: 'config' });
249
331
  }
250
332
  ctx.json({ apps });
251
333
  });
252
334
 
253
335
  // ------------------------------------------------------------------
254
336
  // GraphQL gateway — builtin hello + discovered subgraphs (federated)
337
+ // React stack only — future uses live+canvas
255
338
  // ------------------------------------------------------------------
256
339
  let graphqlMounted = false;
257
340
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
258
341
  let graphqlGateway: any = null;
342
+ if (tech === 'react') {
259
343
  {
260
344
  const explorerEnabled = (config.graphql as unknown as { explorer?: boolean })?.explorer ?? true;
261
345
  const helloEnabled = (config.graphql as unknown as { hello?: boolean })?.hello !== false;
@@ -368,6 +452,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
368
452
  if (helloEnabled) graphqlMounted = true;
369
453
  }
370
454
  }
455
+ }
371
456
 
372
457
  // ------------------------------------------------------------------
373
458
  // Admin module — request log buffer, lazy DB, admin + AI proxy routes
@@ -377,9 +462,12 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
377
462
 
378
463
  if (config.admin.enabled) {
379
464
  // Connect to MongoDB + register the User model so /auth/* can work.
380
- try {
381
- connect(config.db.uri, { autoIndex: config.db.autoIndex });
382
- } catch { /* Mongo unreachable — auth routes will fail lazily */ }
465
+ // Skipped when mongodb is disabled (fusion-active backends).
466
+ if (mongoEnabled(config)) {
467
+ try {
468
+ connect(mongoUri(config), { autoIndex: config.db.mongodb.autoIndex });
469
+ } catch { /* Mongo unreachable — auth routes will fail lazily */ }
470
+ }
383
471
 
384
472
  // Auth routes (register/login/refresh/logout/me + OAuth). Returns the
385
473
  // AuthService used to build the admin guard.
@@ -428,12 +516,37 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
428
516
  ? { certFile: config.server.certFile, keyFile: config.server.keyFile }
429
517
  : {}),
430
518
  onError: (err, ctx) => errorHandler.toApiError(err),
519
+ renderError: (err, ctx) => {
520
+ const ne = toNexusError(err);
521
+ const lookup = (status: number) =>
522
+ errorPages.render(status, { requestId: ctx.requestId, message: ne.message });
523
+ return errorHandler.render(err, ctx, lookup);
524
+ },
525
+ renderNotFound: (ctx) => {
526
+ const notFound = new NotFoundError(`No route for ${ctx.method} ${ctx.path}`);
527
+ const lookup = (status: number) =>
528
+ errorPages.render(status, { requestId: ctx.requestId, message: notFound.message });
529
+ return errorHandler.render(notFound, ctx, lookup);
530
+ },
431
531
  });
432
532
 
533
+ // License gate — when the project-start check failed, answer every request
534
+ // (except /health) with 402 so the UI can show the license error rather than
535
+ // an opaque 500 from a crashed backend.
536
+ if (licenseError) {
537
+ const message = licenseError;
538
+ server.use(async (ctx, next) => {
539
+ if (ctx.path === '/health') return next();
540
+ ctx.json({ error: { code: 'LICENSE_REQUIRED', message } }, 402);
541
+ });
542
+ }
543
+
433
544
  // ------------------------------------------------------------------
434
545
  // Realtime WS server — mount discovered ws/*.room.ts handlers (chat lobby)
546
+ // React stack only — future uses live canvas
435
547
  // ------------------------------------------------------------------
436
548
  let realtime: any = null;
549
+ if (tech === 'react') {
437
550
  try {
438
551
  const { RealtimeServer } = await import('@bhooai/nexus-realtime');
439
552
  realtime = new RealtimeServer({ httpServer: server.httpServer, path: '/ws' });
@@ -534,6 +647,60 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
534
647
  // realtime optional — if not installed, ws lobby falls back to no-op
535
648
  if ((e as any)?.code !== 'ERR_MODULE_NOT_FOUND') console.warn('[realtime] ws init failed:', e);
536
649
  }
650
+ } // tech === react
651
+
652
+ // ------------------------------------------------------------------
653
+ // Live pages — mount discovered src/live/*.live.js (future runtime)
654
+ // Self-contained: state travels with definition, auto-subscribed.
655
+ // Shares the same httpServer/port as the existing backend.
656
+ // Future stack only — react uses http+ws+graphql
657
+ // ------------------------------------------------------------------
658
+ let liveServer: any = null;
659
+ if (tech === 'future') {
660
+ try {
661
+ // @ts-ignore — nexus-future is JS/JSDoc, no .d.ts (v3)
662
+ const { LiveServer } = await import('@bhooai/nexus-future/live');
663
+ liveServer = new LiveServer({ httpServer: server.httpServer, onLog: (m: string) => console.log(`[live] ${m}`) });
664
+ // Global sense() — adaptive vibe (time/weather/mood) broadcast to every live page
665
+ try {
666
+ // @ts-ignore — nexus-future is JS/JSDoc, no .d.ts (v3)
667
+ const { sense } = await import('@bhooai/nexus-future/ui');
668
+ const feel = sense();
669
+ (liveServer as any).attachSense(feel);
670
+ console.log(`[${name}] sense attached: ${JSON.stringify((feel.vibe() as any)?.period)}/${JSON.stringify((feel.vibe() as any)?.weather)} — vibe broadcasts live`);
671
+ } catch (e) {
672
+ console.warn('[live] sense not attached:', e);
673
+ }
674
+ for (const file of discovery.live) {
675
+ try {
676
+ const mod: any = await importDefault(file);
677
+ const def = mod?.default ?? mod;
678
+ if (def?.path && typeof def.render === 'function') {
679
+ liveServer.addPage(def); // state on def auto-subscribes
680
+ console.log(`[${name}] live page mounted: ${def.path} (${file.name})`);
681
+ } else {
682
+ console.warn(`[${name}] live file ${file.path} does not export live() — skipped`);
683
+ }
684
+ } catch (e) {
685
+ console.warn(`[${name}] failed to load live page ${file.path}:`, e);
686
+ }
687
+ }
688
+ // Wire live pages into the NexusServer pipeline — live pages share the
689
+ // same port as the backend. Check before other handlers.
690
+ if (liveServer) {
691
+ server.use(async (ctx, next) => {
692
+ try {
693
+ const handled = liveServer.handleRequest(ctx.req as any, ctx.res as any);
694
+ if (handled) return;
695
+ } catch {}
696
+ await next();
697
+ });
698
+ }
699
+ } catch (e) {
700
+ if ((e as any)?.code !== 'ERR_MODULE_NOT_FOUND') console.warn('[live] init failed:', e);
701
+ }
702
+ } // tech === future
703
+ void liveServer;
537
704
 
538
705
  // ------------------------------------------------------------------
539
706
  // GraphQL subscriptions over WebSocket — mount when a gateway exists
@@ -695,6 +862,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
695
862
  },
696
863
  async close() {
697
864
  await server.close();
865
+ await closeAppFusion();
698
866
  },
699
867
  };
700
868