access-manager/server.js

922 lines
33 KiB
JavaScript

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 = process.env.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 <noreply@scottfelten.com>';
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 = {};
// 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', `
<h2>Login to scottfelten.com</h2>
<p>Click the link below to sign in. This link expires in 15 minutes.</p>
<p><a href="${link}" style="display:inline-block;padding:12px 24px;background:#3b82f6;color:white;border-radius:8px;text-decoration:none;font-weight:600;">Sign In</a></p>
<p style="color:#666;font-size:12px;">Or copy this URL: ${link}</p>
<p style="color:#999;font-size:11px;">If you didn't request this, ignore this email.</p>
`);
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', `
<h2>Reset your password</h2>
<p>Click below to set a new password. This link expires in 1 hour.</p>
<p><a href="${link}" style="display:inline-block;padding:12px 24px;background:#3b82f6;color:white;border-radius:8px;text-decoration:none;font-weight:600;">Reset Password</a></p>
<p style="color:#666;font-size:12px;">Or copy: ${link}</p>
<p style="color:#999;font-size:11px;">If you didn't request this, ignore this email.</p>
`);
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);
});