@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,160 @@
|
|
|
1
|
+
import type { Router, AiProviderConfig } from '../index.js';
|
|
2
|
+
import { AiClient, type ChatCompletionRequest } from '@bhooai/nexus-ai-client';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* OpenAI-compatible AI routes for the admin SPA.
|
|
6
|
+
*
|
|
7
|
+
* POST /ai/chat/completions (streaming SSE or JSON)
|
|
8
|
+
* GET /ai/models
|
|
9
|
+
*
|
|
10
|
+
* The browser never calls external AI APIs directly — Node proxies:
|
|
11
|
+
* - a configured, enabled provider with a baseUrl → direct OpenAI-compatible
|
|
12
|
+
* fetch to its endpoint (streaming is relayed chunk-by-chunk)
|
|
13
|
+
* - otherwise → the Python AI server via @bhooai/nexus-ai-client
|
|
14
|
+
*
|
|
15
|
+
* Node re-emits the SSE stream so CSRF + auth still apply.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface AiProxyOptions {
|
|
19
|
+
serverUrl: string;
|
|
20
|
+
timeoutMs: number;
|
|
21
|
+
/** Live AI providers array from config (shared reference — updated in-place
|
|
22
|
+
* by the admin provider management endpoints). */
|
|
23
|
+
providers?: AiProviderConfig[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function findProvider(providers: AiProviderConfig[], id?: string): AiProviderConfig | undefined {
|
|
27
|
+
if (!id) return undefined;
|
|
28
|
+
return providers.find((p) => p.id.toLowerCase() === id.toLowerCase() && p.enabled);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Normalise a provider baseUrl to its OpenAI-compatible root (append /v1 unless it already ends in a version segment). */
|
|
32
|
+
function openAiBase(baseUrl: string): string {
|
|
33
|
+
const u = baseUrl.replace(/\/+$/, '');
|
|
34
|
+
return /\/v\d+(\/)?$/.test(u) ? u : `${u}/v1`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Relay a streaming chat request to an OpenAI-compatible provider, re-emitting SSE. */
|
|
38
|
+
async function relayProviderStream(ctx: any, p: AiProviderConfig, req: ChatCompletionRequest): Promise<void> {
|
|
39
|
+
ctx.res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
40
|
+
const headers: Record<string, string> = { 'content-type': 'application/json' };
|
|
41
|
+
if (p.apiKey) headers.authorization = `Bearer ${p.apiKey}`;
|
|
42
|
+
try {
|
|
43
|
+
const upstream = await fetch(`${openAiBase(p.baseUrl ?? '')}/chat/completions`, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers,
|
|
46
|
+
body: JSON.stringify({ model: req.model, messages: req.messages, stream: true }),
|
|
47
|
+
});
|
|
48
|
+
if (!upstream.ok || !upstream.body) {
|
|
49
|
+
const text = await upstream.text();
|
|
50
|
+
throw new Error(`provider responded with HTTP ${upstream.status} ${text.slice(0, 300)}`);
|
|
51
|
+
}
|
|
52
|
+
const reader = upstream.body.getReader();
|
|
53
|
+
const decoder = new TextDecoder();
|
|
54
|
+
let buffer = '';
|
|
55
|
+
for (;;) {
|
|
56
|
+
const { done, value } = await reader.read();
|
|
57
|
+
if (done) break;
|
|
58
|
+
buffer += decoder.decode(value, { stream: true });
|
|
59
|
+
const lines = buffer.split('\n');
|
|
60
|
+
buffer = lines.pop() ?? '';
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
const trimmed = line.trim();
|
|
63
|
+
if (trimmed.startsWith('data: ')) ctx.res.write(`${trimmed}\n\n`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
ctx.res.write('data: [DONE]\n\n');
|
|
67
|
+
} catch (err) {
|
|
68
|
+
ctx.res.write(`data: ${JSON.stringify({ error: { message: String((err as Error)?.message ?? err) } })}\n\n`);
|
|
69
|
+
} finally {
|
|
70
|
+
if (!ctx.res.writableEnded) ctx.res.end();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Register the AI proxy routes (CSRF-guarded via `router.use('/ai', csrf())` in the app). */
|
|
75
|
+
export function registerAiProxyRoutes(router: Router, opts: AiProxyOptions): void {
|
|
76
|
+
const client = new AiClient({ serverUrl: opts.serverUrl, timeoutMs: opts.timeoutMs });
|
|
77
|
+
const providers = opts.providers ?? [];
|
|
78
|
+
|
|
79
|
+
router.post('/ai/chat/completions', async (ctx) => {
|
|
80
|
+
const req = (ctx.body ?? {}) as ChatCompletionRequest & { provider?: string };
|
|
81
|
+
|
|
82
|
+
// Configured OpenAI-compatible provider (direct).
|
|
83
|
+
const cfgProvider = findProvider(providers, req.provider);
|
|
84
|
+
if (cfgProvider && cfgProvider.baseUrl) {
|
|
85
|
+
if (req.stream) {
|
|
86
|
+
await relayProviderStream(ctx, cfgProvider, req);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const headers: Record<string, string> = { 'content-type': 'application/json' };
|
|
90
|
+
if (cfgProvider.apiKey) headers.authorization = `Bearer ${cfgProvider.apiKey}`;
|
|
91
|
+
try {
|
|
92
|
+
const upstream = await fetch(`${openAiBase(cfgProvider.baseUrl)}/chat/completions`, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers,
|
|
95
|
+
body: JSON.stringify({ model: req.model, messages: req.messages }),
|
|
96
|
+
});
|
|
97
|
+
const data = await upstream.json().catch(() => ({})) as any;
|
|
98
|
+
if (!upstream.ok) {
|
|
99
|
+
ctx.json({ error: { message: (data as { error?: { message?: string } })?.error?.message ?? `provider responded with HTTP ${upstream.status}` } }, upstream.status === 401 ? 401 : 502);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
ctx.json(data);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
ctx.json({ error: { message: String((err as Error)?.message ?? err) } }, 502);
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Default: proxy to the Python AI server.
|
|
110
|
+
if (req.stream) {
|
|
111
|
+
ctx.res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
112
|
+
try {
|
|
113
|
+
for await (const chunk of client.chatStream(req)) ctx.res.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
|
114
|
+
ctx.res.write('data: [DONE]\n\n');
|
|
115
|
+
} catch (err) {
|
|
116
|
+
ctx.res.write(`data: ${JSON.stringify({ error: { message: String((err as Error)?.message ?? err) } })}\n\n`);
|
|
117
|
+
} finally {
|
|
118
|
+
if (!ctx.res.writableEnded) ctx.res.end();
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
ctx.json(await client.chat(req));
|
|
124
|
+
} catch (err) {
|
|
125
|
+
ctx.json({ error: { message: String((err as Error)?.message ?? err) } }, (err as { status?: number })?.status ?? 502);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Provider-aware model listing.
|
|
130
|
+
router.get('/ai/models', async (ctx) => {
|
|
131
|
+
const provider = typeof ctx.query['provider'] === 'string' ? (ctx.query['provider'] as string) : undefined;
|
|
132
|
+
|
|
133
|
+
const cfgProvider = findProvider(providers, provider);
|
|
134
|
+
if (cfgProvider && cfgProvider.baseUrl) {
|
|
135
|
+
const headers: Record<string, string> = {};
|
|
136
|
+
if (cfgProvider.apiKey) headers.authorization = `Bearer ${cfgProvider.apiKey}`;
|
|
137
|
+
try {
|
|
138
|
+
const upstream = await fetch(`${openAiBase(cfgProvider.baseUrl)}/models`, { headers });
|
|
139
|
+
const data = await upstream.json().catch(() => ({})) as any;
|
|
140
|
+
if (!upstream.ok) {
|
|
141
|
+
ctx.json({ provider: cfgProvider.id, data: [], error: (data as { error?: { message?: string } })?.error?.message ?? `provider responded with HTTP ${upstream.status}` });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const models = Array.isArray(data?.data)
|
|
145
|
+
? (data.data as any[]).map((m: any) => ({ id: String(m.id ?? m), owned_by: m.owned_by ?? cfgProvider.id }))
|
|
146
|
+
: [];
|
|
147
|
+
ctx.json({ provider: cfgProvider.id, data: models });
|
|
148
|
+
} catch (err) {
|
|
149
|
+
ctx.json({ provider: cfgProvider.id, data: [], error: String((err as Error)?.message ?? err) });
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
ctx.json(await client.listModels(provider));
|
|
156
|
+
} catch (err) {
|
|
157
|
+
ctx.json({ error: { message: String((err as Error)?.message ?? err) } }, (err as { status?: number })?.status ?? 502);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { Router, AiProviderConfig } from '../index.js';
|
|
2
|
+
import { AiClient } from '@bhooai/nexus-ai-client';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* AI-enabled MongoDB schema generation for the admin app.
|
|
6
|
+
*
|
|
7
|
+
* POST /admin/schemas/generate — generate a MongoDB $jsonSchema from text
|
|
8
|
+
*
|
|
9
|
+
* Provider data (keys, enabled state) lives in the adminModule's shared
|
|
10
|
+
* `providers` array; this module only needs the AI server + model defaults.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface SchemaAIOptions {
|
|
14
|
+
serverUrl: string;
|
|
15
|
+
timeoutMs: number;
|
|
16
|
+
/** Model to use for generation (config: `ai.schemaModel`). */
|
|
17
|
+
model?: string;
|
|
18
|
+
/** AI providers from config (used to resolve provider model defaults). */
|
|
19
|
+
providers?: AiProviderConfig[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface GeneratedSchema {
|
|
23
|
+
collection: string;
|
|
24
|
+
fields: Array<{ name: string; type: string; required?: boolean; unique?: boolean; enum?: string[]; description?: string }>;
|
|
25
|
+
jsonSchema: { $jsonSchema: { bsonType: string; required?: string[]; properties: Record<string, unknown> } };
|
|
26
|
+
model: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const SYSTEM_PROMPT = `You are a MongoDB schema designer. Given the user's description of the data they want to store, produce ONLY a valid JSON object — no markdown fences, no commentary — shaped exactly like this:
|
|
30
|
+
|
|
31
|
+
{
|
|
32
|
+
"collection": "<plural, snake_case collection name>",
|
|
33
|
+
"fields": [
|
|
34
|
+
{ "name": "<field>", "type": "String|Number|Boolean|Date|ObjectId|Array|Mixed", "required": false, "unique": false, "enum": ["a","b"], "description": "<short note>" }
|
|
35
|
+
],
|
|
36
|
+
"jsonSchema": { "$jsonSchema": { "bsonType": "object", "required": ["..."], "properties": { "<field>": { "bsonType": "..." } } } }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
Rules:
|
|
40
|
+
- Every listed field must appear in properties with a matching bsonType (string, int/double/long, bool, date, objectId, array, object).
|
|
41
|
+
- required lists only fields marked required: true. unique fields must be marked with a unique index note in their description.
|
|
42
|
+
- Give the collection a sensible plural snake_case name.`;
|
|
43
|
+
|
|
44
|
+
/** Register `POST /admin/schemas/generate`. */
|
|
45
|
+
export function registerAiSchemaRoutes(router: Router, opts: SchemaAIOptions): void {
|
|
46
|
+
router.post('/admin/schemas/generate', async (ctx) => {
|
|
47
|
+
const body = (ctx.body ?? {}) as { prompt?: unknown; model?: unknown; provider?: unknown };
|
|
48
|
+
const prompt = typeof body.prompt === 'string' && body.prompt.trim() ? body.prompt.trim() : '';
|
|
49
|
+
if (!prompt) {
|
|
50
|
+
ctx.json({ error: 'prompt (string) is required — describe the data you want to store' }, 400);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const usedModel = typeof body.model === 'string' && body.model.trim() ? body.model.trim() : (opts.model ?? 'gpt-4o-mini');
|
|
54
|
+
|
|
55
|
+
const ai = new AiClient({ serverUrl: opts.serverUrl, timeoutMs: opts.timeoutMs });
|
|
56
|
+
let text: string;
|
|
57
|
+
try {
|
|
58
|
+
const reply = await ai.chat({
|
|
59
|
+
model: usedModel,
|
|
60
|
+
temperature: 0,
|
|
61
|
+
provider: body.provider === 'auto' || body.provider === undefined ? undefined : String(body.provider),
|
|
62
|
+
messages: [
|
|
63
|
+
{ role: 'system', content: SYSTEM_PROMPT },
|
|
64
|
+
{ role: 'user', content: prompt },
|
|
65
|
+
],
|
|
66
|
+
});
|
|
67
|
+
text = reply.choices?.[0]?.message?.content ?? '';
|
|
68
|
+
} catch (e) {
|
|
69
|
+
ctx.json({ error: `AI server error: ${(e as Error).message}` }, 502);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let parsed: GeneratedSchema;
|
|
74
|
+
try {
|
|
75
|
+
parsed = extractSchema(text);
|
|
76
|
+
} catch (e) {
|
|
77
|
+
ctx.json({ error: `AI response was not a usable schema: ${(e as Error).message}` }, 502);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
ctx.json({ ...parsed, model: usedModel });
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Parse the AI reply (tolerates markdown fences and stray prose around the JSON). */
|
|
85
|
+
function extractSchema(text: string): GeneratedSchema {
|
|
86
|
+
const cleaned = stripFences(text).trim();
|
|
87
|
+
const start = cleaned.indexOf('{');
|
|
88
|
+
const end = cleaned.lastIndexOf('}');
|
|
89
|
+
if (start === -1 || end <= start) throw new Error('no JSON object found in the response');
|
|
90
|
+
const parsed = JSON.parse(cleaned.slice(start, end + 1)) as {
|
|
91
|
+
collection?: unknown;
|
|
92
|
+
fields?: unknown;
|
|
93
|
+
jsonSchema?: unknown;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
if (typeof parsed.collection !== 'string' || !/^[A-Za-z][A-Za-z0-9_]*$/.test(parsed.collection)) {
|
|
97
|
+
throw new Error(`invalid collection name from AI: ${String(parsed.collection)}`);
|
|
98
|
+
}
|
|
99
|
+
if (!Array.isArray(parsed.fields)) throw new Error('"fields" must be an array');
|
|
100
|
+
const schema = parsed.jsonSchema as { $jsonSchema?: unknown } | undefined;
|
|
101
|
+
const jsonSchema = schema?.$jsonSchema as Record<string, unknown> | undefined;
|
|
102
|
+
if (!jsonSchema || jsonSchema.bsonType !== 'object' || typeof jsonSchema.properties !== 'object' || jsonSchema.properties === null) {
|
|
103
|
+
throw new Error('"jsonSchema" must contain $jsonSchema with bsonType "object" and properties');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const fields = parsed.fields.map((f) => {
|
|
107
|
+
const raw = (f ?? {}) as Record<string, unknown>;
|
|
108
|
+
return {
|
|
109
|
+
name: String(raw.name ?? ''),
|
|
110
|
+
type: String(raw.type ?? 'Mixed'),
|
|
111
|
+
required: !!raw.required,
|
|
112
|
+
unique: !!raw.unique,
|
|
113
|
+
enum: Array.isArray(raw.enum) ? (raw.enum as string[]).map(String) : undefined,
|
|
114
|
+
description: typeof raw.description === 'string' ? raw.description : undefined,
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
if (!fields.length || fields.some((f) => !f.name)) throw new Error('fields must have unique names');
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
collection: parsed.collection,
|
|
121
|
+
fields,
|
|
122
|
+
jsonSchema: { $jsonSchema: jsonSchema as GeneratedSchema['jsonSchema']['$jsonSchema'] },
|
|
123
|
+
model: '',
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function stripFences(text: string): string {
|
|
128
|
+
const m = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
|
|
129
|
+
return m ? m[1]! : text;
|
|
130
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* authModule — framework-provided /auth/* routes (register/login/refresh/logout/me).
|
|
3
|
+
*
|
|
4
|
+
* Auto-mounted by `createNexusApp()` when `config.admin.enabled`. Uses the
|
|
5
|
+
* `@bhooai/nexus-auth` primitives (AuthService, MemorySessionStore, CSRF, OAuth)
|
|
6
|
+
* and the framework User model (see `userModel.ts`). The very first registered
|
|
7
|
+
* user is bootstrapped as an admin.
|
|
8
|
+
*
|
|
9
|
+
* Routes:
|
|
10
|
+
* POST /auth/register — create a user (first user → admin) + sign in
|
|
11
|
+
* POST /auth/login — verify credentials + sign in
|
|
12
|
+
* POST /auth/refresh — rotate the refresh token (HttpOnly cookie)
|
|
13
|
+
* POST /auth/logout — end the session + clear cookies
|
|
14
|
+
* GET /auth/me — current user (auth-guarded)
|
|
15
|
+
* PUT /auth/profile — update display name (auth-guarded)
|
|
16
|
+
* POST /auth/change-password — verify current, set new (auth-guarded)
|
|
17
|
+
* GET /auth/google — OAuth start (only if configured)
|
|
18
|
+
* GET /auth/google/callback — OAuth callback (only if configured)
|
|
19
|
+
* GET /auth/facebook — OAuth start (only if configured)
|
|
20
|
+
* GET /auth/facebook/callback — OAuth callback (only if configured)
|
|
21
|
+
*/
|
|
22
|
+
import type { NexusConfig } from '../config/types.js';
|
|
23
|
+
import type { Router } from '../http/Router.js';
|
|
24
|
+
import {
|
|
25
|
+
AuthService,
|
|
26
|
+
MemorySessionStore,
|
|
27
|
+
authToken,
|
|
28
|
+
requireAuth,
|
|
29
|
+
setAuthCookies,
|
|
30
|
+
clearAuthCookies,
|
|
31
|
+
readCookieValue,
|
|
32
|
+
hashPassword,
|
|
33
|
+
verifyPassword,
|
|
34
|
+
buildGoogleAuthUrl,
|
|
35
|
+
buildFacebookAuthUrl,
|
|
36
|
+
exchangeGoogleCode,
|
|
37
|
+
exchangeFacebookCode,
|
|
38
|
+
fetchGoogleProfile,
|
|
39
|
+
fetchFacebookProfile,
|
|
40
|
+
generateState,
|
|
41
|
+
generatePkceVerifier,
|
|
42
|
+
MemoryOAuthStateStore,
|
|
43
|
+
type GoogleOAuthConfig,
|
|
44
|
+
type FacebookOAuthConfig,
|
|
45
|
+
} from '@bhooai/nexus-auth';
|
|
46
|
+
import { ConflictError, AuthenticationError, ValidationError } from '../index.js';
|
|
47
|
+
import { ObjectId } from '@bhooai/nexus-data';
|
|
48
|
+
import { initUserModel, getUserModel, findUserForLogin, upsertOAuthUser, type UserInstance } from './userModel.js';
|
|
49
|
+
|
|
50
|
+
const sessions = new MemorySessionStore();
|
|
51
|
+
const oauthState = new MemoryOAuthStateStore();
|
|
52
|
+
|
|
53
|
+
function origin(config: NexusConfig): string {
|
|
54
|
+
const proto = config.server.https ? 'https' : 'http';
|
|
55
|
+
return `${proto}://${config.server.host === '0.0.0.0' ? 'localhost' : config.server.host}:${config.server.port}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function jwtOptions(config: NexusConfig) {
|
|
59
|
+
return {
|
|
60
|
+
secret: config.auth.jwt.secret ?? 'nexus-insecure-secret-change-me',
|
|
61
|
+
algorithm: 'HS256' as const,
|
|
62
|
+
issuer: config.auth.jwt.issuer,
|
|
63
|
+
audience: config.auth.jwt.audience,
|
|
64
|
+
accessTtl: config.auth.jwt.accessTtl,
|
|
65
|
+
refreshTtl: config.auth.jwt.refreshTtl,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function authService(config: NexusConfig): AuthService {
|
|
70
|
+
return new AuthService(jwtOptions(config), sessions);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Register /auth/* routes onto the given router. Call after `connect()` + `initUserModel()`. */
|
|
74
|
+
export function registerAuthRoutes(router: Router, config: NexusConfig, onUserCreated?: () => void): AuthService {
|
|
75
|
+
initUserModel();
|
|
76
|
+
const service = authService(config);
|
|
77
|
+
|
|
78
|
+
// POST /auth/register
|
|
79
|
+
router.post('/auth/register', async (ctx) => {
|
|
80
|
+
const body = (ctx.body ?? {}) as { email?: string; password?: string; name?: string };
|
|
81
|
+
if (!body.email || !body.password) throw new ValidationError('email and password are required');
|
|
82
|
+
if (body.password.length < 8) throw new ValidationError('password must be at least 8 characters');
|
|
83
|
+
|
|
84
|
+
const User = getUserModel();
|
|
85
|
+
const existing = await User.findOne({ email: body.email.toLowerCase() }).lean();
|
|
86
|
+
if (existing) throw new ConflictError('A user with that email already exists');
|
|
87
|
+
|
|
88
|
+
const passwordHash = await hashPassword(body.password);
|
|
89
|
+
// Bootstrap: the very first registered user becomes an admin.
|
|
90
|
+
const userCount = await User.countDocuments();
|
|
91
|
+
const roles = userCount === 0 ? ['admin'] : ['user'];
|
|
92
|
+
const [user] = await User.create({ email: body.email, name: body.name, passwordHash, roles });
|
|
93
|
+
// Notify subscribers (e.g. a GraphQL `userCount` subscription) if wired.
|
|
94
|
+
onUserCreated?.();
|
|
95
|
+
const pair = await service.login({ userId: String((user as UserInstance)._id), roles: (user as UserInstance).roles, meta: { ip: ctx.req.socket.remoteAddress } });
|
|
96
|
+
setAuthCookies(ctx, pair, { accessTokenName: config.auth.cookieName, refreshTokenName: config.auth.refreshCookieName });
|
|
97
|
+
ctx.json({ user: publicUser(user), accessToken: pair.accessToken, refreshToken: pair.refreshToken });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// POST /auth/login
|
|
101
|
+
router.post('/auth/login', async (ctx) => {
|
|
102
|
+
const body = (ctx.body ?? {}) as { email?: string; password?: string };
|
|
103
|
+
if (!body.email || !body.password) throw new ValidationError('email and password are required');
|
|
104
|
+
const user = await findUserForLogin(body.email);
|
|
105
|
+
if (!user || !user.passwordHash) throw new AuthenticationError('Invalid email or password');
|
|
106
|
+
const ok = await verifyPassword(body.password, user.passwordHash);
|
|
107
|
+
if (!ok) throw new AuthenticationError('Invalid email or password');
|
|
108
|
+
const pair = await service.login({ userId: String(user._id), roles: user.roles });
|
|
109
|
+
setAuthCookies(ctx, pair, { accessTokenName: config.auth.cookieName, refreshTokenName: config.auth.refreshCookieName });
|
|
110
|
+
ctx.json({ user: publicUser(user), accessToken: pair.accessToken, refreshToken: pair.refreshToken });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// POST /auth/refresh
|
|
114
|
+
router.post('/auth/refresh', async (ctx) => {
|
|
115
|
+
const body = (ctx.body ?? {}) as { refreshToken?: string };
|
|
116
|
+
const refreshToken = body.refreshToken ?? readCookieValue(ctx, config.auth.refreshCookieName);
|
|
117
|
+
if (!refreshToken) throw new AuthenticationError('Missing refresh token');
|
|
118
|
+
const pair = await service.refresh(refreshToken);
|
|
119
|
+
setAuthCookies(ctx, pair, { accessTokenName: config.auth.cookieName, refreshTokenName: config.auth.refreshCookieName });
|
|
120
|
+
ctx.json({ accessToken: pair.accessToken, refreshToken: pair.refreshToken });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// POST /auth/logout
|
|
124
|
+
router.post('/auth/logout', async (ctx) => {
|
|
125
|
+
// Optional auth: end the session if a token is present, but never 401 on logout.
|
|
126
|
+
await authToken(service, { required: false, allowCookie: true, cookieName: config.auth.cookieName })(ctx, async () => {});
|
|
127
|
+
const sid = (ctx.state.user as { sid?: string } | undefined)?.sid;
|
|
128
|
+
if (sid) await service.logout(sid);
|
|
129
|
+
clearAuthCookies(ctx, { accessTokenName: config.auth.cookieName, refreshTokenName: config.auth.refreshCookieName });
|
|
130
|
+
ctx.json({ ok: true });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// GET /auth/me
|
|
134
|
+
router.get('/auth/me', async (ctx) => {
|
|
135
|
+
const User = getUserModel();
|
|
136
|
+
const id = (ctx.state.user as { id: string }).id;
|
|
137
|
+
const user = await User.findById(id).lean();
|
|
138
|
+
ctx.json({ user: publicUser(user) });
|
|
139
|
+
}, [authToken(service, { cookieName: config.auth.cookieName }), requireAuth()]);
|
|
140
|
+
|
|
141
|
+
// PUT /auth/profile — update the signed-in user's display name.
|
|
142
|
+
router.put('/auth/profile', async (ctx) => {
|
|
143
|
+
const body = (ctx.body ?? {}) as { name?: string };
|
|
144
|
+
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
|
145
|
+
if (!name) throw new ValidationError('name is required');
|
|
146
|
+
const id = (ctx.state.user as { id: string }).id;
|
|
147
|
+
const User = getUserModel();
|
|
148
|
+
await User.updateOne({ _id: id }, { $set: { name } });
|
|
149
|
+
const user = await User.findById(id).lean();
|
|
150
|
+
ctx.json({ ok: true, user: publicUser(user) });
|
|
151
|
+
}, [authToken(service, { cookieName: config.auth.cookieName }), requireAuth()]);
|
|
152
|
+
|
|
153
|
+
// POST /auth/change-password — verify the current password, then set a new one.
|
|
154
|
+
router.post('/auth/change-password', async (ctx) => {
|
|
155
|
+
const body = (ctx.body ?? {}) as { currentPassword?: string; newPassword?: string };
|
|
156
|
+
if (!body.currentPassword) throw new ValidationError('current password is required');
|
|
157
|
+
if (!body.newPassword || body.newPassword.length < 8) throw new ValidationError('password must be at least 8 characters');
|
|
158
|
+
|
|
159
|
+
const id = (ctx.state.user as { id: string }).id;
|
|
160
|
+
const User = getUserModel();
|
|
161
|
+
const coll = await User.collection;
|
|
162
|
+
const raw = await coll.findOne({ _id: new ObjectId(id) });
|
|
163
|
+
if (!raw) throw new AuthenticationError('User not found');
|
|
164
|
+
const passwordHash = raw.passwordHash as string | undefined;
|
|
165
|
+
if (!passwordHash) throw new AuthenticationError('This account has no password (OAuth account)');
|
|
166
|
+
const ok = await verifyPassword(body.currentPassword, passwordHash);
|
|
167
|
+
if (!ok) throw new AuthenticationError('Current password is incorrect');
|
|
168
|
+
const nextHash = await hashPassword(body.newPassword);
|
|
169
|
+
await User.updateOne({ _id: id }, { $set: { passwordHash: nextHash } });
|
|
170
|
+
ctx.json({ ok: true });
|
|
171
|
+
}, [authToken(service, { cookieName: config.auth.cookieName }), requireAuth()]);
|
|
172
|
+
|
|
173
|
+
// ── OAuth: Google ──────────────────────────────────────────────────────────
|
|
174
|
+
if (config.auth.google?.clientId) {
|
|
175
|
+
const google: GoogleOAuthConfig = {
|
|
176
|
+
clientId: config.auth.google.clientId,
|
|
177
|
+
clientSecret: config.auth.google.clientSecret,
|
|
178
|
+
redirectUri: `${origin(config)}${config.auth.google.callbackPath}`,
|
|
179
|
+
scope: config.auth.google.scope.split(/\s+/).filter(Boolean),
|
|
180
|
+
};
|
|
181
|
+
router.get('/auth/google', async (ctx) => {
|
|
182
|
+
const state = generateState();
|
|
183
|
+
const verifier = generatePkceVerifier();
|
|
184
|
+
await oauthState.set(state, { provider: 'google', verifier }, 5 * 60_000);
|
|
185
|
+
ctx.redirect(buildGoogleAuthUrl(google, { state, verifier }));
|
|
186
|
+
});
|
|
187
|
+
router.get(config.auth.google.callbackPath, async (ctx) => {
|
|
188
|
+
const code = ctx.query.code as string | undefined;
|
|
189
|
+
const state = ctx.query.state as string | undefined;
|
|
190
|
+
if (!code || !state) throw new AuthenticationError('Missing OAuth code/state');
|
|
191
|
+
const data = await oauthState.consume(state);
|
|
192
|
+
if (!data || data.provider !== 'google') throw new AuthenticationError('Invalid OAuth state');
|
|
193
|
+
const tokens = await exchangeGoogleCode(code, google, data.verifier as string);
|
|
194
|
+
const profile = await fetchGoogleProfile(tokens.accessToken);
|
|
195
|
+
const user = await upsertOAuthUser({ provider: 'google', providerUserId: profile.providerUserId, email: profile.email, name: profile.name });
|
|
196
|
+
const pair = await service.login({ userId: String(user._id), roles: user.roles });
|
|
197
|
+
setAuthCookies(ctx, pair, { accessTokenName: config.auth.cookieName, refreshTokenName: config.auth.refreshCookieName });
|
|
198
|
+
ctx.json({ user: publicUser(user), accessToken: pair.accessToken });
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── OAuth: Facebook ─────────────────────────────────────────────────────────
|
|
203
|
+
if (config.auth.facebook?.clientId) {
|
|
204
|
+
const facebook: FacebookOAuthConfig = {
|
|
205
|
+
clientId: config.auth.facebook.clientId,
|
|
206
|
+
clientSecret: config.auth.facebook.clientSecret,
|
|
207
|
+
redirectUri: `${origin(config)}${config.auth.facebook.callbackPath}`,
|
|
208
|
+
scope: config.auth.facebook.scope.split(/\s+/).filter(Boolean),
|
|
209
|
+
};
|
|
210
|
+
router.get('/auth/facebook', async (ctx) => {
|
|
211
|
+
const state = generateState();
|
|
212
|
+
await oauthState.set(state, { provider: 'facebook' }, 5 * 60_000);
|
|
213
|
+
ctx.redirect(buildFacebookAuthUrl(facebook, state));
|
|
214
|
+
});
|
|
215
|
+
router.get(config.auth.facebook.callbackPath, async (ctx) => {
|
|
216
|
+
const code = ctx.query.code as string | undefined;
|
|
217
|
+
const state = ctx.query.state as string | undefined;
|
|
218
|
+
if (!code || !state) throw new AuthenticationError('Missing OAuth code/state');
|
|
219
|
+
const data = await oauthState.consume(state);
|
|
220
|
+
if (!data || data.provider !== 'facebook') throw new AuthenticationError('Invalid OAuth state');
|
|
221
|
+
const tokens = await exchangeFacebookCode(code, facebook);
|
|
222
|
+
const profile = await fetchFacebookProfile(tokens.accessToken);
|
|
223
|
+
const user = await upsertOAuthUser({ provider: 'facebook', providerUserId: profile.providerUserId, email: profile.email, name: profile.name });
|
|
224
|
+
const pair = await service.login({ userId: String(user._id), roles: user.roles });
|
|
225
|
+
setAuthCookies(ctx, pair, { accessTokenName: config.auth.cookieName, refreshTokenName: config.auth.refreshCookieName });
|
|
226
|
+
ctx.json({ user: publicUser(user), accessToken: pair.accessToken });
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return service;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function publicUser(user: unknown): Record<string, unknown> | null {
|
|
234
|
+
if (!user) return null;
|
|
235
|
+
// DocumentInstance → use toObject() (avoids spreading the Proxy and its circular _model).
|
|
236
|
+
// Plain objects (raw lean docs / JWT claims) → spread directly.
|
|
237
|
+
const obj =
|
|
238
|
+
(user as { toObject?: () => Record<string, unknown> }).toObject?.() ??
|
|
239
|
+
(user as Record<string, unknown>);
|
|
240
|
+
const u = { ...obj };
|
|
241
|
+
delete u.passwordHash;
|
|
242
|
+
return u;
|
|
243
|
+
}
|
|
@@ -32,6 +32,11 @@ import { policyRegistry } from './policies.js';
|
|
|
32
32
|
import { configureStorageFromEnv } from './Storage.js';
|
|
33
33
|
import { maintenanceMiddleware } from './maintenance.js';
|
|
34
34
|
import { createLazyDb, registerAdminRoutes, RequestLogBuffer } from './adminModule.js';
|
|
35
|
+
import { registerAuthRoutes } from './authModule.js';
|
|
36
|
+
import { issueCsrfToken, csrf, authToken, requireRole } from '@bhooai/nexus-auth';
|
|
37
|
+
import { connect } from '@bhooai/nexus-data';
|
|
38
|
+
import { createGateway, graphqlHttpHandler } from '@bhooai/nexus-graphql';
|
|
39
|
+
import type { Subgraph } from '@bhooai/nexus-graphql';
|
|
35
40
|
|
|
36
41
|
export interface CreateNexusAppOptions {
|
|
37
42
|
/** Identifier for this backend, used in admin, telemetry, logs. */
|
|
@@ -230,6 +235,40 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
230
235
|
ctx.json({ apps });
|
|
231
236
|
});
|
|
232
237
|
|
|
238
|
+
// ------------------------------------------------------------------
|
|
239
|
+
// GraphQL gateway — compose discovered subgraphs into a /graphql endpoint
|
|
240
|
+
// ------------------------------------------------------------------
|
|
241
|
+
let graphqlMounted = false;
|
|
242
|
+
if (discovery.graphql.length > 0) {
|
|
243
|
+
try {
|
|
244
|
+
const subgraphs: Subgraph[] = [];
|
|
245
|
+
for (const file of discovery.graphql) {
|
|
246
|
+
const sg = await importDefault<Subgraph>(file);
|
|
247
|
+
if (sg && typeof sg === 'object' && 'sdl' in sg && 'resolvers' in sg) {
|
|
248
|
+
subgraphs.push(sg);
|
|
249
|
+
} else {
|
|
250
|
+
console.warn(`[${name}] graphql file ${file.path} does not export a subgraph — skipped`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (subgraphs.length === 1) {
|
|
254
|
+
const gateway = createGateway({ subgraph: subgraphs[0]! });
|
|
255
|
+
const graphqlPath = config.graphql?.path ?? '/graphql';
|
|
256
|
+
const handler = graphqlHttpHandler({
|
|
257
|
+
gateway,
|
|
258
|
+
introspection: config.graphql?.introspection ?? true,
|
|
259
|
+
});
|
|
260
|
+
router.add('GET', graphqlPath, handler);
|
|
261
|
+
router.add('POST', graphqlPath, handler);
|
|
262
|
+
graphqlMounted = true;
|
|
263
|
+
console.log(`[${name}] GraphQL gateway mounted at ${graphqlPath}`);
|
|
264
|
+
} else if (subgraphs.length > 1) {
|
|
265
|
+
console.warn(`[${name}] found ${subgraphs.length} subgraphs — multi-subgraph federation is Phase 6; GraphQL not mounted`);
|
|
266
|
+
}
|
|
267
|
+
} catch (err) {
|
|
268
|
+
console.warn(`[${name}] failed to mount GraphQL gateway:`, err);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
233
272
|
// ------------------------------------------------------------------
|
|
234
273
|
// Admin module — request log buffer, lazy DB, admin + AI proxy routes
|
|
235
274
|
// ------------------------------------------------------------------
|
|
@@ -237,7 +276,35 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
237
276
|
const lazyDb = createLazyDb(config);
|
|
238
277
|
|
|
239
278
|
if (config.admin.enabled) {
|
|
240
|
-
|
|
279
|
+
// Connect to MongoDB + register the User model so /auth/* can work.
|
|
280
|
+
try {
|
|
281
|
+
connect(config.db.uri, { autoIndex: config.db.autoIndex });
|
|
282
|
+
} catch { /* Mongo unreachable — auth routes will fail lazily */ }
|
|
283
|
+
|
|
284
|
+
// Auth routes (register/login/refresh/logout/me + OAuth). Returns the
|
|
285
|
+
// AuthService used to build the admin guard.
|
|
286
|
+
const authService = registerAuthRoutes(router, config);
|
|
287
|
+
|
|
288
|
+
// CSRF token endpoint — mints a double-submit token cookie + returns it.
|
|
289
|
+
router.get('/csrf-token', (ctx) => {
|
|
290
|
+
const token = issueCsrfToken(ctx);
|
|
291
|
+
ctx.json({ token });
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// Guard all /admin/* routes with bearer-token auth + the 'admin' role.
|
|
295
|
+
const guard: Middleware[] = [
|
|
296
|
+
authToken(authService, { cookieName: config.auth.cookieName }),
|
|
297
|
+
requireRole('admin'),
|
|
298
|
+
];
|
|
299
|
+
|
|
300
|
+
// CSRF protection (double-submit cookie) on the auth + admin surfaces.
|
|
301
|
+
router.use('/auth', csrf());
|
|
302
|
+
router.use('/admin', csrf());
|
|
303
|
+
// The admin SPA's AI proxy calls (POST /ai/chat/completions) carry the
|
|
304
|
+
// same double-submit token — safe methods (GET /ai/models) stay exempt.
|
|
305
|
+
router.use('/ai', csrf());
|
|
306
|
+
|
|
307
|
+
registerAdminRoutes(router, { name, config, projectRoot, graphqlMounted }, logBuffer, lazyDb, guard);
|
|
241
308
|
}
|
|
242
309
|
|
|
243
310
|
// ------------------------------------------------------------------
|
|
@@ -260,7 +327,7 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
260
327
|
// Maintenance mode (checked first)
|
|
261
328
|
server.use(maintenanceMiddleware({ projectRoot }));
|
|
262
329
|
|
|
263
|
-
// Request logging into the ring buffer (source for /admin/logs/tail).
|
|
330
|
+
// Request logging into the ring buffer (source for /admin/logs/tail + /admin/requests).
|
|
264
331
|
server.use(async (ctx, next) => {
|
|
265
332
|
const start = performance.now();
|
|
266
333
|
const res = ctx.res;
|
|
@@ -268,12 +335,16 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
268
335
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
269
336
|
(res as any).end = (...args: any[]) => {
|
|
270
337
|
const latencyMs = Math.round(performance.now() - start);
|
|
338
|
+
const forwardedFor = (ctx.headers['x-forwarded-for'] as string) ?? '';
|
|
271
339
|
logBuffer.push({
|
|
272
340
|
ts: Date.now(),
|
|
273
341
|
method: ctx.method,
|
|
274
342
|
path: ctx.path,
|
|
275
343
|
status: res.statusCode,
|
|
276
344
|
latencyMs,
|
|
345
|
+
ip: (forwardedFor.split(',')[0] ?? '').trim() || ctx.req.socket?.remoteAddress,
|
|
346
|
+
referer: (ctx.headers['referer'] as string) || undefined,
|
|
347
|
+
userAgent: (ctx.headers['user-agent'] as string) || undefined,
|
|
277
348
|
});
|
|
278
349
|
return originalEnd(...args);
|
|
279
350
|
};
|