@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.
- package/package.json +4 -2
- package/src/app/Storage.ts +616 -9
- package/src/app/Upload.ts +114 -0
- package/src/app/adminModule.ts +256 -52
- package/src/app/authModule.ts +56 -30
- package/src/app/createNexusApp.ts +196 -28
- package/src/app/databaseAdminModule.ts +171 -2
- package/src/app/discover.ts +4 -2
- package/src/app/errorPages.ts +21 -5
- package/src/app/fusionEngine.ts +53 -0
- package/src/app/index.ts +3 -0
- package/src/app/preflightModule.ts +41 -4
- package/src/app/userStore.ts +321 -0
- package/src/config/dbAccess.ts +26 -0
- package/src/config/defaults.ts +34 -4
- package/src/config/index.ts +1 -0
- package/src/config/schema.ts +72 -6
- package/src/config/types.ts +94 -1
- package/src/errors.ts +22 -1
- package/src/http/Server.ts +27 -3
- package/src/http/static.ts +1 -1
- package/tests/config.test.ts +24 -1
- package/tests/fusion-userstore.test.ts +68 -0
- package/vitest.config.ts +1 -0
package/src/app/authModule.ts
CHANGED
|
@@ -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 {
|
|
48
|
-
import { initUserModel, getUserModel, findUserForLogin, upsertOAuthUser, type UserInstance } from './userModel.js';
|
|
47
|
+
import { getUserStore, type StoredUser } from './userStore.js';
|
|
49
48
|
|
|
50
|
-
|
|
51
|
-
|
|
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),
|
|
77
|
+
return new AuthService(jwtOptions(config), getSessions());
|
|
71
78
|
}
|
|
72
79
|
|
|
73
|
-
/** Register /auth/* routes onto the given router.
|
|
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
|
-
|
|
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
|
|
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
|
|
97
|
+
const userCount = await users.count();
|
|
91
98
|
const roles = userCount === 0 ? ['admin'] : ['user'];
|
|
92
|
-
const
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
102
|
-
//
|
|
103
|
-
//
|
|
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
|
-
|
|
107
|
-
|
|
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
|
|
125
|
-
|
|
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.
|
|
230
|
-
//
|
|
231
|
-
//
|
|
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: '
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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: '
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
|