@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.
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Upload — single DB entry per unique image (sha256), multiple logical placements.
3
+ *
4
+ * Physical file is stored once (canonical) + symlinked for duplicates across
5
+ * directories/disks. DB is source of truth for millions of images.
6
+ *
7
+ * Schema: one document per sha256, placements[] holds every logical path.
8
+ * Separate directories (media, avatars, products) are just placements entries.
9
+ */
10
+ import { Schema, model } from '@bhooai/nexus-data';
11
+ import type { DocumentInstance } from '@bhooai/nexus-data';
12
+
13
+ export interface ImageVariantDoc {
14
+ path: string;
15
+ url: string;
16
+ width: number;
17
+ height: number;
18
+ format: string;
19
+ size: number;
20
+ sha256: string;
21
+ }
22
+
23
+ export interface PlacementDoc {
24
+ id: string;
25
+ disk: string;
26
+ path: string;
27
+ url: string;
28
+ dir: string;
29
+ userId?: string | null;
30
+ createdAt: Date;
31
+ }
32
+
33
+ export interface UploadDoc extends DocumentInstance {
34
+ sha256: string;
35
+ originalName: string;
36
+ mime: string;
37
+ size: number;
38
+ width?: number;
39
+ height?: number;
40
+ canonical: { disk: string; path: string; url: string };
41
+ placements: PlacementDoc[];
42
+ variants?: Record<string, ImageVariantDoc>;
43
+ createdAt: Date;
44
+ updatedAt: Date;
45
+ }
46
+
47
+ const uploadSchema = new Schema(
48
+ {
49
+ sha256: { type: String, required: true, unique: true, index: true },
50
+ originalName: { type: String, required: true },
51
+ mime: { type: String, required: true },
52
+ size: { type: Number, required: true },
53
+ width: { type: Number },
54
+ height: { type: Number },
55
+ canonical: { type: Object, required: true }, // {disk, path, url}
56
+ placements: { type: Array, default: [] },
57
+ variants: { type: Object, default: {} },
58
+ createdAt: { type: Date, default: () => new Date() },
59
+ updatedAt: { type: Date, default: () => new Date() },
60
+ },
61
+ { timestamps: false },
62
+ );
63
+
64
+ // Additional indexes for millions-scale queries + search
65
+ // sha256 unique is already defined via field `unique:true` → index sha256_1 (unique), do not add duplicate
66
+ uploadSchema.indexes.push({ spec: { 'placements.path': 1 }, options: {} });
67
+ uploadSchema.indexes.push({ spec: { mime: 1 }, options: {} });
68
+ uploadSchema.indexes.push({ spec: { createdAt: -1 }, options: {} });
69
+ // Text index for admin search (originalName, mime, sha256) — single text index per collection
70
+ uploadSchema.indexes.push({
71
+ spec: { originalName: 'text', mime: 'text', sha256: 'text' } as any,
72
+ options: { name: 'uploads_text_idx', weights: { originalName: 10, sha256: 5, mime: 2 } as any, default_language: 'english' },
73
+ });
74
+
75
+ let _cached: any = null;
76
+
77
+ export function getUploadModel(): any {
78
+ if (_cached) return _cached;
79
+ try {
80
+ _cached = model<UploadDoc>('uploads', uploadSchema);
81
+ // Ensure indexes in background (non-blocking)
82
+ void _cached.createIndexes().catch(() => {});
83
+ return _cached;
84
+ } catch (e) {
85
+ // Not connected yet — caller should fallback to filesystem-only
86
+ return null;
87
+ }
88
+ }
89
+
90
+ export function toFileView(doc: any, placementPath?: string) {
91
+ if (!doc) return null;
92
+ const obj = typeof doc.toObject === 'function' ? doc.toObject() : doc;
93
+ const placements: PlacementDoc[] = obj.placements ?? [];
94
+ // Pick placement matching requested path or first
95
+ const placement = placementPath ? placements.find((p: any) => p.path === placementPath) ?? placements[0] : placements[0];
96
+ const variants: Record<string, ImageVariantDoc> = obj.variants ?? {};
97
+ return {
98
+ id: obj.sha256, // single entry id is sha256 (stable dedup)
99
+ _id: obj._id,
100
+ sha256: obj.sha256,
101
+ originalName: obj.originalName,
102
+ mime: obj.mime,
103
+ size: obj.size,
104
+ width: obj.width,
105
+ height: obj.height,
106
+ disk: placement?.disk ?? obj.canonical?.disk,
107
+ path: placement?.path ?? obj.canonical?.path,
108
+ url: placement?.url ?? obj.canonical?.url,
109
+ canonical: obj.canonical,
110
+ placements,
111
+ variants,
112
+ createdAt: obj.createdAt,
113
+ };
114
+ }
@@ -40,6 +40,7 @@ import type { Router } from '../http/Router.js';
40
40
  import type { Middleware, RequestContext } from '../http/context.js';
41
41
  import { AiClient } from '@bhooai/nexus-ai-client';
42
42
  import { discoverUserConfigPath, frameworkDefaultConfigPath, mergeConfig } from '../config/ConfigLoader.js';
43
+ import { mongoEnabled, mongoUri } from '../config/dbAccess.js';
43
44
  import { readRuntimeJson, writeRuntimeJson, mergeRuntimeJson } from '../config/runtimeJson.js';
44
45
  import { readEnvFile, writeEnvEntries, writeEnvKey, deleteEnvKey } from '../config/envFile.js';
45
46
  import {
@@ -50,9 +51,11 @@ import {
50
51
  getProjectInfoCollection,
51
52
  upsertProjectInfo,
52
53
  deleteProjectInfo,
53
- ObjectId,
54
54
  type ProjectInfo,
55
55
  } from '@bhooai/nexus-data';
56
+ import { getUserStore } from './userStore.js';
57
+ import { getAppFusion } from './fusionEngine.js';
58
+ import { activeDatabase } from '../config/dbAccess.js';
56
59
  import { ROLE_CATALOG, findRole } from './roleCatalog.js';
57
60
  import { registerPreflightRoutes, aiHealthUrl } from './preflightModule.js';
58
61
  import { registerLintRoutes } from './lintModule.js';
@@ -140,7 +143,10 @@ export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, lo
140
143
  // Open the shared project-info connection so /admin/config, /admin/project
141
144
  // and the payment provider persistence can read/write the registry. Failure
142
145
  // is non-fatal — the affected endpoints degrade gracefully.
143
- try { connectProjectInfo(config.db.uri); } catch { /* Mongo may be down */ }
146
+ // Skipped when mongodb is disabled (fusion-active backends).
147
+ if (mongoEnabled(config)) {
148
+ try { connectProjectInfo(mongoUri(config)); } catch { /* Mongo may be down */ }
149
+ }
144
150
 
145
151
  // ------------------------------------------------------------------
146
152
  // /admin/supervisor — report where `nexus dev`'s control API is bound so the
@@ -162,8 +168,8 @@ export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, lo
162
168
  // /admin/health/services — TCP probes for infra services
163
169
  // ------------------------------------------------------------------
164
170
  router.get('/admin/health/services', async (ctx) => {
165
- const mongoHost = hostOf(config.db.uri, 'localhost');
166
- const mongoPort = portOf(config.db.uri, 27017);
171
+ const mongoHost = hostOf(mongoUri(config), 'localhost');
172
+ const mongoPort = portOf(mongoUri(config), 27017);
167
173
  const redisHost = hostOf(config.redis.url, 'localhost');
168
174
  const redisPort = portOf(config.redis.url, 6379);
169
175
  const aiPort = portOf(config.ai.serverUrl, 8000);
@@ -185,12 +191,12 @@ export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, lo
185
191
  });
186
192
 
187
193
  // ------------------------------------------------------------------
188
- // /admin/apps — registry + per-app /health probe
194
+ // /admin/apps — nexus.config.ts `apps[]` + per-app /health probe
189
195
  // ------------------------------------------------------------------
190
196
  router.get('/admin/apps', async (ctx) => {
191
- const registry = await readRegistry(projectRoot);
197
+ const entries = (config.apps ?? []).filter((a) => a.enabled !== false);
192
198
  const apps = await Promise.all(
193
- Object.entries(registry).map(async ([appName, port]) => {
199
+ entries.map(async ({ name: appName, port }) => {
194
200
  const kind = kindOf(appName);
195
201
  const probe = await probeHealth(port, kind);
196
202
  return {
@@ -219,6 +225,42 @@ export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, lo
219
225
  ctx.json({ ok: true });
220
226
  });
221
227
 
228
+ // ------------------------------------------------------------------
229
+ // /admin/certs — TLS cert status + self-signed generation
230
+ // ------------------------------------------------------------------
231
+ router.get('/admin/certs/status', async (ctx) => {
232
+ try {
233
+ const { getCertStatus } = await import('../certs/generator.js');
234
+ ctx.json(await getCertStatus(projectRoot, config));
235
+ } catch (err) {
236
+ ctx.json({ ok: false, reason: String((err as Error).message) }, 500);
237
+ }
238
+ });
239
+
240
+ router.post('/admin/certs/generate', async (ctx) => {
241
+ const body = (ctx.body ?? {}) as { force?: unknown; keyType?: unknown; rsaModulus?: unknown; validityDays?: unknown; commonName?: unknown };
242
+ const force = body.force === true;
243
+ const keyType = body.keyType === 'ec' || body.keyType === 'rsa' ? (body.keyType as 'ec' | 'rsa') : undefined;
244
+ const rsaModulus = typeof body.rsaModulus === 'number' && Number.isFinite(body.rsaModulus) ? body.rsaModulus : undefined;
245
+ const validityDays = typeof body.validityDays === 'number' && Number.isFinite(body.validityDays) ? body.validityDays : undefined;
246
+ const commonName = typeof body.commonName === 'string' && body.commonName.trim() ? body.commonName.trim() : undefined;
247
+ try {
248
+ const { generateSelfSignedCerts, getCertStatus } = await import('../certs/generator.js');
249
+ const result = await generateSelfSignedCerts(projectRoot, config, { force, keyType, rsaModulus, validityDays, commonName });
250
+ // Persist the generated paths so the next boot can use them when https is toggled on
251
+ try {
252
+ await mergeRuntimeJson(projectRoot, { server: { certFile: result.certFile, keyFile: result.keyFile } });
253
+ // keep in-memory config in sync so status reflects new files without restart
254
+ (config.server as unknown as Record<string, unknown>).certFile = result.certFile;
255
+ (config.server as unknown as Record<string, unknown>).keyFile = result.keyFile;
256
+ } catch { /* non-fatal */ }
257
+ const status = await getCertStatus(projectRoot, config);
258
+ ctx.json({ ok: true, method: result.method, overwritten: result.overwritten, certFile: result.certFile, keyFile: result.keyFile, status });
259
+ } catch (err) {
260
+ ctx.json({ ok: false, reason: String((err as Error).message) }, 500);
261
+ }
262
+ });
263
+
222
264
  // ------------------------------------------------------------------
223
265
  // /admin/config — read-only, redacted (+ runtime overrides, config file, project)
224
266
  // ------------------------------------------------------------------
@@ -238,16 +280,11 @@ export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, lo
238
280
  });
239
281
 
240
282
  // ------------------------------------------------------------------
241
- // /admin/users + /admin/payments — lazy Mongo
283
+ // /admin/users + /admin/payments — via the user store / orders collection
242
284
  // ------------------------------------------------------------------
243
285
  router.get('/admin/users', async (ctx) => {
244
- const db = await lazy.db();
245
- if (!db) {
246
- ctx.json({ ok: false, reason: 'MongoDB not reachable' });
247
- return;
248
- }
249
286
  try {
250
- const users = await db.collection('users').find({}).sort({ createdAt: -1 }).limit(200).toArray();
287
+ const users = await getUserStore().list(200);
251
288
  ctx.json({
252
289
  ok: true,
253
290
  users: users.map((u) => ({
@@ -266,24 +303,11 @@ export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, lo
266
303
  });
267
304
 
268
305
  router.get('/admin/payments', async (ctx) => {
269
- const db = await lazy.db();
270
- if (!db) {
271
- ctx.json({ ok: false, reason: 'MongoDB not reachable' });
272
- return;
273
- }
274
306
  try {
275
- const orders = await db.collection('orders').find({}).sort({ createdAt: -1 }).limit(50).toArray();
307
+ const orders = await listOrders(config, lazy, 50);
276
308
  ctx.json({
277
309
  ok: true,
278
- orders: orders.map((o) => ({
279
- id: String(o._id),
280
- userId: o.userId,
281
- provider: o.provider,
282
- amount: o.amount,
283
- currency: o.currency,
284
- status: o.status,
285
- createdAt: o.createdAt,
286
- })),
310
+ orders,
287
311
  providers: providerStatus(config),
288
312
  });
289
313
  } catch (err) {
@@ -569,8 +593,6 @@ export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, lo
569
593
  });
570
594
 
571
595
  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
596
  const body = (ctx.body ?? {}) as { roles?: unknown };
575
597
  const incoming = body.roles;
576
598
  if (!Array.isArray(incoming) || !incoming.length || incoming.some((r) => typeof r !== 'string')) {
@@ -585,24 +607,23 @@ export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, lo
585
607
  }
586
608
 
587
609
  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 });
610
+ if (!id) { ctx.json({ error: 'invalid user id' }, 400); return; }
611
+ const store = getUserStore();
612
+ const target = await store.findById(id);
591
613
  if (!target) { ctx.json({ error: 'user not found' }, 404); return; }
592
614
 
593
- const currentRoles = (target.roles ?? []) as string[];
615
+ const currentRoles = target.roles ?? [];
594
616
  const isAdmin = currentRoles.includes('admin');
595
617
  const keepsAdmin = nextRoles.includes('admin');
596
618
  const caller = ctx.state.user as { id?: string; roles?: string[] } | undefined;
597
619
 
598
620
  if (isAdmin && !keepsAdmin) {
599
- const adminCount = await db.collection('users').countDocuments({ roles: 'admin' });
621
+ const adminCount = await store.countAdmins();
600
622
  if (adminCount <= 1) { ctx.json({ error: 'cannot remove the last admin from the system' }, 400); return; }
601
623
  if (caller?.id === id) { ctx.json({ error: 'you cannot remove your own admin role' }, 400); return; }
602
624
  }
603
625
 
604
- await db.collection('users').updateOne({ _id: oid }, { $set: { roles: nextRoles } });
605
- const updated = await db.collection('users').findOne({ _id: oid });
626
+ const updated = await store.setRoles(id, nextRoles);
606
627
  ctx.json({ ok: true, user: updated });
607
628
  });
608
629
 
@@ -856,6 +877,163 @@ export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, lo
856
877
  ctx.json({ series: { range: r, points } });
857
878
  });
858
879
 
880
+ // ------------------------------------------------------------------
881
+ // Uploads — search index admin (millions-scale)
882
+ // ------------------------------------------------------------------
883
+ router.get('/admin/uploads', async (ctx) => {
884
+ const q = typeof ctx.query.q === 'string' ? ctx.query.q.trim() : '';
885
+ const mime = typeof ctx.query.mime === 'string' ? ctx.query.mime.trim() : '';
886
+ const page = Math.max(1, parseInt(String(ctx.query.page ?? '1'), 10) || 1);
887
+ const limit = clampInt(ctx.query.limit, 50);
888
+ const sort = typeof ctx.query.sort === 'string' ? ctx.query.sort : 'recent';
889
+ const skip = (page - 1) * limit;
890
+ try {
891
+ const { getUploadModel } = await import('./Upload.js');
892
+ const M = getUploadModel();
893
+ if (!M) { ctx.json({ ok: false, reason: 'MongoDB not reachable' }, 503); return; }
894
+ const filter: Record<string, unknown> = {};
895
+ if (mime) filter.mime = mime;
896
+ let cursor: any;
897
+ let useText = false;
898
+ if (q) {
899
+ // Try text search first, fallback to regex if no index or no results
900
+ const textFilter = { ...filter, $text: { $search: q } };
901
+ const countText = await M.find(textFilter).countDocuments().catch(() => 0);
902
+ if (countText > 0) {
903
+ filter.$text = { $search: q };
904
+ useText = true;
905
+ } else {
906
+ const rx = new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
907
+ filter.$or = [{ originalName: rx }, { sha256: rx }, { mime: rx }, { 'placements.dir': rx }, { 'placements.path': rx }];
908
+ }
909
+ }
910
+ const sortSpec: Record<string, 1 | -1> = sort === 'oldest' ? { createdAt: 1 } : sort === 'size' ? { size: -1 } : sort === 'name' ? { originalName: 1 } : { createdAt: -1 };
911
+ if (useText) (sortSpec as any).score = { $meta: 'textScore' } as any;
912
+ const docs = await M.find(filter).sort(sortSpec as any).skip(skip).limit(limit).exec().catch(async () => {
913
+ // Fallback if $text fails (no index)
914
+ const fallbackFilter: Record<string, unknown> = { ...filter };
915
+ if (fallbackFilter.$text) delete fallbackFilter.$text;
916
+ if (q && !fallbackFilter.$or) {
917
+ const rx = new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
918
+ fallbackFilter.$or = [{ originalName: rx }, { sha256: rx }, { mime: rx }];
919
+ }
920
+ return M.find(fallbackFilter).sort({ createdAt: -1 }).skip(skip).limit(limit).exec();
921
+ });
922
+ const total = await M.countDocuments(filter).catch(() => docs.length);
923
+ ctx.json({
924
+ ok: true,
925
+ page,
926
+ limit,
927
+ total,
928
+ pages: Math.ceil(total / limit),
929
+ q,
930
+ mime,
931
+ sort,
932
+ uploads: docs.map((d: any) => {
933
+ const o = typeof d.toObject === 'function' ? d.toObject() : d;
934
+ const p = o.placements?.[0] ?? o.canonical;
935
+ return {
936
+ id: o.sha256,
937
+ sha256: o.sha256,
938
+ originalName: o.originalName,
939
+ mime: o.mime,
940
+ size: o.size,
941
+ width: o.width,
942
+ height: o.height,
943
+ disk: p?.disk ?? o.canonical?.disk,
944
+ path: p?.path ?? o.canonical?.path,
945
+ url: p?.url ?? o.canonical?.url,
946
+ canonical: o.canonical,
947
+ placements: o.placements,
948
+ placementsCount: o.placements?.length ?? 0,
949
+ variants: o.variants,
950
+ createdAt: o.createdAt,
951
+ };
952
+ }),
953
+ });
954
+ } catch (err) {
955
+ ctx.json({ ok: false, reason: String((err as Error).message) }, 500);
956
+ }
957
+ });
958
+
959
+ router.get('/admin/uploads/stats', async (ctx) => {
960
+ try {
961
+ const { getUploadModel } = await import('./Upload.js');
962
+ const M = getUploadModel();
963
+ if (!M) { ctx.json({ ok: false, reason: 'MongoDB not reachable' }, 503); return; }
964
+ const [total, totalSizeAgg, mimeAgg, recent] = await Promise.all([
965
+ M.countDocuments({}),
966
+ M.aggregate([{ $group: { _id: null, totalSize: { $sum: '$size' } } }]).catch(() => []),
967
+ M.aggregate([{ $group: { _id: '$mime', count: { $sum: 1 } } }, { $sort: { count: -1 } }, { $limit: 10 }]).catch(() => []),
968
+ M.find({}).sort({ createdAt: -1 }).limit(5).exec().catch(() => []),
969
+ ]);
970
+ const totalSize = (totalSizeAgg[0] as any)?.totalSize ?? 0;
971
+ ctx.json({
972
+ ok: true,
973
+ total,
974
+ totalSize,
975
+ mimeBreakdown: mimeAgg,
976
+ recent: (recent as any[]).map((d: any) => {
977
+ const o = typeof d.toObject === 'function' ? d.toObject() : d;
978
+ return { sha256: o.sha256, originalName: o.originalName, mime: o.mime, size: o.size, createdAt: o.createdAt };
979
+ }),
980
+ });
981
+ } catch (err) {
982
+ ctx.json({ ok: false, reason: String((err as Error).message) }, 500);
983
+ }
984
+ });
985
+
986
+ router.get('/admin/uploads/index', async (ctx) => {
987
+ try {
988
+ const { getUploadModel } = await import('./Upload.js');
989
+ const M = getUploadModel();
990
+ if (!M) { ctx.json({ ok: false, reason: 'MongoDB not reachable' }, 503); return; }
991
+ const coll = await (M as any).collection;
992
+ const indexes = await coll.indexes().catch(() => []);
993
+ const hasText = indexes.some((idx: any) => idx.name === 'uploads_text_idx');
994
+ const hasSha256 = indexes.some((idx: any) => idx.key?.sha256);
995
+ ctx.json({ ok: true, indexes, hasText, hasSha256, count: await M.countDocuments({}).catch(() => 0) });
996
+ } catch (err) {
997
+ ctx.json({ ok: false, reason: String((err as Error).message) }, 500);
998
+ }
999
+ });
1000
+
1001
+ router.post('/admin/uploads/index/rebuild', async (ctx) => {
1002
+ try {
1003
+ const { getUploadModel } = await import('./Upload.js');
1004
+ const M = getUploadModel();
1005
+ if (!M) { ctx.json({ ok: false, reason: 'MongoDB not reachable' }, 503); return; }
1006
+ const coll = await (M as any).collection;
1007
+ // Drop existing text index if present, then recreate all
1008
+ try {
1009
+ await coll.dropIndex('uploads_text_idx');
1010
+ } catch {}
1011
+ await (M as any).createIndexes().catch(() => {});
1012
+ // Ensure text index
1013
+ try {
1014
+ await coll.createIndex({ originalName: 'text', mime: 'text', sha256: 'text', 'placements.dir': 'text' } as any, { name: 'uploads_text_idx', weights: { originalName: 10, sha256: 5, mime: 2 } } as any);
1015
+ } catch {}
1016
+ const indexes = await coll.indexes().catch(() => []);
1017
+ ctx.json({ ok: true, indexes });
1018
+ } catch (err) {
1019
+ ctx.json({ ok: false, reason: String((err as Error).message) }, 500);
1020
+ }
1021
+ });
1022
+
1023
+ router.post('/admin/uploads/reindex', async (ctx) => {
1024
+ // Alias for rebuild
1025
+ try {
1026
+ const { getUploadModel } = await import('./Upload.js');
1027
+ const M = getUploadModel();
1028
+ if (!M) { ctx.json({ ok: false, reason: 'MongoDB not reachable' }, 503); return; }
1029
+ const coll = await (M as any).collection;
1030
+ await (M as any).createIndexes().catch(() => {});
1031
+ ctx.json({ ok: true, reindexed: true, count: await M.countDocuments({}).catch(() => 0) });
1032
+ } catch (err) {
1033
+ ctx.json({ ok: false, reason: String((err as Error).message) }, 500);
1034
+ }
1035
+ });
1036
+
859
1037
  // ------------------------------------------------------------------
860
1038
  // Wire the rest of the admin surface modules (guarded via /admin).
861
1039
  // ------------------------------------------------------------------
@@ -866,7 +1044,7 @@ export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, lo
866
1044
  });
867
1045
  registerPreflightRoutes(router, config, { graphqlMounted: opts.graphqlMounted });
868
1046
  registerLintRoutes(router, config, projectRoot);
869
- registerDatabaseAdminRoutes(router, lazy);
1047
+ registerDatabaseAdminRoutes(router, lazy, activeDatabase(config));
870
1048
  registerAiSchemaRoutes(router, {
871
1049
  serverUrl: config.ai.serverUrl,
872
1050
  timeoutMs: config.ai.timeoutMs,
@@ -909,16 +1087,6 @@ async function probeHealth(port: number, kind: string): Promise<{ ok: boolean; u
909
1087
  }
910
1088
  }
911
1089
 
912
- async function readRegistry(projectRoot: string): Promise<Record<string, number>> {
913
- const p = resolve(projectRoot, '.nexus-ports.json');
914
- if (!existsSync(p)) return {};
915
- try {
916
- return JSON.parse(await readFile(p, 'utf-8')) as Record<string, number>;
917
- } catch {
918
- return {};
919
- }
920
- }
921
-
922
1090
  function hostOf(uri: string, fallback: string): string {
923
1091
  try {
924
1092
  const u = new URL(uri);
@@ -978,15 +1146,51 @@ function providerEnvKey(providerId: string): string {
978
1146
  return `NEXUS_AI_${providerId.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_API_KEY`;
979
1147
  }
980
1148
 
1149
+ /** Orders for /admin/payments — Fusion `orders` collection or legacy Mongo. */
1150
+ async function listOrders(
1151
+ config: NexusConfig,
1152
+ lazy: LazyDb,
1153
+ limit: number,
1154
+ ): Promise<Array<Record<string, unknown>>> {
1155
+ if (activeDatabase(config) === 'fusion') {
1156
+ const snap = await getAppFusion()
1157
+ .collection<Record<string, unknown>>('orders')
1158
+ .orderByField('createdAt', 'desc')
1159
+ .limit(limit)
1160
+ .get();
1161
+ return snap.docs.map((d) => ({
1162
+ id: d.id,
1163
+ userId: d.data.userId,
1164
+ provider: d.data.provider,
1165
+ amount: d.data.amount,
1166
+ currency: d.data.currency,
1167
+ status: d.data.status,
1168
+ createdAt: d.data.createdAt,
1169
+ }));
1170
+ }
1171
+ const db = await lazy.db();
1172
+ if (!db) throw new Error('MongoDB not reachable');
1173
+ const orders = await db.collection('orders').find({}).sort({ createdAt: -1 }).limit(limit).toArray();
1174
+ return orders.map((o) => ({
1175
+ id: String(o._id),
1176
+ userId: o.userId,
1177
+ provider: o.provider,
1178
+ amount: o.amount,
1179
+ currency: o.currency,
1180
+ status: o.status,
1181
+ createdAt: o.createdAt,
1182
+ }));
1183
+ }
1184
+
981
1185
  /** Convenience for wiring: create a lazy Mongo db resolver from a config. */
982
- export function createLazyDb(config: NexusConfig): LazyDb {
983
- let cached: import('mongodb').Db | null | undefined;
1186
+ export function createLazyDb(config: NexusConfig): LazyDb { let cached: import('mongodb').Db | null | undefined;
984
1187
  return {
985
1188
  async db() {
986
1189
  if (cached !== undefined) return cached;
1190
+ if (!mongoEnabled(config)) { cached = null; return null; }
987
1191
  try {
988
1192
  const { connect } = await import('@bhooai/nexus-data');
989
- const connection = connect(config.db.uri, { maxPoolSize: 2 });
1193
+ const connection = connect(mongoUri(config), { maxPoolSize: 2 });
990
1194
  cached = await connection.db;
991
1195
  return cached;
992
1196
  } catch (err) {