@bhooai/nexus-core 2.0.13 → 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.
- package/package.json +3 -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 +171 -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/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/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';
|
|
@@ -42,6 +46,8 @@ import type { Subgraph, GraphQLContext } from '@bhooai/nexus-graphql';
|
|
|
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/.. */
|
|
@@ -98,13 +104,23 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
98
104
|
let config = opts.config ?? (await loadConfigAuto({ root: projectRoot }));
|
|
99
105
|
|
|
100
106
|
// ------------------------------------------------------------------
|
|
101
|
-
// Per-app ports:
|
|
102
|
-
//
|
|
103
|
-
//
|
|
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.
|
|
104
112
|
// ------------------------------------------------------------------
|
|
113
|
+
const configuredPort = config.apps?.find((a) => a.name === name)?.port ?? config.server?.port;
|
|
105
114
|
const envPort = process.env.NEXUS_PORT ? parseInt(process.env.NEXUS_PORT, 10) : null;
|
|
106
|
-
|
|
107
|
-
|
|
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;
|
|
108
124
|
}
|
|
109
125
|
|
|
110
126
|
const container = new Container();
|
|
@@ -114,6 +130,8 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
114
130
|
// Discover convention folders
|
|
115
131
|
// ------------------------------------------------------------------
|
|
116
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'})`);
|
|
117
135
|
|
|
118
136
|
// ------------------------------------------------------------------
|
|
119
137
|
// DB must be connected before importing routes/graphql that define models
|
|
@@ -121,8 +139,28 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
121
139
|
// connect was only inside `if (admin.enabled)` after routes were mounted,
|
|
122
140
|
// causing "Not connected — call connect(uri) first." and missing /api/posts)
|
|
123
141
|
// ------------------------------------------------------------------
|
|
124
|
-
console.log(`[db] early connect ${config
|
|
125
|
-
|
|
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
|
+
}
|
|
126
164
|
|
|
127
165
|
// ------------------------------------------------------------------
|
|
128
166
|
// Storage facade + queue + mail — always configured, even if minimal
|
|
@@ -135,7 +173,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
135
173
|
// ------------------------------------------------------------------
|
|
136
174
|
// Error pages + handler
|
|
137
175
|
// ------------------------------------------------------------------
|
|
138
|
-
const errorPages = new ErrorPages(projectRoot);
|
|
176
|
+
const errorPages = new ErrorPages(projectRoot, srcRoot);
|
|
139
177
|
let errorHandler: ErrorHandler = opts.errorHandler ?? new DefaultErrorHandler();
|
|
140
178
|
if (discovery.errorHandler) {
|
|
141
179
|
try {
|
|
@@ -219,6 +257,44 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
219
257
|
|
|
220
258
|
for (const def of opts.additionalRoutes ?? []) registerRoute(router, def, '');
|
|
221
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
|
+
|
|
222
298
|
// ------------------------------------------------------------------
|
|
223
299
|
// Built-in: /health + /admin/_registry
|
|
224
300
|
// ------------------------------------------------------------------
|
|
@@ -226,36 +302,30 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
226
302
|
ctx.json({ ok: true, name, time: new Date().toISOString() });
|
|
227
303
|
});
|
|
228
304
|
|
|
229
|
-
// Apps registry — used by the admin SPA's Apps tab.
|
|
230
|
-
//
|
|
231
|
-
//
|
|
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.
|
|
232
308
|
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
|
|
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' });
|
|
245
313
|
}
|
|
246
314
|
// Always include the current app if not listed
|
|
247
315
|
if (!apps.some((a) => a.name === name)) {
|
|
248
|
-
apps.unshift({ name, port: config.server.port, source: '
|
|
316
|
+
apps.unshift({ name, port: config.server.port, source: 'config' });
|
|
249
317
|
}
|
|
250
318
|
ctx.json({ apps });
|
|
251
319
|
});
|
|
252
320
|
|
|
253
321
|
// ------------------------------------------------------------------
|
|
254
322
|
// GraphQL gateway — builtin hello + discovered subgraphs (federated)
|
|
323
|
+
// React stack only — future uses live+canvas
|
|
255
324
|
// ------------------------------------------------------------------
|
|
256
325
|
let graphqlMounted = false;
|
|
257
326
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
258
327
|
let graphqlGateway: any = null;
|
|
328
|
+
if (tech === 'react') {
|
|
259
329
|
{
|
|
260
330
|
const explorerEnabled = (config.graphql as unknown as { explorer?: boolean })?.explorer ?? true;
|
|
261
331
|
const helloEnabled = (config.graphql as unknown as { hello?: boolean })?.hello !== false;
|
|
@@ -368,6 +438,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
368
438
|
if (helloEnabled) graphqlMounted = true;
|
|
369
439
|
}
|
|
370
440
|
}
|
|
441
|
+
}
|
|
371
442
|
|
|
372
443
|
// ------------------------------------------------------------------
|
|
373
444
|
// Admin module — request log buffer, lazy DB, admin + AI proxy routes
|
|
@@ -377,9 +448,12 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
377
448
|
|
|
378
449
|
if (config.admin.enabled) {
|
|
379
450
|
// Connect to MongoDB + register the User model so /auth/* can work.
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
+
}
|
|
383
457
|
|
|
384
458
|
// Auth routes (register/login/refresh/logout/me + OAuth). Returns the
|
|
385
459
|
// AuthService used to build the admin guard.
|
|
@@ -428,12 +502,26 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
428
502
|
? { certFile: config.server.certFile, keyFile: config.server.keyFile }
|
|
429
503
|
: {}),
|
|
430
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
|
+
},
|
|
431
517
|
});
|
|
432
518
|
|
|
433
519
|
// ------------------------------------------------------------------
|
|
434
520
|
// Realtime WS server — mount discovered ws/*.room.ts handlers (chat lobby)
|
|
521
|
+
// React stack only — future uses live canvas
|
|
435
522
|
// ------------------------------------------------------------------
|
|
436
523
|
let realtime: any = null;
|
|
524
|
+
if (tech === 'react') {
|
|
437
525
|
try {
|
|
438
526
|
const { RealtimeServer } = await import('@bhooai/nexus-realtime');
|
|
439
527
|
realtime = new RealtimeServer({ httpServer: server.httpServer, path: '/ws' });
|
|
@@ -534,6 +622,60 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
534
622
|
// realtime optional — if not installed, ws lobby falls back to no-op
|
|
535
623
|
if ((e as any)?.code !== 'ERR_MODULE_NOT_FOUND') console.warn('[realtime] ws init failed:', e);
|
|
536
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;
|
|
537
679
|
|
|
538
680
|
// ------------------------------------------------------------------
|
|
539
681
|
// GraphQL subscriptions over WebSocket — mount when a gateway exists
|
|
@@ -695,6 +837,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
695
837
|
},
|
|
696
838
|
async close() {
|
|
697
839
|
await server.close();
|
|
840
|
+
await closeAppFusion();
|
|
698
841
|
},
|
|
699
842
|
};
|
|
700
843
|
|