@bhooai/nexus-core 2.0.4 → 2.0.6
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 -1
- package/src/app/adminModule.ts +579 -48
- package/src/app/aiProxyModule.ts +160 -0
- package/src/app/aiSchemaModule.ts +130 -0
- package/src/app/authModule.ts +243 -0
- package/src/app/createNexusApp.ts +73 -2
- package/src/app/databaseAdminModule.ts +222 -0
- package/src/app/index.ts +2 -0
- package/src/app/lintModule.ts +102 -0
- package/src/app/preflightModule.ts +259 -0
- package/src/app/roleCatalog.ts +78 -0
- package/src/app/userModel.ts +101 -0
- package/src/config/runtimeJson.ts +25 -2
package/src/app/adminModule.ts
CHANGED
|
@@ -30,21 +30,42 @@
|
|
|
30
30
|
* (endpoints return { ok: false, reason: '...' }).
|
|
31
31
|
*/
|
|
32
32
|
import { existsSync } from 'node:fs';
|
|
33
|
-
import { readFile } from 'node:fs/promises';
|
|
33
|
+
import { readFile, writeFile, rename, unlink } from 'node:fs/promises';
|
|
34
34
|
import { createConnection } from 'node:net';
|
|
35
35
|
import { join, resolve } from 'node:path';
|
|
36
|
-
import
|
|
36
|
+
import { pathToFileURL } from 'node:url';
|
|
37
|
+
import type { NexusConfig, DeepPartial } from '../config/types.js';
|
|
37
38
|
import type { AiProviderConfig } from '../config/types.js';
|
|
38
39
|
import type { Router } from '../http/Router.js';
|
|
39
|
-
import type { RequestContext } from '../http/context.js';
|
|
40
|
+
import type { Middleware, RequestContext } from '../http/context.js';
|
|
40
41
|
import { AiClient } from '@bhooai/nexus-ai-client';
|
|
42
|
+
import { discoverUserConfigPath, frameworkDefaultConfigPath, mergeConfig } from '../config/ConfigLoader.js';
|
|
41
43
|
import { readRuntimeJson, writeRuntimeJson, mergeRuntimeJson } from '../config/runtimeJson.js';
|
|
42
44
|
import { readEnvFile, writeEnvEntries, writeEnvKey, deleteEnvKey } from '../config/envFile.js';
|
|
45
|
+
import {
|
|
46
|
+
connectProjectInfo,
|
|
47
|
+
resolveProjectInfo,
|
|
48
|
+
listProjectInfo,
|
|
49
|
+
getProjectInfo,
|
|
50
|
+
getProjectInfoCollection,
|
|
51
|
+
upsertProjectInfo,
|
|
52
|
+
deleteProjectInfo,
|
|
53
|
+
ObjectId,
|
|
54
|
+
type ProjectInfo,
|
|
55
|
+
} from '@bhooai/nexus-data';
|
|
56
|
+
import { ROLE_CATALOG, findRole } from './roleCatalog.js';
|
|
57
|
+
import { registerPreflightRoutes, aiHealthUrl } from './preflightModule.js';
|
|
58
|
+
import { registerLintRoutes } from './lintModule.js';
|
|
59
|
+
import { registerDatabaseAdminRoutes } from './databaseAdminModule.js';
|
|
60
|
+
import { registerAiSchemaRoutes } from './aiSchemaModule.js';
|
|
61
|
+
import { registerAiProxyRoutes } from './aiProxyModule.js';
|
|
43
62
|
|
|
44
63
|
export interface AdminModuleOptions {
|
|
45
64
|
name: string;
|
|
46
65
|
config: NexusConfig;
|
|
47
66
|
projectRoot: string;
|
|
67
|
+
/** True when a GraphQL gateway is mounted at config.graphql.path. */
|
|
68
|
+
graphqlMounted?: boolean;
|
|
48
69
|
}
|
|
49
70
|
|
|
50
71
|
export interface LazyDb {
|
|
@@ -59,6 +80,9 @@ export interface RequestLogEntry {
|
|
|
59
80
|
path: string;
|
|
60
81
|
status: number;
|
|
61
82
|
latencyMs: number;
|
|
83
|
+
ip?: string;
|
|
84
|
+
referer?: string;
|
|
85
|
+
userAgent?: string;
|
|
62
86
|
}
|
|
63
87
|
|
|
64
88
|
const MAX_LOG_ENTRIES = 1000;
|
|
@@ -107,9 +131,33 @@ function tcpReachable(host: string, port: number, timeoutMs = 1200): Promise<boo
|
|
|
107
131
|
}
|
|
108
132
|
|
|
109
133
|
/** Register all admin + AI proxy routes onto the router. */
|
|
110
|
-
export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, logBuffer: RequestLogBuffer, lazy: LazyDb): void {
|
|
134
|
+
export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, logBuffer: RequestLogBuffer, lazy: LazyDb, guard: Middleware[] = []): void {
|
|
111
135
|
const { config, projectRoot } = opts;
|
|
112
136
|
|
|
137
|
+
// Apply the auth guard (authToken + requireRole('admin')) to all /admin/* routes.
|
|
138
|
+
for (const mw of guard) router.use('/admin', mw);
|
|
139
|
+
|
|
140
|
+
// Open the shared project-info connection so /admin/config, /admin/project
|
|
141
|
+
// and the payment provider persistence can read/write the registry. Failure
|
|
142
|
+
// is non-fatal — the affected endpoints degrade gracefully.
|
|
143
|
+
try { connectProjectInfo(config.db.uri); } catch { /* Mongo may be down */ }
|
|
144
|
+
|
|
145
|
+
// ------------------------------------------------------------------
|
|
146
|
+
// /admin/supervisor — report where `nexus dev`'s control API is bound so the
|
|
147
|
+
// admin SPA can drive process control. Reads supervisor.json written by the
|
|
148
|
+
// CLI; falls back to null (the SPA then uses its 7474 default).
|
|
149
|
+
// ------------------------------------------------------------------
|
|
150
|
+
router.get('/admin/supervisor', async (ctx) => {
|
|
151
|
+
try {
|
|
152
|
+
const p = resolve(projectRoot, 'supervisor.json');
|
|
153
|
+
if (!existsSync(p)) { ctx.json({ port: null, url: null }); return; }
|
|
154
|
+
const info = JSON.parse(await readFile(p, 'utf8')) as { port?: number; url?: string };
|
|
155
|
+
ctx.json({ port: info.port ?? null, url: info.url ?? null });
|
|
156
|
+
} catch {
|
|
157
|
+
ctx.json({ port: null, url: null });
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
113
161
|
// ------------------------------------------------------------------
|
|
114
162
|
// /admin/health/services — TCP probes for infra services
|
|
115
163
|
// ------------------------------------------------------------------
|
|
@@ -172,10 +220,21 @@ export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, lo
|
|
|
172
220
|
});
|
|
173
221
|
|
|
174
222
|
// ------------------------------------------------------------------
|
|
175
|
-
// /admin/config — read-only, redacted
|
|
223
|
+
// /admin/config — read-only, redacted (+ runtime overrides, config file, project)
|
|
176
224
|
// ------------------------------------------------------------------
|
|
177
|
-
router.get('/admin/config', (ctx) => {
|
|
178
|
-
|
|
225
|
+
router.get('/admin/config', async (ctx) => {
|
|
226
|
+
const project = await resolveProjectInfo(projectRoot);
|
|
227
|
+
// Prefer the stored record matched by path so a renamed project is found.
|
|
228
|
+
const stored = (await listProjectInfo().catch(() => [] as ProjectInfo[])).find((p) => p.path === project.path)
|
|
229
|
+
?? (await getProjectInfo(project.name).catch(() => null));
|
|
230
|
+
const pkg = await readPackageJson(projectRoot);
|
|
231
|
+
const resolved = stored ?? project;
|
|
232
|
+
ctx.json({
|
|
233
|
+
runtime: await readRuntimeJson(projectRoot),
|
|
234
|
+
config: redactConfig(config),
|
|
235
|
+
file: await readUserConfigFile(projectRoot),
|
|
236
|
+
project: { ...resolved, version: pkg?.version ?? resolved.version },
|
|
237
|
+
});
|
|
179
238
|
});
|
|
180
239
|
|
|
181
240
|
// ------------------------------------------------------------------
|
|
@@ -192,11 +251,13 @@ export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, lo
|
|
|
192
251
|
ctx.json({
|
|
193
252
|
ok: true,
|
|
194
253
|
users: users.map((u) => ({
|
|
195
|
-
|
|
254
|
+
_id: String(u._id),
|
|
196
255
|
email: u.email,
|
|
197
256
|
name: u.name,
|
|
198
257
|
roles: u.roles ?? ['user'],
|
|
258
|
+
emailVerified: !!u.emailVerified,
|
|
199
259
|
createdAt: u.createdAt,
|
|
260
|
+
updatedAt: u.updatedAt,
|
|
200
261
|
})),
|
|
201
262
|
});
|
|
202
263
|
} catch (err) {
|
|
@@ -231,45 +292,15 @@ export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, lo
|
|
|
231
292
|
});
|
|
232
293
|
|
|
233
294
|
// ------------------------------------------------------------------
|
|
234
|
-
// /ai/* —
|
|
295
|
+
// /ai/* — proxied by registerAiProxyRoutes() below (streaming chat +
|
|
296
|
+
// provider-aware model listing). The AiClient here is reused by the AI
|
|
297
|
+
// provider management + status routes.
|
|
235
298
|
// ------------------------------------------------------------------
|
|
236
299
|
const aiClient = new AiClient({
|
|
237
300
|
serverUrl: config.ai.serverUrl,
|
|
238
301
|
timeoutMs: config.ai.timeoutMs,
|
|
239
302
|
});
|
|
240
303
|
|
|
241
|
-
router.get('/ai/models', async (ctx) => {
|
|
242
|
-
try {
|
|
243
|
-
const models = await aiClient.listModels();
|
|
244
|
-
ctx.json({ ok: true, models });
|
|
245
|
-
} catch (err) {
|
|
246
|
-
ctx.json({ ok: false, reason: String((err as Error).message) });
|
|
247
|
-
}
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
router.post('/ai/chat', async (ctx) => {
|
|
251
|
-
const body = ctx.body as { model?: string; messages?: unknown[]; temperature?: number; max_tokens?: number; provider?: string };
|
|
252
|
-
if (!body?.messages) {
|
|
253
|
-
ctx.json({ ok: false, reason: 'messages is required' }, 400);
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
try {
|
|
257
|
-
const providerId = body.provider ?? config.ai.defaultProvider;
|
|
258
|
-
const provider = providers.find((p) => p.id === providerId);
|
|
259
|
-
const defaultModel = provider?.defaultModel ?? config.ai.schemaModel;
|
|
260
|
-
const res = await aiClient.chat({
|
|
261
|
-
model: body.model ?? defaultModel,
|
|
262
|
-
messages: body.messages as never,
|
|
263
|
-
temperature: body.temperature,
|
|
264
|
-
max_tokens: body.max_tokens,
|
|
265
|
-
provider: providerId,
|
|
266
|
-
});
|
|
267
|
-
ctx.json({ ok: true, ...res });
|
|
268
|
-
} catch (err) {
|
|
269
|
-
ctx.json({ ok: false, reason: String((err as Error).message) });
|
|
270
|
-
}
|
|
271
|
-
});
|
|
272
|
-
|
|
273
304
|
// ------------------------------------------------------------------
|
|
274
305
|
// AI provider management
|
|
275
306
|
// ------------------------------------------------------------------
|
|
@@ -325,15 +356,33 @@ export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, lo
|
|
|
325
356
|
const ctrl = new AbortController();
|
|
326
357
|
const timer = setTimeout(() => ctrl.abort(), 10_000);
|
|
327
358
|
try {
|
|
328
|
-
const res = await fetch(
|
|
359
|
+
const res = await fetch(aiHealthUrl(config.ai.serverUrl, '127.0.0.1'), { signal: ctrl.signal });
|
|
329
360
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
330
361
|
return (await res.json()) as { status?: string; providers?: string[] };
|
|
331
362
|
} finally { clearTimeout(timer); }
|
|
332
363
|
});
|
|
333
364
|
|
|
365
|
+
// Probe per-provider model availability (openai / ollama) so the admin's
|
|
366
|
+
// AI Settings surface can render the auto-resolution it relies on.
|
|
367
|
+
const listModels = async (provider: 'openai' | 'ollama') => {
|
|
368
|
+
const res = await aiClient.listModels(provider);
|
|
369
|
+
const models = (res.data ?? []).map((m) => String(m.id ?? m)).slice(0, 10);
|
|
370
|
+
return { provider: res.provider, modelCount: (res.data ?? []).length, models };
|
|
371
|
+
};
|
|
372
|
+
const openai = await probe(() => listModels('openai'));
|
|
373
|
+
const ollama = await probe(() => listModels('ollama'));
|
|
374
|
+
|
|
375
|
+
const autoResolvesTo =
|
|
376
|
+
aiServer.detail && Array.isArray(aiServer.detail.providers)
|
|
377
|
+
? (aiServer.detail.providers.includes('openai') ? 'openai' : 'ollama')
|
|
378
|
+
: 'ollama';
|
|
379
|
+
|
|
334
380
|
ctx.json({
|
|
335
381
|
checkedAt: new Date().toISOString(),
|
|
336
382
|
aiServer,
|
|
383
|
+
openai,
|
|
384
|
+
ollama,
|
|
385
|
+
autoResolvesTo,
|
|
337
386
|
defaultProvider: config.ai.defaultProvider,
|
|
338
387
|
schemaModel: config.ai.schemaModel,
|
|
339
388
|
serverUrl: config.ai.serverUrl,
|
|
@@ -486,6 +535,344 @@ export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, lo
|
|
|
486
535
|
ctx.json({ ok: false, reason: String((err as Error).message) }, 500);
|
|
487
536
|
}
|
|
488
537
|
});
|
|
538
|
+
|
|
539
|
+
// ------------------------------------------------------------------
|
|
540
|
+
// /admin/config/file — save the human-edited config file (validated)
|
|
541
|
+
// ------------------------------------------------------------------
|
|
542
|
+
router.put('/admin/config/file', async (ctx) => {
|
|
543
|
+
const content = (ctx.body as { content?: unknown } | undefined)?.content;
|
|
544
|
+
if (typeof content !== 'string') {
|
|
545
|
+
ctx.json({ error: 'content (string) is required' }, 400);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const info = await readUserConfigFile(projectRoot);
|
|
549
|
+
if (!info) {
|
|
550
|
+
ctx.json({ error: 'No user config file found (nexus.config.js / .ts)' }, 404);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
const original = info.content;
|
|
554
|
+
await writeTextAtomic(info.path, content);
|
|
555
|
+
try {
|
|
556
|
+
await validateUserConfigFile(info.path);
|
|
557
|
+
ctx.json({ ok: true, path: info.path, note: 'Saved to config file — restart services to apply' });
|
|
558
|
+
} catch (e) {
|
|
559
|
+
await writeTextAtomic(info.path, original);
|
|
560
|
+
ctx.json({ error: `Config file invalid — not saved: ${(e as Error).message}` }, 400);
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
// ------------------------------------------------------------------
|
|
565
|
+
// /admin/roles + /admin/users/:id — role catalog + role assignment
|
|
566
|
+
// ------------------------------------------------------------------
|
|
567
|
+
router.get('/admin/roles', (ctx) => {
|
|
568
|
+
ctx.json({ roles: ROLE_CATALOG });
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
router.put('/admin/users/:id', async (ctx) => {
|
|
572
|
+
const db = await lazy.db();
|
|
573
|
+
if (!db) { ctx.json({ error: 'MongoDB not reachable' }, 503); return; }
|
|
574
|
+
const body = (ctx.body ?? {}) as { roles?: unknown };
|
|
575
|
+
const incoming = body.roles;
|
|
576
|
+
if (!Array.isArray(incoming) || !incoming.length || incoming.some((r) => typeof r !== 'string')) {
|
|
577
|
+
ctx.json({ error: 'roles (a non-empty string array) is required' }, 400);
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const nextRoles = [...new Set(incoming as string[])];
|
|
581
|
+
const unknown = nextRoles.filter((r) => !findRole(r));
|
|
582
|
+
if (unknown.length) {
|
|
583
|
+
ctx.json({ error: `unknown role(s): ${unknown.join(', ')}` }, 400);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const id = String(ctx.params.id ?? '');
|
|
588
|
+
let oid: import('mongodb').ObjectId;
|
|
589
|
+
try { oid = new ObjectId(id); } catch { ctx.json({ error: 'invalid user id' }, 400); return; }
|
|
590
|
+
const target = await db.collection('users').findOne({ _id: oid });
|
|
591
|
+
if (!target) { ctx.json({ error: 'user not found' }, 404); return; }
|
|
592
|
+
|
|
593
|
+
const currentRoles = (target.roles ?? []) as string[];
|
|
594
|
+
const isAdmin = currentRoles.includes('admin');
|
|
595
|
+
const keepsAdmin = nextRoles.includes('admin');
|
|
596
|
+
const caller = ctx.state.user as { id?: string; roles?: string[] } | undefined;
|
|
597
|
+
|
|
598
|
+
if (isAdmin && !keepsAdmin) {
|
|
599
|
+
const adminCount = await db.collection('users').countDocuments({ roles: 'admin' });
|
|
600
|
+
if (adminCount <= 1) { ctx.json({ error: 'cannot remove the last admin from the system' }, 400); return; }
|
|
601
|
+
if (caller?.id === id) { ctx.json({ error: 'you cannot remove your own admin role' }, 400); return; }
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
await db.collection('users').updateOne({ _id: oid }, { $set: { roles: nextRoles } });
|
|
605
|
+
const updated = await db.collection('users').findOne({ _id: oid });
|
|
606
|
+
ctx.json({ ok: true, user: updated });
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
// ------------------------------------------------------------------
|
|
610
|
+
// /admin/metrics — version / node / uptime / pid + request snapshot
|
|
611
|
+
// ------------------------------------------------------------------
|
|
612
|
+
router.get('/admin/metrics', async (ctx) => {
|
|
613
|
+
const pkg = await readPackageJson(projectRoot);
|
|
614
|
+
ctx.json({
|
|
615
|
+
metrics: {
|
|
616
|
+
http_requests_total: { values: { all: logBuffer.size } },
|
|
617
|
+
},
|
|
618
|
+
version: pkg?.version ?? null,
|
|
619
|
+
node: process.version,
|
|
620
|
+
uptime: process.uptime(),
|
|
621
|
+
pid: process.pid,
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
// ------------------------------------------------------------------
|
|
626
|
+
// /admin/project — update the project registry record + package.json
|
|
627
|
+
// ------------------------------------------------------------------
|
|
628
|
+
router.put('/admin/project', async (ctx) => {
|
|
629
|
+
const body = (ctx.body ?? {}) as { name?: string; version?: string };
|
|
630
|
+
const project = await resolveProjectInfo(projectRoot);
|
|
631
|
+
const stored = (await listProjectInfo().catch(() => [] as ProjectInfo[])).find((p) => p.path === project.path)
|
|
632
|
+
?? await getProjectInfo(project.name).catch(() => null);
|
|
633
|
+
const current = stored ?? project;
|
|
634
|
+
const next: ProjectInfo = { ...current };
|
|
635
|
+
|
|
636
|
+
if (typeof body.name === 'string' && body.name.trim() && body.name.trim() !== current.name) {
|
|
637
|
+
const newName = body.name.trim();
|
|
638
|
+
if (!/^[\w@/. -]+$/.test(newName)) { ctx.json({ error: 'project name contains unsupported characters' }, 400); return; }
|
|
639
|
+
const clash = (await listProjectInfo().catch(() => [] as ProjectInfo[])).find(
|
|
640
|
+
(p) => p.name === newName && p.path !== current.path,
|
|
641
|
+
);
|
|
642
|
+
if (clash) { ctx.json({ error: `a project named "${newName}" already exists` }, 400); return; }
|
|
643
|
+
next.name = newName;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (typeof body.version === 'string' && body.version.trim() && body.version.trim() !== (await readPackageJson(projectRoot))?.version) {
|
|
647
|
+
const version = body.version.trim();
|
|
648
|
+
if (!/^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/.test(version)) { ctx.json({ error: `"${version}" is not a valid semver version` }, 400); return; }
|
|
649
|
+
const pkgPath = resolve(projectRoot, 'package.json');
|
|
650
|
+
let pkg: Record<string, unknown> = {};
|
|
651
|
+
try { pkg = JSON.parse(await readFile(pkgPath, 'utf8')) as Record<string, unknown>; }
|
|
652
|
+
catch { ctx.json({ error: 'package.json is missing or unreadable' }, 400); return; }
|
|
653
|
+
pkg.version = version;
|
|
654
|
+
await writeTextAtomic(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
655
|
+
next.version = version;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (next.name !== current.name || next.version !== current.version) {
|
|
659
|
+
try {
|
|
660
|
+
await upsertProjectInfo(next);
|
|
661
|
+
if (next.name !== current.name) await deleteProjectInfo(current.name).catch(() => {});
|
|
662
|
+
} catch (err) {
|
|
663
|
+
ctx.json({ error: `could not persist project info: ${(err as Error).message}` }, 500);
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
ctx.json({ ok: true, project: next });
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
// ------------------------------------------------------------------
|
|
671
|
+
// /admin/plugins — plugin admin extensions (pages/slots)
|
|
672
|
+
// ------------------------------------------------------------------
|
|
673
|
+
router.get('/admin/plugins', (ctx) => {
|
|
674
|
+
// The framework does not auto-wire plugin admin extensions in
|
|
675
|
+
// createNexusApp yet; return an empty registry so the admin tab renders.
|
|
676
|
+
ctx.json({ pages: [], slots: {} });
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
// ------------------------------------------------------------------
|
|
680
|
+
// Payments — orders / transactions / provider status + keys
|
|
681
|
+
// ------------------------------------------------------------------
|
|
682
|
+
const paymentsCfg = config.payments ?? {} as Record<string, unknown>;
|
|
683
|
+
|
|
684
|
+
router.get('/admin/payments/orders', async (ctx) => {
|
|
685
|
+
const db = await lazy.db();
|
|
686
|
+
if (!db) { ctx.json({ orders: [] }); return; }
|
|
687
|
+
const limit = clampInt(ctx.query.limit, 100);
|
|
688
|
+
const filter = providerFilter(ctx.query.provider);
|
|
689
|
+
try {
|
|
690
|
+
const orders = await db.collection('orders').find(filter).sort({ createdAt: -1 }).limit(limit).toArray();
|
|
691
|
+
ctx.json({
|
|
692
|
+
orders: orders.map((o) => ({
|
|
693
|
+
provider: o.provider,
|
|
694
|
+
orderId: o.orderId,
|
|
695
|
+
reference: o.reference,
|
|
696
|
+
status: o.status,
|
|
697
|
+
amount: o.amount,
|
|
698
|
+
currency: o.currency,
|
|
699
|
+
paymentId: o.paymentId,
|
|
700
|
+
createdAt: o.createdAt,
|
|
701
|
+
})),
|
|
702
|
+
});
|
|
703
|
+
} catch (err) {
|
|
704
|
+
ctx.json({ error: (err as Error).message }, 500);
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
router.get('/admin/payments/transactions', async (ctx) => {
|
|
709
|
+
const db = await lazy.db();
|
|
710
|
+
if (!db) { ctx.json({ transactions: [] }); return; }
|
|
711
|
+
const limit = clampInt(ctx.query.limit, 100);
|
|
712
|
+
const filter = providerFilter(ctx.query.provider);
|
|
713
|
+
try {
|
|
714
|
+
const transactions = await db.collection('transactions').find(filter).sort({ createdAt: -1 }).limit(limit).toArray();
|
|
715
|
+
ctx.json({
|
|
716
|
+
transactions: transactions.map((t) => ({
|
|
717
|
+
provider: t.provider,
|
|
718
|
+
event: t.event,
|
|
719
|
+
verified: !!t.verified,
|
|
720
|
+
orderId: t.orderId,
|
|
721
|
+
paymentId: t.paymentId,
|
|
722
|
+
amount: t.amount,
|
|
723
|
+
currency: t.currency,
|
|
724
|
+
status: t.status,
|
|
725
|
+
createdAt: t.createdAt,
|
|
726
|
+
})),
|
|
727
|
+
});
|
|
728
|
+
} catch (err) {
|
|
729
|
+
ctx.json({ error: (err as Error).message }, 500);
|
|
730
|
+
}
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
router.get('/admin/payments/status', async (ctx) => {
|
|
734
|
+
const db = await lazy.db();
|
|
735
|
+
const results: Array<Record<string, unknown>> = [];
|
|
736
|
+
for (const name of PAYMENT_PROVIDER_NAMES) {
|
|
737
|
+
const pc = (paymentsCfg as Record<string, any>)[name] ?? {};
|
|
738
|
+
const keyFields = PAYMENT_PROVIDER_KEYS[name];
|
|
739
|
+
const entry: Record<string, unknown> = {
|
|
740
|
+
name,
|
|
741
|
+
enabled: !!pc.enabled,
|
|
742
|
+
sandbox: !!pc.sandbox,
|
|
743
|
+
configured: keyFields.every((k) => typeof pc[k] === 'string' && (pc[k] as string).length > 0),
|
|
744
|
+
live: !!db,
|
|
745
|
+
fields: keyFields.map((k) => ({
|
|
746
|
+
field: k,
|
|
747
|
+
label: PAYMENT_FIELD_LABELS[k] ?? k,
|
|
748
|
+
hasValue: typeof pc[k] === 'string' && (pc[k] as string).length > 0,
|
|
749
|
+
})),
|
|
750
|
+
};
|
|
751
|
+
entry.ok = entry.configured && !!pc.enabled;
|
|
752
|
+
entry.note = 'no public probe — create a test order to verify';
|
|
753
|
+
results.push(entry);
|
|
754
|
+
}
|
|
755
|
+
ctx.json({ checkedAt: new Date().toISOString(), providers: results });
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
router.put('/admin/payments/providers/:id', async (ctx) => {
|
|
759
|
+
const id = ctx.params.id ?? '';
|
|
760
|
+
if (!PAYMENT_PROVIDER_NAMES.includes(id as any)) { ctx.json({ error: `unknown provider "${id}"` }, 400); return; }
|
|
761
|
+
const body = (ctx.body ?? {}) as Record<string, unknown>;
|
|
762
|
+
const pc = (paymentsCfg as Record<string, any>)[id] ?? {};
|
|
763
|
+
if (typeof body.enabled === 'boolean') pc.enabled = body.enabled;
|
|
764
|
+
if (typeof body.sandbox === 'boolean') pc.sandbox = body.sandbox;
|
|
765
|
+
(paymentsCfg as Record<string, any>)[id] = pc;
|
|
766
|
+
const savedFields: string[] = [];
|
|
767
|
+
for (const field of PAYMENT_PROVIDER_KEYS[id as (typeof PAYMENT_PROVIDER_NAMES)[number]]) {
|
|
768
|
+
const value = body[field];
|
|
769
|
+
if (typeof value === 'string' && value.trim()) {
|
|
770
|
+
pc[field] = value.trim();
|
|
771
|
+
savedFields.push(field);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
if (savedFields.length > 0) await writePaymentEnvKeys(projectRoot, id, pc);
|
|
775
|
+
const persistence = await persistPaymentProvider(projectRoot, id, pc);
|
|
776
|
+
ctx.json({
|
|
777
|
+
ok: true,
|
|
778
|
+
persistence,
|
|
779
|
+
savedFields,
|
|
780
|
+
provider: {
|
|
781
|
+
name: id,
|
|
782
|
+
enabled: !!pc.enabled,
|
|
783
|
+
sandbox: !!pc.sandbox,
|
|
784
|
+
configured: PAYMENT_PROVIDER_KEYS[id as (typeof PAYMENT_PROVIDER_NAMES)[number]].every((k) => typeof pc[k] === 'string' && (pc[k] as string).length > 0),
|
|
785
|
+
},
|
|
786
|
+
});
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
// POST /payments/order — record a test order (admin Payments test console).
|
|
790
|
+
// This is a record-only creation: it persists an order to Mongo so the
|
|
791
|
+
// Orders table reflects it. Live provider checkout is out of scope.
|
|
792
|
+
router.post('/payments/order', async (ctx) => {
|
|
793
|
+
const db = await lazy.db();
|
|
794
|
+
if (!db) { ctx.json({ error: 'MongoDB not reachable' }, 503); return; }
|
|
795
|
+
const body = (ctx.body ?? {}) as { provider?: unknown; amount?: unknown; currency?: unknown; reference?: unknown };
|
|
796
|
+
const provider = typeof body.provider === 'string' ? body.provider.trim() : '';
|
|
797
|
+
const amount = typeof body.amount === 'number' && Number.isFinite(body.amount) ? body.amount : NaN;
|
|
798
|
+
const currency = typeof body.currency === 'string' && body.currency.trim() ? body.currency.trim().toUpperCase() : (config.payments?.currency ?? 'INR');
|
|
799
|
+
const reference = typeof body.reference === 'string' && body.reference.trim() ? body.reference.trim() : `test_${Date.now()}`;
|
|
800
|
+
if (!provider || !Number.isFinite(amount)) {
|
|
801
|
+
ctx.json({ error: 'provider (string) and amount (number) are required' }, 400);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
const order = {
|
|
805
|
+
provider,
|
|
806
|
+
orderId: `ord_${Date.now()}`,
|
|
807
|
+
reference,
|
|
808
|
+
status: 'created',
|
|
809
|
+
amount,
|
|
810
|
+
currency,
|
|
811
|
+
createdAt: new Date().toISOString(),
|
|
812
|
+
};
|
|
813
|
+
try {
|
|
814
|
+
const res = await db.collection('orders').insertOne(order);
|
|
815
|
+
ctx.json({ ok: true, order: { ...order, _id: String(res.insertedId) } });
|
|
816
|
+
} catch (err) {
|
|
817
|
+
ctx.json({ error: (err as Error).message }, 500);
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
// ------------------------------------------------------------------
|
|
822
|
+
// /admin/requests — recent request log (from the in-memory buffer)
|
|
823
|
+
// ------------------------------------------------------------------
|
|
824
|
+
router.get('/admin/requests', (ctx) => {
|
|
825
|
+
const limit = clampInt(ctx.query.limit, 100);
|
|
826
|
+
const entries = logBuffer.tail(limit).reverse();
|
|
827
|
+
ctx.json({
|
|
828
|
+
requests: entries.map((e) => ({
|
|
829
|
+
time: e.ts,
|
|
830
|
+
method: e.method,
|
|
831
|
+
path: e.path,
|
|
832
|
+
status: e.status,
|
|
833
|
+
durationMs: e.latencyMs,
|
|
834
|
+
ip: e.ip,
|
|
835
|
+
referer: e.referer,
|
|
836
|
+
userAgent: e.userAgent,
|
|
837
|
+
})),
|
|
838
|
+
});
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
router.get('/admin/requests/series', (ctx) => {
|
|
842
|
+
const range = (ctx.query.range as string) ?? 'today';
|
|
843
|
+
const valid = ['today', '5d', 'week', 'month', 'year'].includes(range);
|
|
844
|
+
const r = valid ? range as RequestSeriesRange : 'today';
|
|
845
|
+
const bucketMs = r === 'today' ? 60_000 : r === '5d' || r === 'week' ? 3_600_000 : 86_400_000;
|
|
846
|
+
const now = Date.now();
|
|
847
|
+
const buckets = new Map<number, number>();
|
|
848
|
+
for (const e of logBuffer.tail(1000)) {
|
|
849
|
+
const t = Math.floor(e.ts / bucketMs) * bucketMs;
|
|
850
|
+
buckets.set(t, (buckets.get(t) ?? 0) + 1);
|
|
851
|
+
}
|
|
852
|
+
const points = [...buckets.entries()]
|
|
853
|
+
.sort((a, b) => a[0] - b[0])
|
|
854
|
+
.filter(([t]) => t >= now - rangeMs(r))
|
|
855
|
+
.map(([t, count]) => ({ t, count }));
|
|
856
|
+
ctx.json({ series: { range: r, points } });
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
// ------------------------------------------------------------------
|
|
860
|
+
// Wire the rest of the admin surface modules (guarded via /admin).
|
|
861
|
+
// ------------------------------------------------------------------
|
|
862
|
+
registerAiProxyRoutes(router, {
|
|
863
|
+
serverUrl: config.ai.serverUrl,
|
|
864
|
+
timeoutMs: config.ai.timeoutMs,
|
|
865
|
+
providers,
|
|
866
|
+
});
|
|
867
|
+
registerPreflightRoutes(router, config, { graphqlMounted: opts.graphqlMounted });
|
|
868
|
+
registerLintRoutes(router, config, projectRoot);
|
|
869
|
+
registerDatabaseAdminRoutes(router, lazy);
|
|
870
|
+
registerAiSchemaRoutes(router, {
|
|
871
|
+
serverUrl: config.ai.serverUrl,
|
|
872
|
+
timeoutMs: config.ai.timeoutMs,
|
|
873
|
+
model: config.ai.schemaModel,
|
|
874
|
+
providers,
|
|
875
|
+
});
|
|
489
876
|
}
|
|
490
877
|
|
|
491
878
|
// ---------------------------------------------------------------------------
|
|
@@ -550,24 +937,24 @@ function portOf(uri: string, fallback: number): number {
|
|
|
550
937
|
}
|
|
551
938
|
}
|
|
552
939
|
|
|
553
|
-
/** Deep-clone config, masking secret-ish fields. */
|
|
940
|
+
/** Deep-clone config, masking secret-ish fields (but not cookie names). */
|
|
554
941
|
function redactConfig(config: NexusConfig): Record<string, unknown> {
|
|
555
942
|
const clone = JSON.parse(JSON.stringify(config)) as Record<string, unknown>;
|
|
556
|
-
redact(clone, /(secret|token|clientsecret|apikey|keyid|password|pass|refresh)/i);
|
|
943
|
+
redact(clone, /(secret|token|clientsecret|apikey|keyid|password|pass|refresh)/i, /cookiename$/i);
|
|
557
944
|
return clone;
|
|
558
945
|
}
|
|
559
946
|
|
|
560
|
-
function redact(obj: unknown, pattern: RegExp): void {
|
|
947
|
+
function redact(obj: unknown, pattern: RegExp, exclude: RegExp): void {
|
|
561
948
|
if (!obj || typeof obj !== 'object') return;
|
|
562
949
|
if (Array.isArray(obj)) {
|
|
563
|
-
for (const item of obj) redact(item, pattern);
|
|
950
|
+
for (const item of obj) redact(item, pattern, exclude);
|
|
564
951
|
return;
|
|
565
952
|
}
|
|
566
953
|
for (const [k, v] of Object.entries(obj)) {
|
|
567
|
-
if (typeof v === 'string' && pattern.test(k)) {
|
|
954
|
+
if (typeof v === 'string' && pattern.test(k) && !exclude.test(k)) {
|
|
568
955
|
(obj as Record<string, unknown>)[k] = '••••••••';
|
|
569
956
|
} else if (v && typeof v === 'object') {
|
|
570
|
-
redact(v, pattern);
|
|
957
|
+
redact(v, pattern, exclude);
|
|
571
958
|
}
|
|
572
959
|
}
|
|
573
960
|
}
|
|
@@ -611,3 +998,147 @@ export function createLazyDb(config: NexusConfig): LazyDb {
|
|
|
611
998
|
}
|
|
612
999
|
|
|
613
1000
|
export type { RequestContext };
|
|
1001
|
+
|
|
1002
|
+
// ---------------------------------------------------------------------------
|
|
1003
|
+
// Admin helper functions + payment provider metadata
|
|
1004
|
+
// ---------------------------------------------------------------------------
|
|
1005
|
+
|
|
1006
|
+
export type RequestSeriesRange = 'today' | '5d' | 'week' | 'month' | 'year';
|
|
1007
|
+
|
|
1008
|
+
function rangeMs(range: RequestSeriesRange): number {
|
|
1009
|
+
const day = 86_400_000;
|
|
1010
|
+
switch (range) {
|
|
1011
|
+
case '5d': return 5 * day;
|
|
1012
|
+
case 'week': return 7 * day;
|
|
1013
|
+
case 'month': return 31 * day;
|
|
1014
|
+
case 'year': return 365 * day;
|
|
1015
|
+
default: return day;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function clampInt(v: unknown, fallback: number): number {
|
|
1020
|
+
const n = typeof v === 'string' ? Number(v) : NaN;
|
|
1021
|
+
return Number.isFinite(n) && n > 0 && n <= 500 ? Math.floor(n) : fallback;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function providerFilter(v: unknown): { provider?: string } {
|
|
1025
|
+
const p = typeof v === 'string' ? v.toLowerCase().trim() : '';
|
|
1026
|
+
return p ? { provider: p } : {};
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/** Read the nearest human-edited `nexus.config.{ts,js,mjs,cjs}` (skipping the framework default). */
|
|
1030
|
+
async function readUserConfigFile(root: string): Promise<{ path: string; content: string } | null> {
|
|
1031
|
+
const path = discoverUserConfigPath(root, frameworkDefaultConfigPath());
|
|
1032
|
+
if (!path || !existsSync(path)) return null;
|
|
1033
|
+
return { path, content: await readFile(path, 'utf8') };
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Validate a config file by loading it exactly like the boot loader does:
|
|
1038
|
+
* dynamic `import()` (ESM + TS under tsx, with a CJS interop default), then
|
|
1039
|
+
* run it through the schema. Throws with a readable message when broken.
|
|
1040
|
+
*/
|
|
1041
|
+
async function validateUserConfigFile(absPath: string): Promise<void> {
|
|
1042
|
+
const url = `${pathToFileURL(absPath).href}?t=${Date.now()}`;
|
|
1043
|
+
const mod = (await import(url)) as Record<string, unknown>;
|
|
1044
|
+
const cfg = (mod as { default?: unknown }).default ?? (mod as { config?: unknown }).config;
|
|
1045
|
+
if (!cfg || typeof cfg !== 'object') {
|
|
1046
|
+
throw new Error('config file must export an object (default export or named "config")');
|
|
1047
|
+
}
|
|
1048
|
+
mergeConfig(cfg as DeepPartial<NexusConfig>);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
async function writeTextAtomic(path: string, content: string): Promise<void> {
|
|
1052
|
+
const tempPath = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
1053
|
+
await writeFile(tempPath, content, 'utf8');
|
|
1054
|
+
try {
|
|
1055
|
+
await rename(tempPath, path);
|
|
1056
|
+
} catch (err) {
|
|
1057
|
+
// Windows does not replace an existing file with rename(). Keep the write
|
|
1058
|
+
// safe while supporting the local development platform.
|
|
1059
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
1060
|
+
if (code !== 'EEXIST' && code !== 'EPERM') throw err;
|
|
1061
|
+
await unlink(path).catch(() => undefined);
|
|
1062
|
+
await rename(tempPath, path);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
async function readPackageJson(root: string): Promise<{ name?: string; version?: string } | null> {
|
|
1067
|
+
try {
|
|
1068
|
+
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as Record<string, unknown>;
|
|
1069
|
+
return {
|
|
1070
|
+
name: typeof pkg.name === 'string' ? pkg.name : undefined,
|
|
1071
|
+
version: typeof pkg.version === 'string' ? pkg.version : undefined,
|
|
1072
|
+
};
|
|
1073
|
+
} catch {
|
|
1074
|
+
return null;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const PAYMENT_PROVIDER_NAMES = ['razorpay', 'paypal', 'payu', 'skrill', 'payoneer'] as const;
|
|
1079
|
+
|
|
1080
|
+
/** Credential fields that make a provider "configured". */
|
|
1081
|
+
const PAYMENT_PROVIDER_KEYS: Record<(typeof PAYMENT_PROVIDER_NAMES)[number], string[]> = {
|
|
1082
|
+
razorpay: ['keyId', 'keySecret'],
|
|
1083
|
+
paypal: ['clientId', 'clientSecret'],
|
|
1084
|
+
payu: ['merchantKey', 'salt'],
|
|
1085
|
+
skrill: ['merchantEmail'],
|
|
1086
|
+
payoneer: ['programId', 'apiKey'],
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
/** Human-friendly labels for the credential fields shown in the admin dialog. */
|
|
1090
|
+
const PAYMENT_FIELD_LABELS: Record<string, string> = {
|
|
1091
|
+
keyId: 'Key ID',
|
|
1092
|
+
keySecret: 'Key secret',
|
|
1093
|
+
clientId: 'Client ID',
|
|
1094
|
+
clientSecret: 'Client secret',
|
|
1095
|
+
merchantKey: 'Merchant key',
|
|
1096
|
+
salt: 'Salt',
|
|
1097
|
+
merchantEmail: 'Merchant email',
|
|
1098
|
+
programId: 'Program ID',
|
|
1099
|
+
apiKey: 'API key',
|
|
1100
|
+
};
|
|
1101
|
+
|
|
1102
|
+
/** Env var name for a payment credential, e.g. razorpay.keyId → NEXUS_PAYMENTS_RAZORPAY_KEY_ID. */
|
|
1103
|
+
function paymentEnvKey(provider: string, field: string): string {
|
|
1104
|
+
const snake = field.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase();
|
|
1105
|
+
return `NEXUS_PAYMENTS_${provider.toUpperCase()}_${snake}`;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/** Write credential fields for a provider into the project .env + process.env. */
|
|
1109
|
+
async function writePaymentEnvKeys(root: string, provider: string, pc: Record<string, unknown>): Promise<void> {
|
|
1110
|
+
for (const field of PAYMENT_PROVIDER_KEYS[provider as (typeof PAYMENT_PROVIDER_NAMES)[number]]) {
|
|
1111
|
+
const value = pc[field];
|
|
1112
|
+
if (typeof value !== 'string' || !value) continue;
|
|
1113
|
+
const envKey = paymentEnvKey(provider, field);
|
|
1114
|
+
process.env[envKey] = value;
|
|
1115
|
+
await writeEnvKey(root, envKey, value);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/** Persist a payment provider's enabled/sandbox state to runtime.json + MongoDB settings. */
|
|
1120
|
+
async function persistPaymentProvider(
|
|
1121
|
+
root: string,
|
|
1122
|
+
provider: string,
|
|
1123
|
+
pc: Record<string, unknown>,
|
|
1124
|
+
): Promise<{ runtime: boolean; database: boolean }> {
|
|
1125
|
+
const runtimeState = { enabled: !!pc.enabled, sandbox: !!pc.sandbox };
|
|
1126
|
+
let runtime = false;
|
|
1127
|
+
let database = false;
|
|
1128
|
+
try {
|
|
1129
|
+
await mergeRuntimeJson(root, { payments: { [provider]: runtimeState } });
|
|
1130
|
+
runtime = true;
|
|
1131
|
+
} catch { /* surfaced via persistence flags */ }
|
|
1132
|
+
try {
|
|
1133
|
+
const project = await resolveProjectInfo(root);
|
|
1134
|
+
const coll = await getProjectInfoCollection();
|
|
1135
|
+
const result = await coll.updateOne(
|
|
1136
|
+
{ name: project.name },
|
|
1137
|
+
{ $set: { [`settings.payments.${provider}`]: runtimeState } },
|
|
1138
|
+
{ upsert: false },
|
|
1139
|
+
);
|
|
1140
|
+
if (result.matchedCount === 0) throw new Error(`project "${project.name}" was not found in nexus_projects`);
|
|
1141
|
+
database = true;
|
|
1142
|
+
} catch { /* surfaced via persistence flags */ }
|
|
1143
|
+
return { runtime, database };
|
|
1144
|
+
}
|