const express = require('express'); const fs = require('fs'); const path = require('path'); const http = require('http'); const crypto = require('crypto'); const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const app = express(); const HOST = process.env.HOST || '127.0.0.1'; const PORT = process.env.PORT || 3030; // ─── Paths ─── const DATA_DIR = path.join(__dirname, 'data'); const USERS_FILE = path.join(DATA_DIR, 'users.json'); const SERVICES_CACHE = path.join(DATA_DIR, 'services-cache.json'); const TOKENS_FILE = path.join(DATA_DIR, 'tokens.json'); const LOCKOUT_FILE = path.join(DATA_DIR, 'lockouts.json'); const CONFIG_FILE = path.join(DATA_DIR, 'config.json'); // ─── Config ─── const INVENTORY_API = process.env.INVENTORY_API || 'http://127.0.0.1:3025'; const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days const MAGIC_LINK_EXPIRY = 15 * 60 * 1000; // 15 min const RESET_TOKEN_EXPIRY = 60 * 60 * 1000; // 1 hour const MAX_LOGIN_ATTEMPTS = 5; const LOCKOUT_DURATION = 30 * 60 * 1000; // 30 min const SERVICE_SYNC_INTERVAL = 5 * 60 * 1000; // 5 min // ─── Bootstrap data files ─── if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); const defaults = { [USERS_FILE]: '[]', [SERVICES_CACHE]: '{"services":{},"synced_at":null}', [TOKENS_FILE]: '{}', [LOCKOUT_FILE]: '{}', [CONFIG_FILE]: '{}', }; for (const [f, d] of Object.entries(defaults)) { if (!fs.existsSync(f)) fs.writeFileSync(f, d); } // ─── JWT Secret (persistent across restarts) ─── function getJwtSecret() { const cfg = loadJSON(CONFIG_FILE); if (cfg.jwtSecret) return cfg.jwtSecret; const secret = crypto.randomBytes(64).toString('hex'); cfg.jwtSecret = secret; saveJSON(CONFIG_FILE, cfg); return secret; } const JWT_SECRET = getJwtSecret(); // ─── Email config ─── let emailTransport = null; function initEmail() { try { const nodemailer = require('nodemailer'); const cfg = loadJSON(CONFIG_FILE); if (cfg.smtp) { emailTransport = nodemailer.createTransport(cfg.smtp); console.log('[email] SMTP transport configured'); } else { console.log('[email] No SMTP config — magic links and resets will log to console'); } } catch (e) { console.log('[email] nodemailer not installed — using console fallback'); } } async function sendEmail(to, subject, html) { const cfg = loadJSON(CONFIG_FILE); const from = cfg.emailFrom || 'TARS '; if (emailTransport) { try { await emailTransport.sendMail({ from, to, subject, html }); console.log(`[email] Sent to ${to}: ${subject}`); return true; } catch (e) { console.error(`[email] Failed to send to ${to}:`, e.message); return false; } } // Console fallback console.log(`\n══════ EMAIL (console fallback) ══════`); console.log(`To: ${to}`); console.log(`Subject: ${subject}`); console.log(`Body: ${html}`); console.log(`══════════════════════════════════════\n`); return true; } // ─── JSON helpers ─── function loadJSON(f) { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return {}; } } function saveJSON(f, d) { fs.writeFileSync(f, JSON.stringify(d, null, 2)); } function loadUsers() { try { return JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); } catch { return []; } } function saveUsers(u) { fs.writeFileSync(USERS_FILE, JSON.stringify(u, null, 2)); } function findUser(email) { return loadUsers().find(u => u.email.toLowerCase() === email.toLowerCase()); } // ─── Lockout ─── function checkLockout(email) { const lockouts = loadJSON(LOCKOUT_FILE); const entry = lockouts[email.toLowerCase()]; if (!entry) return { locked: false, attempts: 0 }; if (entry.lockedUntil && Date.now() < entry.lockedUntil) { return { locked: true, attempts: entry.attempts, minutesLeft: Math.ceil((entry.lockedUntil - Date.now()) / 60000) }; } if (entry.lockedUntil && Date.now() >= entry.lockedUntil) { // Lockout expired — reset delete lockouts[email.toLowerCase()]; saveJSON(LOCKOUT_FILE, lockouts); return { locked: false, attempts: 0 }; } return { locked: false, attempts: entry.attempts || 0 }; } function recordFailedAttempt(email) { const lockouts = loadJSON(LOCKOUT_FILE); const key = email.toLowerCase(); if (!lockouts[key]) lockouts[key] = { attempts: 0 }; lockouts[key].attempts++; lockouts[key].lastAttempt = Date.now(); if (lockouts[key].attempts >= MAX_LOGIN_ATTEMPTS) { lockouts[key].lockedUntil = Date.now() + LOCKOUT_DURATION; console.log(`[lockout] ${email} locked for ${LOCKOUT_DURATION / 60000} minutes`); } saveJSON(LOCKOUT_FILE, lockouts); return lockouts[key]; } function clearLockout(email) { const lockouts = loadJSON(LOCKOUT_FILE); delete lockouts[email.toLowerCase()]; saveJSON(LOCKOUT_FILE, lockouts); } // ─── Token store (magic links + password resets) ─── function createToken(email, type) { const tokens = loadJSON(TOKENS_FILE); const token = crypto.randomBytes(32).toString('hex'); const expiry = type === 'magic' ? MAGIC_LINK_EXPIRY : RESET_TOKEN_EXPIRY; tokens[token] = { email: email.toLowerCase(), type, created: Date.now(), expires: Date.now() + expiry }; // Clean expired tokens while we're here for (const [k, v] of Object.entries(tokens)) { if (v.expires < Date.now()) delete tokens[k]; } saveJSON(TOKENS_FILE, tokens); return token; } function verifyToken(token, type) { const tokens = loadJSON(TOKENS_FILE); const entry = tokens[token]; if (!entry) return null; if (entry.type !== type) return null; if (entry.expires < Date.now()) { delete tokens[token]; saveJSON(TOKENS_FILE, tokens); return null; } // Consume the token (one-time use) delete tokens[token]; saveJSON(TOKENS_FILE, tokens); return entry.email; } // ═══════════════════════════════════════ // SERVICE DISCOVERY (from Inventory API) // ═══════════════════════════════════════ let serviceMap = {}; // hostname → service-id (live in memory, backed by cache file) function loadServiceCache() { const cache = loadJSON(SERVICES_CACHE); if (cache.services) serviceMap = cache.services; console.log(`[services] Loaded ${Object.keys(serviceMap).length} services from cache`); } async function syncServices() { try { const resp = await fetch(`${INVENTORY_API}/api/github`); if (!resp.ok) throw new Error(`Inventory API returned ${resp.status}`); const data = await resp.json(); // Build service map from inventory repos const newMap = {}; const STATE_FILE = path.join(DATA_DIR, '../api/inventory-data.json'); // Also try to get the inventory state which has service URLs let stateData = {}; try { const stateResp = await fetch(`${INVENTORY_API}/api/state`); if (stateResp.ok) stateData = await stateResp.json(); } catch {} // Known subdomain → repo mappings from inventory state // The inventory stores overrides with service URLs const SERVICE_URL_TO_REPO = {}; // Build from repos — every repo with a known service URL gets mapped if (data.repos) { for (const repo of data.repos) { if (repo.archived) continue; // Check if there's state data with a service URL for this repo const state = stateData[repo.name]; if (state && state.serviceUrl) { try { const url = new URL(state.serviceUrl); newMap[url.hostname] = repo.name; // Also map hostname:port if non-standard if (url.port) newMap[`${url.hostname}:${url.port}`] = repo.name; } catch {} } } } // Hardcoded fallbacks for services without inventory state // These cover *.scottfelten.com subdomains and external domains const FALLBACKS = { 'docs.scottfelten.com': 'command-center', 'ops.scottfelten.com': 'ops-dashboard', 'models.scottfelten.com': 'models-embeddings', 'goalstack.scottfelten.com': 'taos', 'collab.scottfelten.com': 'ccgg-collab', 'agents.scottfelten.com': 'agent-bios', 'signal.scottfelten.com': 'signal-tower', 'c360.scottfelten.com': 'customer-360', 'research.scottfelten.com': 'research-queue', 'files.scottfelten.com': 'workspace-file-server', 'taos.scottfelten.com': 'taos', 'inv.scottfelten.com': 'inventory', 'paste.scottfelten.com': 'paste-to-md', 'users.scottfelten.com': 'access-manager', 'keys.scottfelten.com': 'api-keys-dashboard', 'iag.scottfelten.com': 'iag-data-viewer', 'intelligenceadvisorygroup.com': 'iag-website', 'www.intelligenceadvisorygroup.com': 'iag-website', 'intellicert.app': 'intellicert', 'www.intellicert.app': 'intellicert', 'app.usecasegen.app': 'usecasegen', 'usecasegen.app': 'usecasegen', 'www.usecasegen.app': 'usecasegen', 'intelligenceagentpartners.com': 'intelligent-agent-partners', 'www.intelligenceagentpartners.com': 'intelligent-agent-partners', 'intelligentageintpartners.com': 'intelligent-agent-partners', 'www.intelligentagentpartners.com': 'intelligent-agent-partners', }; // Merge: inventory wins, fallbacks fill gaps for (const [host, svc] of Object.entries(FALLBACKS)) { if (!newMap[host]) newMap[host] = svc; } serviceMap = newMap; const cache = { services: newMap, synced_at: new Date().toISOString(), count: Object.keys(newMap).length }; saveJSON(SERVICES_CACHE, cache); console.log(`[services] Synced ${Object.keys(newMap).length} service mappings from inventory`); } catch (e) { console.error(`[services] Sync failed: ${e.message} — using cached data`); } } // ═══════════════════════════════════════ // SESSION MANAGEMENT (JWT cookie) // ═══════════════════════════════════════ function createSession(user, method) { const payload = { email: user.email, name: user.name, role: user.role, services: user.services, method, // 'google', 'magic-link', 'password' }; return jwt.sign(payload, JWT_SECRET, { expiresIn: '7d' }); } function verifySession(token) { try { return jwt.verify(token, JWT_SECRET); } catch { return null; } } function setSessionCookie(res, token) { res.cookie('_am_session', token, { httpOnly: true, secure: true, sameSite: 'lax', domain: '.scottfelten.com', maxAge: SESSION_MAX_AGE, path: '/', }); } // ═══════════════════════════════════════ // MIDDLEWARE // ═══════════════════════════════════════ app.use(express.json()); app.use(require('cookie-parser')()); // Serve static files for public pages app.use(express.static(path.join(__dirname, 'public'))); // ═══════════════════════════════════════ // AUTH CHECK (nginx auth_request target) // ═══════════════════════════════════════ // // Called by every service's nginx config. // Checks TWO auth methods: // 1. Our own _am_session cookie (magic link / password users) // 2. oauth2-proxy session (Google SSO users) // Returns 200 (allowed), 401 (not authed), 403 (no access) app.get('/auth/check', (req, res) => { const host = req.headers['x-original-host'] || req.headers['host'] || ''; const cookies = req.headers['cookie'] || ''; console.log(`[auth/check] host=${host}`); // ── Method 1: Check our own session cookie ── const amSession = req.cookies?.['_am_session']; if (amSession) { const session = verifySession(amSession); if (session) { console.log(`[auth/check] Valid _am_session for ${session.email} (${session.method})`); return handleAccessCheck(res, session.email, host); } } // ── Method 2: Check oauth2-proxy ── const opts = { hostname: '127.0.0.1', port: 4180, path: '/oauth2/auth', method: 'GET', headers: { 'Cookie': cookies, 'Host': host, 'X-Real-IP': req.headers['x-real-ip'] || '127.0.0.1', 'X-Forwarded-For': req.headers['x-forwarded-for'] || '127.0.0.1', 'X-Forwarded-Proto': 'https', 'X-Forwarded-Host': host, }, }; const proxyReq = http.request(opts, (proxyRes) => { if (proxyRes.statusCode < 200 || proxyRes.statusCode >= 300) { return res.status(401).send('Not authenticated'); } const email = proxyRes.headers['x-auth-request-email'] || ''; if (!email) return res.status(401).send('No email from oauth2'); res.setHeader('X-Auth-Request-User', proxyRes.headers['x-auth-request-user'] || ''); res.setHeader('X-Auth-Request-Email', email); return handleAccessCheck(res, email, host); }); proxyReq.on('error', (e) => { console.error('[auth/check] oauth2-proxy error:', e.message); res.status(500).send('Auth service error'); }); proxyReq.end(); }); function handleAccessCheck(res, email, host) { const user = findUser(email); if (!user) return res.status(403).send('User not registered'); // Update last login (throttle) const users = loadUsers(); const idx = users.findIndex(u => u.email.toLowerCase() === email.toLowerCase()); if (idx >= 0) { const last = users[idx].lastLogin ? new Date(users[idx].lastLogin).getTime() : 0; if (Date.now() - last > 60000) { users[idx].lastLogin = new Date().toISOString(); saveUsers(users); } } // Admin wildcard if (user.services.includes('*')) return res.status(200).send('OK'); // Map host to service const serviceId = serviceMap[host]; if (!serviceId) { console.log(`[auth/check] No service mapping for host: ${host}`); return res.status(403).send('Service not mapped'); } if (user.services.includes(serviceId)) return res.status(200).send('OK'); console.log(`[auth/check] ${email} denied access to ${serviceId} (${host})`); return res.status(403).send('Access denied'); } // ═══════════════════════════════════════ // MAGIC LINK AUTH // ═══════════════════════════════════════ app.post('/auth/magic/request', async (req, res) => { const { email, redirect } = req.body; if (!email) return res.status(400).json({ error: 'Email required' }); const user = findUser(email); if (!user) { // Don't reveal whether email exists — always return success console.log(`[magic] Request for unknown email: ${email}`); return res.json({ ok: true, message: 'If that email is registered, a login link has been sent.' }); } const token = createToken(email, 'magic'); const rd = redirect || 'https://users.scottfelten.com'; const link = `https://users.scottfelten.com/auth/magic/verify?token=${token}&rd=${encodeURIComponent(rd)}`; await sendEmail(email, 'Your login link — scottfelten.com', `

Login to scottfelten.com

Click the link below to sign in. This link expires in 15 minutes.

Sign In

Or copy this URL: ${link}

If you didn't request this, ignore this email.

`); res.json({ ok: true, message: 'If that email is registered, a login link has been sent.' }); }); app.get('/auth/magic/verify', (req, res) => { const { token, rd } = req.query; if (!token) return res.status(400).send('Missing token'); const email = verifyToken(token, 'magic'); if (!email) { return res.redirect('/login.html?error=expired'); } const user = findUser(email); if (!user) return res.redirect('/login.html?error=not_registered'); const sessionToken = createSession(user, 'magic-link'); setSessionCookie(res, sessionToken); console.log(`[magic] Verified login for ${email}`); res.redirect(rd || '/'); }); // ═══════════════════════════════════════ // EMAIL + PASSWORD AUTH // ═══════════════════════════════════════ app.post('/auth/password/login', async (req, res) => { const { email, password } = req.body; if (!email || !password) return res.status(400).json({ error: 'Email and password required' }); // Check lockout const lockout = checkLockout(email); if (lockout.locked) { return res.status(429).json({ error: `Account locked. Try again in ${lockout.minutesLeft} minutes.`, locked: true, minutesLeft: lockout.minutesLeft, }); } const user = findUser(email); if (!user || !user.passwordHash) { recordFailedAttempt(email); return res.status(401).json({ error: 'Invalid email or password' }); } const valid = await bcrypt.compare(password, user.passwordHash); if (!valid) { const attempt = recordFailedAttempt(email); const remaining = MAX_LOGIN_ATTEMPTS - attempt.attempts; return res.status(401).json({ error: 'Invalid email or password', attemptsRemaining: Math.max(0, remaining), }); } // Success — clear lockout, create session clearLockout(email); const sessionToken = createSession(user, 'password'); setSessionCookie(res, sessionToken); console.log(`[password] Login success for ${email}`); res.json({ ok: true, user: { email: user.email, name: user.name, role: user.role } }); }); // Admin sets password for a user app.post('/auth/password/set', requireAdmin, async (req, res) => { const { email, password } = req.body; if (!email || !password) return res.status(400).json({ error: 'Email and password required' }); if (password.length < 12) return res.status(400).json({ error: 'Password must be at least 12 characters' }); const users = loadUsers(); const idx = users.findIndex(u => u.email.toLowerCase() === email.toLowerCase()); if (idx < 0) return res.status(404).json({ error: 'User not found' }); users[idx].passwordHash = await bcrypt.hash(password, 12); users[idx].authMethods = [...new Set([...(users[idx].authMethods || ['google']), 'password'])]; saveUsers(users); console.log(`[password] Password set for ${email} by admin`); res.json({ ok: true }); }); // Password reset request app.post('/auth/password/reset-request', async (req, res) => { const { email } = req.body; if (!email) return res.status(400).json({ error: 'Email required' }); const user = findUser(email); if (!user) { // Don't reveal existence return res.json({ ok: true, message: 'If that email is registered, a reset link has been sent.' }); } const token = createToken(email, 'reset'); const link = `https://users.scottfelten.com/login.html?reset=${token}`; await sendEmail(email, 'Password reset — scottfelten.com', `

Reset your password

Click below to set a new password. This link expires in 1 hour.

Reset Password

Or copy: ${link}

If you didn't request this, ignore this email.

`); res.json({ ok: true, message: 'If that email is registered, a reset link has been sent.' }); }); // Password reset execution app.post('/auth/password/reset', async (req, res) => { const { token, password } = req.body; if (!token || !password) return res.status(400).json({ error: 'Token and password required' }); if (password.length < 12) return res.status(400).json({ error: 'Password must be at least 12 characters' }); const email = verifyToken(token, 'reset'); if (!email) return res.status(400).json({ error: 'Invalid or expired reset token' }); const users = loadUsers(); const idx = users.findIndex(u => u.email.toLowerCase() === email.toLowerCase()); if (idx < 0) return res.status(404).json({ error: 'User not found' }); users[idx].passwordHash = await bcrypt.hash(password, 12); users[idx].authMethods = [...new Set([...(users[idx].authMethods || ['google']), 'password'])]; saveUsers(users); clearLockout(email); console.log(`[password] Password reset complete for ${email}`); res.json({ ok: true }); }); // ═══════════════════════════════════════ // SESSION INFO // ═══════════════════════════════════════ app.get('/auth/session', (req, res) => { const amSession = req.cookies?.['_am_session']; if (amSession) { const session = verifySession(amSession); if (session) return res.json({ authenticated: true, ...session }); } res.json({ authenticated: false }); }); app.post('/auth/logout', (req, res) => { res.clearCookie('_am_session', { domain: '.scottfelten.com', path: '/' }); res.json({ ok: true }); }); // ═══════════════════════════════════════ // ADMIN MIDDLEWARE // ═══════════════════════════════════════ function requireAdmin(req, res, next) { // Check our session cookie first const amSession = req.cookies?.['_am_session']; if (amSession) { const session = verifySession(amSession); if (session) { const user = findUser(session.email); if (user && user.role === 'admin') return next(); } } // Fall back to nginx-forwarded headers (oauth2-proxy) const email = req.headers['x-email'] || req.headers['x-forwarded-email'] || ''; if (email) { const user = findUser(email); if (user && user.role === 'admin') return next(); } return res.status(403).json({ error: 'Admin access required' }); } // ═══════════════════════════════════════ // ADMIN API // ═══════════════════════════════════════ // List users app.get('/api/users', requireAdmin, (req, res) => { const users = loadUsers().map(u => { const { passwordHash, ...safe } = u; return { ...safe, hasPassword: !!passwordHash }; }); res.json(users); }); // Get single user app.get('/api/users/:email', requireAdmin, (req, res) => { const user = findUser(req.params.email); if (!user) return res.status(404).json({ error: 'User not found' }); const { passwordHash, ...safe } = user; res.json({ ...safe, hasPassword: !!passwordHash }); }); // Create user app.post('/api/users', requireAdmin, async (req, res) => { const { email, name, role, services, metadata, tags, password, authMethods } = req.body; if (!email || !name) return res.status(400).json({ error: 'email and name required' }); const users = loadUsers(); if (users.find(u => u.email.toLowerCase() === email.toLowerCase())) { return res.status(409).json({ error: 'User already exists' }); } const adminEmail = req.headers['x-email'] || req.headers['x-forwarded-email'] || 'api'; const newUser = { email: email.toLowerCase(), name, role: role || 'client', services: services || [], metadata: metadata || {}, tags: tags || [], authMethods: authMethods || ['google'], passwordHash: password ? await bcrypt.hash(password, 12) : null, createdAt: new Date().toISOString().split('T')[0], createdBy: adminEmail, lastLogin: null, }; users.push(newUser); saveUsers(users); syncAllowlist(users); const { passwordHash, ...safe } = newUser; res.status(201).json({ ...safe, hasPassword: !!passwordHash }); }); // Update user app.put('/api/users/:email', requireAdmin, (req, res) => { const users = loadUsers(); const idx = users.findIndex(u => u.email.toLowerCase() === req.params.email.toLowerCase()); if (idx < 0) return res.status(404).json({ error: 'User not found' }); const { name, role, services, metadata, tags, authMethods } = req.body; if (name !== undefined) users[idx].name = name; if (role !== undefined) users[idx].role = role; if (services !== undefined) users[idx].services = services; if (metadata !== undefined) users[idx].metadata = { ...users[idx].metadata, ...metadata }; if (tags !== undefined) users[idx].tags = tags; if (authMethods !== undefined) users[idx].authMethods = authMethods; saveUsers(users); syncAllowlist(users); const { passwordHash, ...safe } = users[idx]; res.json({ ...safe, hasPassword: !!passwordHash }); }); // Delete user app.delete('/api/users/:email', requireAdmin, (req, res) => { const users = loadUsers(); const idx = users.findIndex(u => u.email.toLowerCase() === req.params.email.toLowerCase()); if (idx < 0) return res.status(404).json({ error: 'User not found' }); const adminEmail = (req.headers['x-email'] || '').toLowerCase(); if (users[idx].email.toLowerCase() === adminEmail) { return res.status(400).json({ error: 'Cannot delete yourself' }); } users.splice(idx, 1); saveUsers(users); syncAllowlist(users); res.json({ deleted: true }); }); // ═══════════════════════════════════════ // SERVICES API (dynamic from inventory) // ═══════════════════════════════════════ // List available services (for UI dropdown) app.get('/api/services', requireAdmin, (req, res) => { // Deduplicate: return unique service IDs const serviceIds = [...new Set(Object.values(serviceMap))].sort(); res.json(serviceIds); }); // Full service map (hostname → service-id) app.get('/api/services/map', requireAdmin, (req, res) => { res.json(serviceMap); }); // Force sync from inventory app.post('/api/services/sync', requireAdmin, async (req, res) => { await syncServices(); res.json({ services: [...new Set(Object.values(serviceMap))].sort(), count: Object.keys(serviceMap).length, synced_at: new Date().toISOString(), }); }); // ─── Sync oauth2-proxy allowlist ─── function syncAllowlist(users) { // Only include users with google as an auth method (or admin/legacy users) const emails = users .filter(u => !u.authMethods || u.authMethods.includes('google') || u.role === 'admin') .map(u => u.email) .join('\n') + '\n'; try { fs.writeFileSync('/etc/oauth2-proxy-users.txt', emails); } catch (e) { console.error('Failed to sync allowlist:', e.message); } } // ─── Email config management ─── app.get('/api/config/email', requireAdmin, (req, res) => { const cfg = loadJSON(CONFIG_FILE); const smtpConfigured = !!cfg.smtp; res.json({ configured: smtpConfigured, from: cfg.emailFrom || 'noreply@scottfelten.com', host: cfg.smtp?.host || null, }); }); app.put('/api/config/email', requireAdmin, (req, res) => { const { host, port, user, pass, from } = req.body; const cfg = loadJSON(CONFIG_FILE); if (host && user && pass) { cfg.smtp = { host, port: port || 587, secure: false, auth: { user, pass } }; cfg.emailFrom = from || `TARS <${user}>`; saveJSON(CONFIG_FILE, cfg); initEmail(); // Re-initialize transport res.json({ ok: true, configured: true }); } else { res.status(400).json({ error: 'host, user, and pass required' }); } }); // ─── Health ─── // ═══════════════════════════════════════ // DASHBOARD API // ═══════════════════════════════════════ const DASHBOARD_SYSTEMS_FILE = path.join(DATA_DIR, 'dashboard-systems.json'); function loadDashboardSystems() { try { return JSON.parse(fs.readFileSync(DASHBOARD_SYSTEMS_FILE, 'utf8')).systems; } catch { return []; } } function saveDashboardSystems(systems) { fs.writeFileSync(DASHBOARD_SYSTEMS_FILE, JSON.stringify({ systems }, null, 2)); } async function fetchWithTimeout(url, timeoutMs = 500) { return new Promise((resolve, reject) => { const req = http.get(url, { timeout: timeoutMs }, (res) => { if (res.statusCode < 200 || res.statusCode >= 300) { reject(new Error(`HTTP ${res.statusCode}`)); return; } let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve({ raw: data }); } }); }); req.on('error', reject); req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); }); req.setTimeout(timeoutMs); }); } function normalizeStats(system, raw) { const systemId = system.id; if (systemId === 'access-manager') { const users = loadUsers(); return { totalUsers: users.length, activeUsers: users.filter(u => u.lastLogin && (Date.now() - new Date(u.lastLogin).getTime()) < 24 * 60 * 60 * 1000).length }; } if (systemId === 'model-service') { return { totalModels: raw.models || 0, totalApps: raw.apps || 0, status: raw.status }; } if (systemId === 'inventory') { // Inventory no longer syncs GitHub repos; show online with no metrics return {}; } if (systemId.includes('usecasegen')) { // Query actual DB for user count (session counters reset on restart) let dbUsers = 0; try { const dbPath = system.dbPath; if (dbPath) { const Database = require('better-sqlite3'); const db = new Database(dbPath, { readonly: true }); dbUsers = db.prepare('SELECT COUNT(*) as count FROM users').get()?.count || 0; db.close(); } } catch (e) { /* fallback to raw */ } return { totalUsers: dbUsers || raw.totalUsers || raw.userCount || 0, activeToday: raw.activeToday || 0, totalSessions: raw.totalSessions || raw.sessionCount || 0, totalCost: raw.totalCost || raw.estimatedCost || 0 }; } return raw; } // Dashboard stats endpoint app.get('/api/dashboard/stats', requireAdmin, async (req, res) => { const systems = loadDashboardSystems(); const results = await Promise.all( systems.map(async (sys) => { try { let stats; if (sys.authType === 'self') { // Local query stats = normalizeStats(sys, {}); } else { const url = sys.apiBase + sys.statsEndpoint; const raw = await fetchWithTimeout(url, 1000); stats = normalizeStats(sys, raw); } return { ...sys, status: 'ok', stats, error: null }; } catch (e) { return { ...sys, status: 'error', stats: {}, error: e.message }; } }) ); const summary = { internalUsers: results.filter(s => s.category === 'internal').reduce((a, s) => a + (s.stats?.totalUsers || 0), 0), customerUsers: results.filter(s => s.category === 'customer').reduce((a, s) => a + (s.stats?.totalUsers || 0), 0), totalSystems: results.length, healthySystems: results.filter(s => s.status === 'ok').length }; res.json({ generatedAt: new Date().toISOString(), systems: results, summary }); }); // Dashboard systems config app.get('/api/dashboard/systems', requireAdmin, (req, res) => { res.json(loadDashboardSystems()); }); app.put('/api/dashboard/systems', requireAdmin, (req, res) => { const { systems } = req.body; if (!Array.isArray(systems)) return res.status(400).json({ error: 'systems array required' }); saveDashboardSystems(systems); res.json({ ok: true }); }); // Internal stats (for self-reference) app.get('/api/dashboard/internal-stats', requireAdmin, (req, res) => { const users = loadUsers(); res.json({ totalUsers: users.length, activeUsers: users.filter(u => u.lastLogin && (Date.now() - new Date(u.lastLogin).getTime()) < 24 * 60 * 60 * 1000).length }); }); app.get('/health', (req, res) => { res.json({ status: 'ok', users: loadUsers().length, services: Object.keys(serviceMap).length, emailConfigured: !!emailTransport, }); }); // ═══════════════════════════════════════ // START // ═══════════════════════════════════════ app.listen(PORT, HOST, async () => { console.log(`access-manager v2 listening on ${HOST}:${PORT}`); console.log(`Users: ${loadUsers().length}`); // Load cached services, then sync loadServiceCache(); await syncServices(); // Initialize email initEmail(); // Periodic service sync setInterval(syncServices, SERVICE_SYNC_INTERVAL); });