@bhooai/nexus-core 2.0.12 → 2.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +199 -32
- 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/runtimeJson.ts +6 -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';
|
|
@@ -36,12 +40,14 @@ import { createLazyDb, registerAdminRoutes, RequestLogBuffer } from './adminModu
|
|
|
36
40
|
import { registerAuthRoutes } from './authModule.js';
|
|
37
41
|
import { issueCsrfToken, getCsrfToken, csrf, authToken, requireRole } from '@bhooai/nexus-auth';
|
|
38
42
|
import { connect } from '@bhooai/nexus-data';
|
|
39
|
-
import { createGateway, createFederatedGateway, graphqlHttpHandler, getExplorerHtml, helloSubgraph } from '@bhooai/nexus-graphql';
|
|
40
|
-
import type { Subgraph } from '@bhooai/nexus-graphql';
|
|
43
|
+
import { createGateway, createFederatedGateway, graphqlHttpHandler, getExplorerHtml, helloSubgraph, SubscriptionServer } from '@bhooai/nexus-graphql';
|
|
44
|
+
import type { Subgraph, GraphQLContext } from '@bhooai/nexus-graphql';
|
|
41
45
|
|
|
42
46
|
export interface CreateNexusAppOptions {
|
|
43
47
|
/** Identifier for this backend, used in admin, telemetry, logs. */
|
|
44
48
|
name?: string;
|
|
49
|
+
/** Stack: react = http+ws+graphql, future = live+canvas+ai+autoui. Auto-detected from src/live if omitted. */
|
|
50
|
+
tech?: 'react' | 'future';
|
|
45
51
|
/** Path to the backend src/ folder. Defaults to `<cwd>/src`. */
|
|
46
52
|
srcRoot?: string;
|
|
47
53
|
/** Project root (where .nexus-down and storage/ live). Defaults to srcRoot/.. */
|
|
@@ -59,6 +65,11 @@ export interface CreateNexusAppOptions {
|
|
|
59
65
|
/** Hooks for extending boot. */
|
|
60
66
|
beforeStart?: (app: NexusApp) => Promise<void> | void;
|
|
61
67
|
afterStart?: (app: NexusApp) => Promise<void> | void;
|
|
68
|
+
/**
|
|
69
|
+
* Extra values injected into every GraphQL resolver context (e.g. a payments
|
|
70
|
+
* service). Merged with the default `{ request, user }` context.
|
|
71
|
+
*/
|
|
72
|
+
graphqlContext?: Record<string, unknown>;
|
|
62
73
|
}
|
|
63
74
|
|
|
64
75
|
export interface NexusApp {
|
|
@@ -93,13 +104,23 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
93
104
|
let config = opts.config ?? (await loadConfigAuto({ root: projectRoot }));
|
|
94
105
|
|
|
95
106
|
// ------------------------------------------------------------------
|
|
96
|
-
// Per-app ports:
|
|
97
|
-
//
|
|
98
|
-
//
|
|
107
|
+
// Per-app ports: nexus.config.ts `apps[]` is authoritative. The app's own
|
|
108
|
+
// entry (matched by `opts.name`) wins over `server.port`. NEXUS_PORT (set
|
|
109
|
+
// by `nexus dev`, which resolves from the same config) is honored only as
|
|
110
|
+
// the ephemeral run-time value — e.g. after a coordinated step-up — and a
|
|
111
|
+
// mismatch is logged so silent drift is impossible to miss.
|
|
99
112
|
// ------------------------------------------------------------------
|
|
113
|
+
const configuredPort = config.apps?.find((a) => a.name === name)?.port ?? config.server?.port;
|
|
100
114
|
const envPort = process.env.NEXUS_PORT ? parseInt(process.env.NEXUS_PORT, 10) : null;
|
|
101
|
-
|
|
102
|
-
|
|
115
|
+
const finalPort = envPort && Number.isFinite(envPort) ? envPort : configuredPort;
|
|
116
|
+
if (envPort && Number.isFinite(envPort) && configuredPort != null && envPort !== configuredPort) {
|
|
117
|
+
console.warn(
|
|
118
|
+
`[${name}] NEXUS_PORT=${envPort} differs from configured port ${configuredPort} ` +
|
|
119
|
+
`(ephemeral step-up for this run — nexus.config.ts unchanged).`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (finalPort != null && config.server && finalPort !== config.server.port) {
|
|
123
|
+
config = Object.freeze({ ...config, server: { ...config.server, port: finalPort } }) as NexusConfig;
|
|
103
124
|
}
|
|
104
125
|
|
|
105
126
|
const container = new Container();
|
|
@@ -109,6 +130,8 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
109
130
|
// Discover convention folders
|
|
110
131
|
// ------------------------------------------------------------------
|
|
111
132
|
const discovery = await discoverBackend(srcRoot);
|
|
133
|
+
const tech = opts.tech ?? (discovery.live.length > 0 ? 'future' : 'react');
|
|
134
|
+
console.log(`[${name}] stack: ${tech} (${tech === 'future' ? 'live+canvas+ai+autoui' : 'http+ws+graphql'})`);
|
|
112
135
|
|
|
113
136
|
// ------------------------------------------------------------------
|
|
114
137
|
// DB must be connected before importing routes/graphql that define models
|
|
@@ -116,8 +139,28 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
116
139
|
// connect was only inside `if (admin.enabled)` after routes were mounted,
|
|
117
140
|
// causing "Not connected — call connect(uri) first." and missing /api/posts)
|
|
118
141
|
// ------------------------------------------------------------------
|
|
119
|
-
console.log(`[db] early connect ${config
|
|
120
|
-
|
|
142
|
+
console.log(`[db] early connect ${mongoUri(config)} autoIndex=${config.db.mongodb.autoIndex} enabled=${mongoEnabled(config)} active=${activeDatabase(config)}`);
|
|
143
|
+
if (mongoEnabled(config)) {
|
|
144
|
+
try { connect(mongoUri(config), { autoIndex: config.db.mongodb.autoIndex }); console.log(`[db] early connect ok`); } catch (e) { console.error(`[db] early connect failed`, e); }
|
|
145
|
+
} else {
|
|
146
|
+
console.log(`[db] mongodb disabled — skipping early connect`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ------------------------------------------------------------------
|
|
150
|
+
// User store — Mongo (default) or Fusion (db.active: 'fusion').
|
|
151
|
+
// ------------------------------------------------------------------
|
|
152
|
+
if (activeDatabase(config) === 'fusion') {
|
|
153
|
+
if (!fusionEnabled(config)) {
|
|
154
|
+
throw new ConfigError(
|
|
155
|
+
"db.active is 'fusion' but db.fusion.enabled is false — enable it in nexus.config.ts",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const fusionDb = await openAppFusion(projectRoot, config);
|
|
159
|
+
initUserStore(new FusionUserStore(fusionDb));
|
|
160
|
+
} else {
|
|
161
|
+
if (mongoEnabled(config)) initUserModel();
|
|
162
|
+
initUserStore(new MongoUserStore());
|
|
163
|
+
}
|
|
121
164
|
|
|
122
165
|
// ------------------------------------------------------------------
|
|
123
166
|
// Storage facade + queue + mail — always configured, even if minimal
|
|
@@ -130,7 +173,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
130
173
|
// ------------------------------------------------------------------
|
|
131
174
|
// Error pages + handler
|
|
132
175
|
// ------------------------------------------------------------------
|
|
133
|
-
const errorPages = new ErrorPages(projectRoot);
|
|
176
|
+
const errorPages = new ErrorPages(projectRoot, srcRoot);
|
|
134
177
|
let errorHandler: ErrorHandler = opts.errorHandler ?? new DefaultErrorHandler();
|
|
135
178
|
if (discovery.errorHandler) {
|
|
136
179
|
try {
|
|
@@ -214,6 +257,44 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
214
257
|
|
|
215
258
|
for (const def of opts.additionalRoutes ?? []) registerRoute(router, def, '');
|
|
216
259
|
|
|
260
|
+
// ------------------------------------------------------------------
|
|
261
|
+
// Placeholder upload route for empty projects — keeps POST /api/files alive
|
|
262
|
+
// Ensures file upload works even before file-storage example is installed.
|
|
263
|
+
// ------------------------------------------------------------------
|
|
264
|
+
const hasUploadRoute = router.routes().some((r) => r.method === 'POST' && r.path === '/api/files');
|
|
265
|
+
if (!hasUploadRoute) {
|
|
266
|
+
router.add('POST', '/api/files', async (ctx) => {
|
|
267
|
+
try {
|
|
268
|
+
const { uploadedFiles } = await import('../http/uploads.js');
|
|
269
|
+
const files = uploadedFiles(ctx as any);
|
|
270
|
+
if (!files.length) {
|
|
271
|
+
(ctx as any).json({ error: { code: 'VALIDATION_ERROR', message: 'file is required' } }, 400);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const f = files[0]!;
|
|
275
|
+
const stored = await storage.disk('uploads').put('media', { buffer: f.data, originalName: f.filename, mime: f.contentType });
|
|
276
|
+
(ctx as any).json({ file: stored }, 201);
|
|
277
|
+
} catch (e: any) {
|
|
278
|
+
(ctx as any).json({ error: { code: 'VALIDATION_ERROR', message: e.message ?? 'upload failed' } }, 400);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
router.add('POST', '/api/files/private', async (ctx) => {
|
|
282
|
+
try {
|
|
283
|
+
const { uploadedFiles } = await import('../http/uploads.js');
|
|
284
|
+
const files = uploadedFiles(ctx as any);
|
|
285
|
+
if (!files.length) {
|
|
286
|
+
(ctx as any).json({ error: { code: 'VALIDATION_ERROR', message: 'file is required' } }, 400);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const f = files[0]!;
|
|
290
|
+
const stored = await storage.disk('private').put('secure', { buffer: f.data, originalName: f.filename, mime: f.contentType });
|
|
291
|
+
(ctx as any).json({ file: stored }, 201);
|
|
292
|
+
} catch (e: any) {
|
|
293
|
+
(ctx as any).json({ error: { code: 'VALIDATION_ERROR', message: e.message ?? 'upload failed' } }, 400);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
217
298
|
// ------------------------------------------------------------------
|
|
218
299
|
// Built-in: /health + /admin/_registry
|
|
219
300
|
// ------------------------------------------------------------------
|
|
@@ -221,34 +302,30 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
221
302
|
ctx.json({ ok: true, name, time: new Date().toISOString() });
|
|
222
303
|
});
|
|
223
304
|
|
|
224
|
-
// Apps registry — used by the admin SPA's Apps tab.
|
|
225
|
-
//
|
|
226
|
-
//
|
|
305
|
+
// Apps registry — used by the admin SPA's Apps tab. Ports come from
|
|
306
|
+
// nexus.config.ts `apps[]` (single source of truth). Apps that aren't
|
|
307
|
+
// running show healthy: false.
|
|
227
308
|
router.get('/admin/_registry', async (ctx) => {
|
|
228
|
-
const apps: Array<{ name: string; port: number; source: '
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const raw = await readFile(regPath, 'utf-8');
|
|
233
|
-
const reg = JSON.parse(raw) as Record<string, number>;
|
|
234
|
-
for (const [appName, port] of Object.entries(reg)) {
|
|
235
|
-
apps.push({ name: appName, port, source: 'registry' });
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
} catch (err) {
|
|
239
|
-
void err; // fall through with empty list
|
|
309
|
+
const apps: Array<{ name: string; port: number; source: 'config' }> = [];
|
|
310
|
+
for (const entry of config.apps ?? []) {
|
|
311
|
+
if (entry.enabled === false) continue;
|
|
312
|
+
apps.push({ name: entry.name, port: entry.port, source: 'config' });
|
|
240
313
|
}
|
|
241
314
|
// Always include the current app if not listed
|
|
242
315
|
if (!apps.some((a) => a.name === name)) {
|
|
243
|
-
apps.unshift({ name, port: config.server.port, source: '
|
|
316
|
+
apps.unshift({ name, port: config.server.port, source: 'config' });
|
|
244
317
|
}
|
|
245
318
|
ctx.json({ apps });
|
|
246
319
|
});
|
|
247
320
|
|
|
248
321
|
// ------------------------------------------------------------------
|
|
249
322
|
// GraphQL gateway — builtin hello + discovered subgraphs (federated)
|
|
323
|
+
// React stack only — future uses live+canvas
|
|
250
324
|
// ------------------------------------------------------------------
|
|
251
325
|
let graphqlMounted = false;
|
|
326
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
327
|
+
let graphqlGateway: any = null;
|
|
328
|
+
if (tech === 'react') {
|
|
252
329
|
{
|
|
253
330
|
const explorerEnabled = (config.graphql as unknown as { explorer?: boolean })?.explorer ?? true;
|
|
254
331
|
const helloEnabled = (config.graphql as unknown as { hello?: boolean })?.hello !== false;
|
|
@@ -295,6 +372,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
295
372
|
gateway,
|
|
296
373
|
introspection: config.graphql?.introspection ?? true,
|
|
297
374
|
requireMutationCsrf: config.graphql?.requireMutationCsrf ?? true,
|
|
375
|
+
context: (ctx) => ({ request: ctx, user: (ctx.state.user as GraphQLContext['user']), ...opts.graphqlContext }),
|
|
298
376
|
});
|
|
299
377
|
if (explorerEnabled) {
|
|
300
378
|
const explorerHtml = getExplorerHtml({
|
|
@@ -314,6 +392,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
314
392
|
}
|
|
315
393
|
router.add('POST', graphqlPath, handler);
|
|
316
394
|
graphqlMounted = true;
|
|
395
|
+
graphqlGateway = gateway;
|
|
317
396
|
if (subgraphs.length === 1) {
|
|
318
397
|
console.log(`[${name}] GraphQL gateway mounted at ${graphqlPath} (${subgraphs[0]!.name} subgraph${explorerEnabled ? ' + explorer at /graphiql' : ''})`);
|
|
319
398
|
} else {
|
|
@@ -340,7 +419,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
340
419
|
if (helloEnabled) {
|
|
341
420
|
// hello is available even in fallback — route through its gateway
|
|
342
421
|
const gw = createGateway({ subgraph: helloSubgraph });
|
|
343
|
-
const h = graphqlHttpHandler({ gateway: gw, introspection: config.graphql?.introspection ?? true, requireMutationCsrf: config.graphql?.requireMutationCsrf ?? true });
|
|
422
|
+
const h = graphqlHttpHandler({ gateway: gw, introspection: config.graphql?.introspection ?? true, requireMutationCsrf: config.graphql?.requireMutationCsrf ?? true, context: (ctx) => ({ request: ctx, user: (ctx.state.user as GraphQLContext['user']), ...opts.graphqlContext }) });
|
|
344
423
|
return h(ctx);
|
|
345
424
|
}
|
|
346
425
|
ctx.json({ errors: [{ message: 'No GraphQL subgraph found. Add one with: npx nexus make:subgraph <name> (then restart)' }] }, 404);
|
|
@@ -350,7 +429,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
350
429
|
try {
|
|
351
430
|
if (helloEnabled) {
|
|
352
431
|
const gw = createGateway({ subgraph: helloSubgraph });
|
|
353
|
-
router.add('POST', graphqlPath, graphqlHttpHandler({ gateway: gw, introspection: config.graphql?.introspection ?? true, requireMutationCsrf: config.graphql?.requireMutationCsrf ?? true }));
|
|
432
|
+
router.add('POST', graphqlPath, graphqlHttpHandler({ gateway: gw, introspection: config.graphql?.introspection ?? true, requireMutationCsrf: config.graphql?.requireMutationCsrf ?? true, context: (ctx) => ({ request: ctx, user: (ctx.state.user as GraphQLContext['user']), ...opts.graphqlContext }) }));
|
|
354
433
|
} else {
|
|
355
434
|
router.add('POST', graphqlPath, async (ctx) => ctx.json({ errors: [{ message: 'No GraphQL subgraph found. Add one with: npx nexus make:subgraph <name>' }] }, 404));
|
|
356
435
|
}
|
|
@@ -359,6 +438,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
359
438
|
if (helloEnabled) graphqlMounted = true;
|
|
360
439
|
}
|
|
361
440
|
}
|
|
441
|
+
}
|
|
362
442
|
|
|
363
443
|
// ------------------------------------------------------------------
|
|
364
444
|
// Admin module — request log buffer, lazy DB, admin + AI proxy routes
|
|
@@ -368,9 +448,12 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
368
448
|
|
|
369
449
|
if (config.admin.enabled) {
|
|
370
450
|
// Connect to MongoDB + register the User model so /auth/* can work.
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
451
|
+
// Skipped when mongodb is disabled (fusion-active backends).
|
|
452
|
+
if (mongoEnabled(config)) {
|
|
453
|
+
try {
|
|
454
|
+
connect(mongoUri(config), { autoIndex: config.db.mongodb.autoIndex });
|
|
455
|
+
} catch { /* Mongo unreachable — auth routes will fail lazily */ }
|
|
456
|
+
}
|
|
374
457
|
|
|
375
458
|
// Auth routes (register/login/refresh/logout/me + OAuth). Returns the
|
|
376
459
|
// AuthService used to build the admin guard.
|
|
@@ -419,12 +502,26 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
419
502
|
? { certFile: config.server.certFile, keyFile: config.server.keyFile }
|
|
420
503
|
: {}),
|
|
421
504
|
onError: (err, ctx) => errorHandler.toApiError(err),
|
|
505
|
+
renderError: (err, ctx) => {
|
|
506
|
+
const ne = toNexusError(err);
|
|
507
|
+
const lookup = (status: number) =>
|
|
508
|
+
errorPages.render(status, { requestId: ctx.requestId, message: ne.message });
|
|
509
|
+
return errorHandler.render(err, ctx, lookup);
|
|
510
|
+
},
|
|
511
|
+
renderNotFound: (ctx) => {
|
|
512
|
+
const notFound = new NotFoundError(`No route for ${ctx.method} ${ctx.path}`);
|
|
513
|
+
const lookup = (status: number) =>
|
|
514
|
+
errorPages.render(status, { requestId: ctx.requestId, message: notFound.message });
|
|
515
|
+
return errorHandler.render(notFound, ctx, lookup);
|
|
516
|
+
},
|
|
422
517
|
});
|
|
423
518
|
|
|
424
519
|
// ------------------------------------------------------------------
|
|
425
520
|
// Realtime WS server — mount discovered ws/*.room.ts handlers (chat lobby)
|
|
521
|
+
// React stack only — future uses live canvas
|
|
426
522
|
// ------------------------------------------------------------------
|
|
427
523
|
let realtime: any = null;
|
|
524
|
+
if (tech === 'react') {
|
|
428
525
|
try {
|
|
429
526
|
const { RealtimeServer } = await import('@bhooai/nexus-realtime');
|
|
430
527
|
realtime = new RealtimeServer({ httpServer: server.httpServer, path: '/ws' });
|
|
@@ -525,6 +622,75 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
525
622
|
// realtime optional — if not installed, ws lobby falls back to no-op
|
|
526
623
|
if ((e as any)?.code !== 'ERR_MODULE_NOT_FOUND') console.warn('[realtime] ws init failed:', e);
|
|
527
624
|
}
|
|
625
|
+
} // tech === react
|
|
626
|
+
|
|
627
|
+
// ------------------------------------------------------------------
|
|
628
|
+
// Live pages — mount discovered src/live/*.live.js (future runtime)
|
|
629
|
+
// Self-contained: state travels with definition, auto-subscribed.
|
|
630
|
+
// Shares the same httpServer/port as the existing backend.
|
|
631
|
+
// Future stack only — react uses http+ws+graphql
|
|
632
|
+
// ------------------------------------------------------------------
|
|
633
|
+
let liveServer: any = null;
|
|
634
|
+
if (tech === 'future') {
|
|
635
|
+
try {
|
|
636
|
+
// @ts-ignore — nexus-future is JS/JSDoc, no .d.ts (v3)
|
|
637
|
+
const { LiveServer } = await import('@bhooai/nexus-future/live');
|
|
638
|
+
liveServer = new LiveServer({ httpServer: server.httpServer, onLog: (m: string) => console.log(`[live] ${m}`) });
|
|
639
|
+
// Global sense() — adaptive vibe (time/weather/mood) broadcast to every live page
|
|
640
|
+
try {
|
|
641
|
+
// @ts-ignore — nexus-future is JS/JSDoc, no .d.ts (v3)
|
|
642
|
+
const { sense } = await import('@bhooai/nexus-future/ui');
|
|
643
|
+
const feel = sense();
|
|
644
|
+
(liveServer as any).attachSense(feel);
|
|
645
|
+
console.log(`[${name}] sense attached: ${JSON.stringify((feel.vibe() as any)?.period)}/${JSON.stringify((feel.vibe() as any)?.weather)} — vibe broadcasts live`);
|
|
646
|
+
} catch (e) {
|
|
647
|
+
console.warn('[live] sense not attached:', e);
|
|
648
|
+
}
|
|
649
|
+
for (const file of discovery.live) {
|
|
650
|
+
try {
|
|
651
|
+
const mod: any = await importDefault(file);
|
|
652
|
+
const def = mod?.default ?? mod;
|
|
653
|
+
if (def?.path && typeof def.render === 'function') {
|
|
654
|
+
liveServer.addPage(def); // state on def auto-subscribes
|
|
655
|
+
console.log(`[${name}] live page mounted: ${def.path} (${file.name})`);
|
|
656
|
+
} else {
|
|
657
|
+
console.warn(`[${name}] live file ${file.path} does not export live() — skipped`);
|
|
658
|
+
}
|
|
659
|
+
} catch (e) {
|
|
660
|
+
console.warn(`[${name}] failed to load live page ${file.path}:`, e);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
// Wire live pages into the NexusServer pipeline — live pages share the
|
|
664
|
+
// same port as the backend. Check before other handlers.
|
|
665
|
+
if (liveServer) {
|
|
666
|
+
server.use(async (ctx, next) => {
|
|
667
|
+
try {
|
|
668
|
+
const handled = liveServer.handleRequest(ctx.req as any, ctx.res as any);
|
|
669
|
+
if (handled) return;
|
|
670
|
+
} catch {}
|
|
671
|
+
await next();
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
} catch (e) {
|
|
675
|
+
if ((e as any)?.code !== 'ERR_MODULE_NOT_FOUND') console.warn('[live] init failed:', e);
|
|
676
|
+
}
|
|
677
|
+
} // tech === future
|
|
678
|
+
void liveServer;
|
|
679
|
+
|
|
680
|
+
// ------------------------------------------------------------------
|
|
681
|
+
// GraphQL subscriptions over WebSocket — mount when a gateway exists
|
|
682
|
+
// and config.graphql.subscriptions is enabled.
|
|
683
|
+
// ------------------------------------------------------------------
|
|
684
|
+
const graphqlSubscriptions = (config.graphql as unknown as { subscriptions?: boolean })?.subscriptions ?? true;
|
|
685
|
+
if (graphqlGateway && graphqlSubscriptions && typeof graphqlGateway.subscribe === 'function') {
|
|
686
|
+
try {
|
|
687
|
+
const subPath = `${config.graphql?.path ?? '/graphql'}/ws`;
|
|
688
|
+
new SubscriptionServer({ httpServer: server.httpServer, gateway: graphqlGateway, path: subPath });
|
|
689
|
+
console.log(`[${name}] GraphQL subscriptions mounted at ${subPath}`);
|
|
690
|
+
} catch (err) {
|
|
691
|
+
console.warn(`[${name}] failed to mount GraphQL subscriptions:`, err);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
528
694
|
|
|
529
695
|
// Public uploads: serve GET /uploads/* from storage/uploads (file-storage example).
|
|
530
696
|
// This was missing — 404 `No route for GET /uploads/media/...`.
|
|
@@ -671,6 +837,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
671
837
|
},
|
|
672
838
|
async close() {
|
|
673
839
|
await server.close();
|
|
840
|
+
await closeAppFusion();
|
|
674
841
|
},
|
|
675
842
|
};
|
|
676
843
|
|