@bhooai/nexus-core 2.0.5 → 2.0.7
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
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import type { Router } from '../index.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Admin routes for MongoDB database / collection administration.
|
|
5
|
+
*
|
|
6
|
+
* The admin app uses these to create, rename and drop databases and
|
|
7
|
+
* collections, and to preview documents. Collections can be created with a
|
|
8
|
+
* `$jsonSchema` validator (used by the AI schema generator).
|
|
9
|
+
*
|
|
10
|
+
* Routes (guarded by the caller's /admin middleware):
|
|
11
|
+
* GET /admin/databases → { databases: [{ name, sizeOnDisk, collections: [{ name, count }] }] }
|
|
12
|
+
* POST /admin/databases → { name } → create
|
|
13
|
+
* DELETE /admin/databases/:db → drop database
|
|
14
|
+
* POST /admin/databases/:db/collections → { name, jsonSchema? } → create
|
|
15
|
+
* PUT /admin/databases/:db/collections/:name → { newName? | validator? } → rename / collMod
|
|
16
|
+
* DELETE /admin/databases/:db/collections/:name → drop collection
|
|
17
|
+
* GET /admin/databases/:db/collections/:name/docs → { count, docs } (preview, max 10)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Resolves the connected Mongo Db (or null when Mongo is unreachable). */
|
|
21
|
+
export interface LazyDb {
|
|
22
|
+
db(): Promise<import('mongodb').Db | null>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DOC_PREVIEW_LIMIT = 10;
|
|
26
|
+
|
|
27
|
+
/** Ugly but real: dropping a db's only collection deletes the whole database. */
|
|
28
|
+
const BOOTSTRAP_COLLECTION = '_nexus_bootstrap';
|
|
29
|
+
|
|
30
|
+
/** Databases managed by MongoDB itself — hidden from the admin surface. */
|
|
31
|
+
const SYSTEM_DATABASES = new Set(['admin', 'local', 'config']);
|
|
32
|
+
|
|
33
|
+
export function registerDatabaseAdminRoutes(router: Router, lazy: LazyDb): void {
|
|
34
|
+
router.get('/admin/databases', async (ctx) => {
|
|
35
|
+
const db = await lazy.db();
|
|
36
|
+
if (!db) { ctx.json({ error: 'MongoDB not reachable' }, 503); return; }
|
|
37
|
+
try {
|
|
38
|
+
const list = await db.client.db().admin().listDatabases();
|
|
39
|
+
const databases = [];
|
|
40
|
+
for (const d of list.databases) {
|
|
41
|
+
if (!d.name || SYSTEM_DATABASES.has(d.name)) continue;
|
|
42
|
+
const mongoDb = db.client.db(d.name);
|
|
43
|
+
const raw = await mongoDb.listCollections().toArray();
|
|
44
|
+
const collections = [];
|
|
45
|
+
for (const c of raw) {
|
|
46
|
+
if (c.name.startsWith(BOOTSTRAP_COLLECTION)) continue;
|
|
47
|
+
let count = 0;
|
|
48
|
+
try { count = await mongoDb.collection(c.name).countDocuments({}, { maxTimeMS: 5000 }); } catch { /* stats unavailable */ }
|
|
49
|
+
collections.push({ name: c.name, count });
|
|
50
|
+
}
|
|
51
|
+
databases.push({ name: d.name, sizeOnDisk: d.sizeOnDisk ?? 0, collections });
|
|
52
|
+
}
|
|
53
|
+
ctx.json({ databases });
|
|
54
|
+
} catch (err) {
|
|
55
|
+
ctx.json({ error: (err as Error).message }, 500);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
router.post('/admin/databases', async (ctx) => {
|
|
60
|
+
const db = await lazy.db();
|
|
61
|
+
if (!db) { ctx.json({ error: 'MongoDB not reachable' }, 503); return; }
|
|
62
|
+
const name = assertDbName((ctx.body as { name?: unknown } | undefined)?.name, ctx);
|
|
63
|
+
if (!name) return;
|
|
64
|
+
try {
|
|
65
|
+
// Mongo creates a database lazily on first write. Keep a hidden bootstrap
|
|
66
|
+
// collection so the database exists immediately; internal collections are
|
|
67
|
+
// filtered out of the listing.
|
|
68
|
+
await db.client.db(name).createCollection(BOOTSTRAP_COLLECTION);
|
|
69
|
+
ctx.json({ ok: true, name });
|
|
70
|
+
} catch (err) {
|
|
71
|
+
ctx.json({ error: (err as Error).message }, 500);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
router.delete('/admin/databases/:db', async (ctx) => {
|
|
76
|
+
const db = await lazy.db();
|
|
77
|
+
if (!db) { ctx.json({ error: 'MongoDB not reachable' }, 503); return; }
|
|
78
|
+
const name = assertDbName(ctx.params.db, ctx);
|
|
79
|
+
if (!name) return;
|
|
80
|
+
try {
|
|
81
|
+
await db.client.db(name).dropDatabase();
|
|
82
|
+
ctx.json({ ok: true, dropped: name });
|
|
83
|
+
} catch (err) {
|
|
84
|
+
ctx.json({ error: (err as Error).message }, 500);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
router.post('/admin/databases/:db/collections', async (ctx) => {
|
|
89
|
+
const db = await lazy.db();
|
|
90
|
+
if (!db) { ctx.json({ error: 'MongoDB not reachable' }, 503); return; }
|
|
91
|
+
const dbName = assertDbName(ctx.params.db, ctx);
|
|
92
|
+
if (!dbName) return;
|
|
93
|
+
const body = (ctx.body ?? {}) as { name?: unknown; jsonSchema?: unknown };
|
|
94
|
+
const name = assertCollectionName(body.name, ctx);
|
|
95
|
+
if (!name) return;
|
|
96
|
+
const validator = parseJsonSchema(body.jsonSchema, ctx);
|
|
97
|
+
if (ctx.res.writableEnded) return;
|
|
98
|
+
try {
|
|
99
|
+
const mongoDb = db.client.db(dbName);
|
|
100
|
+
if (validator) {
|
|
101
|
+
await mongoDb.createCollection(name, {
|
|
102
|
+
validator: { $jsonSchema: validator },
|
|
103
|
+
validationLevel: 'strict',
|
|
104
|
+
validationAction: 'error',
|
|
105
|
+
});
|
|
106
|
+
} else {
|
|
107
|
+
await mongoDb.createCollection(name);
|
|
108
|
+
}
|
|
109
|
+
ctx.json({ ok: true, db: dbName, collection: name, validator: validator ? { $jsonSchema: validator } : null });
|
|
110
|
+
} catch (err) {
|
|
111
|
+
ctx.json({ error: (err as Error).message }, 500);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
router.put('/admin/databases/:db/collections/:name', async (ctx) => {
|
|
116
|
+
const db = await lazy.db();
|
|
117
|
+
if (!db) { ctx.json({ error: 'MongoDB not reachable' }, 503); return; }
|
|
118
|
+
const dbName = assertDbName(ctx.params.db, ctx);
|
|
119
|
+
if (!dbName) return;
|
|
120
|
+
const name = assertCollectionName(ctx.params.name, ctx);
|
|
121
|
+
if (!name) return;
|
|
122
|
+
const body = (ctx.body ?? {}) as { newName?: unknown; validator?: unknown };
|
|
123
|
+
const mongoDb = db.client.db(dbName);
|
|
124
|
+
const changed: string[] = [];
|
|
125
|
+
try {
|
|
126
|
+
if (body.newName !== undefined) {
|
|
127
|
+
const newName = assertCollectionName(body.newName, ctx);
|
|
128
|
+
if (!newName) return;
|
|
129
|
+
if (newName !== name) {
|
|
130
|
+
await mongoDb.collection(name).rename(newName);
|
|
131
|
+
changed.push(`renamed to ${newName}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (body.validator !== undefined) {
|
|
135
|
+
const validator = parseJsonSchema(body.validator, ctx);
|
|
136
|
+
if (ctx.res.writableEnded) return;
|
|
137
|
+
await mongoDb.command({
|
|
138
|
+
collMod: changed.length ? String(body.newName) : name,
|
|
139
|
+
validator: { $jsonSchema: validator },
|
|
140
|
+
validationLevel: 'strict',
|
|
141
|
+
});
|
|
142
|
+
changed.push('validator updated');
|
|
143
|
+
}
|
|
144
|
+
if (!changed.length) {
|
|
145
|
+
ctx.json({ error: 'nothing to modify — send newName and/or validator' }, 400);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
ctx.json({ ok: true, db: dbName, collection: name, changed });
|
|
149
|
+
} catch (err) {
|
|
150
|
+
ctx.json({ error: (err as Error).message }, 500);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
router.delete('/admin/databases/:db/collections/:name', async (ctx) => {
|
|
155
|
+
const db = await lazy.db();
|
|
156
|
+
if (!db) { ctx.json({ error: 'MongoDB not reachable' }, 503); return; }
|
|
157
|
+
const dbName = assertDbName(ctx.params.db, ctx);
|
|
158
|
+
if (!dbName) return;
|
|
159
|
+
const name = assertCollectionName(ctx.params.name, ctx);
|
|
160
|
+
if (!name) return;
|
|
161
|
+
try {
|
|
162
|
+
await db.client.db(dbName).dropCollection(name);
|
|
163
|
+
ctx.json({ ok: true, dropped: { db: dbName, collection: name } });
|
|
164
|
+
} catch (err) {
|
|
165
|
+
ctx.json({ error: (err as Error).message }, 500);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
router.get('/admin/databases/:db/collections/:name/docs', async (ctx) => {
|
|
170
|
+
const db = await lazy.db();
|
|
171
|
+
if (!db) { ctx.json({ error: 'MongoDB not reachable' }, 503); return; }
|
|
172
|
+
const dbName = assertDbName(ctx.params.db, ctx);
|
|
173
|
+
if (!dbName) return;
|
|
174
|
+
const name = assertCollectionName(ctx.params.name, ctx);
|
|
175
|
+
if (!name) return;
|
|
176
|
+
try {
|
|
177
|
+
const coll = db.client.db(dbName).collection(name);
|
|
178
|
+
const count = await coll.countDocuments();
|
|
179
|
+
const docs = await coll.find({}).limit(DOC_PREVIEW_LIMIT).toArray();
|
|
180
|
+
ctx.json({ db: dbName, collection: name, count, docs });
|
|
181
|
+
} catch (err) {
|
|
182
|
+
ctx.json({ error: (err as Error).message }, 500);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function assertDbName(v: unknown, ctx: { json(data: unknown, status?: number): void; res: { writableEnded: boolean } }): string | null {
|
|
188
|
+
const name = typeof v === 'string' ? v.trim() : '';
|
|
189
|
+
if (!name || name.length > 63) { ctx.json({ error: 'database name is required (max 63 chars)' }, 400); return null; }
|
|
190
|
+
if (!/^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(name) || name.includes('..')) {
|
|
191
|
+
ctx.json({ error: `invalid database name: ${name}` }, 400);
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
return name;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function assertCollectionName(v: unknown, ctx: { json(data: unknown, status?: number): void; res: { writableEnded: boolean } }): string | null {
|
|
198
|
+
const name = typeof v === 'string' ? v.trim() : '';
|
|
199
|
+
if (!name || name.length > 255) { ctx.json({ error: 'collection name is required (max 255 chars)' }, 400); return null; }
|
|
200
|
+
if (!/^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(name) || name.startsWith('system.')) {
|
|
201
|
+
ctx.json({ error: `invalid collection name: ${name}` }, 400);
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
return name;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function parseJsonSchema(v: unknown, ctx: { json(data: unknown, status?: number): void; res: { writableEnded: boolean } }): Record<string, unknown> | null {
|
|
208
|
+
if (v === undefined || v === null) return null;
|
|
209
|
+
if (typeof v === 'string') {
|
|
210
|
+
try { v = JSON.parse(v); } catch { ctx.json({ error: 'jsonSchema is not valid JSON' }, 400); return null; }
|
|
211
|
+
}
|
|
212
|
+
if (typeof v !== 'object' || v === null || Array.isArray(v)) {
|
|
213
|
+
ctx.json({ error: 'jsonSchema must be an object' }, 400);
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
const schema = v as Record<string, unknown>;
|
|
217
|
+
if (schema.bsonType !== 'object' || typeof schema.properties !== 'object' || schema.properties === null) {
|
|
218
|
+
ctx.json({ error: 'jsonSchema must have bsonType: "object" and a properties object' }, 400);
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
return schema;
|
|
222
|
+
}
|
package/src/app/index.ts
CHANGED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import type { Router, NexusConfig } from '../index.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Config / .env linter proxy.
|
|
8
|
+
*
|
|
9
|
+
* Node reads the raw files (auth + file access boundary) and posts the text to
|
|
10
|
+
* the Python AI server, which computes the lint report. This keeps linting
|
|
11
|
+
* logic in Python and file/secret ownership in Node. When the engine is not
|
|
12
|
+
* reachable a graceful `engine_offline` report is returned (the admin UI
|
|
13
|
+
* renders that state).
|
|
14
|
+
*
|
|
15
|
+
* POST /admin/lint/env { env } -> report
|
|
16
|
+
* POST /admin/lint/config -> report
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface LintCheck {
|
|
20
|
+
key: string;
|
|
21
|
+
severity: 'error' | 'warning' | 'info' | 'ok';
|
|
22
|
+
kind: string;
|
|
23
|
+
message: string;
|
|
24
|
+
errorCategory?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface LintReport {
|
|
28
|
+
ranAt: string;
|
|
29
|
+
engineOk?: boolean;
|
|
30
|
+
summary: { error: number; warning: number; info: number; ok: number };
|
|
31
|
+
checks: LintCheck[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function envPathOf(root: string, name: string): string {
|
|
35
|
+
const safe = /^\.env(?:\.\w+)*$/.test(name ?? '.env') ? name : '.env';
|
|
36
|
+
return join(root, safe);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Register `POST /admin/lint/env` + `/admin/lint/config`. */
|
|
40
|
+
export function registerLintRoutes(router: Router, config: NexusConfig, root: string): void {
|
|
41
|
+
const serverUrlRaw = config.ai?.serverUrl ?? 'http://localhost:8000';
|
|
42
|
+
const serverUrl = serverUrlRaw.replace(/\/+$/, '');
|
|
43
|
+
const timeoutMs = config.ai?.timeoutMs ?? 60_000;
|
|
44
|
+
|
|
45
|
+
const toPython = async (path: string, body: Record<string, unknown>): Promise<LintReport> => {
|
|
46
|
+
const controller = new AbortController();
|
|
47
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch(`${serverUrl}${path}`, {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers: { 'content-type': 'application/json' },
|
|
52
|
+
body: JSON.stringify(body),
|
|
53
|
+
signal: controller.signal,
|
|
54
|
+
});
|
|
55
|
+
if (!res.ok) throw new Error(`AI server returned HTTP ${res.status}`);
|
|
56
|
+
return (await res.json()) as LintReport;
|
|
57
|
+
} finally {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const failed = (): LintReport => ({
|
|
63
|
+
ranAt: new Date().toISOString(),
|
|
64
|
+
engineOk: false,
|
|
65
|
+
summary: { error: 1, warning: 0, info: 0, ok: 0 },
|
|
66
|
+
checks: [
|
|
67
|
+
{
|
|
68
|
+
key: 'linter-engine',
|
|
69
|
+
severity: 'error',
|
|
70
|
+
kind: 'engine',
|
|
71
|
+
errorCategory: 'engine_offline',
|
|
72
|
+
message: 'Python diagnostics engine offline — start it with "python main.py" or "nexus dev"',
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
router.post('/admin/lint/env', async (ctx) => {
|
|
78
|
+
const body = (ctx.body ?? {}) as { env?: unknown };
|
|
79
|
+
const envName = typeof body.env === 'string' && body.env.trim() ? body.env.trim() : '.env';
|
|
80
|
+
try {
|
|
81
|
+
const envPath = envPathOf(root, envName);
|
|
82
|
+
const envText = existsSync(envPath) ? await readFile(envPath, 'utf8') : '';
|
|
83
|
+
try {
|
|
84
|
+
ctx.json(await toPython('/lint/env', { envText }));
|
|
85
|
+
} catch {
|
|
86
|
+
ctx.json(failed());
|
|
87
|
+
}
|
|
88
|
+
} catch (err) {
|
|
89
|
+
ctx.json({ error: (err as Error).message, summary: { error: 0, warning: 0, info: 0, ok: 0 }, checks: [] }, 400);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
router.post('/admin/lint/config', async (ctx) => {
|
|
94
|
+
const runtimePath = join(root, 'nexus.runtime.json');
|
|
95
|
+
const content = existsSync(runtimePath) ? await readFile(runtimePath, 'utf8') : '{}';
|
|
96
|
+
try {
|
|
97
|
+
ctx.json(await toPython('/lint/config', { content }));
|
|
98
|
+
} catch {
|
|
99
|
+
ctx.json(failed());
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { createConnection } from 'node:net';
|
|
2
|
+
import type { Router, NexusConfig } from '../index.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Preflight diagnostics — native Node connectivity/latency probes.
|
|
6
|
+
*
|
|
7
|
+
* The admin Overview's "run checks" button hits `POST /admin/preflight`.
|
|
8
|
+
* Node performs the probes itself (fetch + net), so the report does not depend
|
|
9
|
+
* on the Python engine. Target addresses are derived from config but always
|
|
10
|
+
* normalized to a connectable loopback address — binding on 0.0.0.0 is legal
|
|
11
|
+
* for a listener but invalid as a connect target.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface PreflightTarget {
|
|
15
|
+
name: string;
|
|
16
|
+
kind: 'http' | 'tcp';
|
|
17
|
+
url?: string;
|
|
18
|
+
host?: string;
|
|
19
|
+
port?: number;
|
|
20
|
+
timeout?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PreflightCheck {
|
|
24
|
+
name: string;
|
|
25
|
+
kind: string;
|
|
26
|
+
ok: boolean;
|
|
27
|
+
latencyMs?: number;
|
|
28
|
+
status?: number | null;
|
|
29
|
+
host?: string;
|
|
30
|
+
port?: number;
|
|
31
|
+
url?: string;
|
|
32
|
+
error?: string | null;
|
|
33
|
+
errorCategory?: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PreflightReport {
|
|
37
|
+
ranAt: string;
|
|
38
|
+
durationMs: number;
|
|
39
|
+
engineOk?: boolean;
|
|
40
|
+
passed: number;
|
|
41
|
+
warnings: number;
|
|
42
|
+
failed: number;
|
|
43
|
+
checks: PreflightCheck[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Map a bind/alias host to a connectable address. `0.0.0.0`/`::` are bind-only. */
|
|
47
|
+
function normalizeProbeHost(host: string | undefined, fallback = '127.0.0.1'): string {
|
|
48
|
+
if (!host) return fallback;
|
|
49
|
+
const h = host.trim().replace(/^\[|\]$/g, '');
|
|
50
|
+
if (h === '0.0.0.0' || h === '::' || h === 'localhost' || h === 'localhost.localdomain') return fallback;
|
|
51
|
+
return h;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Parse a mongodb:// or redis:// URL into a host/port pair with defaults applied. */
|
|
55
|
+
function endpointFromUrl(uri: string, defaultPort: number, defaultHost = '127.0.0.1'): { host: string; port: number } {
|
|
56
|
+
let host = defaultHost;
|
|
57
|
+
let port = defaultPort;
|
|
58
|
+
try {
|
|
59
|
+
const u = new URL(uri);
|
|
60
|
+
if (u.hostname) host = u.hostname;
|
|
61
|
+
if (u.port) port = Number(u.port) || defaultPort;
|
|
62
|
+
} catch {
|
|
63
|
+
/* fall back to defaults */
|
|
64
|
+
}
|
|
65
|
+
return { host: normalizeProbeHost(host), port };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type Category = 'refused' | 'timeout' | 'dns' | 'http' | 'other';
|
|
69
|
+
|
|
70
|
+
function classifyTransient(err: unknown): { category: Category; message: string } {
|
|
71
|
+
const code = (err as { code?: string })?.code;
|
|
72
|
+
const why = (err as { cause?: unknown })?.cause;
|
|
73
|
+
const causeCode = (why as { code?: string })?.code;
|
|
74
|
+
if (code === 'ECONNREFUSED' || causeCode === 'ECONNREFUSED') {
|
|
75
|
+
return { category: 'refused', message: 'connection refused — is the service running?' };
|
|
76
|
+
}
|
|
77
|
+
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN' || causeCode === 'ENOTFOUND' || causeCode === 'EAI_AGAIN') {
|
|
78
|
+
return { category: 'dns', message: 'host not found' };
|
|
79
|
+
}
|
|
80
|
+
if (code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT' || causeCode === 'ETIMEDOUT' || code === 'ABORT_ERR') {
|
|
81
|
+
return { category: 'timeout', message: 'timed out — no response within the probe window' };
|
|
82
|
+
}
|
|
83
|
+
return { category: 'other', message: (err as Error).message || String(err) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function httpProbe(url: string, timeoutMs: number): Promise<PreflightCheck> {
|
|
87
|
+
const start = Date.now();
|
|
88
|
+
return fetch(url, { signal: AbortSignal.timeout(timeoutMs), redirect: 'follow' })
|
|
89
|
+
.then((res) => {
|
|
90
|
+
const ok = res.status < 400;
|
|
91
|
+
return {
|
|
92
|
+
name: '',
|
|
93
|
+
kind: 'http',
|
|
94
|
+
url,
|
|
95
|
+
ok,
|
|
96
|
+
status: res.status,
|
|
97
|
+
latencyMs: Date.now() - start,
|
|
98
|
+
errorCategory: ok ? null : 'http',
|
|
99
|
+
error: ok ? null : `HTTP ${res.status}`,
|
|
100
|
+
};
|
|
101
|
+
})
|
|
102
|
+
.catch((err: unknown) => {
|
|
103
|
+
const { category, message } = classifyTransient(err);
|
|
104
|
+
return {
|
|
105
|
+
name: '',
|
|
106
|
+
kind: 'http',
|
|
107
|
+
url,
|
|
108
|
+
ok: false,
|
|
109
|
+
status: null,
|
|
110
|
+
latencyMs: Date.now() - start,
|
|
111
|
+
errorCategory: category,
|
|
112
|
+
error: sanitize(message),
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function tcpProbe(host: string, port: number, timeoutMs: number): Promise<PreflightCheck> {
|
|
118
|
+
const start = Date.now();
|
|
119
|
+
return new Promise<PreflightCheck>((resolve) => {
|
|
120
|
+
const socket = createConnection({ host, port });
|
|
121
|
+
const settled = (check: PreflightCheck) => {
|
|
122
|
+
socket.destroy();
|
|
123
|
+
resolve(check);
|
|
124
|
+
};
|
|
125
|
+
let connected = false;
|
|
126
|
+
socket.setTimeout(timeoutMs);
|
|
127
|
+
socket.once('connect', () => {
|
|
128
|
+
connected = true;
|
|
129
|
+
settled({
|
|
130
|
+
name: '',
|
|
131
|
+
kind: 'tcp',
|
|
132
|
+
host,
|
|
133
|
+
port,
|
|
134
|
+
ok: true,
|
|
135
|
+
latencyMs: Date.now() - start,
|
|
136
|
+
errorCategory: null,
|
|
137
|
+
error: null,
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
socket.once('error', (err: NodeJS.ErrnoException) => {
|
|
141
|
+
if (connected) return;
|
|
142
|
+
const { category, message } = classifyTransient(err);
|
|
143
|
+
settled({
|
|
144
|
+
name: '',
|
|
145
|
+
kind: 'tcp',
|
|
146
|
+
host,
|
|
147
|
+
port,
|
|
148
|
+
ok: false,
|
|
149
|
+
latencyMs: Date.now() - start,
|
|
150
|
+
errorCategory: category,
|
|
151
|
+
error: sanitize(message),
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
socket.once('timeout', () => {
|
|
155
|
+
if (connected) return;
|
|
156
|
+
settled({
|
|
157
|
+
name: '',
|
|
158
|
+
kind: 'tcp',
|
|
159
|
+
host,
|
|
160
|
+
port,
|
|
161
|
+
ok: false,
|
|
162
|
+
latencyMs: Date.now() - start,
|
|
163
|
+
errorCategory: 'timeout',
|
|
164
|
+
error: 'timed out — no response within the probe window',
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Raw error strings that carry no diagnostic value. */
|
|
171
|
+
const NOISE = /address(?:not valid| already in use)|WinError/i;
|
|
172
|
+
|
|
173
|
+
function sanitize(message: string): string {
|
|
174
|
+
return NOISE.test(message) ? 'unreachable from this host' : message;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Derive the AI liveness URL from its OpenAI-compatible base URL.
|
|
179
|
+
*
|
|
180
|
+
* `config.ai.serverUrl` is the OpenAI base (`http://host:port/v1`) that the AI
|
|
181
|
+
* client appends `/chat/completions`, `/models`, etc. to. The FastAPI server
|
|
182
|
+
* exposes liveness at the *origin* root (`/health`), so append the health path
|
|
183
|
+
* to the origin — not to the `/v1` base.
|
|
184
|
+
*/
|
|
185
|
+
export function aiHealthUrl(serverUrl: string, host: string): string {
|
|
186
|
+
try {
|
|
187
|
+
const u = new URL(serverUrl);
|
|
188
|
+
const h = normalizeProbeHost(u.hostname);
|
|
189
|
+
return `${u.protocol}//${h}${u.port ? `:${u.port}` : ''}/health`;
|
|
190
|
+
} catch {
|
|
191
|
+
return `http://${host}:8000/health`;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export interface PreflightOptions {
|
|
196
|
+
/** True when a GraphQL gateway is actually mounted (probe it); false to skip. */
|
|
197
|
+
graphqlMounted?: boolean;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Register `POST /admin/preflight` (guarded by the caller's /admin middleware). */
|
|
201
|
+
export function registerPreflightRoutes(router: Router, config: NexusConfig, opts: PreflightOptions = {}): void {
|
|
202
|
+
router.post('/admin/preflight', async (ctx) => {
|
|
203
|
+
const started = Date.now();
|
|
204
|
+
const serverUrlRaw = config.ai?.serverUrl ?? 'http://localhost:8000';
|
|
205
|
+
const serverUrl = serverUrlRaw.replace(/\/+$/, '');
|
|
206
|
+
const host = normalizeProbeHost(config.server.host);
|
|
207
|
+
const port = config.server.port ?? 8080;
|
|
208
|
+
|
|
209
|
+
const targets: PreflightTarget[] = [];
|
|
210
|
+
|
|
211
|
+
const backendUrl = `http://${host}:${port}/health`;
|
|
212
|
+
targets.push({ name: 'Backend API', kind: 'http', url: backendUrl, timeout: 3000 });
|
|
213
|
+
|
|
214
|
+
targets.push({ name: 'AI server', kind: 'http', url: aiHealthUrl(serverUrl, host), timeout: 3000 });
|
|
215
|
+
|
|
216
|
+
if (config.graphql?.path && opts.graphqlMounted) {
|
|
217
|
+
// A bare GET would 400 (no query) and POST is CSRF-blocked — probe with a
|
|
218
|
+
// minimal read-query so the endpoint answers 200 instead.
|
|
219
|
+
const graphqlUrl = `http://${host}:${port}${config.graphql.path}?query=${encodeURIComponent('{ __typename }')}`;
|
|
220
|
+
targets.push({ name: 'GraphQL', kind: 'http', url: graphqlUrl, timeout: 3000 });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Mongo + Redis as TCP reachability probes (authenticated ping is out of scope here).
|
|
224
|
+
const dbUri = config.db?.uri ?? 'mongodb://127.0.0.1:27017';
|
|
225
|
+
const mongo = endpointFromUrl(dbUri, 27017);
|
|
226
|
+
targets.push({ name: 'MongoDB', kind: 'tcp', host: mongo.host, port: mongo.port, timeout: 2000 });
|
|
227
|
+
|
|
228
|
+
const redisUrl = config.redis?.url ?? '';
|
|
229
|
+
if (redisUrl) {
|
|
230
|
+
const redis = endpointFromUrl(redisUrl, 6379);
|
|
231
|
+
targets.push({ name: 'Redis', kind: 'tcp', host: redis.host, port: redis.port, timeout: 2000 });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const runner = async (t: PreflightTarget): Promise<PreflightCheck> => {
|
|
235
|
+
if (t.kind === 'http') {
|
|
236
|
+
return httpProbe(t.url ?? '', t.timeout ?? 3000).then((c) => ({ ...c, name: t.name }));
|
|
237
|
+
}
|
|
238
|
+
return tcpProbe(t.host ?? '127.0.0.1', t.port ?? 0, t.timeout ?? 2000).then((c) => ({ ...c, name: t.name }));
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const checks = await Promise.all(targets.map(runner));
|
|
242
|
+
const durationMs = Date.now() - started;
|
|
243
|
+
|
|
244
|
+
const failed = checks.filter((c) => !c.ok);
|
|
245
|
+
const passed = checks.filter((c) => c.ok);
|
|
246
|
+
const slow = passed.filter((c) => typeof c.latencyMs === 'number' && (c.latencyMs as number) > 800);
|
|
247
|
+
const rest = passed.filter((c) => !slow.includes(c));
|
|
248
|
+
|
|
249
|
+
ctx.json({
|
|
250
|
+
ranAt: new Date().toISOString(),
|
|
251
|
+
durationMs,
|
|
252
|
+
engineOk: true,
|
|
253
|
+
passed: passed.length,
|
|
254
|
+
warnings: slow.length,
|
|
255
|
+
failed: failed.length,
|
|
256
|
+
checks: [...failed, ...slow, ...rest],
|
|
257
|
+
} satisfies PreflightReport);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Role catalog for the admin console.
|
|
3
|
+
*
|
|
4
|
+
* Describes every assignable role: what the holder CAN do (grants) and what
|
|
5
|
+
* they are RESTRICTED from (restricts), plus an icon, accent color and short
|
|
6
|
+
* blurb. The catalog is returned to the admin UI (`GET /admin/roles`) so role
|
|
7
|
+
* editors show accurate, useful boxes. Enforcement stays role-name based via
|
|
8
|
+
* the auth `requireRole` middleware (only `admin` can reach /admin/*).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface RolePermission {
|
|
12
|
+
label: string;
|
|
13
|
+
detail: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RoleDefinition {
|
|
17
|
+
/** Role value stored on the user document. */
|
|
18
|
+
id: string;
|
|
19
|
+
/** Human-readable role name. */
|
|
20
|
+
label: string;
|
|
21
|
+
/** Icon shown in the chip / box. */
|
|
22
|
+
icon: string;
|
|
23
|
+
/** Accent color used for the role chip / box accent. */
|
|
24
|
+
accent: string;
|
|
25
|
+
/** One-line description. */
|
|
26
|
+
description: string;
|
|
27
|
+
/** Things the role CAN do. */
|
|
28
|
+
grants: RolePermission[];
|
|
29
|
+
/** Things the role is RESTRICTED from doing. */
|
|
30
|
+
restricts: RolePermission[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const ROLE_CATALOG: RoleDefinition[] = [
|
|
34
|
+
{
|
|
35
|
+
id: 'admin',
|
|
36
|
+
label: 'Administrator',
|
|
37
|
+
icon: '◈',
|
|
38
|
+
accent: '#63bdff',
|
|
39
|
+
description: 'Full operational control of this Nexus project — services, configuration, data, payments and team access.',
|
|
40
|
+
grants: [
|
|
41
|
+
{ label: 'Manage users & roles', detail: 'View every account and assign or revoke roles for other users.' },
|
|
42
|
+
{ label: 'Restart services', detail: 'Stop, start and restart backend, frontend and AI services.' },
|
|
43
|
+
{ label: 'Edit runtime configuration', detail: 'Change runtime.json overrides and the human-edited config file.' },
|
|
44
|
+
{ label: 'Manage environment variables', detail: 'Read masked env values and update keys in the project .env.' },
|
|
45
|
+
{ label: 'Administer plugins', detail: 'See and configure plugin extensions and their admin surfaces.' },
|
|
46
|
+
{ label: 'Manage databases', detail: 'Create, rename and drop databases and collections; preview documents.' },
|
|
47
|
+
{ label: 'Operate payments', detail: 'Review orders, transactions and provider status; create test orders.' },
|
|
48
|
+
{ label: 'Monitor the build', detail: 'View live metrics, uptime, PID and process health.' },
|
|
49
|
+
{ label: 'Generate AI schemas', detail: 'Produce MongoDB schemas and models from natural language.' },
|
|
50
|
+
],
|
|
51
|
+
restricts: [
|
|
52
|
+
{ label: 'Read stored password hashes', detail: 'Hashes are excluded from every user query; they are never returned to any client.' },
|
|
53
|
+
{ label: 'Read secret values', detail: 'Env and payment secrets are masked — real values stay on disk and in memory only.' },
|
|
54
|
+
{ label: 'Escape the project root', detail: 'Uploads, config and database paths are confined to the project directory.' },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'user',
|
|
59
|
+
label: 'Member',
|
|
60
|
+
icon: '◎',
|
|
61
|
+
accent: '#8ba0bd',
|
|
62
|
+
description: 'A standard end-user account. Can use the application and manage their own profile.',
|
|
63
|
+
grants: [
|
|
64
|
+
{ label: 'Own account', detail: 'Register, sign in, and manage their own profile and sessions.' },
|
|
65
|
+
{ label: 'Application data', detail: 'Use the app and its data, scoped to their own account.' },
|
|
66
|
+
],
|
|
67
|
+
restricts: [
|
|
68
|
+
{ label: 'Admin console', detail: 'No /admin/* endpoints are accessible — the console requires the admin role.' },
|
|
69
|
+
{ label: 'Service & config control', detail: 'Runtime, processes, plugins, databases and payments are read-only or hidden.' },
|
|
70
|
+
{ label: 'Other accounts & secrets', detail: 'Only their own account is returned; roles and other users are internal.' },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/** Look up a role's definition by id, or `undefined` if it is not assignable. */
|
|
76
|
+
export function findRole(id: string): RoleDefinition | undefined {
|
|
77
|
+
return ROLE_CATALOG.find((r) => r.id === id);
|
|
78
|
+
}
|