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/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
+ };