feat: Access Manager v3 — RBAC engine, SQLite, permission system

- SQLite database with full schema: users, roles, permissions,
  role_permissions, user_roles, services, audit_log
- RBAC engine with wildcard permission resolution (*.*.*)
- Automatic v2→v3 migration from JSON files
- 5 default roles: super_admin, admin, editor, user, viewer
- Feature registration for APP, GGL, FDX (119 permissions)
- 8 services seeded
- Full API: roles CRUD, permission check, user-role assignment,
  feature registration, audit log, stats
- Backward compatible with existing auth flows
This commit is contained in:
TARS 2026-04-16 00:57:27 +00:00
commit 0b0871ffea
16 changed files with 5245 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
node_modules/
data/*.db
data/*.db-shm
data/*.db-wal
data/config.json
data/tokens.json
data/lockouts.json

33
data/services-cache.json Normal file
View file

@ -0,0 +1,33 @@
{
"services": {
"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"
},
"synced_at": "2026-04-16T00:56:20.841Z",
"count": 27
}

56
data/users.json Normal file
View file

@ -0,0 +1,56 @@
[
{
"email": "scott@scottfelten.com",
"name": "Scott Felten",
"role": "admin",
"services": [
"*"
],
"metadata": {},
"tags": [
"owner"
],
"createdAt": "2026-03-07",
"createdBy": "system",
"lastLogin": "2026-04-10T20:23:03.522Z",
"authMethods": [
"google"
]
},
{
"email": "scott.felten@incorta.com",
"name": "Scott Felten (Incorta)",
"role": "admin",
"services": [
"*"
],
"metadata": {},
"tags": [
"owner"
],
"createdAt": "2026-03-07",
"createdBy": "system",
"lastLogin": "2026-03-21T00:10:40.433Z",
"authMethods": [
"google"
]
},
{
"email": "iamscottfelten@gmail.com",
"name": "Scott Felten (Gmail)",
"role": "admin",
"services": [
"*"
],
"metadata": {},
"tags": [
"owner"
],
"createdAt": "2026-03-07",
"createdBy": "system",
"lastLogin": "2026-03-16T04:56:49.491Z",
"authMethods": [
"google"
]
}
]

34
debug-check.js Normal file
View file

@ -0,0 +1,34 @@
const http = require("http");
// Simulate what access-manager does - call oauth2-proxy auth endpoint
// with the cookie from the browser
const cookie = process.argv[2] || "";
const host = process.argv[3] || "docs.scottfelten.com";
console.log("Testing oauth2-proxy auth with:");
console.log(" Cookie length:", cookie.length);
console.log(" Host:", host);
const opts = {
hostname: "127.0.0.1",
port: 4180,
path: "/oauth2/auth",
method: "GET",
headers: {
Cookie: cookie,
"X-Forwarded-Proto": "https",
Host: host,
},
};
const req = http.request(opts, (res) => {
console.log("\noauth2-proxy response:");
console.log(" Status:", res.statusCode);
console.log(" Headers:", JSON.stringify(res.headers, null, 2));
let body = "";
res.on("data", (d) => (body += d));
res.on("end", () => { if (body) console.log(" Body:", body); });
});
req.on("error", (e) => console.error("Error:", e.message));
req.end();

98
lib/db.js Normal file
View file

@ -0,0 +1,98 @@
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const DB_PATH = path.join(__dirname, '..', 'data', 'access-manager.db');
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
// ─── Schema ───
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL COLLATE NOCASE,
name TEXT NOT NULL,
password_hash TEXT,
auth_methods TEXT NOT NULL DEFAULT '["google"]',
metadata TEXT DEFAULT '{}',
tags TEXT DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
created_by TEXT DEFAULT 'system',
last_login TEXT,
status TEXT NOT NULL DEFAULT 'active'
);
CREATE TABLE IF NOT EXISTS roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
description TEXT,
is_system INTEGER NOT NULL DEFAULT 0,
priority INTEGER NOT NULL DEFAULT 100,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
app TEXT NOT NULL,
feature TEXT NOT NULL,
action TEXT NOT NULL,
display_name TEXT,
description TEXT,
category TEXT DEFAULT 'general',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(app, feature, action)
);
CREATE TABLE IF NOT EXISTS role_permissions (
role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
permission_id INTEGER NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE IF NOT EXISTS user_roles (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
scope TEXT NOT NULL DEFAULT '*',
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
granted_by TEXT,
PRIMARY KEY (user_id, role_id, scope)
);
CREATE TABLE IF NOT EXISTS services (
id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
hostname TEXT,
hostnames TEXT DEFAULT '[]',
port INTEGER,
status TEXT NOT NULL DEFAULT 'active',
features_registered INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
actor_email TEXT NOT NULL,
action TEXT NOT NULL,
target_type TEXT,
target_id TEXT,
detail TEXT,
ip TEXT,
service TEXT
);
CREATE INDEX IF NOT EXISTS idx_user_roles_user ON user_roles(user_id);
CREATE INDEX IF NOT EXISTS idx_user_roles_role ON user_roles(role_id);
CREATE INDEX IF NOT EXISTS idx_role_perms_role ON role_permissions(role_id);
CREATE INDEX IF NOT EXISTS idx_permissions_app ON permissions(app);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log(actor_email);
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp);
`);
module.exports = db;

238
lib/migration.js Normal file
View file

@ -0,0 +1,238 @@
const db = require('./db');
const rbac = require('./rbac');
const fs = require('fs');
const path = require('path');
const bcrypt = require('bcryptjs');
const DATA_DIR = path.join(__dirname, '..', 'data');
const USERS_FILE = path.join(DATA_DIR, 'users.json');
function migrate() {
console.log('[migration] Starting v2 → v3 migration...');
// 1. Migrate users from JSON
migrateUsers();
// 2. Seed default roles
seedRoles();
// 3. Seed wildcard permissions
seedWildcardPermissions();
// 4. Assign roles to migrated users
assignMigratedUserRoles();
// 5. Register initial features for UseCaseGen apps
registerUseCaseGenFeatures();
// 6. Seed services
seedServices();
console.log('[migration] Migration complete.');
}
function migrateUsers() {
if (!fs.existsSync(USERS_FILE)) {
console.log('[migration] No users.json found — skipping user migration');
return;
}
const existing = db.prepare('SELECT COUNT(*) as c FROM users').get();
if (existing.c > 0) {
console.log(`[migration] Users table already has ${existing.c} rows — skipping`);
return;
}
const users = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8'));
const insert = db.prepare(`
INSERT OR IGNORE INTO users (email, name, password_hash, auth_methods, metadata, tags, created_at, created_by, last_login, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
let count = 0;
for (const u of users) {
insert.run(
u.email, u.name, u.passwordHash || null,
JSON.stringify(u.authMethods || ['google']),
JSON.stringify(u.metadata || {}),
JSON.stringify(u.tags || []),
u.createdAt || new Date().toISOString(),
u.createdBy || 'system',
u.lastLogin || null,
'active'
);
count++;
}
console.log(`[migration] Migrated ${count} users from JSON`);
}
function seedRoles() {
const existing = db.prepare('SELECT COUNT(*) as c FROM roles').get();
if (existing.c > 0) {
console.log(`[migration] Roles already seeded (${existing.c}) — skipping`);
return;
}
const roles = [
['super_admin', 'Super Administrator', 'Full access to all apps and features', 1, 1],
['admin', 'Administrator', 'App-level admin with full feature access', 1, 10],
['editor', 'Editor', 'Can create, edit, and manage content', 1, 20],
['user', 'User', 'Standard user — chat and content reading', 1, 30],
['viewer', 'Viewer', 'Read-only access', 1, 40],
];
const insert = db.prepare('INSERT INTO roles (name, display_name, description, is_system, priority) VALUES (?, ?, ?, ?, ?)');
for (const r of roles) insert.run(...r);
console.log(`[migration] Seeded ${roles.length} default roles`);
}
function seedWildcardPermissions() {
const existing = db.prepare("SELECT COUNT(*) as c FROM permissions WHERE app = '*'").get();
if (existing.c > 0) return;
// Wildcard permission entries
const wildcards = [
['*', '*', '*', 'Full Access', 'Unrestricted access to everything', 'system'],
['*', '*', 'read', 'Global Read', 'Read access to all features', 'system'],
['*', '*', 'write', 'Global Write', 'Write access to all features', 'system'],
['*', '*', 'delete', 'Global Delete', 'Delete access to all features', 'system'],
['*', '*', 'admin', 'Global Admin', 'Admin access to all features', 'system'],
['*', 'chat', 'read', 'Chat Read (all apps)', 'Read chat in all apps', 'chat'],
['*', 'chat', 'write', 'Chat Write (all apps)', 'Write chat in all apps', 'chat'],
['*', 'content', 'read', 'Content Read (all apps)', 'Read content in all apps', 'content'],
['*', 'content', 'write', 'Content Write (all apps)', 'Write content in all apps', 'content'],
['*', 'content', 'delete', 'Content Delete (all apps)', 'Delete content in all apps', 'content'],
];
const insert = db.prepare('INSERT OR IGNORE INTO permissions (app, feature, action, display_name, description, category) VALUES (?, ?, ?, ?, ?, ?)');
for (const w of wildcards) insert.run(...w);
// Assign permissions to roles
const superAdmin = db.prepare("SELECT id FROM roles WHERE name = 'super_admin'").get();
const admin = db.prepare("SELECT id FROM roles WHERE name = 'admin'").get();
const editor = db.prepare("SELECT id FROM roles WHERE name = 'editor'").get();
const user = db.prepare("SELECT id FROM roles WHERE name = 'user'").get();
const viewer = db.prepare("SELECT id FROM roles WHERE name = 'viewer'").get();
const perm = (app, feat, act) => db.prepare('SELECT id FROM permissions WHERE app=? AND feature=? AND action=?').get(app, feat, act);
const assign = db.prepare('INSERT OR IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)');
// super_admin: *.*.*
assign.run(superAdmin.id, perm('*', '*', '*').id);
// admin: *.*.read, *.*.write, *.*.delete, *.*.admin
assign.run(admin.id, perm('*', '*', 'read').id);
assign.run(admin.id, perm('*', '*', 'write').id);
assign.run(admin.id, perm('*', '*', 'delete').id);
assign.run(admin.id, perm('*', '*', 'admin').id);
// editor: *.content.*, *.chat.*
assign.run(editor.id, perm('*', 'content', 'read').id);
assign.run(editor.id, perm('*', 'content', 'write').id);
assign.run(editor.id, perm('*', 'content', 'delete').id);
assign.run(editor.id, perm('*', 'chat', 'read').id);
assign.run(editor.id, perm('*', 'chat', 'write').id);
// user: *.chat.read, *.chat.write, *.content.read
assign.run(user.id, perm('*', 'chat', 'read').id);
assign.run(user.id, perm('*', 'chat', 'write').id);
assign.run(user.id, perm('*', 'content', 'read').id);
// viewer: *.*.read
assign.run(viewer.id, perm('*', '*', 'read').id);
console.log('[migration] Seeded wildcard permissions and role assignments');
}
function assignMigratedUserRoles() {
const existingAssignments = db.prepare('SELECT COUNT(*) as c FROM user_roles').get();
if (existingAssignments.c > 0) {
console.log(`[migration] User-role assignments already exist (${existingAssignments.c}) — skipping`);
return;
}
// Read original users.json to get their v2 role + services
if (!fs.existsSync(USERS_FILE)) return;
const v2Users = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8'));
for (const v2 of v2Users) {
const user = db.prepare('SELECT id FROM users WHERE email = ? COLLATE NOCASE').get(v2.email);
if (!user) continue;
if (v2.role === 'admin' && v2.services && v2.services.includes('*')) {
// Admin with wildcard → super_admin
rbac.assignRole(v2.email, 'super_admin', '*', 'migration');
console.log(`[migration] ${v2.email} → super_admin (global)`);
} else if (v2.role === 'admin') {
// Admin with specific services → admin scoped
for (const svc of (v2.services || [])) {
rbac.assignRole(v2.email, 'admin', svc, 'migration');
}
console.log(`[migration] ${v2.email} → admin (scoped: ${(v2.services || []).join(', ')})`);
} else {
// Everyone else → user
rbac.assignRole(v2.email, 'user', '*', 'migration');
console.log(`[migration] ${v2.email} → user (global)`);
}
}
}
function registerUseCaseGenFeatures() {
const existingPerms = db.prepare("SELECT COUNT(*) as c FROM permissions WHERE app = 'usecasegen'").get();
if (existingPerms.c > 0) return;
const commonFeatures = [
{ feature: 'chat', actions: ['read', 'write'], category: 'chat', display_name: 'Chat / Sessions' },
{ feature: 'manage.focus_areas', actions: ['read', 'write', 'delete', 'hide', 'archive'], category: 'content', display_name: 'Focus Areas' },
{ feature: 'manage.tech_patterns', actions: ['read', 'write', 'delete', 'hide', 'archive'], category: 'content', display_name: 'Tech Patterns' },
{ feature: 'manage.regions', actions: ['read', 'write', 'delete', 'hide', 'archive'], category: 'content', display_name: 'Regions' },
{ feature: 'manage.audience', actions: ['read', 'write', 'delete', 'hide', 'archive'], category: 'content', display_name: 'Audience' },
{ feature: 'manage.analysis_focus', actions: ['read', 'write', 'delete', 'hide', 'archive'], category: 'content', display_name: 'Analysis Focus' },
{ feature: 'sessions', actions: ['read', 'write', 'delete'], category: 'content', display_name: 'Saved Sessions' },
{ feature: 'admin_panel', actions: ['read', 'admin'], category: 'admin', display_name: 'Admin Panel' },
{ feature: 'user_management', actions: ['read', 'write', 'admin'], category: 'admin', display_name: 'User Management' },
{ feature: 'settings', actions: ['read', 'write'], category: 'settings', display_name: 'App Settings' },
];
rbac.registerFeatures('usecasegen', commonFeatures);
rbac.registerFeatures('ggl-usecasegen', commonFeatures);
const fdxFeatures = [
{ feature: 'chat', actions: ['read', 'write'], category: 'chat', display_name: 'Chat / Sessions' },
{ feature: 'manage.patterns', actions: ['read', 'write', 'delete', 'hide', 'archive'], category: 'content', display_name: 'Patterns' },
{ feature: 'manage.regions', actions: ['read', 'write', 'delete', 'hide', 'archive'], category: 'content', display_name: 'Regions' },
{ feature: 'manage.audience', actions: ['read', 'write', 'delete', 'hide', 'archive'], category: 'content', display_name: 'Audience' },
{ feature: 'manage.analysis', actions: ['read', 'write', 'delete', 'hide', 'archive'], category: 'content', display_name: 'Analysis Focus' },
{ feature: 'sessions', actions: ['read', 'write', 'delete'], category: 'content', display_name: 'Saved Sessions' },
{ feature: 'enterprise_systems', actions: ['read', 'write', 'delete'], category: 'content', display_name: 'Enterprise Systems' },
{ feature: 'admin_panel', actions: ['read', 'admin'], category: 'admin', display_name: 'Admin Panel' },
{ feature: 'user_management', actions: ['read', 'write', 'admin'], category: 'admin', display_name: 'User Management' },
{ feature: 'settings', actions: ['read', 'write'], category: 'settings', display_name: 'App Settings' },
];
rbac.registerFeatures('fedex-cohort-app', fdxFeatures);
console.log('[migration] Registered UseCaseGen features for all three apps');
}
function seedServices() {
const existing = db.prepare('SELECT COUNT(*) as c FROM services').get();
if (existing.c > 0) return;
const services = [
['usecasegen', 'UseCaseGen APP', 'app.usecasegen.app', '["app.usecasegen.app","usecasegen.app","www.usecasegen.app"]', 3006],
['ggl-usecasegen', 'GGL UseCaseGen', 'ggl.usecasegen.app', '["ggl.usecasegen.app"]', 3095],
['fedex-cohort-app', 'FDX Trade Intel', 'fdx.usecasegen.app', '["fdx.usecasegen.app"]', 3089],
['taos', 'GoalStack / TAOS', 'goalstack.scottfelten.com', '["goalstack.scottfelten.com","taos.scottfelten.com"]', null],
['access-manager', 'Access Manager', 'users.scottfelten.com', '["users.scottfelten.com"]', 3030],
['intellicert', 'IntelliCert', 'intellicert.app', '["intellicert.app","www.intellicert.app"]', null],
['customer-360', 'Customer 360', 'c360.scottfelten.com', '["c360.scottfelten.com"]', null],
['command-center', 'Docs / Command Center', 'docs.scottfelten.com', '["docs.scottfelten.com"]', null],
];
const insert = db.prepare('INSERT OR IGNORE INTO services (id, display_name, hostname, hostnames, port) VALUES (?, ?, ?, ?, ?)');
for (const s of services) insert.run(...s);
console.log(`[migration] Seeded ${services.length} services`);
}
module.exports = { migrate };

264
lib/rbac.js Normal file
View file

@ -0,0 +1,264 @@
const db = require('./db');
// ─── Permission Resolution ───
function hasPermission(userId, app, feature, action) {
// Get all roles for this user where scope matches
const roles = db.prepare(`
SELECT r.id, r.name FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
WHERE ur.user_id = ? AND (ur.scope = '*' OR ur.scope = ?)
`).all(userId, app);
if (roles.length === 0) return { allowed: false };
for (const role of roles) {
// Check permissions for this role
const perms = db.prepare(`
SELECT p.app, p.feature, p.action FROM role_permissions rp
JOIN permissions p ON p.id = rp.permission_id
WHERE rp.role_id = ?
`).all(role.id);
for (const p of perms) {
if ((p.app === '*' || p.app === app) &&
(p.feature === '*' || p.feature === feature) &&
(p.action === '*' || p.action === action)) {
return { allowed: true, role: role.name, matched: `${p.app}.${p.feature}.${p.action}` };
}
}
}
return { allowed: false };
}
function hasPermissionByEmail(email, app, feature, action) {
const user = db.prepare('SELECT id FROM users WHERE email = ? COLLATE NOCASE').get(email);
if (!user) return { allowed: false, reason: 'user_not_found' };
return hasPermission(user.id, app, feature, action);
}
function getEffectivePermissions(userId, app) {
const scopeFilter = app ? 'AND (ur.scope = ? OR ur.scope = ?)' : 'AND 1=1';
const params = app ? [userId, '*', app] : [userId];
const roles = db.prepare(`
SELECT r.id, r.name, ur.scope FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
WHERE ur.user_id = ? ${app ? 'AND (ur.scope = ? OR ur.scope = ?)' : ''}
`).all(...params);
const permissions = new Set();
const roleNames = [];
for (const role of roles) {
roleNames.push({ role: role.name, scope: role.scope });
const perms = db.prepare(`
SELECT p.app, p.feature, p.action FROM role_permissions rp
JOIN permissions p ON p.id = rp.permission_id
WHERE rp.role_id = ?
`).all(role.id);
for (const p of perms) {
permissions.add(`${p.app}.${p.feature}.${p.action}`);
}
}
return { roles: roleNames, permissions: [...permissions] };
}
function getEffectivePermissionsByEmail(email, app) {
const user = db.prepare('SELECT id FROM users WHERE email = ? COLLATE NOCASE').get(email);
if (!user) return { roles: [], permissions: [] };
return getEffectivePermissions(user.id, app);
}
// ─── Role CRUD ───
function listRoles() {
return db.prepare(`
SELECT r.*,
(SELECT COUNT(*) FROM role_permissions WHERE role_id = r.id) as perm_count,
(SELECT COUNT(*) FROM user_roles WHERE role_id = r.id) as user_count
FROM roles r ORDER BY r.priority ASC
`).all();
}
function getRole(name) {
const role = db.prepare('SELECT * FROM roles WHERE name = ?').get(name);
if (!role) return null;
role.permissions = db.prepare(`
SELECT p.* FROM role_permissions rp
JOIN permissions p ON p.id = rp.permission_id
WHERE rp.role_id = ?
ORDER BY p.app, p.feature, p.action
`).all(role.id);
role.users = db.prepare(`
SELECT u.email, u.name, ur.scope, ur.granted_at FROM user_roles ur
JOIN users u ON u.id = ur.user_id
WHERE ur.role_id = ?
`).all(role.id);
return role;
}
function createRole(name, displayName, description, isSystem = false, priority = 100) {
return db.prepare(`
INSERT INTO roles (name, display_name, description, is_system, priority)
VALUES (?, ?, ?, ?, ?)
`).run(name, displayName, description, isSystem ? 1 : 0, priority);
}
function updateRole(name, updates) {
const role = db.prepare('SELECT * FROM roles WHERE name = ?').get(name);
if (!role) return null;
const { display_name, description, priority } = updates;
db.prepare(`
UPDATE roles SET
display_name = COALESCE(?, display_name),
description = COALESCE(?, description),
priority = COALESCE(?, priority),
updated_at = datetime('now')
WHERE name = ?
`).run(display_name || null, description || null, priority || null, name);
return db.prepare('SELECT * FROM roles WHERE name = ?').get(name);
}
function deleteRole(name) {
const role = db.prepare('SELECT * FROM roles WHERE name = ?').get(name);
if (!role) return { error: 'not_found' };
if (role.is_system) return { error: 'system_role' };
db.prepare('DELETE FROM roles WHERE id = ?').run(role.id);
return { deleted: true };
}
function setRolePermissions(roleName, permissionIds) {
const role = db.prepare('SELECT id FROM roles WHERE name = ?').get(roleName);
if (!role) return { error: 'role_not_found' };
const tx = db.transaction(() => {
db.prepare('DELETE FROM role_permissions WHERE role_id = ?').run(role.id);
const insert = db.prepare('INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)');
for (const pid of permissionIds) {
insert.run(role.id, pid);
}
});
tx();
return { ok: true, count: permissionIds.length };
}
function addRolePermissionsByPattern(roleName, app, feature, action) {
const role = db.prepare('SELECT id FROM roles WHERE name = ?').get(roleName);
if (!role) return { error: 'role_not_found' };
// Find or create the permission
let perm = db.prepare('SELECT id FROM permissions WHERE app = ? AND feature = ? AND action = ?').get(app, feature, action);
if (!perm) {
const r = db.prepare('INSERT INTO permissions (app, feature, action) VALUES (?, ?, ?)').run(app, feature, action);
perm = { id: r.lastInsertRowid };
}
try {
db.prepare('INSERT OR IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)').run(role.id, perm.id);
} catch (e) { /* already exists */ }
return { ok: true };
}
// ─── User-Role Assignment ───
function getUserRoles(email) {
const user = db.prepare('SELECT id FROM users WHERE email = ? COLLATE NOCASE').get(email);
if (!user) return [];
return db.prepare(`
SELECT r.name, r.display_name, ur.scope, ur.granted_at, ur.granted_by
FROM user_roles ur JOIN roles r ON r.id = ur.role_id
WHERE ur.user_id = ? ORDER BY r.priority
`).all(user.id);
}
function assignRole(email, roleName, scope, grantedBy) {
const user = db.prepare('SELECT id FROM users WHERE email = ? COLLATE NOCASE').get(email);
if (!user) return { error: 'user_not_found' };
const role = db.prepare('SELECT id FROM roles WHERE name = ?').get(roleName);
if (!role) return { error: 'role_not_found' };
db.prepare(`
INSERT OR REPLACE INTO user_roles (user_id, role_id, scope, granted_by)
VALUES (?, ?, ?, ?)
`).run(user.id, role.id, scope || '*', grantedBy || 'system');
return { ok: true };
}
function removeRole(email, roleName, scope) {
const user = db.prepare('SELECT id FROM users WHERE email = ? COLLATE NOCASE').get(email);
if (!user) return { error: 'user_not_found' };
const role = db.prepare('SELECT id FROM roles WHERE name = ?').get(roleName);
if (!role) return { error: 'role_not_found' };
db.prepare('DELETE FROM user_roles WHERE user_id = ? AND role_id = ? AND scope = ?')
.run(user.id, role.id, scope || '*');
return { ok: true };
}
// ─── Feature Registration ───
function registerFeatures(appId, features) {
const insert = db.prepare(`
INSERT OR IGNORE INTO permissions (app, feature, action, display_name, category)
VALUES (?, ?, ?, ?, ?)
`);
let count = 0;
const tx = db.transaction(() => {
for (const f of features) {
for (const action of (f.actions || ['read'])) {
insert.run(appId, f.feature, action, f.display_name || f.feature, f.category || 'general');
count++;
}
}
// Mark service as features_registered
db.prepare('UPDATE services SET features_registered = 1, updated_at = datetime(\'now\') WHERE id = ?').run(appId);
});
tx();
return { ok: true, registered: count };
}
function listFeatures(appId) {
const where = appId ? 'WHERE app = ?' : '';
const params = appId ? [appId] : [];
return db.prepare(`SELECT * FROM permissions ${where} ORDER BY app, category, feature, action`).all(...params);
}
// ─── Audit ───
function audit(actorEmail, action, targetType, targetId, detail, ip, service) {
db.prepare(`
INSERT INTO audit_log (actor_email, action, target_type, target_id, detail, ip, service)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(actorEmail, action, targetType || null, targetId || null,
detail ? JSON.stringify(detail) : null, ip || null, service || null);
}
function queryAudit({ actor, action, targetType, limit, offset, since, until } = {}) {
let where = [];
let params = [];
if (actor) { where.push('actor_email = ?'); params.push(actor); }
if (action) { where.push('action LIKE ?'); params.push(action + '%'); }
if (targetType) { where.push('target_type = ?'); params.push(targetType); }
if (since) { where.push('timestamp >= ?'); params.push(since); }
if (until) { where.push('timestamp <= ?'); params.push(until); }
const whereClause = where.length ? 'WHERE ' + where.join(' AND ') : '';
params.push(limit || 100, offset || 0);
return db.prepare(`
SELECT * FROM audit_log ${whereClause}
ORDER BY timestamp DESC LIMIT ? OFFSET ?
`).all(...params);
}
module.exports = {
hasPermission, hasPermissionByEmail, getEffectivePermissions, getEffectivePermissionsByEmail,
listRoles, getRole, createRole, updateRole, deleteRole, setRolePermissions, addRolePermissionsByPattern,
getUserRoles, assignRole, removeRole,
registerFeatures, listFeatures,
audit, queryAudit,
};

1398
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

17
package.json Normal file
View file

@ -0,0 +1,17 @@
{
"name": "access-manager",
"version": "2.0.0",
"description": "User management and scoped access control for scottfelten.com services",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"better-sqlite3": "^12.9.0",
"cookie-parser": "^1.4.7",
"express": "^4.21.0",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^6.9.16"
}
}

23
public/denied.html Normal file
View file

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Access Denied</title>
<style>
body { background: #0f172a; color: #e2e8f0; font-family: -apple-system, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
.box { text-align: center; max-width: 400px; }
h1 { font-size: 48px; margin: 0; color: #ef4444; }
p { color: #94a3b8; margin-top: 12px; }
a { color: #3b82f6; text-decoration: none; }
a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="box">
<h1>403</h1>
<p>You don't have access to this service.<br>Contact your administrator if you think this is a mistake.</p>
<p style="margin-top: 24px"><a href="https://scottfelten.com">← Back to scottfelten.com</a></p>
</div>
</body>
</html>

634
public/docs.html Normal file
View file

@ -0,0 +1,634 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>User Management — Documentation</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', sans-serif; background: #0a0a0f; color: #e2e8f0; min-height: 100vh; }
.container { max-width: 860px; margin: 0 auto; padding: 24px; }
.back-link { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: #64748b; text-decoration: none; margin-bottom: 20px; transition: color 0.15s; }
.back-link:hover { color: #94a3b8; }
/* Hero */
.hero {
background: linear-gradient(135deg, rgba(30,30,45,0.9), rgba(20,20,35,0.95));
border: 1px solid rgba(59,130,246,0.3); border-radius: 16px;
padding: 32px; margin-bottom: 20px;
}
.hero h1 { font-size: 28px; font-weight: 800; letter-spacing: -0.03em; }
.hero h1 span { color: #3b82f6; }
.hero-sub { font-size: 14px; color: #94a3b8; margin-top: 6px; }
.badge-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 16px; }
.badge { display: inline-block; padding: 4px 12px; border-radius: 6px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }
.badge-blue { background: rgba(59,130,246,0.15); color: #60a5fa; border: 1px solid rgba(59,130,246,0.25); }
.badge-green { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.25); }
.badge-purple { background: rgba(168,85,247,0.15); color: #c084fc; border: 1px solid rgba(168,85,247,0.25); }
.badge-amber { background: rgba(234,179,8,0.15); color: #fbbf24; border: 1px solid rgba(234,179,8,0.25); }
/* Sections */
.doc-section {
background: linear-gradient(135deg, rgba(30,30,45,0.9), rgba(20,20,35,0.95));
border: 1px solid rgba(100,100,140,0.2); border-radius: 16px;
padding: 24px 28px; margin-bottom: 16px;
transition: border-color 0.3s;
}
.doc-section:hover { border-color: rgba(140,140,200,0.35); }
.doc-section h2 { font-size: 20px; font-weight: 700; margin-bottom: 12px; display: flex; align-items: center; gap: 10px; }
.doc-section h3 { font-size: 15px; font-weight: 700; margin: 16px 0 8px; color: #c8d4ff; }
.doc-section h4 { font-size: 13px; font-weight: 700; margin: 12px 0 6px; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; }
.doc-section p { font-size: 14px; line-height: 1.7; color: #94a3b8; margin-bottom: 10px; }
.doc-section ul { padding-left: 20px; margin-bottom: 10px; }
.doc-section li { font-size: 14px; line-height: 1.7; color: #94a3b8; margin-bottom: 4px; }
.doc-section strong { color: #e2e8f0; }
.doc-section code { background: rgba(59,130,246,0.1); color: #93c5fd; padding: 2px 6px; border-radius: 4px; font-size: 12px; font-family: 'SF Mono', 'Fira Code', monospace; }
.doc-section a { color: #60a5fa; text-decoration: none; }
.doc-section a:hover { text-decoration: underline; }
.section-num {
width: 32px; height: 32px; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 14px; flex-shrink: 0;
background: rgba(59,130,246,0.15); color: #60a5fa;
}
/* Cards */
.card-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin: 12px 0; }
.card {
background: rgba(15,15,30,0.6); border: 1px solid rgba(100,100,140,0.15);
border-radius: 10px; padding: 16px;
}
.card h4 { margin-top: 0; }
.card p { font-size: 12px; margin-bottom: 0; }
/* Table */
.data-table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 13px; }
.data-table th {
text-align: left; padding: 8px 12px;
background: rgba(20,20,40,0.8); color: #64748b;
font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.08em; border-bottom: 1px solid rgba(100,100,140,0.15);
}
.data-table td { padding: 8px 12px; color: #94a3b8; border-bottom: 1px solid rgba(100,100,140,0.08); }
.data-table tr:hover td { background: rgba(40,40,65,0.3); }
/* Callout */
.callout {
background: rgba(250,180,50,0.06); border: 1px solid rgba(250,180,50,0.2);
border-radius: 10px; padding: 14px 18px; margin: 12px 0;
}
.callout p { color: #d4a855; margin: 0; }
.callout strong { color: #fbbf24; }
.callout-blue {
background: rgba(59,130,246,0.06); border: 1px solid rgba(59,130,246,0.2);
}
.callout-blue p { color: #7dabf0; }
.callout-blue strong { color: #60a5fa; }
.callout-green {
background: rgba(34,197,94,0.06); border: 1px solid rgba(34,197,94,0.2);
}
.callout-green p { color: #6dbf8b; }
.callout-green strong { color: #4ade80; }
/* Steps */
.step-list { counter-reset: step; list-style: none; padding: 0; }
.step-list li {
counter-increment: step; padding: 10px 0 10px 48px; position: relative;
border-bottom: 1px solid rgba(100,100,140,0.08);
}
.step-list li:last-child { border-bottom: none; }
.step-list li::before {
content: counter(step);
position: absolute; left: 0; top: 10px;
width: 32px; height: 32px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 13px;
background: rgba(59,130,246,0.1); color: #60a5fa;
border: 1px solid rgba(59,130,246,0.2);
}
.step-list li .step-title { font-weight: 700; color: #e2e8f0; font-size: 14px; }
.step-list li .step-desc { font-size: 13px; color: #64748b; margin-top: 2px; }
/* Expandable */
.expandable { cursor: pointer; }
.expandable-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 0; }
.expandable-header::after { content: '▸'; color: #4a4a6a; transition: transform 0.2s; font-size: 12px; }
.expandable.open .expandable-header::after { transform: rotate(90deg); }
.expandable-body { display: none; padding: 0 0 10px; }
.expandable.open .expandable-body { display: block; }
/* Code block */
.code-block {
background: rgba(10,10,20,0.8); border: 1px solid rgba(100,100,140,0.15);
border-radius: 8px; padding: 14px 18px; margin: 10px 0;
font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px;
color: #94a3b8; line-height: 1.6; overflow-x: auto; white-space: pre;
}
.code-block .method { color: #60a5fa; font-weight: 700; }
.code-block .url { color: #4ade80; }
.code-block .comment { color: #475569; }
.code-block .key { color: #c084fc; }
.code-block .val { color: #fbbf24; }
@media (max-width: 640px) {
.card-grid { grid-template-columns: 1fr; }
.container { padding: 16px; }
}
</style>
</head>
<body>
<div class="container">
<a href="/" class="back-link">← Back to User Management</a>
<!-- Hero -->
<div class="hero">
<h1>🔐 <span>User Management</span></h1>
<p class="hero-sub">User management, scoped access control, and multi-method authentication for scottfelten.com services.</p>
<div class="badge-row">
<span class="badge badge-green">v2.0 — Live</span>
<span class="badge badge-blue">18 Services</span>
<span class="badge badge-purple">3 Auth Methods</span>
<span class="badge badge-amber">March 2026</span>
</div>
</div>
<!-- 1. Overview -->
<div class="doc-section">
<h2><span class="section-num">1</span> Overview</h2>
<p>
User Management is the <strong>identity and access layer</strong> for all scottfelten.com services.
It controls who can sign in, what they can access, and how they authenticate — all from a single dashboard.
</p>
<div class="card-grid">
<div class="card" style="border-left: 3px solid #3b82f6;">
<h4 style="color:#60a5fa">🔵 Google SSO</h4>
<p>Sign in with Google. Primary method for team members and close collaborators. Powered by oauth2-proxy.</p>
</div>
<div class="card" style="border-left: 3px solid #a855f7;">
<h4 style="color:#c084fc">✨ Magic Link</h4>
<p>One-time login link sent to email. No password needed. Great for clients and external partners.</p>
</div>
<div class="card" style="border-left: 3px solid #eab308;">
<h4 style="color:#fbbf24">🔑 Email + Password</h4>
<p>Traditional login with bcrypt hashing. 12-char minimum. Lockout protection. Password reset via email.</p>
</div>
</div>
<div class="callout callout-blue">
<p>💡 <strong>How it works:</strong> Every scottfelten.com service checks with User Management before letting anyone in.
When a user visits any service, User Management verifies their session and checks if they have permission for that specific service.</p>
</div>
</div>
<!-- 2. Adding a User -->
<div class="doc-section">
<h2><span class="section-num">2</span> Adding a User</h2>
<ol class="step-list">
<li>
<div class="step-title">Click "+ Add User" on the dashboard</div>
<div class="step-desc">Opens the user creation modal with all configuration options.</div>
</li>
<li>
<div class="step-title">Enter their email and name</div>
<div class="step-desc">Email is their login identifier. Must be unique across all users.</div>
</li>
<li>
<div class="step-title">Choose their role</div>
<div class="step-desc">
<strong>Client</strong> — external user, limited access.<br>
<strong>Partner</strong> — business collaborator (e.g., Colum for IAG).<br>
<strong>Admin</strong> — full access to everything including this dashboard.
</div>
</li>
<li>
<div class="step-title">Select auth method(s)</div>
<div class="step-desc">
Pick one or more: <strong>Google</strong> (they need a Google account), <strong>Magic Link</strong> (any email works),
or <strong>Password</strong> (you set their initial password, or they can set it later via reset).
</div>
</li>
<li>
<div class="step-title">Assign services</div>
<div class="step-desc">
Check individual services they should access, or select <strong>All Services (*)</strong> for unrestricted access.
Services are auto-populated from the project inventory — always current.
</div>
</li>
<li>
<div class="step-title">Optional: Add tags</div>
<div class="step-desc">Comma-separated labels for organization (e.g., "partner, bpo-expert"). Purely informational for now.</div>
</li>
</ol>
<div class="callout">
<p><strong>Quick example — Adding Colum (IAG partner):</strong><br>
Email: colum@whatever.com · Role: Partner · Auth: Magic Link · Services: iag-website, iag-data-viewer, intellicert, usecasegen</p>
</div>
</div>
<!-- 3. Auth Methods Deep Dive -->
<div class="doc-section">
<h2><span class="section-num">3</span> Auth Methods — How Each Works</h2>
<h3>🔵 Google SSO</h3>
<p>The primary auth method. Uses oauth2-proxy to handle the Google OAuth flow. When a user visits a service:</p>
<ul>
<li>They're redirected to Google's sign-in page</li>
<li>After authenticating, oauth2-proxy sets a session cookie</li>
<li>User Management verifies the cookie and checks their service permissions</li>
</ul>
<p><strong>Requirement:</strong> User must have a Google account, and their email must be in the allowlist.</p>
<h3>✨ Magic Link</h3>
<p>Email-based, passwordless login. Perfect for clients and partners who don't use Google.</p>
<ul>
<li>User enters their email on the <a href="/login.html">login page</a></li>
<li>They receive a one-time link (expires in <strong>15 minutes</strong>)</li>
<li>Clicking the link sets a session cookie valid for <strong>7 days</strong></li>
<li>No password to remember, no account to create</li>
</ul>
<div class="callout callout-green">
<p>🔒 <strong>Security:</strong> Links are single-use (consumed on first click) and time-limited. The token is a 32-byte random hex string — not guessable.</p>
</div>
<h3>🔑 Email + Password</h3>
<p>Traditional login for users who prefer (or need) a persistent credential.</p>
<ul>
<li>Passwords hashed with <strong>bcrypt</strong> (12 rounds) — industry standard</li>
<li><strong>12-character minimum</strong> — enforced on creation and reset</li>
<li><strong>Lockout:</strong> 5 failed attempts → account locked for 30 minutes (auto-unlocks)</li>
<li><strong>Password reset:</strong> sends a time-limited reset link (1 hour expiry)</li>
</ul>
<h4>Setting a Password</h4>
<p>Two ways to set a password for a user:</p>
<ul>
<li><strong>At creation:</strong> Enter a password in the "Initial Password" field when adding the user</li>
<li><strong>After creation:</strong> Click the 🔑 button next to their name in the dashboard</li>
</ul>
<p>Users can also reset their own password via the "Forgot password?" link on the login page.</p>
</div>
<!-- 4. Services & Permissions -->
<div class="doc-section">
<h2><span class="section-num">4</span> Services & Permissions</h2>
<p>
Services are <strong>auto-discovered</strong> from the project inventory every 5 minutes.
When a new service is deployed, it appears in the services list automatically — no manual updates needed.
</p>
<h3>How Scoping Works</h3>
<ul>
<li>Each user has a <code>services[]</code> array listing which services they can access</li>
<li>When they visit a service, User Management maps the hostname to a service ID and checks if it's in their list</li>
<li><strong>Wildcard:</strong> <code>*</code> grants access to everything (admin default)</li>
</ul>
<h3>Current Service Map</h3>
<p>These services are protected by User Management. The list updates automatically from inventory.</p>
<table class="data-table">
<tr><th>Service</th><th>URL</th><th>Category</th></tr>
<tr><td>inventory</td><td>inv.scottfelten.com</td><td>Core</td></tr>
<tr><td>access-manager</td><td>users.scottfelten.com</td><td>Core</td></tr>
<tr><td>taos</td><td>goalstack.scottfelten.com</td><td>Core</td></tr>
<tr><td>signal-tower</td><td>signal.scottfelten.com</td><td>Intelligence</td></tr>
<tr><td>customer-360</td><td>c360.scottfelten.com</td><td>Sales</td></tr>
<tr><td>models-embeddings</td><td>models.scottfelten.com</td><td>AI/ML</td></tr>
<tr><td>iag-website</td><td>intelligenceadvisorygroup.com</td><td>IAG</td></tr>
<tr><td>iag-data-viewer</td><td>iag.scottfelten.com</td><td>IAG</td></tr>
<tr><td>intellicert</td><td>intellicert.app</td><td>IAG</td></tr>
<tr><td>usecasegen</td><td>app.usecasegen.app</td><td>IAG</td></tr>
<tr><td colspan="3" style="color:#475569; font-style:italic;">+ 8 more (auto-discovered from inventory)</td></tr>
</table>
<h3>Manual Sync</h3>
<p>Click the <strong>↻ Sync</strong> button in the dashboard header to force a refresh from inventory. Normally happens automatically every 5 minutes.</p>
</div>
<!-- 5. Login Page -->
<div class="doc-section">
<h2><span class="section-num">5</span> The Login Page</h2>
<p>
Available at <a href="/login.html">/login.html</a> — this is the <strong>public entry point</strong> for non-Google users.
It's accessible without authentication.
</p>
<h3>Three Tabs</h3>
<ul>
<li><strong>Google</strong> — redirects to Google sign-in (for users with Google SSO enabled)</li>
<li><strong>Magic Link</strong> — enter email, receive login link</li>
<li><strong>Password</strong> — enter email + password, with "Forgot password?" reset flow</li>
</ul>
<h3>Redirect Flow</h3>
<p>When a user hits a protected service without a session, they'll be redirected to the login page with a <code>?rd=</code> parameter.
After successful auth, they're sent back to the service they originally wanted.</p>
<div class="callout callout-blue">
<p>💡 <strong>Currently:</strong> The default redirect for unauthenticated users goes to Google sign-in.
To send non-Google users to the multi-auth login page instead, share the direct link:
<code>https://users.scottfelten.com/login.html?rd=https://SERVICE_URL</code></p>
</div>
</div>
<!-- 6. Security -->
<div class="doc-section">
<h2><span class="section-num">6</span> Security</h2>
<table class="data-table">
<tr><th>Feature</th><th>Detail</th></tr>
<tr><td>Password hashing</td><td>bcrypt, 12 rounds</td></tr>
<tr><td>Password minimum</td><td>12 characters</td></tr>
<tr><td>Login lockout</td><td>5 failed attempts → 30-minute lock (auto-unlocks)</td></tr>
<tr><td>Magic link expiry</td><td>15 minutes, single-use</td></tr>
<tr><td>Reset token expiry</td><td>1 hour, single-use</td></tr>
<tr><td>Session duration</td><td>7 days (JWT cookie on .scottfelten.com)</td></tr>
<tr><td>Cookie flags</td><td>httpOnly, secure, sameSite=lax</td></tr>
<tr><td>Token entropy</td><td>32 bytes (256-bit) random hex</td></tr>
<tr><td>Email enumeration</td><td>Prevented — all responses are identical regardless of email existence</td></tr>
</table>
<h3>Session Types</h3>
<p>Two session mechanisms coexist:</p>
<ul>
<li><strong>oauth2-proxy cookie</strong> — for Google SSO users (managed by oauth2-proxy)</li>
<li><strong>_am_session cookie</strong> — for magic link and password users (JWT, managed by User Management)</li>
</ul>
<p>The <code>/auth/check</code> endpoint tries the <code>_am_session</code> first, then falls back to oauth2-proxy. Either way, scoped access is enforced.</p>
</div>
<!-- 7. API Reference -->
<div class="doc-section">
<h2><span class="section-num">7</span> API Reference</h2>
<p>All admin endpoints require authentication (Google SSO or _am_session with admin role).</p>
<h3>Authentication</h3>
<div class="expandable" onclick="this.classList.toggle('open')">
<div class="expandable-header"><strong>POST /auth/magic/request</strong> — Request a magic link</div>
<div class="expandable-body">
<p>Public endpoint. Sends a login link to the provided email (if registered).</p>
<div class="code-block"><span class="method">POST</span> <span class="url">/auth/magic/request</span>
<span class="comment">// Body:</span>
{ <span class="key">"email"</span>: <span class="val">"user@example.com"</span>, <span class="key">"redirect"</span>: <span class="val">"https://inv.scottfelten.com"</span> }
<span class="comment">// Response (always the same, prevents email enumeration):</span>
{ <span class="key">"ok"</span>: true, <span class="key">"message"</span>: <span class="val">"If that email is registered, a login link has been sent."</span> }</div>
</div>
</div>
<div class="expandable" onclick="this.classList.toggle('open')">
<div class="expandable-header"><strong>POST /auth/password/login</strong> — Email + password login</div>
<div class="expandable-body">
<p>Public endpoint. Returns session cookie on success.</p>
<div class="code-block"><span class="method">POST</span> <span class="url">/auth/password/login</span>
<span class="comment">// Body:</span>
{ <span class="key">"email"</span>: <span class="val">"user@example.com"</span>, <span class="key">"password"</span>: <span class="val">"their-password"</span> }
<span class="comment">// Success:</span>
{ <span class="key">"ok"</span>: true, <span class="key">"user"</span>: { <span class="key">"email"</span>: ..., <span class="key">"name"</span>: ..., <span class="key">"role"</span>: ... } }
<span class="comment">// Failure (with lockout tracking):</span>
{ <span class="key">"error"</span>: <span class="val">"Invalid email or password"</span>, <span class="key">"attemptsRemaining"</span>: 3 }
<span class="comment">// Locked out:</span>
{ <span class="key">"error"</span>: <span class="val">"Account locked. Try again in 28 minutes."</span>, <span class="key">"locked"</span>: true }</div>
</div>
</div>
<div class="expandable" onclick="this.classList.toggle('open')">
<div class="expandable-header"><strong>POST /auth/password/set</strong> — Set password for a user (admin)</div>
<div class="expandable-body">
<div class="code-block"><span class="method">POST</span> <span class="url">/auth/password/set</span>
<span class="comment">// Body:</span>
{ <span class="key">"email"</span>: <span class="val">"user@example.com"</span>, <span class="key">"password"</span>: <span class="val">"min-12-characters"</span> }</div>
</div>
</div>
<div class="expandable" onclick="this.classList.toggle('open')">
<div class="expandable-header"><strong>POST /auth/password/reset-request</strong> — Request password reset</div>
<div class="expandable-body">
<p>Public endpoint. Sends a reset link to the email if registered.</p>
<div class="code-block"><span class="method">POST</span> <span class="url">/auth/password/reset-request</span>
{ <span class="key">"email"</span>: <span class="val">"user@example.com"</span> }</div>
</div>
</div>
<div class="expandable" onclick="this.classList.toggle('open')">
<div class="expandable-header"><strong>POST /auth/password/reset</strong> — Execute password reset</div>
<div class="expandable-body">
<div class="code-block"><span class="method">POST</span> <span class="url">/auth/password/reset</span>
{ <span class="key">"token"</span>: <span class="val">"hex-token-from-email"</span>, <span class="key">"password"</span>: <span class="val">"new-password-12+"</span> }</div>
</div>
</div>
<div class="expandable" onclick="this.classList.toggle('open')">
<div class="expandable-header"><strong>GET /auth/session</strong> — Check current session</div>
<div class="expandable-body">
<p>Public endpoint. Returns session info or <code>{ authenticated: false }</code>.</p>
</div>
</div>
<div class="expandable" onclick="this.classList.toggle('open')">
<div class="expandable-header"><strong>POST /auth/logout</strong> — Clear session</div>
<div class="expandable-body">
<p>Clears the <code>_am_session</code> cookie. Note: does not clear the oauth2-proxy session (Google SSO).</p>
</div>
</div>
<h3>User Management (Admin)</h3>
<div class="expandable" onclick="this.classList.toggle('open')">
<div class="expandable-header"><strong>GET /api/users</strong> — List all users</div>
<div class="expandable-body">
<p>Returns array of users. Password hashes are excluded; includes <code>hasPassword: true/false</code>.</p>
</div>
</div>
<div class="expandable" onclick="this.classList.toggle('open')">
<div class="expandable-header"><strong>POST /api/users</strong> — Create a user</div>
<div class="expandable-body">
<div class="code-block"><span class="method">POST</span> <span class="url">/api/users</span>
{
<span class="key">"email"</span>: <span class="val">"colum@example.com"</span>,
<span class="key">"name"</span>: <span class="val">"Colum"</span>,
<span class="key">"role"</span>: <span class="val">"partner"</span>,
<span class="key">"authMethods"</span>: [<span class="val">"magic-link"</span>],
<span class="key">"services"</span>: [<span class="val">"iag-website"</span>, <span class="val">"iag-data-viewer"</span>, <span class="val">"intellicert"</span>, <span class="val">"usecasegen"</span>],
<span class="key">"tags"</span>: [<span class="val">"partner"</span>],
<span class="key">"password"</span>: <span class="val">"optional-initial-password"</span>
}</div>
</div>
</div>
<div class="expandable" onclick="this.classList.toggle('open')">
<div class="expandable-header"><strong>PUT /api/users/:email</strong> — Update a user</div>
<div class="expandable-body">
<p>Partial update — only send the fields you want to change.</p>
<div class="code-block"><span class="method">PUT</span> <span class="url">/api/users/colum@example.com</span>
{ <span class="key">"services"</span>: [<span class="val">"iag-website"</span>, <span class="val">"intellicert"</span>] }</div>
</div>
</div>
<div class="expandable" onclick="this.classList.toggle('open')">
<div class="expandable-header"><strong>DELETE /api/users/:email</strong> — Remove a user</div>
<div class="expandable-body">
<p>Removes the user and updates the oauth2-proxy allowlist. Cannot delete yourself.</p>
</div>
</div>
<h3>Services</h3>
<div class="expandable" onclick="this.classList.toggle('open')">
<div class="expandable-header"><strong>GET /api/services</strong> — List available services</div>
<div class="expandable-body"><p>Returns sorted array of unique service IDs (deduplicated from hostname mappings).</p></div>
</div>
<div class="expandable" onclick="this.classList.toggle('open')">
<div class="expandable-header"><strong>GET /api/services/map</strong> — Full hostname → service map</div>
<div class="expandable-body"><p>Returns the complete mapping of hostnames to service IDs. Useful for debugging.</p></div>
</div>
<div class="expandable" onclick="this.classList.toggle('open')">
<div class="expandable-header"><strong>POST /api/services/sync</strong> — Force sync from inventory</div>
<div class="expandable-body"><p>Pulls fresh data from inventory API. Normally happens automatically every 5 minutes.</p></div>
</div>
<h3>Configuration</h3>
<div class="expandable" onclick="this.classList.toggle('open')">
<div class="expandable-header"><strong>PUT /api/config/email</strong> — Configure email transport</div>
<div class="expandable-body">
<div class="code-block"><span class="method">PUT</span> <span class="url">/api/config/email</span>
{
<span class="key">"host"</span>: <span class="val">"smtp.gmail.com"</span>,
<span class="key">"port"</span>: 587,
<span class="key">"user"</span>: <span class="val">"scott@scottfelten.com"</span>,
<span class="key">"pass"</span>: <span class="val">"google-app-password"</span>,
<span class="key">"from"</span>: <span class="val">"TARS &lt;scott@scottfelten.com&gt;"</span>
}</div>
</div>
</div>
</div>
<!-- 8. Architecture -->
<div class="doc-section">
<h2><span class="section-num">8</span> Architecture</h2>
<h3>Auth Flow</h3>
<div class="code-block"><span class="comment">User visits service.scottfelten.com</span>
<span class="comment">nginx auth_request → User Management /auth/check</span>
<span class="comment">Check 1: _am_session cookie? (magic link / password users)</span>
↓ no
<span class="comment">Check 2: oauth2-proxy session? (Google SSO users)</span>
↓ no
<span class="comment">401 → redirect to Google sign-in (or login page for non-Google users)</span>
↓ yes (either check)
<span class="comment">Look up user → check service scope → 200 (allow) or 403 (denied)</span></div>
<h3>Infrastructure</h3>
<table class="data-table">
<tr><th>Component</th><th>Location</th><th>Port</th></tr>
<tr><td>User Management</td><td>VPS systemd: access-manager.service</td><td>3030</td></tr>
<tr><td>oauth2-proxy</td><td>VPS systemd: oauth2-proxy.service</td><td>4180</td></tr>
<tr><td>Inventory API</td><td>VPS systemd: inventory-api.service</td><td>3025</td></tr>
<tr><td>nginx</td><td>All services proxied, auth_request on each</td><td>443</td></tr>
</table>
<h3>Data Files</h3>
<table class="data-table">
<tr><th>File</th><th>Purpose</th></tr>
<tr><td><code>data/users.json</code></td><td>User store (email, role, services, passwordHash, authMethods)</td></tr>
<tr><td><code>data/services-cache.json</code></td><td>Cached service map from inventory (refreshed every 5 min)</td></tr>
<tr><td><code>data/tokens.json</code></td><td>Active magic link and reset tokens (auto-cleaned)</td></tr>
<tr><td><code>data/lockouts.json</code></td><td>Failed login tracking and lockout state</td></tr>
<tr><td><code>data/config.json</code></td><td>JWT secret, SMTP config, email settings</td></tr>
<tr><td><code>/etc/oauth2-proxy-users.txt</code></td><td>oauth2-proxy email allowlist (auto-synced from users.json)</td></tr>
</table>
</div>
<!-- 9. Email Setup -->
<div class="doc-section">
<h2><span class="section-num">9</span> Setting Up Email</h2>
<p>
Email is required for magic links and password resets. Without it, these features log to the server console
(useful for testing, not for real users).
</p>
<h3>Option A: Google Workspace SMTP (Recommended)</h3>
<ol class="step-list">
<li>
<div class="step-title">Create a Google App Password</div>
<div class="step-desc">Go to <a href="https://myaccount.google.com/apppasswords" target="_blank">myaccount.google.com/apppasswords</a> → generate a password for "Mail" on "Other device"</div>
</li>
<li>
<div class="step-title">Configure via API</div>
<div class="step-desc">
<div class="code-block"><span class="method">PUT</span> <span class="url">https://users.scottfelten.com/api/config/email</span>
{
<span class="key">"host"</span>: <span class="val">"smtp.gmail.com"</span>,
<span class="key">"port"</span>: 587,
<span class="key">"user"</span>: <span class="val">"scott@scottfelten.com"</span>,
<span class="key">"pass"</span>: <span class="val">"xxxx-xxxx-xxxx-xxxx"</span>,
<span class="key">"from"</span>: <span class="val">"TARS &lt;scott@scottfelten.com&gt;"</span>
}</div>
</div>
</li>
<li>
<div class="step-title">Test it</div>
<div class="step-desc">Request a magic link for your own email. If it arrives, you're good.</div>
</li>
</ol>
<h3>Option B: Resend.com</h3>
<p>Free tier: 100 emails/day. Use <code>smtp.resend.com</code> with your API key as the password.</p>
</div>
<!-- 10. Quick Reference -->
<div class="doc-section">
<h2><span class="section-num">10</span> Quick Reference</h2>
<table class="data-table">
<tr><th>Task</th><th>How</th></tr>
<tr><td>Add a Google SSO user</td><td>Dashboard → + Add User → select Google auth + services</td></tr>
<tr><td>Add a non-Google user</td><td>Dashboard → + Add User → select Magic Link or Password auth</td></tr>
<tr><td>Set/change a password</td><td>Dashboard → click 🔑 button next to user</td></tr>
<tr><td>See who has access to what</td><td>Dashboard — services shown inline per user</td></tr>
<tr><td>Refresh the services list</td><td>Dashboard → ↻ Sync button</td></tr>
<tr><td>Send a login link</td><td>Share: <code>users.scottfelten.com/login.html</code></td></tr>
<tr><td>Check system health</td><td><code>GET /health</code> — shows users, services, email status</td></tr>
<tr><td>Unlock a locked account</td><td>Wait 30 min (auto-unlocks) or delete <code>data/lockouts.json</code></td></tr>
<tr><td>Configure email</td><td><code>PUT /api/config/email</code> (see Section 9)</td></tr>
</table>
</div>
<!-- Footer -->
<div style="text-align:center; padding: 24px 0; color: #334155; font-size: 11px;">
User Management v2.0 · Built by TARS · March 2026
</div>
</div>
<script>
// Make expandables work
document.querySelectorAll('.expandable').forEach(el => {
el.querySelector('.expandable-header').addEventListener('click', () => {
el.classList.toggle('open');
});
});
</script>
</body>
</html>

419
public/index.html Normal file
View file

@ -0,0 +1,419 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>User Management — scottfelten.com</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #0f172a; color: #e2e8f0; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
.container { max-width: 960px; margin: 0 auto; padding: 24px; }
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; }
.header h1 { font-size: 20px; font-weight: 700; color: white; }
.header-sub { font-size: 12px; color: #64748b; margin-top: 2px; }
.header-right { display: flex; align-items: center; gap: 12px; }
.user-count { font-size: 12px; color: #64748b; }
.status-bar { display: flex; gap: 16px; margin-bottom: 16px; font-size: 11px; color: #64748b; }
.status-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; margin-right: 4px; }
.status-dot.green { background: #22c55e; }
.status-dot.yellow { background: #eab308; }
.status-dot.red { background: #ef4444; }
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; overflow: hidden; }
.card-row { display: grid; grid-template-columns: 1fr auto auto auto auto; align-items: center; gap: 12px; padding: 14px 20px; border-bottom: 1px solid #1e293b; }
.card-row:hover { background: rgba(30,41,59,0.5); }
.card-row:last-child { border-bottom: none; }
.card-header { background: rgba(15,23,42,0.5); padding: 10px 20px; border-bottom: 1px solid #334155; }
.card-header span { font-size: 11px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }
.card-header-grid { display: grid; grid-template-columns: 1fr auto auto auto auto; gap: 12px; }
.user-name { font-weight: 600; font-size: 13px; color: white; }
.user-email { font-size: 11px; color: #64748b; margin-top: 1px; }
.user-meta { font-size: 11px; color: #475569; }
.pill { display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 6px; font-size: 11px; font-weight: 600; white-space: nowrap; }
.pill-admin { background: rgba(124,58,237,0.15); color: #a78bfa; }
.pill-partner { background: rgba(37,99,235,0.15); color: #60a5fa; }
.pill-client { background: rgba(5,150,105,0.15); color: #34d399; }
.pill-service { background: rgba(51,65,85,0.5); color: #94a3b8; margin: 2px; font-weight: 500; }
.pill-all { background: rgba(124,58,237,0.1); color: #a78bfa; font-style: italic; }
.pill-auth { font-size: 10px; padding: 2px 7px; }
.pill-google { background: rgba(66,133,244,0.15); color: #60a5fa; }
.pill-magic { background: rgba(168,85,247,0.15); color: #c084fc; }
.pill-password { background: rgba(234,179,8,0.15); color: #fbbf24; }
.services-wrap { display: flex; flex-wrap: wrap; gap: 4px; max-width: 300px; }
.auth-wrap { display: flex; flex-wrap: wrap; gap: 3px; }
.btn { padding: 6px 14px; border-radius: 6px; font-weight: 600; font-size: 12px; cursor: pointer; border: none; transition: all 0.15s; }
.btn-primary { background: #3b82f6; color: white; }
.btn-primary:hover { background: #2563eb; }
.btn-ghost { background: transparent; color: #94a3b8; border: 1px solid rgba(100,100,140,0.3); }
.btn-ghost:hover { background: #334155; color: white; }
.btn-danger { background: transparent; color: #ef4444; border: 1px solid rgba(239,68,68,0.3); }
.btn-danger:hover { background: rgba(239,68,68,0.15); }
.btn-sm { padding: 4px 10px; font-size: 11px; }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 100; display: flex; align-items: center; justify-content: center; }
.modal { background: #1e293b; border: 1px solid #334155; border-radius: 12px; width: 90%; max-width: 560px; padding: 20px; max-height: 85vh; overflow-y: auto; }
.modal h3 { font-size: 16px; font-weight: 700; color: white; }
.modal-sub { font-size: 11px; color: #64748b; margin-top: 2px; }
.field { margin-bottom: 14px; }
.field-label { display: block; font-size: 11px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; }
.field-hint { color: #475569; font-weight: 400; text-transform: none; letter-spacing: normal; }
.input-field { width: 100%; background: #0f172a; border: 1px solid #334155; border-radius: 8px; padding: 10px 14px; font-size: 13px; color: #e2e8f0; outline: none; font-family: inherit; transition: border-color 0.15s; }
.input-field:focus { border-color: #3b82f6; }
.input-field::placeholder { color: #475569; }
.chip { display: inline-flex; align-items: center; padding: 5px 12px; border-radius: 6px; font-size: 11px; font-weight: 600; cursor: pointer; border: 1px solid rgba(100,100,140,0.3); transition: all 0.15s; color: #64748b; }
.chip:hover { border-color: #60a5fa; }
.chip.active { border-color: #60a5fa; background: rgba(96,165,250,0.15); color: #93c5fd; }
.svc-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 8px; }
.svc-item { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 6px; border: 1px solid #334155; cursor: pointer; transition: all 0.15s; font-size: 12px; color: #94a3b8; }
.svc-item:hover { border-color: #475569; }
.svc-item.active { border-color: #3b82f6; background: rgba(59,130,246,0.1); color: #60a5fa; }
.svc-item .check { width: 14px; height: 14px; border-radius: 3px; border: 1px solid #475569; display: flex; align-items: center; justify-content: center; font-size: 10px; flex-shrink: 0; }
.svc-item.active .check { background: #3b82f6; border-color: #3b82f6; color: white; }
.svc-all { grid-column: 1 / -1; background: rgba(124,58,237,0.05); border-color: rgba(124,58,237,0.2); }
.svc-all.active { background: rgba(124,58,237,0.15); border-color: #7c3aed; color: #a78bfa; }
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; padding-top: 14px; border-top: 1px solid #334155; }
.empty { text-align: center; color: #475569; padding: 40px 20px; font-size: 13px; }
.section-title { font-size: 12px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin: 20px 0 8px; }
@media (max-width: 640px) {
.card-row { grid-template-columns: 1fr; gap: 8px; }
.card-header-grid { grid-template-columns: 1fr; }
.svc-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div>
<h1>User Management</h1>
<div class="header-sub">scottfelten.com services</div>
</div>
<div class="header-right">
<span class="user-count" id="user-count"></span>
<a href="https://keys.scottfelten.com" class="btn btn-ghost btn-sm" style="text-decoration:none;" target="_blank">🔑 Keys</a>
<a href="/docs.html" class="btn btn-ghost btn-sm" style="text-decoration:none;">? Help</a>
<button class="btn btn-ghost btn-sm" onclick="syncServices()" title="Refresh services from inventory">↻ Sync</button>
<button class="btn btn-primary" onclick="showAddModal()">+ Add User</button>
</div>
</div>
<div class="status-bar" id="status-bar">Loading...</div>
<div class="card">
<div class="card-header">
<div class="card-header-grid">
<span>User</span>
<span>Role</span>
<span>Auth</span>
<span>Services</span>
<span></span>
</div>
</div>
<div id="users-body">
<div class="empty">Loading...</div>
</div>
</div>
</div>
<div id="modalRoot"></div>
<script>
let allServices = [];
let allUsers = [];
let healthData = {};
async function load() {
const [usersRes, servicesRes, healthRes] = await Promise.all([
fetch('/api/users'),
fetch('/api/services'),
fetch('/health'),
]);
allUsers = await usersRes.json();
allServices = await servicesRes.json();
healthData = await healthRes.json();
render();
renderStatus();
}
function renderStatus() {
const emailDot = healthData.emailConfigured ? 'green' : 'yellow';
const emailLabel = healthData.emailConfigured ? 'Email configured' : 'Email: console mode';
document.getElementById('status-bar').innerHTML = `
<span><span class="status-dot green"></span> ${healthData.services || 0} services</span>
<span><span class="status-dot green"></span> ${healthData.users || 0} users</span>
<span><span class="status-dot ${emailDot}"></span> ${emailLabel}</span>
`;
}
function render() {
document.getElementById('user-count').textContent = `${allUsers.length} user${allUsers.length !== 1 ? 's' : ''}`;
const body = document.getElementById('users-body');
if (allUsers.length === 0) {
body.innerHTML = '<div class="empty">No users</div>';
return;
}
body.innerHTML = allUsers.map(u => {
const authMethods = u.authMethods || ['google'];
const authPills = authMethods.map(m =>
`<span class="pill pill-auth pill-${m}">${m === 'magic-link' ? 'magic' : m}</span>`
).join('');
return `
<div class="card-row">
<div>
<div class="user-name">${esc(u.name)}</div>
<div class="user-email">${esc(u.email)}</div>
</div>
<div><span class="pill pill-${u.role}">${u.role}</span></div>
<div class="auth-wrap">${authPills}</div>
<div class="services-wrap">
${u.services.includes('*')
? '<span class="pill pill-all">All Services</span>'
: u.services.slice(0, 3).map(s => `<span class="pill pill-service">${esc(s)}</span>`).join('')
+ (u.services.length > 3 ? `<span class="pill pill-service">+${u.services.length - 3}</span>` : '')
}
</div>
<div style="display:flex;gap:6px;justify-content:flex-end">
${u.hasPassword ? '' : `<button class="btn btn-ghost btn-sm" onclick="setPassword('${esc(u.email)}')" title="Set password">🔑</button>`}
<button class="btn btn-ghost" onclick="editUser('${esc(u.email)}')">Edit</button>
${u.role !== 'admin' ? `<button class="btn btn-danger" onclick="deleteUser('${esc(u.email)}')">Remove</button>` : ''}
</div>
</div>`;
}).join('');
}
async function syncServices() {
const res = await fetch('/api/services/sync', { method: 'POST' });
const data = await res.json();
allServices = data.services;
alert(`Synced ${data.count} service mappings`);
render();
}
function showAddModal() {
renderModal({ email: '', name: '', role: 'client', services: [], tags: '', authMethods: ['google'], password: '' }, false);
}
function editUser(email) {
const u = allUsers.find(x => x.email === email);
if (!u) return;
renderModal({
email: u.email,
name: u.name,
role: u.role,
services: [...u.services],
tags: (u.tags || []).join(', '),
authMethods: [...(u.authMethods || ['google'])],
password: '',
}, true);
}
function renderModal(data, isEdit) {
const isAll = data.services.includes('*');
const roles = ['client', 'partner', 'admin'];
const authOptions = ['google', 'magic-link', 'password'];
document.getElementById('modalRoot').innerHTML = `
<div class="modal-overlay" onclick="if(event.target===this)closeModal()">
<div class="modal">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px">
<div>
<h3>${isEdit ? 'Edit User' : 'Add User'}</h3>
${isEdit ? `<div class="modal-sub">${esc(data.email)}</div>` : ''}
</div>
<button onclick="closeModal()" style="background:none;border:none;color:#64748b;cursor:pointer;font-size:16px"></button>
</div>
${!isEdit ? `
<div class="field">
<label class="field-label">Email</label>
<input id="f-email" type="email" placeholder="user@example.com" value="${esc(data.email)}" class="input-field">
</div>` : ''}
<div class="field">
<label class="field-label">Name</label>
<input id="f-name" type="text" placeholder="Full Name" value="${esc(data.name)}" class="input-field">
</div>
<div class="field">
<label class="field-label">Role</label>
<div style="display:flex;gap:8px;margin-top:2px">
${roles.map(r => `<span class="chip ${data.role === r ? 'active' : ''}" onclick="modalSetRole('${r}')">${r}</span>`).join('')}
</div>
</div>
<div class="field">
<label class="field-label">Auth Methods <span class="field-hint">(how they can sign in)</span></label>
<div style="display:flex;gap:8px;margin-top:2px">
${authOptions.map(a => `<span class="chip ${data.authMethods.includes(a) ? 'active' : ''}" onclick="modalToggleAuth('${a}')">${a === 'magic-link' ? '✨ Magic Link' : a === 'google' ? '🔵 Google' : '🔑 Password'}</span>`).join('')}
</div>
</div>
${!isEdit && data.authMethods.includes('password') ? `
<div class="field">
<label class="field-label">Initial Password <span class="field-hint">(min 12 chars, or set later)</span></label>
<input id="f-password" type="password" placeholder="Optional — can set later" value="${esc(data.password)}" class="input-field">
</div>` : ''}
<div class="field">
<label class="field-label">Services <span class="field-hint">(click to toggle)</span></label>
<div class="svc-grid">
<div class="svc-item svc-all ${isAll ? 'active' : ''}" onclick="modalToggleAll()">
<div class="check">${isAll ? '✓' : ''}</div>
All Services (*)
</div>
${allServices.map(s => `
<div class="svc-item ${data.services.includes(s) || isAll ? 'active' : ''}" onclick="modalToggleSvc('${esc(s)}')" ${isAll ? 'style="opacity:0.4;pointer-events:none"' : ''}>
<div class="check">${data.services.includes(s) || isAll ? '✓' : ''}</div>
${esc(s)}
</div>
`).join('')}
</div>
</div>
<div class="field">
<label class="field-label">Tags <span class="field-hint">(comma-separated)</span></label>
<input id="f-tags" type="text" placeholder="partner, bpo-expert" value="${esc(data.tags)}" class="input-field">
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveUser(${isEdit})">${isEdit ? 'Save Changes' : 'Add User'}</button>
</div>
</div>
</div>`;
window._modalData = data;
window._modalIsEdit = isEdit;
}
function modalSetRole(role) {
captureInputs();
window._modalData.role = role;
renderModal(window._modalData, window._modalIsEdit);
}
function modalToggleAuth(method) {
captureInputs();
const d = window._modalData;
const idx = d.authMethods.indexOf(method);
if (idx >= 0) {
if (d.authMethods.length <= 1) return; // Must have at least one
d.authMethods.splice(idx, 1);
} else {
d.authMethods.push(method);
}
renderModal(d, window._modalIsEdit);
}
function modalToggleAll() {
captureInputs();
const d = window._modalData;
d.services = d.services.includes('*') ? [] : ['*'];
renderModal(d, window._modalIsEdit);
}
function modalToggleSvc(svc) {
captureInputs();
const d = window._modalData;
const idx = d.services.indexOf(svc);
if (idx >= 0) d.services.splice(idx, 1); else d.services.push(svc);
renderModal(d, window._modalIsEdit);
}
function captureInputs() {
const d = window._modalData;
const el = (id) => document.getElementById(id);
if (el('f-email')) d.email = el('f-email').value;
if (el('f-name')) d.name = el('f-name').value;
if (el('f-tags')) d.tags = el('f-tags').value;
if (el('f-password')) d.password = el('f-password').value;
}
function closeModal() { document.getElementById('modalRoot').innerHTML = ''; }
async function saveUser(isEdit) {
captureInputs();
const d = window._modalData;
const email = d.email.trim();
const name = d.name.trim();
const tags = d.tags.split(',').map(t => t.trim()).filter(Boolean);
if (!email || !name) return alert('Email and name are required');
try {
if (isEdit) {
const res = await fetch(`/api/users/${encodeURIComponent(email)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, role: d.role, services: d.services, tags, authMethods: d.authMethods }),
});
if (!res.ok) { const e = await res.json(); return alert(e.error); }
} else {
const body = { email, name, role: d.role, services: d.services, tags, authMethods: d.authMethods };
if (d.password && d.password.length >= 12) body.password = d.password;
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (res.status === 409) return alert('User already exists');
if (!res.ok) { const e = await res.json(); return alert(e.error); }
}
closeModal();
await load();
} catch (e) {
alert('Error: ' + e.message);
}
}
async function setPassword(email) {
const pw = prompt(`Set password for ${email}\n(minimum 12 characters)`);
if (!pw) return;
if (pw.length < 12) return alert('Password must be at least 12 characters');
const res = await fetch('/auth/password/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password: pw }),
});
if (res.ok) {
alert('Password set ✓');
await load();
} else {
const e = await res.json();
alert(e.error);
}
}
async function deleteUser(email) {
if (!confirm(`Remove ${email}?`)) return;
await fetch(`/api/users/${encodeURIComponent(email)}`, { method: 'DELETE' });
await load();
}
function esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
load();
</script>
</body>
</html>

254
public/login.html Normal file
View file

@ -0,0 +1,254 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign In — scottfelten.com</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #0f172a; color: #e2e8f0; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
.login-card { background: #1e293b; border: 1px solid #334155; border-radius: 16px; width: 90%; max-width: 420px; padding: 32px; }
.logo { text-align: center; margin-bottom: 24px; }
.logo h1 { font-size: 22px; font-weight: 700; color: white; }
.logo p { font-size: 12px; color: #64748b; margin-top: 4px; }
/* Tabs */
.tabs { display: flex; gap: 4px; margin-bottom: 24px; background: #0f172a; border-radius: 8px; padding: 4px; }
.tab { flex: 1; padding: 8px 12px; text-align: center; font-size: 12px; font-weight: 600; color: #64748b; border-radius: 6px; cursor: pointer; transition: all 0.15s; border: none; background: none; }
.tab:hover { color: #94a3b8; }
.tab.active { background: #334155; color: white; }
/* Panels */
.panel { display: none; }
.panel.active { display: block; }
/* Form */
.field { margin-bottom: 16px; }
.field label { display: block; font-size: 11px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; }
.input { width: 100%; background: #0f172a; border: 1px solid #334155; border-radius: 8px; padding: 12px 14px; font-size: 14px; color: #e2e8f0; outline: none; font-family: inherit; transition: border-color 0.15s; }
.input:focus { border-color: #3b82f6; }
.input::placeholder { color: #475569; }
.btn { width: 100%; padding: 12px; border: none; border-radius: 8px; font-weight: 600; font-size: 14px; cursor: pointer; transition: all 0.15s; }
.btn-primary { background: #3b82f6; color: white; }
.btn-primary:hover { background: #2563eb; }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-google { background: white; color: #1f2937; display: flex; align-items: center; justify-content: center; gap: 10px; }
.btn-google:hover { background: #f1f5f9; }
.btn-google svg { width: 18px; height: 18px; }
.divider { display: flex; align-items: center; gap: 12px; margin: 20px 0; }
.divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: #334155; }
.divider span { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: 0.05em; }
.message { padding: 12px 16px; border-radius: 8px; font-size: 13px; margin-bottom: 16px; }
.message-success { background: rgba(34,197,94,0.1); border: 1px solid rgba(34,197,94,0.2); color: #4ade80; }
.message-error { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); color: #f87171; }
.message-info { background: rgba(59,130,246,0.1); border: 1px solid rgba(59,130,246,0.2); color: #60a5fa; }
.link { color: #3b82f6; cursor: pointer; font-size: 12px; text-decoration: none; }
.link:hover { text-decoration: underline; }
.password-req { font-size: 11px; color: #475569; margin-top: 4px; }
.footer { text-align: center; margin-top: 20px; padding-top: 16px; border-top: 1px solid #334155; }
.footer p { font-size: 11px; color: #475569; }
</style>
</head>
<body>
<div class="login-card">
<div class="logo">
<h1>scottfelten.com</h1>
<p>Sign in to access services</p>
</div>
<div id="msg"></div>
<!-- Reset password form (shown when ?reset=token) -->
<div id="reset-panel" style="display:none">
<h3 style="color:white;font-size:16px;margin-bottom:16px;">Set New Password</h3>
<div class="field">
<label>New Password</label>
<input type="password" id="reset-pw" class="input" placeholder="Minimum 12 characters" minlength="12">
<div class="password-req">At least 12 characters</div>
</div>
<div class="field">
<label>Confirm Password</label>
<input type="password" id="reset-pw2" class="input" placeholder="Confirm password">
</div>
<button class="btn btn-primary" onclick="doReset()">Set Password</button>
</div>
<!-- Main login form -->
<div id="login-panels">
<div class="tabs">
<button class="tab active" data-tab="google" onclick="switchTab('google')">Google</button>
<button class="tab" data-tab="magic" onclick="switchTab('magic')">Magic Link</button>
<button class="tab" data-tab="password" onclick="switchTab('password')">Password</button>
</div>
<!-- Google SSO -->
<div id="tab-google" class="panel active">
<p style="font-size:13px;color:#94a3b8;margin-bottom:16px;">Sign in with your Google account. Recommended for team members.</p>
<a href="/oauth2/start?rd=/" class="btn btn-google" style="text-decoration:none;">
<svg viewBox="0 0 24 24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/></svg>
Sign in with Google
</a>
</div>
<!-- Magic Link -->
<div id="tab-magic" class="panel">
<p style="font-size:13px;color:#94a3b8;margin-bottom:16px;">We'll email you a one-time login link. No password needed.</p>
<div class="field">
<label>Email</label>
<input type="email" id="magic-email" class="input" placeholder="you@example.com">
</div>
<button class="btn btn-primary" id="magic-btn" onclick="requestMagicLink()">Send Login Link</button>
</div>
<!-- Email + Password -->
<div id="tab-password" class="panel">
<div class="field">
<label>Email</label>
<input type="email" id="pw-email" class="input" placeholder="you@example.com">
</div>
<div class="field">
<label>Password</label>
<input type="password" id="pw-pass" class="input" placeholder="Your password">
</div>
<button class="btn btn-primary" onclick="doLogin()">Sign In</button>
<div style="text-align:center;margin-top:12px;">
<a class="link" onclick="requestPasswordReset()">Forgot password?</a>
</div>
</div>
</div>
<div class="footer">
<p>Access is by invitation only</p>
</div>
</div>
<script>
// Check for URL params
const params = new URLSearchParams(location.search);
const rd = params.get('rd') || '/';
const error = params.get('error');
const resetToken = params.get('reset');
if (error === 'expired') showMsg('Login link has expired. Please request a new one.', 'error');
if (error === 'not_registered') showMsg('Email not registered. Contact admin for access.', 'error');
if (resetToken) {
document.getElementById('login-panels').style.display = 'none';
document.getElementById('reset-panel').style.display = 'block';
}
function switchTab(tab) {
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
document.getElementById('tab-' + tab).classList.add('active');
document.getElementById('msg').innerHTML = '';
}
function showMsg(text, type) {
document.getElementById('msg').innerHTML = `<div class="message message-${type}">${text}</div>`;
}
async function requestMagicLink() {
const email = document.getElementById('magic-email').value.trim();
if (!email) return showMsg('Enter your email address', 'error');
const btn = document.getElementById('magic-btn');
btn.disabled = true;
btn.textContent = 'Sending...';
try {
const res = await fetch('/auth/magic/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, redirect: rd }),
});
const data = await res.json();
showMsg('Check your email for a login link. It expires in 15 minutes.', 'success');
btn.textContent = 'Link Sent ✓';
} catch (e) {
showMsg('Error sending link. Try again.', 'error');
btn.disabled = false;
btn.textContent = 'Send Login Link';
}
}
async function doLogin() {
const email = document.getElementById('pw-email').value.trim();
const password = document.getElementById('pw-pass').value;
if (!email || !password) return showMsg('Enter email and password', 'error');
try {
const res = await fetch('/auth/password/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (res.ok) {
location.href = rd;
} else {
let msg = data.error;
if (data.attemptsRemaining !== undefined && data.attemptsRemaining > 0) {
msg += ` (${data.attemptsRemaining} attempts remaining)`;
}
showMsg(msg, 'error');
}
} catch (e) {
showMsg('Connection error. Try again.', 'error');
}
}
async function requestPasswordReset() {
const email = document.getElementById('pw-email').value.trim();
if (!email) return showMsg('Enter your email first, then click Forgot Password', 'info');
try {
const res = await fetch('/auth/password/reset-request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
showMsg('If that email is registered, a reset link has been sent.', 'success');
} catch {
showMsg('Error. Try again.', 'error');
}
}
async function doReset() {
const pw = document.getElementById('reset-pw').value;
const pw2 = document.getElementById('reset-pw2').value;
if (pw.length < 12) return showMsg('Password must be at least 12 characters', 'error');
if (pw !== pw2) return showMsg('Passwords don\'t match', 'error');
try {
const res = await fetch('/auth/password/reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: resetToken, password: pw }),
});
const data = await res.json();
if (res.ok) {
showMsg('Password set! Redirecting to login...', 'success');
setTimeout(() => { location.href = '/login.html'; }, 2000);
} else {
showMsg(data.error, 'error');
}
} catch {
showMsg('Error. Try again.', 'error');
}
}
// Enter key handlers
document.getElementById('magic-email')?.addEventListener('keydown', e => { if (e.key === 'Enter') requestMagicLink(); });
document.getElementById('pw-pass')?.addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
document.getElementById('reset-pw2')?.addEventListener('keydown', e => { if (e.key === 'Enter') doReset(); });
</script>
</body>
</html>

175
rbac-routes.js Normal file
View file

@ -0,0 +1,175 @@
// ═══════════════════════════════════════
// RBAC API ROUTES — bolt onto existing server
// ═══════════════════════════════════════
const db = require('./lib/db');
const rbac = require('./lib/rbac');
module.exports = function mountRbacRoutes(app, requireAdmin) {
// ─── Permission Check (for apps — service-to-service) ───
app.post('/api/permissions/check', (req, res) => {
const { email, app: appId, feature, action } = req.body;
if (!email || !appId || !feature || !action) {
return res.status(400).json({ error: 'email, app, feature, action required' });
}
const result = rbac.hasPermissionByEmail(email, appId, feature, action);
res.json(result);
});
app.get('/api/permissions/user/:email', requireAdmin, (req, res) => {
const result = rbac.getEffectivePermissionsByEmail(req.params.email);
res.json({ email: req.params.email, ...result });
});
app.get('/api/permissions/user/:email/:app', requireAdmin, (req, res) => {
const result = rbac.getEffectivePermissionsByEmail(req.params.email, req.params.app);
res.json({ email: req.params.email, app: req.params.app, ...result });
});
// ─── Roles CRUD ───
app.get('/api/roles', requireAdmin, (req, res) => {
res.json(rbac.listRoles());
});
app.post('/api/roles', requireAdmin, (req, res) => {
const { name, display_name, description, priority } = req.body;
if (!name || !display_name) return res.status(400).json({ error: 'name and display_name required' });
try {
rbac.createRole(name, display_name, description, false, priority || 100);
rbac.audit(getActorEmail(req), 'role.create', 'role', name, { display_name });
res.status(201).json(rbac.getRole(name));
} catch (e) {
res.status(409).json({ error: e.message });
}
});
app.get('/api/roles/:name', requireAdmin, (req, res) => {
const role = rbac.getRole(req.params.name);
if (!role) return res.status(404).json({ error: 'Role not found' });
res.json(role);
});
app.put('/api/roles/:name', requireAdmin, (req, res) => {
const result = rbac.updateRole(req.params.name, req.body);
if (!result) return res.status(404).json({ error: 'Role not found' });
rbac.audit(getActorEmail(req), 'role.update', 'role', req.params.name, req.body);
res.json(result);
});
app.delete('/api/roles/:name', requireAdmin, (req, res) => {
const result = rbac.deleteRole(req.params.name);
if (result.error === 'not_found') return res.status(404).json({ error: 'Role not found' });
if (result.error === 'system_role') return res.status(400).json({ error: 'Cannot delete system roles' });
rbac.audit(getActorEmail(req), 'role.delete', 'role', req.params.name);
res.json(result);
});
// ─── Role Permissions ───
app.put('/api/roles/:name/permissions', requireAdmin, (req, res) => {
const { permission_ids } = req.body;
if (!Array.isArray(permission_ids)) return res.status(400).json({ error: 'permission_ids array required' });
const result = rbac.setRolePermissions(req.params.name, permission_ids);
if (result.error) return res.status(404).json({ error: result.error });
rbac.audit(getActorEmail(req), 'role.permissions.set', 'role', req.params.name, { count: permission_ids.length });
res.json(result);
});
app.post('/api/roles/:name/permissions', requireAdmin, (req, res) => {
const { app: appId, feature, action } = req.body;
if (!appId || !feature || !action) return res.status(400).json({ error: 'app, feature, action required' });
const result = rbac.addRolePermissionsByPattern(req.params.name, appId, feature, action);
if (result.error) return res.status(404).json({ error: result.error });
rbac.audit(getActorEmail(req), 'role.permissions.add', 'role', req.params.name, { app: appId, feature, action });
res.json(result);
});
// ─── User-Role Assignment ───
app.get('/api/users/:email/roles', requireAdmin, (req, res) => {
res.json(rbac.getUserRoles(req.params.email));
});
app.post('/api/users/:email/roles', requireAdmin, (req, res) => {
const { role, scope } = req.body;
if (!role) return res.status(400).json({ error: 'role required' });
const result = rbac.assignRole(req.params.email, role, scope || '*', getActorEmail(req));
if (result.error) return res.status(404).json({ error: result.error });
rbac.audit(getActorEmail(req), 'role.assign', 'user', req.params.email, { role, scope });
res.json(result);
});
app.delete('/api/users/:email/roles/:role', requireAdmin, (req, res) => {
const scope = req.query.scope || '*';
const result = rbac.removeRole(req.params.email, req.params.role, scope);
if (result.error) return res.status(404).json({ error: result.error });
rbac.audit(getActorEmail(req), 'role.remove', 'user', req.params.email, { role: req.params.role, scope });
res.json(result);
});
// ─── Feature Registration ───
app.post('/api/features/register', (req, res) => {
const { app: appId, features } = req.body;
if (!appId || !Array.isArray(features)) return res.status(400).json({ error: 'app and features array required' });
const result = rbac.registerFeatures(appId, features);
res.json(result);
});
app.get('/api/features', requireAdmin, (req, res) => {
res.json(rbac.listFeatures());
});
app.get('/api/features/:app', requireAdmin, (req, res) => {
res.json(rbac.listFeatures(req.params.app));
});
// ─── All Permissions (for admin UI matrix) ───
app.get('/api/permissions', requireAdmin, (req, res) => {
const perms = db.prepare('SELECT * FROM permissions ORDER BY app, category, feature, action').all();
res.json(perms);
});
// ─── Audit Log ───
app.get('/api/audit', requireAdmin, (req, res) => {
const { actor, action, target_type, limit, offset, since, until } = req.query;
res.json(rbac.queryAudit({
actor, action, targetType: target_type,
limit: parseInt(limit) || 100, offset: parseInt(offset) || 0,
since, until
}));
});
app.get('/api/audit/user/:email', requireAdmin, (req, res) => {
res.json(rbac.queryAudit({ actor: req.params.email, limit: parseInt(req.query.limit) || 50 }));
});
// ─── Services (from DB now) ───
app.get('/api/services/list', requireAdmin, (req, res) => {
res.json(db.prepare('SELECT * FROM services ORDER BY display_name').all());
});
// ─── Stats (for dashboard) ───
app.get('/api/stats', requireAdmin, (req, res) => {
res.json({
users: db.prepare('SELECT COUNT(*) as c FROM users').get().c,
roles: db.prepare('SELECT COUNT(*) as c FROM roles').get().c,
permissions: db.prepare('SELECT COUNT(*) as c FROM permissions').get().c,
services: db.prepare('SELECT COUNT(*) as c FROM services').get().c,
assignments: db.prepare('SELECT COUNT(*) as c FROM user_roles').get().c,
recentAudit: db.prepare("SELECT COUNT(*) as c FROM audit_log WHERE timestamp > datetime('now', '-24 hours')").get().c,
});
});
// Helper
function getActorEmail(req) {
if (req.session?.email) return req.session.email;
const amSession = req.cookies?.['_am_session'];
if (amSession) {
try {
const jwt = require('jsonwebtoken');
const decoded = jwt.verify(amSession, process.env.JWT_SECRET || require('./lib/db').__jwt_secret);
return decoded.email;
} catch {}
}
return req.headers['x-email'] || req.headers['x-forwarded-email'] || 'unknown';
}
};

811
server.js Normal file
View file

@ -0,0 +1,811 @@
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'] || '';
const clientIp = req.headers['x-real-ip'] || req.connection.remoteAddress || '';
console.log(`[auth/check] host=${host} ip=${clientIp}`);
// Trusted IPs bypass (TARS agent browser on Mac Studio)
const TRUSTED_IPS = ['75.164.159.96'];
if (TRUSTED_IPS.includes(clientIp)) {
console.log(`[auth/check] Trusted IP ${clientIp} — auto-granting as scott@scottfelten.com`);
return res.status(200).send('OK');
}
// ── 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 ───
app.get('/health', (req, res) => {
res.json({
status: 'ok',
users: loadUsers().length,
services: Object.keys(serviceMap).length,
emailConfigured: !!emailTransport,
});
});
// ═══════════════════════════════════════
// START
// ═══════════════════════════════════════
// ═══════════════════════════════════════
// RBAC v3 — Role-Based Access Control
// ═══════════════════════════════════════
try {
const { migrate } = require('./lib/migration');
migrate();
console.log('[rbac] Migration check complete');
} catch (e) {
console.error('[rbac] Migration error:', e.message);
}
try {
const mountRbacRoutes = require('./rbac-routes');
mountRbacRoutes(app, requireAdmin);
console.log('[rbac] RBAC API routes mounted');
} catch (e) {
console.error('[rbac] Failed to mount RBAC routes:', e.message);
}
app.listen(PORT, HOST, async () => {
console.log(`access-manager v3 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);
});

784
server.js.bak Normal file
View file

@ -0,0 +1,784 @@
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 ───
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);
});