muxmind-ai 2.1.1
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/.env.example +11 -0
- package/README.md +81 -0
- package/assets/favicon.png +0 -0
- package/assets/muxmind-logo-full.png +0 -0
- package/assets/muxmind-logo-icon.png +0 -0
- package/bin/cli.js +98 -0
- package/index.html +293 -0
- package/package.json +47 -0
- package/server.js +265 -0
- package/src/api-manager.js +227 -0
- package/src/auth.js +159 -0
- package/src/config.js +267 -0
- package/src/file-parser.js +122 -0
- package/src/image-engine.js +73 -0
- package/src/router.js +317 -0
- package/src/tts-engine.js +88 -0
- package/src/ui-render.js +91 -0
- package/src-client/app.js +1306 -0
- package/src-client/i18n.js +229 -0
- package/style.css +948 -0
package/server.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MuxMind AI (v1.0 Enterprise Edition) — High-Concurrency Multi-Core Server
|
|
3
|
+
* Node.js `cluster` module fans requests across all available CPU cores.
|
|
4
|
+
* No provider API keys are ever read from process.env — each request
|
|
5
|
+
* supplies its own vault entries. A local .env is only used for PORT,
|
|
6
|
+
* cluster toggle, and the login password/session secret.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
require('dotenv').config();
|
|
12
|
+
const cluster = require('cluster');
|
|
13
|
+
const os = require('os');
|
|
14
|
+
const express = require('express');
|
|
15
|
+
const cors = require('cors');
|
|
16
|
+
const rateLimit = require('express-rate-limit');
|
|
17
|
+
const multer = require('multer');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
const { SERVER_CONFIG, PROVIDERS, IMAGE_PROVIDERS } = require('./src/config');
|
|
21
|
+
const { healthCheckAll } = require('./src/api-manager');
|
|
22
|
+
const { staggeredMultiModelStream } = require('./src/router');
|
|
23
|
+
const { generateImage } = require('./src/image-engine');
|
|
24
|
+
const { buildSpeechJob } = require('./src/tts-engine');
|
|
25
|
+
const { parseUpload, buildContextFromUploads } = require('./src/file-parser');
|
|
26
|
+
const { createTaskTracker, recordProviderTurn, completeTask, failTask } = require('./src/ui-render');
|
|
27
|
+
const { issueToken, checkPassword, changePassword, requireAuth } = require('./src/auth');
|
|
28
|
+
|
|
29
|
+
const numCPUs = os.cpus().length || 1;
|
|
30
|
+
|
|
31
|
+
if (cluster.isPrimary && process.env.MUXMIND_CLUSTER !== 'off') {
|
|
32
|
+
console.log(`[MuxMind AI] Primary ${process.pid} launching ${numCPUs} worker(s)...`);
|
|
33
|
+
for (let i = 0; i < numCPUs; i++) cluster.fork();
|
|
34
|
+
cluster.on('exit', (worker, code, signal) => {
|
|
35
|
+
console.warn(`[MuxMind AI] Worker ${worker.process.pid} died (${signal || code}). Restarting...`);
|
|
36
|
+
cluster.fork();
|
|
37
|
+
});
|
|
38
|
+
} else {
|
|
39
|
+
startWorker();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function startWorker() {
|
|
43
|
+
const app = express();
|
|
44
|
+
|
|
45
|
+
app.use(cors());
|
|
46
|
+
app.use(express.json({ limit: SERVER_CONFIG.BODY_LIMIT }));
|
|
47
|
+
app.use(express.static(path.join(__dirname), { index: false }));
|
|
48
|
+
|
|
49
|
+
// Basic security headers (no extra dependency required).
|
|
50
|
+
app.use((req, res, next) => {
|
|
51
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
52
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
53
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
54
|
+
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(self), camera=()');
|
|
55
|
+
next();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const apiLimiter = rateLimit({
|
|
59
|
+
windowMs: SERVER_CONFIG.RATE_LIMIT_WINDOW_MS,
|
|
60
|
+
max: SERVER_CONFIG.RATE_LIMIT_MAX,
|
|
61
|
+
standardHeaders: true,
|
|
62
|
+
legacyHeaders: false,
|
|
63
|
+
message: { error: 'Rate limit exceeded. Please slow down.' },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const loginLimiter = rateLimit({
|
|
67
|
+
windowMs: SERVER_CONFIG.RATE_LIMIT_WINDOW_MS,
|
|
68
|
+
max: SERVER_CONFIG.LOGIN_RATE_LIMIT_MAX,
|
|
69
|
+
standardHeaders: true,
|
|
70
|
+
legacyHeaders: false,
|
|
71
|
+
message: { error: 'Too many login attempts. Please wait a minute.' },
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
app.use('/api/', apiLimiter);
|
|
75
|
+
|
|
76
|
+
const upload = multer({
|
|
77
|
+
storage: multer.memoryStorage(),
|
|
78
|
+
limits: { fileSize: SERVER_CONFIG.MAX_UPLOAD_SIZE_BYTES },
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------
|
|
82
|
+
// Health / auth
|
|
83
|
+
// ---------------------------------------------------------------------
|
|
84
|
+
app.get('/api/ping', (req, res) => {
|
|
85
|
+
res.json({ ok: true, pid: process.pid, worker: cluster.worker?.id || 'single', time: Date.now() });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
app.post('/api/auth/login', loginLimiter, (req, res) => {
|
|
89
|
+
const { password } = req.body || {};
|
|
90
|
+
if (!checkPassword(password)) {
|
|
91
|
+
return res.status(401).json({ ok: false, error: 'Incorrect password.' });
|
|
92
|
+
}
|
|
93
|
+
res.json({ ok: true, token: issueToken() });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Change password from inside the app (Settings page). Requires the
|
|
97
|
+
// current valid session AND re-confirmation of the current password.
|
|
98
|
+
app.post('/api/auth/change-password', requireAuth, loginLimiter, (req, res) => {
|
|
99
|
+
const { currentPassword, newPassword } = req.body || {};
|
|
100
|
+
const result = changePassword(currentPassword, newPassword);
|
|
101
|
+
if (!result.ok) return res.status(400).json({ ok: false, error: result.error });
|
|
102
|
+
res.json({ ok: true, token: result.token });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------
|
|
106
|
+
// Provider directory (for the sidebar "Providers" page): id, label,
|
|
107
|
+
// color, and the official console URL to obtain an API key. No secrets.
|
|
108
|
+
// ---------------------------------------------------------------------
|
|
109
|
+
app.get('/api/providers', requireAuth, (req, res) => {
|
|
110
|
+
const list = Object.values(PROVIDERS).map((p) => ({
|
|
111
|
+
id: p.id,
|
|
112
|
+
label: p.label,
|
|
113
|
+
color: p.color,
|
|
114
|
+
keyConsoleUrl: p.keyConsoleUrl,
|
|
115
|
+
docsUrl: p.docsUrl,
|
|
116
|
+
keyPrefix: p.keyPrefix,
|
|
117
|
+
}));
|
|
118
|
+
res.json({ ok: true, providers: list });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------
|
|
122
|
+
// Vault: smart live health-check + real-model probing for a batch of
|
|
123
|
+
// user-supplied provider keys. Keys used only in-memory, never persisted.
|
|
124
|
+
// ---------------------------------------------------------------------
|
|
125
|
+
app.post('/api/vault/health-check', requireAuth, async (req, res) => {
|
|
126
|
+
try {
|
|
127
|
+
const { entries } = req.body || {};
|
|
128
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
129
|
+
return res.status(400).json({ error: 'entries[] is required.' });
|
|
130
|
+
}
|
|
131
|
+
const results = await healthCheckAll(entries);
|
|
132
|
+
res.json({ ok: true, results });
|
|
133
|
+
} catch (err) {
|
|
134
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------
|
|
139
|
+
// File upload / multi-modal parsing
|
|
140
|
+
// ---------------------------------------------------------------------
|
|
141
|
+
app.post('/api/upload', requireAuth, upload.array('files', 10), (req, res) => {
|
|
142
|
+
try {
|
|
143
|
+
const files = req.files || [];
|
|
144
|
+
const parsed = files.map((f) =>
|
|
145
|
+
parseUpload({ filename: f.originalname, mimetype: f.mimetype, buffer: f.buffer })
|
|
146
|
+
);
|
|
147
|
+
const context = buildContextFromUploads(parsed);
|
|
148
|
+
res.json({ ok: true, parsed, ...context });
|
|
149
|
+
} catch (err) {
|
|
150
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------
|
|
155
|
+
// Image generation — uses whichever vault key the client marks capable
|
|
156
|
+
// (OpenAI or Gemini keys the client has already smart-validated).
|
|
157
|
+
// ---------------------------------------------------------------------
|
|
158
|
+
app.post('/api/image/generate', requireAuth, async (req, res) => {
|
|
159
|
+
try {
|
|
160
|
+
const { providerId, apiKey, model, prompt, size } = req.body || {};
|
|
161
|
+
if (!prompt || !prompt.trim()) return res.status(400).json({ ok: false, error: 'prompt is required.' });
|
|
162
|
+
if (!IMAGE_PROVIDERS[providerId]) {
|
|
163
|
+
return res.status(400).json({ ok: false, error: `${providerId || 'This provider'} does not support image generation here. Use OpenAI or Gemini.` });
|
|
164
|
+
}
|
|
165
|
+
if (!apiKey) return res.status(400).json({ ok: false, error: 'apiKey is required.' });
|
|
166
|
+
|
|
167
|
+
const images = await generateImage({ providerId, apiKey, model, prompt: prompt.trim(), size });
|
|
168
|
+
res.json({ ok: true, images });
|
|
169
|
+
} catch (err) {
|
|
170
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------
|
|
175
|
+
// TTS job builder
|
|
176
|
+
// ---------------------------------------------------------------------
|
|
177
|
+
app.post('/api/tts/prepare', requireAuth, (req, res) => {
|
|
178
|
+
try {
|
|
179
|
+
const { text, lang } = req.body || {};
|
|
180
|
+
if (!text) return res.status(400).json({ error: 'text is required.' });
|
|
181
|
+
const job = buildSpeechJob(text, lang === 'off' ? null : lang);
|
|
182
|
+
res.json({ ok: true, job });
|
|
183
|
+
} catch (err) {
|
|
184
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------
|
|
189
|
+
// Core chat/orchestration endpoint — SSE stream driven by the smart
|
|
190
|
+
// staggered multi-model token optimization engine.
|
|
191
|
+
// ---------------------------------------------------------------------
|
|
192
|
+
app.post('/api/chat/stream', requireAuth, async (req, res) => {
|
|
193
|
+
const { vault: rawVault, messages, compressionLevel = 50, taskId = `task_${Date.now()}` } = req.body || {};
|
|
194
|
+
|
|
195
|
+
// Only ever route to providers we recognize and that carry a concrete
|
|
196
|
+
// model id — this is the server-side guarantee behind "Smart Saver
|
|
197
|
+
// only ever uses working models": nothing is invented or guessed here,
|
|
198
|
+
// only what the client already smart-validated (status: 'active') and
|
|
199
|
+
// forwarded with an explicit model.
|
|
200
|
+
const vault = (Array.isArray(rawVault) ? rawVault : []).filter(
|
|
201
|
+
(v) => v && PROVIDERS[v.providerId] && typeof v.apiKey === 'string' && v.apiKey && typeof v.model === 'string' && v.model
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
if (vault.length === 0) {
|
|
205
|
+
return res.status(400).json({ error: 'vault[] with at least one valid, verified provider configuration is required.' });
|
|
206
|
+
}
|
|
207
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
208
|
+
return res.status(400).json({ error: 'messages[] is required.' });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
res.writeHead(200, {
|
|
212
|
+
'Content-Type': 'text/event-stream',
|
|
213
|
+
'Cache-Control': 'no-cache',
|
|
214
|
+
Connection: 'keep-alive',
|
|
215
|
+
'X-Accel-Buffering': 'no',
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const tracker = createTaskTracker(taskId);
|
|
219
|
+
const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
220
|
+
|
|
221
|
+
let aborted = false;
|
|
222
|
+
req.on('close', () => { aborted = true; });
|
|
223
|
+
|
|
224
|
+
send('start', { taskId, pid: process.pid });
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const fullText = await staggeredMultiModelStream({
|
|
228
|
+
vault,
|
|
229
|
+
messages,
|
|
230
|
+
compressionLevel: Number(compressionLevel),
|
|
231
|
+
abortSignal: { get aborted() { return aborted; } },
|
|
232
|
+
onProviderSwitch: (provider, model) => {
|
|
233
|
+
recordProviderTurn(tracker, provider, model, tracker.providerTimeline.length);
|
|
234
|
+
send('provider', { provider, model, turn: tracker.providerTimeline.length });
|
|
235
|
+
},
|
|
236
|
+
onChunk: (text, meta) => send('chunk', { text, ...meta }),
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
completeTask(tracker, fullText);
|
|
240
|
+
send('done', { taskId, tokensApprox: tracker.tokensApprox, providerTimeline: tracker.providerTimeline });
|
|
241
|
+
} catch (err) {
|
|
242
|
+
failTask(tracker, err.message);
|
|
243
|
+
send('error', { taskId, error: err.message });
|
|
244
|
+
} finally {
|
|
245
|
+
res.end();
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------
|
|
250
|
+
// Serve index.html for everything else (single-page app)
|
|
251
|
+
// ---------------------------------------------------------------------
|
|
252
|
+
app.get('*', (req, res, next) => {
|
|
253
|
+
if (req.path.startsWith('/api/')) return next();
|
|
254
|
+
res.sendFile(path.join(__dirname, 'index.html'));
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
app.use((err, req, res, next) => {
|
|
258
|
+
console.error('[MuxMind AI] Unhandled error:', err);
|
|
259
|
+
res.status(500).json({ error: 'Internal server error.' });
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
app.listen(SERVER_CONFIG.PORT, () => {
|
|
263
|
+
console.log(`[MuxMind AI] Worker ${process.pid} listening on port ${SERVER_CONFIG.PORT}`);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MuxMind AI — API Manager
|
|
3
|
+
* Handles server-side health-check proxying for provider keys.
|
|
4
|
+
* IMPORTANT: Keys are never stored server-side. They arrive per-request
|
|
5
|
+
* in the request body/headers from the client's own local vault and are
|
|
6
|
+
* used only for the duration of that single request, then discarded.
|
|
7
|
+
*
|
|
8
|
+
* SMART MODEL VALIDATION (fixes "404" issue):
|
|
9
|
+
* A model appearing in a provider's /models list does NOT guarantee it is
|
|
10
|
+
* usable by our account/key (some are gated, deprecated, or list-only).
|
|
11
|
+
* So after listing, we do a tiny real completion probe against a small
|
|
12
|
+
* shortlist of the most likely-useful models and keep only the ones that
|
|
13
|
+
* actually respond with HTTP 200. This is what "Live: X models" reflects
|
|
14
|
+
* in the UI — every model shown there has been proven callable.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const { PROVIDERS, MODEL_HINTS } = require('./config');
|
|
20
|
+
|
|
21
|
+
function maskKey(key) {
|
|
22
|
+
if (!key || typeof key !== 'string') return '****';
|
|
23
|
+
if (key.length <= 8) return '*'.repeat(key.length);
|
|
24
|
+
return `${key.slice(0, 6)}...${'*'.repeat(4)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function classifyModel(modelId) {
|
|
28
|
+
for (const hint of MODEL_HINTS) {
|
|
29
|
+
if (hint.pattern.test(modelId)) return { tier: hint.tier, power: hint.power };
|
|
30
|
+
}
|
|
31
|
+
return { tier: 'balanced', power: 2 };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* List raw models from a provider's /models endpoint.
|
|
36
|
+
*/
|
|
37
|
+
async function listModels(providerId, apiKey) {
|
|
38
|
+
const provider = PROVIDERS[providerId];
|
|
39
|
+
let url = `${provider.baseUrl}${provider.modelsEndpoint}`;
|
|
40
|
+
const headers = { 'Content-Type': 'application/json', ...provider.authHeader(apiKey) };
|
|
41
|
+
if (provider.authQuery) url += `?${provider.authQuery(apiKey)}`;
|
|
42
|
+
|
|
43
|
+
const res = await fetch(url, { method: 'GET', headers });
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
return { ok: false, status: res.status, models: [] };
|
|
46
|
+
}
|
|
47
|
+
const data = await res.json().catch(() => ({}));
|
|
48
|
+
return { ok: true, status: res.status, models: extractModelList(providerId, data) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function extractModelList(providerId, data) {
|
|
52
|
+
try {
|
|
53
|
+
if (providerId === 'gemini') {
|
|
54
|
+
return (data.models || [])
|
|
55
|
+
.map((m) => (m.name || '').replace('models/', ''))
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
}
|
|
58
|
+
if (Array.isArray(data.data)) {
|
|
59
|
+
return data.data.map((m) => m.id).filter(Boolean);
|
|
60
|
+
}
|
|
61
|
+
if (Array.isArray(data.models)) {
|
|
62
|
+
return data.models.map((m) => m.id || m.name).filter(Boolean);
|
|
63
|
+
}
|
|
64
|
+
return [];
|
|
65
|
+
} catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Send a minimal 1-token probe completion to confirm a model is genuinely
|
|
72
|
+
* callable with this key (catches 404 "listed but not accessible" cases).
|
|
73
|
+
*/
|
|
74
|
+
async function probeModel(providerId, apiKey, modelId) {
|
|
75
|
+
const provider = PROVIDERS[providerId];
|
|
76
|
+
try {
|
|
77
|
+
let url, headers, body;
|
|
78
|
+
|
|
79
|
+
if (providerId === 'anthropic') {
|
|
80
|
+
url = `${provider.baseUrl}/messages`;
|
|
81
|
+
headers = { 'Content-Type': 'application/json', ...provider.authHeader(apiKey) };
|
|
82
|
+
body = JSON.stringify({ model: modelId, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] });
|
|
83
|
+
} else if (providerId === 'gemini') {
|
|
84
|
+
url = `${provider.baseUrl}/models/${modelId}:generateContent?${provider.authQuery(apiKey)}`;
|
|
85
|
+
headers = { 'Content-Type': 'application/json' };
|
|
86
|
+
body = JSON.stringify({ contents: [{ role: 'user', parts: [{ text: 'hi' }] }], generationConfig: { maxOutputTokens: 1 } });
|
|
87
|
+
} else {
|
|
88
|
+
url = `${provider.baseUrl}${provider.chatEndpoint}`;
|
|
89
|
+
headers = { 'Content-Type': 'application/json', ...provider.authHeader(apiKey) };
|
|
90
|
+
body = JSON.stringify({ model: modelId, messages: [{ role: 'user', content: 'hi' }], max_tokens: 1 });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const res = await fetch(url, { method: 'POST', headers, body });
|
|
94
|
+
return { modelId, live: res.ok, status: res.status };
|
|
95
|
+
} catch (err) {
|
|
96
|
+
return { modelId, live: false, status: 0, error: err.message };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Pick a small, high-value shortlist to probe instead of every listed
|
|
102
|
+
* model (keeps health-check fast). Prefers the provider's known-good
|
|
103
|
+
* defaults first, then fills with a few discovered ones across tiers.
|
|
104
|
+
*/
|
|
105
|
+
function buildProbeShortlist(providerId, discoveredModels) {
|
|
106
|
+
const provider = PROVIDERS[providerId];
|
|
107
|
+
const shortlist = [];
|
|
108
|
+
const seen = new Set();
|
|
109
|
+
|
|
110
|
+
// Hardcoded defaults can go stale the moment a provider renames/retires
|
|
111
|
+
// a model, so they're only tried first when the live /models listing
|
|
112
|
+
// actually confirms they still exist. If listing failed (empty array),
|
|
113
|
+
// we still try them as a last resort further down.
|
|
114
|
+
for (const m of provider.defaultModels) {
|
|
115
|
+
if (discoveredModels.includes(m) && !seen.has(m)) { shortlist.push(m); seen.add(m); }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Primary source of truth: whatever the provider's /models endpoint
|
|
119
|
+
// actually returned right now, spread across tiers so we don't only
|
|
120
|
+
// probe (and fail on) one family.
|
|
121
|
+
const byTier = { fast: [], balanced: [], heavy: [] };
|
|
122
|
+
for (const m of discoveredModels) {
|
|
123
|
+
if (seen.has(m)) continue;
|
|
124
|
+
const { tier } = classifyModel(m);
|
|
125
|
+
byTier[tier].push(m);
|
|
126
|
+
}
|
|
127
|
+
// Round-robin across tiers until we have a generous pool to probe.
|
|
128
|
+
let added = true;
|
|
129
|
+
while (shortlist.length < 12 && added) {
|
|
130
|
+
added = false;
|
|
131
|
+
for (const tier of ['fast', 'balanced', 'heavy']) {
|
|
132
|
+
const next = byTier[tier].find((m) => !seen.has(m));
|
|
133
|
+
if (next) { shortlist.push(next); seen.add(next); added = true; }
|
|
134
|
+
if (shortlist.length >= 12) break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Fall back to the hardcoded defaults even if listing didn't confirm
|
|
139
|
+
// them (e.g. /models call failed or is incomplete for this provider) —
|
|
140
|
+
// better to attempt a probe than to give up with zero candidates.
|
|
141
|
+
if (shortlist.length === 0) {
|
|
142
|
+
for (const m of provider.defaultModels) {
|
|
143
|
+
if (!seen.has(m)) { shortlist.push(m); seen.add(m); }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return shortlist.slice(0, 12);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Full smart health-check: list models, then probe a shortlist for real
|
|
152
|
+
* callability, returning only models proven live plus tier classification.
|
|
153
|
+
*/
|
|
154
|
+
async function healthCheckProvider(providerId, apiKey) {
|
|
155
|
+
const provider = PROVIDERS[providerId];
|
|
156
|
+
if (!provider) return { ok: false, error: `Unknown provider: ${providerId}`, models: [] };
|
|
157
|
+
if (!apiKey) return { ok: false, error: 'No API key supplied', models: [] };
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const listing = await listModels(providerId, apiKey);
|
|
161
|
+
|
|
162
|
+
if (!listing.ok && (listing.status === 401 || listing.status === 403)) {
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
error: `Key rejected by provider (HTTP ${listing.status})`,
|
|
166
|
+
models: [],
|
|
167
|
+
maskedKey: maskKey(apiKey),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const discovered = listing.ok ? listing.models : [];
|
|
172
|
+
const shortlist = buildProbeShortlist(providerId, discovered);
|
|
173
|
+
|
|
174
|
+
if (shortlist.length === 0) {
|
|
175
|
+
return { ok: false, error: 'No candidate models to probe', models: [], maskedKey: maskKey(apiKey) };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const probes = await Promise.all(shortlist.map((m) => probeModel(providerId, apiKey, m)));
|
|
179
|
+
const liveModels = probes
|
|
180
|
+
.filter((p) => p.live)
|
|
181
|
+
.map((p) => ({ id: p.modelId, ...classifyModel(p.modelId) }));
|
|
182
|
+
|
|
183
|
+
if (liveModels.length === 0) {
|
|
184
|
+
const statuses = [...new Set(probes.map((p) => p.status).filter(Boolean))];
|
|
185
|
+
const triedList = shortlist.join(', ');
|
|
186
|
+
const hint = statuses.includes(404)
|
|
187
|
+
? ' — model name(s) not found on this account/region; the provider may have renamed or retired them.'
|
|
188
|
+
: '';
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
error: `Key reached ${provider.label} but none of the probed models worked (tried: ${triedList}; statuses: ${statuses.join(', ') || 'n/a'})${hint}`,
|
|
192
|
+
models: [],
|
|
193
|
+
maskedKey: maskKey(apiKey),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
ok: true,
|
|
199
|
+
provider: providerId,
|
|
200
|
+
label: provider.label,
|
|
201
|
+
color: provider.color,
|
|
202
|
+
maskedKey: maskKey(apiKey),
|
|
203
|
+
models: liveModels,
|
|
204
|
+
checkedAt: new Date().toISOString(),
|
|
205
|
+
};
|
|
206
|
+
} catch (err) {
|
|
207
|
+
return { ok: false, error: `Connection failed: ${err.message}`, models: [], maskedKey: maskKey(apiKey) };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function healthCheckAll(entries) {
|
|
212
|
+
const results = await Promise.all(
|
|
213
|
+
entries.map(async ({ providerId, apiKey, label }) => {
|
|
214
|
+
const result = await healthCheckProvider(providerId, apiKey);
|
|
215
|
+
return { ...result, providerId, customLabel: label || null };
|
|
216
|
+
})
|
|
217
|
+
);
|
|
218
|
+
return results;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = {
|
|
222
|
+
maskKey,
|
|
223
|
+
healthCheckProvider,
|
|
224
|
+
healthCheckAll,
|
|
225
|
+
extractModelList,
|
|
226
|
+
classifyModel,
|
|
227
|
+
};
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MuxMind AI — Authentication
|
|
3
|
+
* Lightweight session-token auth so the app can sit behind a login screen.
|
|
4
|
+
*
|
|
5
|
+
* Password storage:
|
|
6
|
+
* - On first boot, the password is seeded from MUXMIND_PASSWORD in .env
|
|
7
|
+
* (or a built-in default if unset) and immediately hashed (scrypt +
|
|
8
|
+
* random salt) into a local file (.muxmind-auth.json, gitignored).
|
|
9
|
+
* - From then on, the hash file is the source of truth. The Settings page
|
|
10
|
+
* can change the password at runtime via /api/auth/change-password —
|
|
11
|
+
* no server restart or .env edit required.
|
|
12
|
+
* - Sessions are stateless (HMAC-signed tokens) so any cluster worker can
|
|
13
|
+
* verify a token without shared session storage. Changing the password
|
|
14
|
+
* rotates the signing secret too, which invalidates old sessions.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const crypto = require('crypto');
|
|
21
|
+
const { SERVER_CONFIG } = require('./config');
|
|
22
|
+
|
|
23
|
+
const DEFAULT_PASSWORD = 'muxmind2026';
|
|
24
|
+
const STORE_PATH = SERVER_CONFIG.AUTH_STORE_PATH;
|
|
25
|
+
const SESSION_TTL_MS = SERVER_CONFIG.SESSION_TTL_MS || 12 * 60 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
function hashPassword(password, salt) {
|
|
28
|
+
return crypto.scryptSync(password, salt, 64).toString('hex');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function loadStore() {
|
|
32
|
+
try {
|
|
33
|
+
const raw = fs.readFileSync(STORE_PATH, 'utf8');
|
|
34
|
+
const parsed = JSON.parse(raw);
|
|
35
|
+
if (parsed && parsed.salt && parsed.hash && parsed.secret) return parsed;
|
|
36
|
+
} catch {
|
|
37
|
+
// fall through to seeding a fresh store
|
|
38
|
+
}
|
|
39
|
+
return seedStore();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function seedStore() {
|
|
43
|
+
const seedPassword = process.env.MUXMIND_PASSWORD || DEFAULT_PASSWORD;
|
|
44
|
+
const salt = crypto.randomBytes(16).toString('hex');
|
|
45
|
+
const hash = hashPassword(seedPassword, salt);
|
|
46
|
+
const secret = crypto.randomBytes(32).toString('hex');
|
|
47
|
+
const store = { salt, hash, secret, updatedAt: new Date().toISOString() };
|
|
48
|
+
persistStore(store);
|
|
49
|
+
if (!process.env.MUXMIND_PASSWORD) {
|
|
50
|
+
console.warn(
|
|
51
|
+
'[MuxMind AI] WARNING: Using default login password. Change it from Settings once signed in, or set MUXMIND_PASSWORD in .env before first boot.'
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return store;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function persistStore(store) {
|
|
58
|
+
try {
|
|
59
|
+
fs.writeFileSync(STORE_PATH, JSON.stringify(store, null, 2), { mode: 0o600 });
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error('[MuxMind AI] Failed to persist auth store:', err.message);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let authStore = loadStore();
|
|
66
|
+
|
|
67
|
+
function sign(payload) {
|
|
68
|
+
const hmac = crypto.createHmac('sha256', authStore.secret);
|
|
69
|
+
hmac.update(payload);
|
|
70
|
+
return hmac.digest('hex');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Issue a signed, stateless session token: base64(expiry) + "." + hmac.
|
|
75
|
+
*/
|
|
76
|
+
function issueToken() {
|
|
77
|
+
const expiresAt = Date.now() + SESSION_TTL_MS;
|
|
78
|
+
const payload = String(expiresAt);
|
|
79
|
+
const sig = sign(payload);
|
|
80
|
+
return Buffer.from(`${payload}.${sig}`).toString('base64url');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function verifyToken(token) {
|
|
84
|
+
try {
|
|
85
|
+
if (!token) return false;
|
|
86
|
+
const decoded = Buffer.from(token, 'base64url').toString('utf8');
|
|
87
|
+
const [payload, sig] = decoded.split('.');
|
|
88
|
+
if (!payload || !sig) return false;
|
|
89
|
+
|
|
90
|
+
const expected = sign(payload);
|
|
91
|
+
const sigBuf = Buffer.from(sig, 'hex');
|
|
92
|
+
const expectedBuf = Buffer.from(expected, 'hex');
|
|
93
|
+
if (sigBuf.length !== expectedBuf.length) return false;
|
|
94
|
+
if (!crypto.timingSafeEqual(sigBuf, expectedBuf)) return false;
|
|
95
|
+
|
|
96
|
+
const expiresAt = Number(payload);
|
|
97
|
+
if (Number.isNaN(expiresAt) || Date.now() > expiresAt) return false;
|
|
98
|
+
|
|
99
|
+
return true;
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Constant-time password check against the current stored hash.
|
|
107
|
+
*/
|
|
108
|
+
function checkPassword(candidate) {
|
|
109
|
+
if (typeof candidate !== 'string' || candidate.length === 0) return false;
|
|
110
|
+
const candidateHash = hashPassword(candidate, authStore.salt);
|
|
111
|
+
const a = Buffer.from(candidateHash);
|
|
112
|
+
const b = Buffer.from(authStore.hash);
|
|
113
|
+
if (a.length !== b.length) return false;
|
|
114
|
+
return crypto.timingSafeEqual(a, b);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Change the password at runtime. Requires the current password to match
|
|
119
|
+
* first. Rotates the session secret, so all existing tokens (this device
|
|
120
|
+
* included) are invalidated — the caller should re-login immediately with
|
|
121
|
+
* the new password and store the fresh token.
|
|
122
|
+
*/
|
|
123
|
+
function changePassword(currentPassword, newPassword) {
|
|
124
|
+
if (!checkPassword(currentPassword)) {
|
|
125
|
+
return { ok: false, error: 'Current password is incorrect.' };
|
|
126
|
+
}
|
|
127
|
+
if (typeof newPassword !== 'string' || newPassword.length < 6) {
|
|
128
|
+
return { ok: false, error: 'New password must be at least 6 characters.' };
|
|
129
|
+
}
|
|
130
|
+
const salt = crypto.randomBytes(16).toString('hex');
|
|
131
|
+
const hash = hashPassword(newPassword, salt);
|
|
132
|
+
const secret = crypto.randomBytes(32).toString('hex');
|
|
133
|
+
authStore = { salt, hash, secret, updatedAt: new Date().toISOString() };
|
|
134
|
+
persistStore(authStore);
|
|
135
|
+
return { ok: true, token: issueToken() };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Express middleware — protects /api/* routes (except /api/auth/*) behind
|
|
140
|
+
* a valid session token sent as "Authorization: Bearer <token>".
|
|
141
|
+
*/
|
|
142
|
+
function requireAuth(req, res, next) {
|
|
143
|
+
const authHeader = req.headers.authorization || '';
|
|
144
|
+
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
|
145
|
+
|
|
146
|
+
if (!verifyToken(token)) {
|
|
147
|
+
return res.status(401).json({ error: 'Unauthorized. Please log in again.' });
|
|
148
|
+
}
|
|
149
|
+
next();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = {
|
|
153
|
+
issueToken,
|
|
154
|
+
verifyToken,
|
|
155
|
+
checkPassword,
|
|
156
|
+
changePassword,
|
|
157
|
+
requireAuth,
|
|
158
|
+
DEFAULT_PASSWORD,
|
|
159
|
+
};
|