@bhooai/nexus-cli 0.1.0
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/PLAN.md +141 -0
- package/README.md +34 -0
- package/package.json +25 -0
- package/src/commands/cluster.ts +133 -0
- package/src/commands/dev.ts +133 -0
- package/src/commands/doctor.ts +199 -0
- package/src/commands/init.ts +960 -0
- package/src/commands/node.ts +101 -0
- package/src/commands/pysetup.ts +136 -0
- package/src/commands/sync.ts +116 -0
- package/src/commands/uninstall.ts +287 -0
- package/src/config-sync.ts +384 -0
- package/src/dotenv.ts +39 -0
- package/src/index.ts +94 -0
- package/src/supervisor.ts +384 -0
- package/src/util.ts +123 -0
- package/src/wizard.ts +149 -0
- package/templates/Dockerfile +60 -0
- package/templates/README.md +69 -0
- package/templates/apps/admin/index.html +12 -0
- package/templates/apps/admin/package.json +24 -0
- package/templates/apps/admin/postcss.config.js +6 -0
- package/templates/apps/admin/src/main.tsx +10 -0
- package/templates/apps/admin/src/vite-env.d.ts +18 -0
- package/templates/apps/admin/tailwind.config.js +9 -0
- package/templates/apps/admin/tsconfig.json +17 -0
- package/templates/apps/admin/vite.config.ts +64 -0
- package/templates/apps/ai-server/main.py +43 -0
- package/templates/apps/ai-server/providers/__init__.py +3 -0
- package/templates/apps/ai-server/providers/base.py +111 -0
- package/templates/apps/ai-server/requirements.txt +3 -0
- package/templates/apps/ai-server/routers/__init__.py +3 -0
- package/templates/apps/ai-server/routers/chat.py +47 -0
- package/templates/apps/ai-server/routers/embeddings.py +30 -0
- package/templates/apps/ai-server/routers/lint.py +167 -0
- package/templates/apps/ai-server/routers/models.py +23 -0
- package/templates/apps/ai-server/routers/preflight.py +169 -0
- package/templates/apps/ai-server/settings.py +48 -0
- package/templates/apps/backend/package.json +33 -0
- package/templates/apps/backend/src/main.ts +375 -0
- package/templates/apps/backend/src/modules/admin/adminRoutes.ts +732 -0
- package/templates/apps/backend/src/modules/admin/clusterRoutes.ts +391 -0
- package/templates/apps/backend/src/modules/admin/databaseRoutes.ts +161 -0
- package/templates/apps/backend/src/modules/admin/lintProxy.ts +89 -0
- package/templates/apps/backend/src/modules/admin/preflightProxy.ts +242 -0
- package/templates/apps/backend/src/modules/admin/roleCatalog.ts +78 -0
- package/templates/apps/backend/src/modules/admin/schemaRoutes.ts +449 -0
- package/templates/apps/backend/src/modules/ai/aiProxy.ts +265 -0
- package/templates/apps/backend/src/modules/auth/authRoutes.ts +220 -0
- package/templates/apps/backend/src/modules/payments/paymentRoutes.ts +100 -0
- package/templates/apps/backend/src/modules/payments/paymentStore.ts +172 -0
- package/templates/apps/backend/src/modules/requests/requestLog.ts +175 -0
- package/templates/apps/backend/src/modules/users/userGraph.ts +83 -0
- package/templates/apps/backend/src/modules/users/userModel.ts +88 -0
- package/templates/apps/backend/src/plugins/CronScheduler.ts +69 -0
- package/templates/apps/backend/src/plugins/loadPlugins.ts +107 -0
- package/templates/apps/backend/tsconfig.json +14 -0
- package/templates/apps/frontend/index.html +12 -0
- package/templates/apps/frontend/package.json +19 -0
- package/templates/apps/frontend/src/main.tsx +64 -0
- package/templates/apps/frontend/vite.config.ts +63 -0
- package/templates/bin/nexus.js +35 -0
- package/templates/bin/serve-all.mjs +45 -0
- package/templates/dockerignore +15 -0
- package/templates/gitignore +12 -0
- package/templates/nexus.config.ts +69 -0
- package/templates/package.json +47 -0
- package/templates/tsconfig.json +17 -0
- package/templates/uploads/.gitkeep +0 -0
- package/tests/cli.test.ts +45 -0
- package/tests/config-sync.test.ts +201 -0
- package/tests/dotenv.test.ts +51 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +9 -0
- package/vitest.config.ts.timestamp-1786095205351-444061f6ff5c58.mjs +13 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import type { Router } from '@bhooai/nexus-core';
|
|
2
|
+
import { AiClient, type ChatCompletionRequest } from '@bhooai/nexus-ai-client';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Mount OpenAI-compatible AI routes on the backend.
|
|
6
|
+
*
|
|
7
|
+
* The browser never calls external AI APIs directly — Node proxies:
|
|
8
|
+
*
|
|
9
|
+
* - provider "together" → Together AI SDK (direct, API key from config)
|
|
10
|
+
* - provider "openai"/"ollama" → Python AI server via @bhooai/nexus-ai-client
|
|
11
|
+
* - any other configured OpenAI-compatible provider → direct fetch to its
|
|
12
|
+
* baseUrl with the configured API key (so e.g. Groq/DeepSeek/Mistral list
|
|
13
|
+
* their own models instead of falling back to the AI server's providers)
|
|
14
|
+
*
|
|
15
|
+
* Node re-emits the SSE stream chunk-by-chunk so CSRF + auth still apply.
|
|
16
|
+
*
|
|
17
|
+
* Routes (CSRF-protected like all unsafe methods):
|
|
18
|
+
* POST /ai/chat/completions (streaming or JSON)
|
|
19
|
+
* POST /ai/embeddings
|
|
20
|
+
* GET /ai/models
|
|
21
|
+
*/
|
|
22
|
+
export interface AiProxyProvider {
|
|
23
|
+
id: string;
|
|
24
|
+
baseUrl?: string;
|
|
25
|
+
apiKey?: string;
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function registerAiRoutes(router: Router, opts: {
|
|
30
|
+
serverUrl: string;
|
|
31
|
+
timeoutMs: number;
|
|
32
|
+
authToken?: string;
|
|
33
|
+
/** Live AI providers array from config (shared reference — updated in-place
|
|
34
|
+
* by the admin provider management endpoints, so API key changes are visible
|
|
35
|
+
* here without restarting). */
|
|
36
|
+
providers?: Array<AiProxyProvider>;
|
|
37
|
+
}): AiClient {
|
|
38
|
+
const client = new AiClient({ serverUrl: opts.serverUrl, timeoutMs: opts.timeoutMs, authToken: opts.authToken });
|
|
39
|
+
|
|
40
|
+
// Find a configured, enabled provider (case-insensitive on the id).
|
|
41
|
+
const findProvider = (id?: string): AiProxyProvider | undefined => {
|
|
42
|
+
if (!id) return undefined;
|
|
43
|
+
return opts.providers?.find((p) => p.id.toLowerCase() === id.toLowerCase() && p.enabled);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Resolve the Together AI key from the live providers array (not a snapshot).
|
|
47
|
+
const resolveTogetherKey = (): string | undefined => {
|
|
48
|
+
if (opts.providers?.find((p) => p.id === 'together' && p.enabled)?.apiKey) {
|
|
49
|
+
return opts.providers.find((p) => p.id === 'together' && p.enabled)?.apiKey;
|
|
50
|
+
}
|
|
51
|
+
return process.env.TOGETHER_API_KEY;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Together SDK is optional — lazy-load it so scaffolded projects without
|
|
55
|
+
// together-ai installed still boot (they'd need the key to use it anyway).
|
|
56
|
+
let togetherCtor: any = null;
|
|
57
|
+
const loadTogether = async (): Promise<any> => {
|
|
58
|
+
if (!togetherCtor) {
|
|
59
|
+
const mod = await import('together-ai');
|
|
60
|
+
togetherCtor = mod.default ?? mod;
|
|
61
|
+
}
|
|
62
|
+
return togetherCtor;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Normalise a provider baseUrl to its OpenAI-compatible root (append /v1
|
|
66
|
+
// unless it already ends in a version segment like /v1).
|
|
67
|
+
const openAiBase = (baseUrl: string): string => {
|
|
68
|
+
const u = baseUrl.replace(/\/+$/, '');
|
|
69
|
+
return /\/v\d+(\/)?$/.test(u) ? u : `${u}/v1`;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const providerAuth = (p: AiProxyProvider) => (p.apiKey ? { authorization: `Bearer ${p.apiKey}` } : {});
|
|
73
|
+
|
|
74
|
+
/** Proxy a streaming chat request to an OpenAI-compatible provider, re-emitting SSE. */
|
|
75
|
+
const relayProviderStream = async (ctx: any, p: AiProxyProvider, req: ChatCompletionRequest): Promise<boolean> => {
|
|
76
|
+
ctx.res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
77
|
+
try {
|
|
78
|
+
const upstream = await fetch(`${openAiBase(p.baseUrl ?? '')}/chat/completions`, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: { 'content-type': 'application/json', ...providerAuth(p) },
|
|
81
|
+
body: JSON.stringify({ model: req.model, messages: req.messages, stream: true }),
|
|
82
|
+
});
|
|
83
|
+
if (!upstream.ok || !upstream.body) {
|
|
84
|
+
const text = await upstream.text();
|
|
85
|
+
throw new Error(`provider responded with HTTP ${upstream.status} ${text.slice(0, 300)}`);
|
|
86
|
+
}
|
|
87
|
+
const reader = upstream.body.getReader();
|
|
88
|
+
const decoder = new TextDecoder();
|
|
89
|
+
let buffer = '';
|
|
90
|
+
while (true) {
|
|
91
|
+
const { done, value } = await reader.read();
|
|
92
|
+
if (done) break;
|
|
93
|
+
buffer += decoder.decode(value, { stream: true });
|
|
94
|
+
const lines = buffer.split('\n');
|
|
95
|
+
buffer = lines.pop() ?? '';
|
|
96
|
+
for (const line of lines) {
|
|
97
|
+
const trimmed = line.trim();
|
|
98
|
+
if (trimmed.startsWith('data: ')) ctx.res.write(`${trimmed}\n\n`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
ctx.res.write('data: [DONE]\n\n');
|
|
102
|
+
} catch (err) {
|
|
103
|
+
ctx.res.write(`data: ${JSON.stringify({ error: { message: String((err as Error)?.message ?? err) } })}\n\n`);
|
|
104
|
+
} finally {
|
|
105
|
+
if (!ctx.res.writableEnded) ctx.res.end();
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// ── Chat completions: configured provider direct, Together SDK, or Python ──
|
|
111
|
+
router.post('/ai/chat/completions', async (ctx) => {
|
|
112
|
+
const req = (ctx.body ?? {}) as ChatCompletionRequest & { provider?: string };
|
|
113
|
+
const isTogether = (req as any).provider === 'together';
|
|
114
|
+
|
|
115
|
+
// ── Together AI direct SDK path ──
|
|
116
|
+
if (isTogether) {
|
|
117
|
+
const apiKey = resolveTogetherKey();
|
|
118
|
+
if (!apiKey) {
|
|
119
|
+
ctx.json({ error: { message: 'Together AI API key not configured. Set it in AI Providers or TOGETHER_API_KEY env var.' } }, 401);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const Together = await loadTogether();
|
|
124
|
+
const together = new Together({ apiKey });
|
|
125
|
+
if (req.stream) {
|
|
126
|
+
ctx.res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
127
|
+
try {
|
|
128
|
+
const stream = await together.chat.completions.create({
|
|
129
|
+
model: req.model,
|
|
130
|
+
messages: req.messages.map((m) => ({ role: m.role as 'system' | 'user' | 'assistant', content: m.content })),
|
|
131
|
+
stream: true,
|
|
132
|
+
...(req.temperature != null ? { temperature: req.temperature } : {}),
|
|
133
|
+
...(req.max_tokens != null ? { max_tokens: req.max_tokens } : {}),
|
|
134
|
+
} as any);
|
|
135
|
+
for await (const chunk of stream as any) ctx.res.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
|
136
|
+
ctx.res.write('data: [DONE]\n\n');
|
|
137
|
+
} catch (err: any) {
|
|
138
|
+
ctx.res.write(`data: ${JSON.stringify({ error: { message: err?.message ?? String(err) } })}\n\n`);
|
|
139
|
+
} finally {
|
|
140
|
+
if (!ctx.res.writableEnded) ctx.res.end();
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const result: any = await together.chat.completions.create({
|
|
145
|
+
model: req.model,
|
|
146
|
+
messages: req.messages.map((m) => ({ role: m.role as 'system' | 'user' | 'assistant', content: m.content })),
|
|
147
|
+
...(req.temperature != null ? { temperature: req.temperature } : {}),
|
|
148
|
+
...(req.max_tokens != null ? { max_tokens: req.max_tokens } : {}),
|
|
149
|
+
} as any);
|
|
150
|
+
if (result?.error) { ctx.json({ error: { message: String(result.error.message ?? result.error) } }, result.error.status ?? 502); return; }
|
|
151
|
+
ctx.json(result);
|
|
152
|
+
} catch (err: any) {
|
|
153
|
+
const status = err?.status ?? 502;
|
|
154
|
+
const message = String(err?.message ?? err?.error?.message ?? JSON.stringify(err));
|
|
155
|
+
const body = JSON.stringify({ error: { message, status } });
|
|
156
|
+
if (!ctx.res.headersSent) { ctx.res.statusCode = status; ctx.res.setHeader('content-type', 'application/json; charset=utf-8'); }
|
|
157
|
+
if (!ctx.res.writableEnded) ctx.res.end(body);
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Other configured OpenAI-compatible providers (direct) ──
|
|
163
|
+
const cfgProvider = findProvider(req.provider);
|
|
164
|
+
if (cfgProvider && cfgProvider.baseUrl) {
|
|
165
|
+
if (req.stream) {
|
|
166
|
+
await relayProviderStream(ctx, cfgProvider, req);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const upstream = await fetch(`${openAiBase(cfgProvider.baseUrl)}/chat/completions`, {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: { 'content-type': 'application/json', ...providerAuth(cfgProvider) },
|
|
173
|
+
body: JSON.stringify({ model: req.model, messages: req.messages }),
|
|
174
|
+
});
|
|
175
|
+
const data = await upstream.json().catch(() => ({}));
|
|
176
|
+
if (!upstream.ok) {
|
|
177
|
+
ctx.json({ error: { message: data?.error?.message ?? `provider responded with HTTP ${upstream.status}` } }, upstream.status === 401 ? 401 : 502);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
ctx.json(data);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
ctx.json({ error: { message: String((err as Error)?.message ?? err) } }, 502);
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Default: proxy to the Python AI server ──
|
|
188
|
+
if (req.stream) {
|
|
189
|
+
ctx.res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
190
|
+
try {
|
|
191
|
+
for await (const chunk of client.chatStream(req)) ctx.res.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
|
192
|
+
ctx.res.write('data: [DONE]\n\n');
|
|
193
|
+
} catch (err) {
|
|
194
|
+
ctx.res.write(`data: ${JSON.stringify({ error: { message: String((err as Error)?.message ?? err) } })}\n\n`);
|
|
195
|
+
} finally {
|
|
196
|
+
if (!ctx.res.writableEnded) ctx.res.end();
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
ctx.json(await client.chat(req));
|
|
202
|
+
} catch (err) {
|
|
203
|
+
ctx.json({ error: { message: String((err as Error)?.message ?? err) } }, (err as { status?: number })?.status ?? 502);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ── Models: configured provider direct, Together SDK, or Python ──────────
|
|
208
|
+
router.get('/ai/models', async (ctx) => {
|
|
209
|
+
const provider = typeof ctx.query['provider'] === 'string' ? (ctx.query['provider'] as string) : undefined;
|
|
210
|
+
|
|
211
|
+
// Other configured OpenAI-compatible providers → list their own /models.
|
|
212
|
+
const cfgProvider = findProvider(provider);
|
|
213
|
+
if (cfgProvider && cfgProvider.baseUrl) {
|
|
214
|
+
try {
|
|
215
|
+
const upstream = await fetch(`${openAiBase(cfgProvider.baseUrl)}/models`, { headers: providerAuth(cfgProvider) });
|
|
216
|
+
const data = await upstream.json().catch(() => ({}));
|
|
217
|
+
if (!upstream.ok) {
|
|
218
|
+
ctx.json({ provider: cfgProvider.id, data: [], error: data?.error?.message ?? `provider responded with HTTP ${upstream.status}` });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const models = Array.isArray(data?.data)
|
|
222
|
+
? (data.data as any[]).map((m: any) => ({ id: String(m.id ?? m), owned_by: m.owned_by ?? cfgProvider.id }))
|
|
223
|
+
: [];
|
|
224
|
+
ctx.json({ provider: cfgProvider.id, data: models });
|
|
225
|
+
} catch (err) {
|
|
226
|
+
ctx.json({ provider: cfgProvider.id, data: [], error: String((err as Error)?.message ?? err) });
|
|
227
|
+
}
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (provider === 'together') {
|
|
232
|
+
const apiKey = resolveTogetherKey();
|
|
233
|
+
if (!apiKey) {
|
|
234
|
+
ctx.json({ provider: 'together', data: [] });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
const Together = await loadTogether();
|
|
239
|
+
const together = new Together({ apiKey });
|
|
240
|
+
const models = await together.models.list();
|
|
241
|
+
ctx.json({ provider: 'together', data: (models as any[])?.map((m: any) => ({ id: m.id, owned_by: m.owned_by ?? 'together' })) ?? [] });
|
|
242
|
+
} catch (err) {
|
|
243
|
+
ctx.json({ provider: 'together', data: [], error: String((err as Error)?.message ?? err) });
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
ctx.json(await client.listModels(provider));
|
|
250
|
+
} catch (err) {
|
|
251
|
+
ctx.json({ error: { message: String((err as Error)?.message ?? err) } }, (err as { status?: number })?.status ?? 502);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
router.post('/ai/embeddings', async (ctx) => {
|
|
256
|
+
const req = (ctx.body ?? {}) as { model: string; input: string | string[]; provider?: string };
|
|
257
|
+
try {
|
|
258
|
+
ctx.json(await client.embeddings(req));
|
|
259
|
+
} catch (err) {
|
|
260
|
+
ctx.json({ error: { message: String((err as Error)?.message ?? err) } }, (err as { status?: number })?.status ?? 502);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
return client;
|
|
265
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { Router } from '@bhooai/nexus-core/http';
|
|
2
|
+
import type { NexusConfig } from '@bhooai/nexus-core';
|
|
3
|
+
import { ConflictError, AuthenticationError, ValidationError } from '@bhooai/nexus-core';
|
|
4
|
+
import { ObjectId } from '@bhooai/nexus-data';
|
|
5
|
+
import {
|
|
6
|
+
AuthService,
|
|
7
|
+
MemorySessionStore,
|
|
8
|
+
authToken,
|
|
9
|
+
requireAuth,
|
|
10
|
+
setAuthCookies,
|
|
11
|
+
clearAuthCookies,
|
|
12
|
+
readCookieValue,
|
|
13
|
+
hashPassword,
|
|
14
|
+
verifyPassword,
|
|
15
|
+
buildGoogleAuthUrl,
|
|
16
|
+
buildFacebookAuthUrl,
|
|
17
|
+
exchangeGoogleCode,
|
|
18
|
+
exchangeFacebookCode,
|
|
19
|
+
fetchGoogleProfile,
|
|
20
|
+
fetchFacebookProfile,
|
|
21
|
+
generateState,
|
|
22
|
+
generatePkceVerifier,
|
|
23
|
+
MemoryOAuthStateStore,
|
|
24
|
+
type GoogleOAuthConfig,
|
|
25
|
+
type FacebookOAuthConfig,
|
|
26
|
+
} from '@bhooai/nexus-auth';
|
|
27
|
+
import { initUserModel, getUserModel, findUserForLogin, upsertOAuthUser } from '../users/userModel.js';
|
|
28
|
+
|
|
29
|
+
const sessions = new MemorySessionStore();
|
|
30
|
+
const oauthState = new MemoryOAuthStateStore();
|
|
31
|
+
|
|
32
|
+
function origin(config: NexusConfig): string {
|
|
33
|
+
const proto = config.server.https ? 'https' : 'http';
|
|
34
|
+
return `${proto}://${config.server.host === '0.0.0.0' ? 'localhost' : config.server.host}:${config.server.port}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function jwtOptions(config: NexusConfig) {
|
|
38
|
+
return {
|
|
39
|
+
secret: config.auth.jwt.secret,
|
|
40
|
+
algorithm: 'HS256' as const,
|
|
41
|
+
issuer: config.auth.jwt.issuer,
|
|
42
|
+
audience: config.auth.jwt.audience,
|
|
43
|
+
accessTtl: config.auth.jwt.accessTtl,
|
|
44
|
+
refreshTtl: config.auth.jwt.refreshTtl,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function authService(config: NexusConfig): AuthService {
|
|
49
|
+
return new AuthService(jwtOptions(config), sessions);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Register /auth/* routes onto the given router. Call after `connect()` + `initUserModel()`. */
|
|
53
|
+
export function registerAuthRoutes(router: Router, config: NexusConfig, onUserCreated?: () => void): void {
|
|
54
|
+
initUserModel();
|
|
55
|
+
const service = authService(config);
|
|
56
|
+
|
|
57
|
+
// POST /auth/register
|
|
58
|
+
router.post('/auth/register', async (ctx) => {
|
|
59
|
+
const body = (ctx.body ?? {}) as { email?: string; password?: string; name?: string };
|
|
60
|
+
if (!body.email || !body.password) throw new ValidationError('email and password are required');
|
|
61
|
+
if (body.password.length < 8) throw new ValidationError('password must be at least 8 characters');
|
|
62
|
+
|
|
63
|
+
const User = getUserModel();
|
|
64
|
+
const existing = await User.findOne({ email: body.email.toLowerCase() }).lean();
|
|
65
|
+
if (existing) throw new ConflictError('A user with that email already exists');
|
|
66
|
+
|
|
67
|
+
const passwordHash = await hashPassword(body.password);
|
|
68
|
+
// Bootstrap: the very first registered user becomes an admin.
|
|
69
|
+
const userCount = await User.countDocuments();
|
|
70
|
+
const roles = userCount === 0 ? ['admin'] : ['user'];
|
|
71
|
+
const [user] = await User.create({ email: body.email, name: body.name, passwordHash, roles });
|
|
72
|
+
// Notify subscribers (e.g. the GraphQL `userCount` subscription) if wired.
|
|
73
|
+
onUserCreated?.();
|
|
74
|
+
const pair = await service.login({ userId: String(user._id), roles: user.roles, meta: { ip: ctx.req.socket.remoteAddress } });
|
|
75
|
+
setAuthCookies(ctx, pair, { accessTokenName: config.auth.cookieName, refreshTokenName: config.auth.refreshCookieName });
|
|
76
|
+
ctx.json({ user: publicUser(user), accessToken: pair.accessToken, refreshToken: pair.refreshToken });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// POST /auth/login
|
|
80
|
+
router.post('/auth/login', async (ctx) => {
|
|
81
|
+
const body = (ctx.body ?? {}) as { email?: string; password?: string };
|
|
82
|
+
if (!body.email || !body.password) throw new ValidationError('email and password are required');
|
|
83
|
+
const user = await findUserForLogin(body.email);
|
|
84
|
+
if (!user || !user.passwordHash) throw new AuthenticationError('Invalid email or password');
|
|
85
|
+
const ok = await verifyPassword(body.password, user.passwordHash);
|
|
86
|
+
if (!ok) throw new AuthenticationError('Invalid email or password');
|
|
87
|
+
const pair = await service.login({ userId: String(user._id), roles: user.roles });
|
|
88
|
+
setAuthCookies(ctx, pair, { accessTokenName: config.auth.cookieName, refreshTokenName: config.auth.refreshCookieName });
|
|
89
|
+
ctx.json({ user: publicUser(user), accessToken: pair.accessToken, refreshToken: pair.refreshToken });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// POST /auth/refresh
|
|
93
|
+
router.post('/auth/refresh', async (ctx) => {
|
|
94
|
+
const body = (ctx.body ?? {}) as { refreshToken?: string };
|
|
95
|
+
const refreshToken = body.refreshToken ?? readCookieValue(ctx, config.auth.refreshCookieName);
|
|
96
|
+
if (!refreshToken) throw new AuthenticationError('Missing refresh token');
|
|
97
|
+
const pair = await service.refresh(refreshToken);
|
|
98
|
+
setAuthCookies(ctx, pair, { accessTokenName: config.auth.cookieName, refreshTokenName: config.auth.refreshCookieName });
|
|
99
|
+
ctx.json({ accessToken: pair.accessToken, refreshToken: pair.refreshToken });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// POST /auth/logout
|
|
103
|
+
router.post('/auth/logout', async (ctx) => {
|
|
104
|
+
// Optional auth: end the session if a token is present, but never 401 on logout.
|
|
105
|
+
await authToken(service, { required: false, allowCookie: true, cookieName: config.auth.cookieName })(ctx, async () => {});
|
|
106
|
+
const sid = (ctx.state.user as { sid?: string } | undefined)?.sid;
|
|
107
|
+
if (sid) await service.logout(sid);
|
|
108
|
+
clearAuthCookies(ctx, { accessTokenName: config.auth.cookieName, refreshTokenName: config.auth.refreshCookieName });
|
|
109
|
+
ctx.json({ ok: true });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// GET /auth/me
|
|
113
|
+
router.get('/auth/me', async (ctx) => {
|
|
114
|
+
const User = getUserModel();
|
|
115
|
+
const id = (ctx.state.user as { id: string }).id;
|
|
116
|
+
const user = await User.findById(id).lean();
|
|
117
|
+
ctx.json({ user: publicUser(user) });
|
|
118
|
+
}, [authToken(service, { cookieName: config.auth.cookieName }), requireAuth()]);
|
|
119
|
+
|
|
120
|
+
// PUT /auth/profile — update the signed-in user's display name.
|
|
121
|
+
router.put('/auth/profile', async (ctx) => {
|
|
122
|
+
const body = (ctx.body ?? {}) as { name?: string };
|
|
123
|
+
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
|
124
|
+
if (!name) throw new ValidationError('name is required');
|
|
125
|
+
const id = (ctx.state.user as { id: string }).id;
|
|
126
|
+
const User = getUserModel();
|
|
127
|
+
await User.updateOne({ _id: id }, { $set: { name } });
|
|
128
|
+
const user = await User.findById(id).lean();
|
|
129
|
+
ctx.json({ ok: true, user: publicUser(user) });
|
|
130
|
+
}, [authToken(service, { cookieName: config.auth.cookieName }), requireAuth()]);
|
|
131
|
+
|
|
132
|
+
// POST /auth/change-password — verify the current password, then set a new one.
|
|
133
|
+
router.post('/auth/change-password', async (ctx) => {
|
|
134
|
+
const body = (ctx.body ?? {}) as { currentPassword?: string; newPassword?: string };
|
|
135
|
+
if (!body.currentPassword) throw new ValidationError('current password is required');
|
|
136
|
+
if (!body.newPassword || body.newPassword.length < 8) throw new ValidationError('password must be at least 8 characters');
|
|
137
|
+
|
|
138
|
+
const id = (ctx.state.user as { id: string }).id;
|
|
139
|
+
const User = getUserModel();
|
|
140
|
+
const coll = await User.collection;
|
|
141
|
+
const raw = await coll.findOne({ _id: new ObjectId(id) });
|
|
142
|
+
if (!raw) throw new AuthenticationError('User not found');
|
|
143
|
+
const passwordHash = raw.passwordHash as string | undefined;
|
|
144
|
+
if (!passwordHash) throw new AuthenticationError('This account has no password (OAuth account)');
|
|
145
|
+
const ok = await verifyPassword(body.currentPassword, passwordHash);
|
|
146
|
+
if (!ok) throw new AuthenticationError('Current password is incorrect');
|
|
147
|
+
const nextHash = await hashPassword(body.newPassword);
|
|
148
|
+
await User.updateOne({ _id: id }, { $set: { passwordHash: nextHash } });
|
|
149
|
+
ctx.json({ ok: true });
|
|
150
|
+
}, [authToken(service, { cookieName: config.auth.cookieName }), requireAuth()]);
|
|
151
|
+
|
|
152
|
+
// ── OAuth: Google ──────────────────────────────────────────────────────────
|
|
153
|
+
if (config.auth.google?.clientId) {
|
|
154
|
+
const google: GoogleOAuthConfig = {
|
|
155
|
+
clientId: config.auth.google.clientId,
|
|
156
|
+
clientSecret: config.auth.google.clientSecret,
|
|
157
|
+
redirectUri: `${origin(config)}${config.auth.google.callbackPath}`,
|
|
158
|
+
scope: config.auth.google.scope.split(/\s+/).filter(Boolean),
|
|
159
|
+
};
|
|
160
|
+
router.get('/auth/google', async (ctx) => {
|
|
161
|
+
const state = generateState();
|
|
162
|
+
const verifier = generatePkceVerifier();
|
|
163
|
+
await oauthState.set(state, { provider: 'google', verifier }, 5 * 60_000);
|
|
164
|
+
ctx.redirect(buildGoogleAuthUrl(google, { state, verifier }));
|
|
165
|
+
});
|
|
166
|
+
router.get(config.auth.google.callbackPath, async (ctx) => {
|
|
167
|
+
const code = ctx.query.code as string | undefined;
|
|
168
|
+
const state = ctx.query.state as string | undefined;
|
|
169
|
+
if (!code || !state) throw new AuthenticationError('Missing OAuth code/state');
|
|
170
|
+
const data = await oauthState.consume(state);
|
|
171
|
+
if (!data || data.provider !== 'google') throw new AuthenticationError('Invalid OAuth state');
|
|
172
|
+
const tokens = await exchangeGoogleCode(code, google, data.verifier as string);
|
|
173
|
+
const profile = await fetchGoogleProfile(tokens.accessToken);
|
|
174
|
+
const user = await upsertOAuthUser({ provider: 'google', providerUserId: profile.providerUserId, email: profile.email, name: profile.name });
|
|
175
|
+
const pair = await service.login({ userId: String(user._id), roles: user.roles });
|
|
176
|
+
setAuthCookies(ctx, pair, { accessTokenName: config.auth.cookieName, refreshTokenName: config.auth.refreshCookieName });
|
|
177
|
+
ctx.json({ user: publicUser(user), accessToken: pair.accessToken });
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── OAuth: Facebook ─────────────────────────────────────────────────────────
|
|
182
|
+
if (config.auth.facebook?.clientId) {
|
|
183
|
+
const facebook: FacebookOAuthConfig = {
|
|
184
|
+
clientId: config.auth.facebook.clientId,
|
|
185
|
+
clientSecret: config.auth.facebook.clientSecret,
|
|
186
|
+
redirectUri: `${origin(config)}${config.auth.facebook.callbackPath}`,
|
|
187
|
+
scope: config.auth.facebook.scope.split(/\s+/).filter(Boolean),
|
|
188
|
+
};
|
|
189
|
+
router.get('/auth/facebook', async (ctx) => {
|
|
190
|
+
const state = generateState();
|
|
191
|
+
await oauthState.set(state, { provider: 'facebook' }, 5 * 60_000);
|
|
192
|
+
ctx.redirect(buildFacebookAuthUrl(facebook, state));
|
|
193
|
+
});
|
|
194
|
+
router.get(config.auth.facebook.callbackPath, async (ctx) => {
|
|
195
|
+
const code = ctx.query.code as string | undefined;
|
|
196
|
+
const state = ctx.query.state as string | undefined;
|
|
197
|
+
if (!code || !state) throw new AuthenticationError('Missing OAuth code/state');
|
|
198
|
+
const data = await oauthState.consume(state);
|
|
199
|
+
if (!data || data.provider !== 'facebook') throw new AuthenticationError('Invalid OAuth state');
|
|
200
|
+
const tokens = await exchangeFacebookCode(code, facebook);
|
|
201
|
+
const profile = await fetchFacebookProfile(tokens.accessToken);
|
|
202
|
+
const user = await upsertOAuthUser({ provider: 'facebook', providerUserId: profile.providerUserId, email: profile.email, name: profile.name });
|
|
203
|
+
const pair = await service.login({ userId: String(user._id), roles: user.roles });
|
|
204
|
+
setAuthCookies(ctx, pair, { accessTokenName: config.auth.cookieName, refreshTokenName: config.auth.refreshCookieName });
|
|
205
|
+
ctx.json({ user: publicUser(user), accessToken: pair.accessToken });
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function publicUser(user: unknown): Record<string, unknown> | null {
|
|
211
|
+
if (!user) return null;
|
|
212
|
+
// DocumentInstance → use toObject() (avoids spreading the Proxy and its circular _model).
|
|
213
|
+
// Plain objects (raw lean docs / JWT claims) → spread directly.
|
|
214
|
+
const obj =
|
|
215
|
+
(user as { toObject?: () => Record<string, unknown> }).toObject?.() ??
|
|
216
|
+
(user as Record<string, unknown>);
|
|
217
|
+
const u = { ...obj };
|
|
218
|
+
delete u.passwordHash;
|
|
219
|
+
return u;
|
|
220
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { Router, NexusConfig } from '@bhooai/nexus-core';
|
|
2
|
+
import type { PaymentsService } from '@bhooai/nexus-payments';
|
|
3
|
+
import { authToken, type AuthService } from '@bhooai/nexus-auth';
|
|
4
|
+
import { upsertOrder } from './paymentStore.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Phase 11 — browser-facing checkout routes. The browser creates an order and
|
|
8
|
+
* (for hosted-checkout providers) is redirected to `paymentUrl`; for direct
|
|
9
|
+
* providers the client confirms capture via a second call. Webhooks remain the
|
|
10
|
+
* source of truth (mounted separately in main.ts).
|
|
11
|
+
*
|
|
12
|
+
* Routes:
|
|
13
|
+
* POST /payments/order — create an order with a named provider
|
|
14
|
+
* POST /payments/order/:id/capture — capture an authorized order
|
|
15
|
+
* GET /payments/order/:id — get order status (provider in query)
|
|
16
|
+
*/
|
|
17
|
+
export function registerPaymentRoutes(
|
|
18
|
+
router: Router,
|
|
19
|
+
config: NexusConfig,
|
|
20
|
+
payments: PaymentsService,
|
|
21
|
+
authService: AuthService,
|
|
22
|
+
): void {
|
|
23
|
+
const guard = [authToken(authService, { cookieName: config.auth.cookieName, allowCookie: true })];
|
|
24
|
+
|
|
25
|
+
router.post(
|
|
26
|
+
'/payments/order',
|
|
27
|
+
async (ctx) => {
|
|
28
|
+
const body = ctx.body as { provider?: string; amount?: number; currency?: string; reference?: string; description?: string; returnUrl?: string; cancelUrl?: string } | undefined;
|
|
29
|
+
const providerName = (body?.provider ?? '').toLowerCase();
|
|
30
|
+
const provider = payments.providers.get(providerName);
|
|
31
|
+
if (!provider) {
|
|
32
|
+
ctx.json({ error: `Unknown or disabled provider: ${providerName}. Enabled: ${[...payments.providers.keys()].join(', ') || 'none'}` }, 400);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (typeof body?.amount !== 'number' || body.amount <= 0) {
|
|
36
|
+
ctx.json({ error: 'amount (major units, > 0) is required' }, 400);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const origin = `http${config.server.https ? 's' : ''}://${ctx.headers.host}`;
|
|
40
|
+
let order;
|
|
41
|
+
try {
|
|
42
|
+
order = await provider.createOrder({
|
|
43
|
+
amount: body.amount,
|
|
44
|
+
currency: body.currency ?? 'USD',
|
|
45
|
+
reference: body.reference ?? `nx_${Date.now()}`,
|
|
46
|
+
description: body.description,
|
|
47
|
+
returnUrl: body.returnUrl ?? `${origin}/checkout/return`,
|
|
48
|
+
cancelUrl: body.cancelUrl ?? `${origin}/checkout/cancel`,
|
|
49
|
+
});
|
|
50
|
+
} catch (e) {
|
|
51
|
+
ctx.json({ error: `${providerName} order failed: ${(e as Error).message}` }, 502);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
await upsertOrder(order, providerName, {
|
|
55
|
+
customer: (body as Record<string, unknown>).customer as Record<string, unknown> | undefined,
|
|
56
|
+
description: body.description,
|
|
57
|
+
});
|
|
58
|
+
ctx.json({ provider: providerName, order });
|
|
59
|
+
},
|
|
60
|
+
guard,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
router.post(
|
|
64
|
+
'/payments/order/:id/capture',
|
|
65
|
+
async (ctx) => {
|
|
66
|
+
const providerName = ((ctx.body as { provider?: string } | undefined)?.provider ?? '').toLowerCase();
|
|
67
|
+
const provider = payments.providers.get(providerName);
|
|
68
|
+
if (!provider) { ctx.json({ error: 'Unknown or disabled provider' }, 400); return; }
|
|
69
|
+
let order;
|
|
70
|
+
try {
|
|
71
|
+
order = await provider.capture({ orderId: ctx.params.id, amount: (ctx.body as { amount?: number } | undefined)?.amount } as any);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
ctx.json({ error: `${providerName} capture failed: ${(e as Error).message}` }, 502);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
await upsertOrder(order, providerName);
|
|
77
|
+
ctx.json({ provider: providerName, order });
|
|
78
|
+
},
|
|
79
|
+
guard,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
router.get(
|
|
83
|
+
'/payments/order/:id',
|
|
84
|
+
async (ctx) => {
|
|
85
|
+
const providerName = (ctx.query.provider ?? '').toString().toLowerCase();
|
|
86
|
+
const provider = payments.providers.get(providerName);
|
|
87
|
+
if (!provider) { ctx.json({ error: 'Unknown or disabled provider (pass ?provider=)' }, 400); return; }
|
|
88
|
+
let order;
|
|
89
|
+
try {
|
|
90
|
+
order = await provider.getOrderStatus(ctx.params.id);
|
|
91
|
+
} catch (e) {
|
|
92
|
+
ctx.json({ error: `${providerName} status failed: ${(e as Error).message}` }, 502);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
await upsertOrder(order, providerName);
|
|
96
|
+
ctx.json({ provider: providerName, order });
|
|
97
|
+
},
|
|
98
|
+
guard,
|
|
99
|
+
);
|
|
100
|
+
}
|