902 lines
32 KiB
JavaScript
902 lines
32 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 = 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 = {};
|
|
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', `
|
|
<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) => {
|
|
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(systemId, raw) {
|
|
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.includes('usecasegen')) {
|
|
return {
|
|
totalUsers: 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.id, {});
|
|
} else {
|
|
const url = sys.apiBase + sys.statsEndpoint;
|
|
const raw = await fetchWithTimeout(url, 1000);
|
|
stats = normalizeStats(sys.id, 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);
|
|
});
|