diff --git a/docs/implementation/trinity-console-2-implementation-guide.md b/docs/implementation/trinity-console-2-implementation-guide.md new file mode 100644 index 0000000..aabc26f --- /dev/null +++ b/docs/implementation/trinity-console-2-implementation-guide.md @@ -0,0 +1,1776 @@ +# Trinity Console 2.0 — Complete Implementation Guide + +**Version:** 1.0 +**Created:** April 5, 2026 +**Authors:** Michael (The Wizard), Claude (Chronicler #61), Gemini (Architectural Partner) +**Purpose:** Cold-start handoff document — any AI or developer can implement Trinity Console 2.0 from this single document. + +--- + +## Table of Contents + +1. [Project Overview](#1-project-overview) +2. [Architecture Decisions](#2-architecture-decisions) +3. [Complete File Structure](#3-complete-file-structure) +4. [Database Migrations](#4-database-migrations) +5. [Core Engine Code](#5-core-engine-code) +6. [RBAC System Code](#6-rbac-system-code) +7. [Feature Flags Code](#7-feature-flags-code) +8. [Authentication System](#8-authentication-system) +9. [Branding & Design System](#9-branding--design-system) +10. [Example Module (Dashboard)](#10-example-module-dashboard) +11. [Deployment Infrastructure](#11-deployment-infrastructure) +12. [Implementation Checklist](#12-implementation-checklist) +13. [Migration from Arbiter 3.5](#13-migration-from-arbiter-35) + +--- + +## 1. Project Overview + +### What is Trinity Console 2.0? + +Trinity Console is the admin panel for Firefrost Gaming, a subscription-based modded Minecraft server community. Version 2.0 is a complete architectural rewrite from a monolithic Express app to a modular plugin-based system. + +### The Trinity (Users) + +| Name | Role | Username | Default Permission Set | +|------|------|----------|------------------------| +| Michael | The Wizard (Owner) | Frostystyle | Admin (full access) | +| Meg | The Emissary (Community) | Gingerfury | Community (player management) | +| Holly | The Catalyst (Builder) | unicorn20089 | Builder (server/infrastructure) | + +### Key Characteristics + +- **Stack:** Node.js 20, Express, HTMX, Tailwind CSS, PostgreSQL, EJS +- **Auth:** Discord OAuth (only Trinity staff can access) +- **Design:** Dark theme with Firefrost branding (Fire/Frost/Arcane colors) +- **Deployment:** Hybrid (Dev VPS for development, Command Center for production) + +### Brand Colors + +| Name | Hex | CSS Variable | Usage | +|------|-----|--------------|-------| +| Fire | `#FF6B35` | `fire` | Fire path, energy, CTAs | +| Frost | `#4ECDC4` | `frost` | Frost path, calm, info | +| Arcane | `#A855F7` | `arcane` | Trinity/founder elements | +| Gold | `#FFD700` | `gold` | Highlights, achievements | +| Void (Dark) | `#0F0F1E` | `void` | Backgrounds | +| Void Surface | `#16162C` | `void-surface` | Cards, panels | +| Void Hover | `#1D1D3A` | `void-hover` | Hover states | +| Void Border | `#2A2A4A` | `void-border` | Dividers | + +--- + +## 2. Architecture Decisions + +### Module System + +- **12 consolidated modules** (not 35 thin ones) +- **Discovery:** `fs.readdirSync` on startup scans `src/modules/` +- **Load order:** Topological sort based on dependencies, fatal on circular deps +- **Hot reload:** Skip — nodemon restart is acceptable +- **Communication:** Shared DB pool + EventEmitter (`core.events.emit`) + +### Modules List + +| # | Module ID | Name | Contains | +|---|-----------|------|----------| +| 1 | dashboard | Dashboard | Overview, stats, quick actions | +| 2 | players | Players | Subscribers, bans, support, community notes, grace period, role audit | +| 3 | servers | Servers & Scheduling | Game servers, scheduler, whitelist sync | +| 4 | infrastructure | Infrastructure | Server inventory, services, domains, backups | +| 5 | financials | Financials | MRR/ARR, transactions, projections, expenses | +| 6 | tasks | Tasks | Work tracking, assignments, blockers, comments | +| 7 | docs | Docs | Ops manual editing via Gitea API, handoffs | +| 8 | team | Team | Staff roster, availability, calendar | +| 9 | marketing | Marketing | Social embeds/webhooks, announcements, assets | +| 10 | chroniclers | Chroniclers | Lineage tracking, memorials, portraits, Codex status | +| 11 | system | System | Modules mgmt, permissions, users, audit log, settings, feature flags, About page | +| 12 | health | Health | Uptime Kuma webhook integration, deadman's switch alerts | + +### RBAC (Role-Based Access Control) + +- **Model:** Roles + Direct Overrides (Option C) +- **Resolution:** Overrides → Role → Default Deny +- **Wildcards:** Store `tasks.*` as literal, resolve in middleware + +### Versioning + +- **Platform:** Trinity Console 2.0 +- **Migrated modules:** Start at 1.0.0 +- **New modules:** Start at 0.1.0 until stable + +### Deployment Strategy (Strangler Fig) + +1. Develop on Dev VPS (64.50.188.128) +2. Connect to Command Center's PostgreSQL (63.143.34.217) +3. Migrate one module at a time +4. Old routes coexist with new until verified +5. Promote to Command Center when stable +6. Dev VPS becomes permanent staging + +--- + +## 3. Complete File Structure + +``` +/opt/trinity-console/ +├── ecosystem.config.js # PM2 configuration +├── package.json +├── .env # Environment variables +├── tailwind.config.js # Tailwind with Firefrost colors +│ +├── migrations/ # Core database migrations (run manually first) +│ ├── 001_rbac_tables.sql +│ ├── 002_feature_flags.sql +│ └── 003_auth_users.sql +│ +├── public/ +│ └── css/ +│ └── output.css # Compiled Tailwind +│ +├── views/ +│ └── layout.ejs # Master layout with sidebar +│ +└── src/ + ├── index.js # Main entry point + ├── db.js # PostgreSQL pool + │ + ├── core/ + │ ├── boot.js # System initialization orchestrator + │ │ + │ ├── auth/ + │ │ ├── routes.js # Login, logout, profile routes + │ │ ├── strategy.js # Discord OAuth strategy + │ │ ├── middleware.js # requireActiveUser (gatekeeper) + │ │ ├── webhook.js # Discord webhook for pending users + │ │ └── views/ + │ │ ├── login.ejs + │ │ ├── profile.ejs + │ │ ├── pending.ejs + │ │ └── rejected.ejs + │ │ + │ ├── database/ + │ │ └── migrations.js # Per-module migration runner + │ │ + │ ├── events/ + │ │ └── index.js # EventEmitter registry + │ │ + │ ├── features/ + │ │ ├── index.js # Feature flag cache + │ │ ├── middleware.js # requireFeature() + │ │ └── routes.js # Toggle API + │ │ + │ ├── modules/ + │ │ ├── loader.js # fs.readdirSync + topological sort + │ │ ├── registry.js # In-memory module store + │ │ └── routes.js # Dynamic route mounting + │ │ + │ ├── navigation/ + │ │ └── index.js # Permission-filtered sidebar builder + │ │ + │ └── permissions/ + │ ├── middleware.js # requirePermission() + │ ├── resolver.js # fetchUserPermissions() + hasPermission() + │ ├── routes.js # RBAC API endpoints + │ └── sync.js # Upsert permissions from module.json + │ + └── modules/ + ├── dashboard/ + │ ├── module.json + │ ├── routes.js + │ ├── api.js + │ ├── events.js + │ ├── views/ + │ │ └── index.ejs + │ └── migrations/ + │ └── 001_dashboard_stats.sql + │ + ├── system/ + │ ├── module.json + │ ├── routes.js + │ └── views/ + │ ├── index.ejs + │ ├── users.ejs + │ ├── permissions.ejs + │ └── about.ejs + │ + ├── servers/ + │ ├── module.json + │ ├── routes.js + │ └── views/ + │ ├── index.ejs + │ └── scheduler.ejs + │ + └── [other modules follow same pattern] +``` + +--- + +## 4. Database Migrations + +**CRITICAL:** Run these manually via psql BEFORE first boot to avoid migration failures. + +### `migrations/001_rbac_tables.sql` + +```sql +-- 1. Roles Table +CREATE TABLE IF NOT EXISTS roles ( + id SERIAL PRIMARY KEY, + name VARCHAR(50) UNIQUE NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 2. Permissions Table +CREATE TABLE IF NOT EXISTS permissions ( + permission_key VARCHAR(100) PRIMARY KEY, + module_id VARCHAR(50) NOT NULL, + name VARCHAR(100) NOT NULL, + description TEXT, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 3. Role Permissions Table +CREATE TABLE IF NOT EXISTS role_permissions ( + role_id INT REFERENCES roles(id) ON DELETE CASCADE, + permission_key VARCHAR(100) REFERENCES permissions(permission_key) ON DELETE CASCADE, + PRIMARY KEY (role_id, permission_key) +); + +-- 4. User Roles Table +CREATE TABLE IF NOT EXISTS user_roles ( + user_id VARCHAR(50) PRIMARY KEY, + role_id INT REFERENCES roles(id) ON DELETE CASCADE, + assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + assigned_by VARCHAR(50) +); + +-- 5. User Permission Overrides Table +CREATE TABLE IF NOT EXISTS user_permission_overrides ( + user_id VARCHAR(50) NOT NULL, + permission_key VARCHAR(100) REFERENCES permissions(permission_key) ON DELETE CASCADE, + is_granted BOOLEAN NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(50), + PRIMARY KEY (user_id, permission_key) +); + +-- 6. Core System Migrations Table +CREATE TABLE IF NOT EXISTS core_migrations ( + id SERIAL PRIMARY KEY, + module_id VARCHAR(50) NOT NULL, + migration_file VARCHAR(255) NOT NULL, + applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(module_id, migration_file) +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_user_overrides ON user_permission_overrides(user_id); +CREATE INDEX IF NOT EXISTS idx_role_permissions ON role_permissions(role_id); +CREATE INDEX IF NOT EXISTS idx_active_permissions ON permissions(is_active); + +-- Seed Roles +INSERT INTO roles (name, description) VALUES +('Admin', 'Full system access (The Wizard)'), +('Community', 'Player management and support (The Emissary)'), +('Builder', 'Server and infrastructure management (The Catalyst)') +ON CONFLICT (name) DO NOTHING; +``` + +### `migrations/002_feature_flags.sql` + +```sql +CREATE TABLE IF NOT EXISTS feature_flags ( + key VARCHAR(100) PRIMARY KEY, + is_enabled BOOLEAN NOT NULL DEFAULT FALSE, + description TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_by VARCHAR(50) +); +``` + +### `migrations/003_auth_users.sql` + +```sql +-- 1. Create the Users table +CREATE TABLE IF NOT EXISTS users ( + discord_id VARCHAR(50) PRIMARY KEY, + username VARCHAR(100) NOT NULL, + avatar VARCHAR(255), + status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'approved', 'rejected' + last_login TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 2. Seed the Founders (Replace with actual Discord IDs!) +INSERT INTO users (discord_id, username, status) VALUES +('MICHAELS_DISCORD_ID', 'Frostystyle', 'approved'), +('MEGS_DISCORD_ID', 'Gingerfury', 'approved'), +('HOLLYS_DISCORD_ID', 'unicorn20089', 'approved') +ON CONFLICT (discord_id) DO UPDATE SET status = 'approved'; + +-- 3. Auto-assign roles to founders +INSERT INTO user_roles (user_id, role_id, assigned_by) +SELECT 'MICHAELS_DISCORD_ID', id, 'SYSTEM' FROM roles WHERE name = 'Admin' +ON CONFLICT (user_id) DO NOTHING; + +INSERT INTO user_roles (user_id, role_id, assigned_by) +SELECT 'MEGS_DISCORD_ID', id, 'SYSTEM' FROM roles WHERE name = 'Community' +ON CONFLICT (user_id) DO NOTHING; + +INSERT INTO user_roles (user_id, role_id, assigned_by) +SELECT 'HOLLYS_DISCORD_ID', id, 'SYSTEM' FROM roles WHERE name = 'Builder' +ON CONFLICT (user_id) DO NOTHING; +``` + +--- + +## 5. Core Engine Code + +### `src/core/boot.js` + +```javascript +const path = require('path'); +const { loadModules } = require('./modules/loader'); +const registry = require('./modules/registry'); +const { runModuleMigrations } = require('./database/migrations'); +const { mountModuleRoutes } = require('./modules/routes'); +const { loadModuleEvents } = require('./events'); +const { syncPermissions } = require('./permissions/sync'); +const { refreshCache: refreshFeatureCache } = require('./features'); + +async function bootSystem(app, pool) { + console.log('[Boot] Initializing Trinity Console 2.0...'); + + // 1. Load Modules + const modulesPath = path.join(__dirname, '../modules'); + const modules = loadModules(modulesPath); + console.log(`[Boot] Discovered ${modules.length} modules`); + + // 2. Register, Migrate, Mount + for (const mod of modules) { + registry.register(mod); + await runModuleMigrations(pool, mod); + mountModuleRoutes(app, mod); + loadModuleEvents(mod); + await syncPermissions(pool, mod); + } + + // 3. Load Feature Flags + await refreshFeatureCache(pool); + + console.log('[Boot] Trinity Console 2.0 ready.'); +} + +module.exports = { bootSystem }; +``` + +### `src/core/modules/registry.js` + +```javascript +const modules = new Map(); + +module.exports = { + register: (moduleConfig) => { + modules.set(moduleConfig.id, moduleConfig); + }, + getModule: (id) => modules.get(id), + getAllModules: () => Array.from(modules.values()), + isModuleEnabled: (id) => modules.has(id) +}; +``` + +### `src/core/modules/loader.js` + +```javascript +const fs = require('fs'); +const path = require('path'); + +function loadModules(modulesPath) { + if (!fs.existsSync(modulesPath)) return []; + + const folders = fs.readdirSync(modulesPath, { withFileTypes: true }) + .filter(dirent => dirent.isDirectory()) + .map(dirent => dirent.name); + + const loadedModules = []; + + for (const folder of folders) { + const configPath = path.join(modulesPath, folder, 'module.json'); + if (fs.existsSync(configPath)) { + try { + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + config.dirPath = path.join(modulesPath, folder); + loadedModules.push(config); + } catch (err) { + console.error(`[Core] Failed to parse module.json for ${folder}:`, err.message); + } + } + } + + return topologicalSort(loadedModules); +} + +function topologicalSort(modules) { + const sorted = []; + const visited = new Set(); + const visiting = new Set(); + const moduleMap = new Map(modules.map(m => [m.id, m])); + + function visit(moduleId) { + if (visiting.has(moduleId)) { + throw new Error(`[Core] Fatal: Circular dependency detected involving module '${moduleId}'`); + } + if (visited.has(moduleId)) return; + + visiting.add(moduleId); + const mod = moduleMap.get(moduleId); + + if (mod && mod.dependencies) { + for (const dep of Object.keys(mod.dependencies)) { + if (!moduleMap.has(dep)) { + console.warn(`[Core] Warning: Module '${moduleId}' depends on missing module '${dep}'`); + continue; + } + visit(dep); + } + } + + visiting.delete(moduleId); + visited.add(moduleId); + if (mod) sorted.push(mod); + } + + for (const mod of modules) { + visit(mod.id); + } + + return sorted; +} + +module.exports = { loadModules }; +``` + +### `src/core/modules/routes.js` + +```javascript +const path = require('path'); +const fs = require('fs'); + +function mountModuleRoutes(app, moduleConfig) { + const routesPath = path.join(moduleConfig.dirPath, 'routes.js'); + if (fs.existsSync(routesPath)) { + const router = require(routesPath); + const prefix = moduleConfig.routes || `/${moduleConfig.id}`; + app.use(prefix, router); + console.log(`[Routes] Mounted ${moduleConfig.id} at ${prefix}`); + } +} + +module.exports = { mountModuleRoutes }; +``` + +### `src/core/database/migrations.js` + +```javascript +const fs = require('fs'); +const path = require('path'); + +async function runModuleMigrations(pool, moduleConfig) { + const migrationsDir = path.join(moduleConfig.dirPath, 'migrations'); + if (!fs.existsSync(migrationsDir)) return; + + const files = fs.readdirSync(migrationsDir) + .filter(f => f.endsWith('.sql')) + .sort(); + + for (const file of files) { + const checkRes = await pool.query( + 'SELECT 1 FROM core_migrations WHERE module_id = $1 AND migration_file = $2', + [moduleConfig.id, file] + ); + + if (checkRes.rowCount === 0) { + console.log(`[Migrations] Applying ${moduleConfig.id}/${file}...`); + const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8'); + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query(sql); + await client.query( + 'INSERT INTO core_migrations (module_id, migration_file) VALUES ($1, $2)', + [moduleConfig.id, file] + ); + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw new Error(`Migration failed: ${moduleConfig.id}/${file} - ${err.message}`); + } finally { + client.release(); + } + } + } +} + +module.exports = { runModuleMigrations }; +``` + +### `src/core/events/index.js` + +```javascript +const EventEmitter = require('events'); +const fs = require('fs'); +const path = require('path'); + +class CoreEvents extends EventEmitter {} +const coreEmitter = new CoreEvents(); + +function loadModuleEvents(moduleConfig) { + const eventsPath = path.join(moduleConfig.dirPath, 'events.js'); + if (fs.existsSync(eventsPath)) { + const registerEvents = require(eventsPath); + registerEvents(coreEmitter); + console.log(`[Events] Registered events for ${moduleConfig.id}`); + } +} + +module.exports = { + emitter: coreEmitter, + loadModuleEvents +}; +``` + +### `src/core/navigation/index.js` + +```javascript +const registry = require('../modules/registry'); + +function buildNavigation(userPermissions) { + const modules = registry.getAllModules(); + const navStructure = {}; + + modules.forEach(mod => { + if (!mod.nav) return; + + // Check if user has at least one permission for this module + const modulePermKeys = (mod.permissions || []).map(p => p.key); + const hasAccess = modulePermKeys.length === 0 || + modulePermKeys.some(key => + userPermissions.includes(key) || + userPermissions.includes(`${mod.id}.*`) + ); + + if (!hasAccess) return; + + const section = mod.nav.section || 'General'; + if (!navStructure[section]) { + navStructure[section] = []; + } + + navStructure[section].push({ + id: mod.id, + name: mod.name, + icon: mod.icon || 'circle', + path: mod.routes || `/${mod.id}`, + position: mod.nav.position || 99, + badge: mod.nav.badge || null + }); + }); + + // Sort each section by position + Object.keys(navStructure).forEach(section => { + navStructure[section].sort((a, b) => a.position - b.position); + }); + + return navStructure; +} + +module.exports = { buildNavigation }; +``` + +--- + +## 6. RBAC System Code + +### `src/core/permissions/resolver.js` + +```javascript +async function fetchUserPermissions(pool, userId) { + // 1. Get role-based permissions + const roleRes = await pool.query(` + SELECT p.permission_key + FROM user_roles ur + JOIN role_permissions rp ON ur.role_id = rp.role_id + JOIN permissions p ON rp.permission_key = p.permission_key + WHERE ur.user_id = $1 AND p.is_active = TRUE + `, [userId]); + + const rolePermissions = roleRes.rows.map(r => r.permission_key); + + // 2. Get direct overrides + const overrideRes = await pool.query(` + SELECT permission_key, is_granted + FROM user_permission_overrides + WHERE user_id = $1 + `, [userId]); + + const overrides = {}; + overrideRes.rows.forEach(r => { + overrides[r.permission_key] = r.is_granted; + }); + + // 3. Apply overrides to role permissions + const finalPermissions = new Set(rolePermissions); + + Object.entries(overrides).forEach(([key, granted]) => { + if (granted) { + finalPermissions.add(key); + } else { + finalPermissions.delete(key); + } + }); + + return Array.from(finalPermissions); +} + +function hasPermission(userPermissions, requiredKey) { + if (userPermissions.includes(requiredKey)) return true; + + // Check for wildcard (e.g., tasks.* grants tasks.edit) + const modulePart = requiredKey.split('.')[0]; + if (userPermissions.includes(`${modulePart}.*`)) return true; + + return false; +} + +module.exports = { fetchUserPermissions, hasPermission }; +``` + +### `src/core/permissions/middleware.js` + +```javascript +const { hasPermission } = require('./resolver'); + +function requirePermission(permissionKey) { + return (req, res, next) => { + if (!req.user || !req.userPermissions) { + return res.redirect('/auth/login'); + } + + if (hasPermission(req.userPermissions, permissionKey)) { + return next(); + } + + const isHtmx = req.headers['hx-request'] === 'true'; + if (isHtmx) { + return res.status(403).send(` +
+ Access Denied: You do not have permission to access this resource. +
+ `); + } + + return res.status(403).render('errors/403', { + message: `Permission required: ${permissionKey}` + }); + }; +} + +module.exports = { requirePermission }; +``` + +### `src/core/permissions/sync.js` + +```javascript +async function syncPermissions(pool, moduleConfig) { + if (!moduleConfig.permissions || moduleConfig.permissions.length === 0) return; + + for (const perm of moduleConfig.permissions) { + await pool.query(` + INSERT INTO permissions (permission_key, module_id, name, description, is_active) + VALUES ($1, $2, $3, $4, TRUE) + ON CONFLICT (permission_key) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + is_active = TRUE + `, [perm.key, moduleConfig.id, perm.name, perm.description || '']); + } + + console.log(`[Permissions] Synced ${moduleConfig.permissions.length} permissions for ${moduleConfig.id}`); +} + +module.exports = { syncPermissions }; +``` + +### `src/core/permissions/routes.js` + +```javascript +const express = require('express'); +const router = express.Router(); +const db = require('../../db'); +const { requirePermission } = require('./middleware'); + +// All routes require system.permissions.manage +router.use(requirePermission('system.permissions.manage')); + +// Get all roles +router.get('/api/roles', async (req, res) => { + const { rows } = await db.query('SELECT * FROM roles ORDER BY id'); + res.json(rows); +}); + +// Get permissions for a role +router.get('/api/roles/:id/permissions', async (req, res) => { + const { rows } = await db.query( + 'SELECT permission_key FROM role_permissions WHERE role_id = $1', + [req.params.id] + ); + res.json(rows.map(r => r.permission_key)); +}); + +// Assign permission to role +router.post('/api/roles/:id/permissions', async (req, res) => { + const { permission_key } = req.body; + await db.query( + 'INSERT INTO role_permissions (role_id, permission_key) VALUES ($1, $2) ON CONFLICT DO NOTHING', + [req.params.id, permission_key] + ); + res.sendStatus(201); +}); + +// Remove permission from role +router.delete('/api/roles/:id/permissions/:key', async (req, res) => { + await db.query( + 'DELETE FROM role_permissions WHERE role_id = $1 AND permission_key = $2', + [req.params.id, req.params.key] + ); + res.sendStatus(200); +}); + +// Get all users with roles +router.get('/api/users', async (req, res) => { + const { rows } = await db.query(` + SELECT u.discord_id, u.username, u.avatar, u.status, r.name as role_name + FROM users u + LEFT JOIN user_roles ur ON u.discord_id = ur.user_id + LEFT JOIN roles r ON ur.role_id = r.id + ORDER BY u.username + `); + res.json(rows); +}); + +// Assign role to user +router.post('/api/users/:id/role', async (req, res) => { + const { role_id } = req.body; + await db.query(` + INSERT INTO user_roles (user_id, role_id, assigned_by) + VALUES ($1, $2, $3) + ON CONFLICT (user_id) DO UPDATE SET role_id = EXCLUDED.role_id, assigned_by = EXCLUDED.assigned_by + `, [req.params.id, role_id, req.user.discord_id]); + res.sendStatus(200); +}); + +// Approve user +router.post('/api/users/:id/approve', async (req, res) => { + await db.query( + 'UPDATE users SET status = $1 WHERE discord_id = $2', + ['approved', req.params.id] + ); + res.sendStatus(200); +}); + +// Get user overrides +router.get('/api/users/:id/overrides', async (req, res) => { + const { rows } = await db.query( + 'SELECT permission_key, is_granted FROM user_permission_overrides WHERE user_id = $1', + [req.params.id] + ); + res.json(rows); +}); + +// Add user override +router.post('/api/users/:id/overrides', async (req, res) => { + const { permission_key, is_granted } = req.body; + await db.query(` + INSERT INTO user_permission_overrides (user_id, permission_key, is_granted, created_by) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id, permission_key) DO UPDATE SET is_granted = EXCLUDED.is_granted + `, [req.params.id, permission_key, is_granted, req.user.discord_id]); + res.sendStatus(201); +}); + +// Remove user override +router.delete('/api/users/:id/overrides/:key', async (req, res) => { + await db.query( + 'DELETE FROM user_permission_overrides WHERE user_id = $1 AND permission_key = $2', + [req.params.id, req.params.key] + ); + res.sendStatus(200); +}); + +module.exports = router; +``` + +--- + +## 7. Feature Flags Code + +### `src/core/features/index.js` + +```javascript +let featureCache = {}; + +async function refreshCache(pool) { + try { + const { rows } = await pool.query('SELECT key, is_enabled FROM feature_flags'); + const newCache = {}; + rows.forEach(r => { newCache[r.key] = r.is_enabled; }); + featureCache = newCache; + console.log('[Features] Feature flag cache refreshed.'); + } catch (err) { + console.error('[Features] Failed to refresh cache:', err); + } +} + +function isEnabled(key) { + return !!featureCache[key]; +} + +async function setFlag(pool, key, enabled, updatedBy) { + await pool.query(` + INSERT INTO feature_flags (key, is_enabled, updated_by, updated_at) + VALUES ($1, $2, $3, CURRENT_TIMESTAMP) + ON CONFLICT (key) DO UPDATE SET + is_enabled = EXCLUDED.is_enabled, + updated_by = EXCLUDED.updated_by, + updated_at = CURRENT_TIMESTAMP + `, [key, enabled, updatedBy]); + await refreshCache(pool); +} + +module.exports = { refreshCache, isEnabled, setFlag }; +``` + +### `src/core/features/middleware.js` + +```javascript +const { isEnabled } = require('./index'); + +function requireFeature(featureKey) { + return (req, res, next) => { + if (isEnabled(featureKey)) { + return next(); + } + + const isHtmx = req.headers['hx-request'] === 'true'; + const msg = `Feature '${featureKey}' is currently disabled.`; + + if (isHtmx) { + return res.status(503).send(` +
+ Service Unavailable: ${msg} +
+ `); + } + + return res.status(503).render('errors/503', { message: msg }); + }; +} + +module.exports = { requireFeature }; +``` + +### `src/core/features/routes.js` + +```javascript +const express = require('express'); +const router = express.Router(); +const db = require('../../db'); +const { requirePermission } = require('../permissions/middleware'); +const { setFlag } = require('./index'); + +router.use(requirePermission('system.features.manage')); + +router.get('/api/features', async (req, res) => { + const { rows } = await db.query('SELECT * FROM feature_flags ORDER BY key'); + res.json(rows); +}); + +router.post('/api/features/:key', async (req, res) => { + const { is_enabled } = req.body; + await setFlag(db, req.params.key, is_enabled, req.user.discord_id); + res.sendStatus(200); +}); + +module.exports = router; +``` + +--- + +## 8. Authentication System + +### `src/core/auth/webhook.js` + +```javascript +async function notifyAdminOfPendingUser(user) { + const webhookUrl = process.env.DISCORD_ADMIN_WEBHOOK_URL; + if (!webhookUrl) return; + + const payload = { + embeds: [{ + title: "🔐 New Trinity Console Access Request", + description: `**${user.username}** has logged in via Discord and is waiting for authorization.`, + color: 16739381, // Fire orange + thumbnail: { + url: `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png` + }, + fields: [ + { name: "Discord ID", value: `\`${user.id}\``, inline: true } + ], + url: `https://trinity.firefrostgaming.com/system/users` + }] + }; + + try { + await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + } catch (err) { + console.error('[Auth] Failed to send Discord webhook alert:', err); + } +} + +module.exports = { notifyAdminOfPendingUser }; +``` + +### `src/core/auth/strategy.js` + +```javascript +const db = require('../../db'); +const { notifyAdminOfPendingUser } = require('./webhook'); + +async function verifyDiscordLogin(accessToken, refreshToken, profile, done) { + const { id, username, avatar } = profile; + + try { + const result = await db.query(` + INSERT INTO users (discord_id, username, avatar, last_login) + VALUES ($1, $2, $3, CURRENT_TIMESTAMP) + ON CONFLICT (discord_id) DO UPDATE + SET username = EXCLUDED.username, + avatar = EXCLUDED.avatar, + last_login = CURRENT_TIMESTAMP + RETURNING status, (xmax = 0) AS is_new_user; + `, [id, username, avatar]); + + const dbUser = result.rows[0]; + + const sessionUser = { + id: id, + discord_id: id, + username: username, + avatar: avatar, + status: dbUser.status + }; + + if (dbUser.is_new_user && dbUser.status === 'pending') { + await notifyAdminOfPendingUser(sessionUser); + } + + return done(null, sessionUser); + } catch (err) { + console.error('[Auth Error]', err); + return done(err, null); + } +} + +module.exports = { verifyDiscordLogin }; +``` + +### `src/core/auth/middleware.js` + +```javascript +function requireActiveUser(req, res, next) { + if (!req.user) { + return res.redirect('/auth/login'); + } + + if (req.user.status === 'pending') { + if (req.headers['hx-request'] === 'true') { + return res.status(403).send(` +
+ Account pending authorization. +
+ `); + } + return res.render('../src/core/auth/views/pending', { layout: false, user: req.user }); + } + + if (req.user.status === 'rejected') { + return res.render('../src/core/auth/views/rejected', { layout: false }); + } + + next(); +} + +module.exports = { requireActiveUser }; +``` + +### `src/core/auth/routes.js` + +```javascript +const express = require('express'); +const router = express.Router(); +const db = require('../../db'); + +router.get('/auth/login', (req, res) => { + if (req.user) return res.redirect('/dashboard'); + res.render('../src/core/auth/views/login', { layout: false }); +}); + +router.get('/auth/logout', (req, res) => { + req.session.destroy((err) => { + if (err) console.error('Session destruction error:', err); + res.redirect('/auth/login?loggedOut=true'); + }); +}); + +router.get('/profile', async (req, res) => { + if (!req.user) return res.redirect('/auth/login'); + + try { + const roleRes = await db.query(` + SELECT r.name + FROM user_roles ur + JOIN roles r ON ur.role_id = r.id + WHERE ur.user_id = $1`, + [req.user.id] + ); + const roleName = roleRes.rowCount > 0 ? roleRes.rows[0].name : 'Member'; + + const { fetchUserPermissions } = require('../permissions/resolver'); + const permissions = await fetchUserPermissions(db, req.user.id); + + res.render('../src/core/auth/views/profile', { + user: req.user, + roleName, + permissions: permissions.sort() + }); + } catch (error) { + console.error(error); + res.status(500).send("Error loading profile"); + } +}); + +module.exports = router; +``` + +### `src/core/auth/views/login.ejs` + +```html + + + + + + Login | Trinity Console + + + +
+
+ +
+
+ +

Trinity Console

+

Command Center for Firefrost Gaming

+ + + + Sign in with Discord + +
+ + +``` + +### `src/core/auth/views/pending.ejs` + +```html + + + + + + Access Pending | Trinity Console + + + +
+ +
+
+ +
+ +
+ +

Access Pending

+

Your Discord identity (<%= user.username %>) has been verified, but your clearance level has not yet been assigned by the Trinity.

+ +
+

STATUS: WAITING_FOR_WIZARD

+
+ +

An alert has been dispatched to the administrators. You will be able to access the console once your profile is approved.

+ + + Return to Login + +
+ + +``` + +### `src/core/auth/views/profile.ejs` + +```html +
+
+
+ + Avatar + +
+

<%= user.username %>

+

+ + Connected via Discord +

+
+ + <%= roleName %> + +
+
+
+ +
+

Active Permissions

+

These are the specific clearance levels assigned to you by the Trinity.

+ + <% if (permissions && permissions.length > 0) { %> +
+ <% permissions.forEach(perm => { %> +
+
+ <%= perm %> +
+ <% }) %> +
+ <% } else { %> +
+

No explicit permissions assigned to this profile.

+
+ <% } %> +
+
+``` + +--- + +## 9. Branding & Design System + +### `tailwind.config.js` + +```javascript +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ["./src/**/*.{ejs,js,html}", "./views/**/*.ejs"], + theme: { + extend: { + colors: { + fire: { + DEFAULT: '#FF6B35', + hover: '#E55A2A', + dim: 'rgba(255, 107, 53, 0.1)' + }, + frost: { + DEFAULT: '#4ECDC4', + hover: '#3EBAB1', + dim: 'rgba(78, 205, 196, 0.1)' + }, + arcane: { + DEFAULT: '#A855F7', + hover: '#9333EA', + dim: 'rgba(168, 85, 247, 0.1)' + }, + gold: { + DEFAULT: '#FFD700', + hover: '#E6C200', + dim: 'rgba(255, 215, 0, 0.1)' + }, + void: { + DEFAULT: '#0F0F1E', + surface: '#16162C', + hover: '#1D1D3A', + border: '#2A2A4A' + } + }, + fontFamily: { + sans: ['Inter', 'system-ui', 'sans-serif'], + mono: ['JetBrains Mono', 'monospace'] + } + }, + }, + plugins: [ + require('@tailwindcss/forms'), + ], +} +``` + +### `views/layout.ejs` + +```html + + + + + + Trinity Console | Firefrost + + + + + + + + +
+ + + + + +
+ + +
+ + +
+ + +
+ + + +
+
+ + +
+ <%- body %> +
+
+ + + + +``` + +### Component Reference + +**Cards:** +```html +
+

Card Title

+ +
+``` + +**Buttons:** +```html + + + + + + + + + + + +``` + +**Form Inputs:** +```html +
+ + +
+``` + +**Alerts:** +```html + +
+

Error message here.

+
+ + +
+

Success message here.

+
+``` + +**Badges:** +```html +Online +Admin +Offline +``` + +--- + +## 10. Example Module (Dashboard) + +### `src/modules/dashboard/module.json` + +```json +{ + "id": "dashboard", + "name": "Dashboard", + "description": "System overview and quick stats", + "version": "1.0.0", + "author": "Trinity", + "dependencies": {}, + "icon": "home", + "nav": { + "section": "General", + "position": 1 + }, + "permissions": [ + { + "key": "dashboard.view", + "name": "View Dashboard", + "description": "Access the main overview screen" + } + ], + "routes": "/dashboard" +} +``` + +### `src/modules/dashboard/routes.js` + +```javascript +const express = require('express'); +const router = express.Router(); +const { requirePermission } = require('../../core/permissions/middleware'); + +router.use(requirePermission('dashboard.view')); + +router.get('/', (req, res) => { + res.render('../src/modules/dashboard/views/index', { user: req.user }); +}); + +module.exports = router; +``` + +### `src/modules/dashboard/views/index.ejs` + +```html +
+
+

Welcome back, <%= user.username %>

+ The Frostwall holds strong. +
+ +
+
+
+

Servers Online

+ +
+

12 / 12

+
+ +
+
+

Active Subscribers

+ +
+

47

+
+ +
+
+

Monthly Revenue

+ +
+

$385

+
+
+
+``` + +### `src/modules/dashboard/events.js` + +```javascript +module.exports = function(coreEvents) { + coreEvents.on('server.status_change', (data) => { + console.log(`[Dashboard] Server ${data.serverId} changed to ${data.status}`); + }); +}; +``` + +### `src/modules/dashboard/api.js` + +```javascript +module.exports = { + getSystemHealthScore: async () => { + return 100; // Placeholder + } +}; +``` + +--- + +## 11. Deployment Infrastructure + +### Server Information + +| Server | IP | Role | +|--------|-----|------| +| Dev VPS | 64.50.188.128 | Development/Staging (Trinity 2.0 development) | +| Command Center | 63.143.34.217 | Production (PostgreSQL, Arbiter 3.5) | + +### Cloudflare DNS + +Create A Record: +- **Name:** `trinity` +- **Target:** `64.50.188.128` +- **Proxy:** Gray Cloud for Certbot, then Orange Cloud + +### PostgreSQL Remote Access (Command Center) + +**Step 1:** Edit `/etc/postgresql/*/main/postgresql.conf`: +``` +listen_addresses = '*' +``` + +**Step 2:** Edit `/etc/postgresql/*/main/pg_hba.conf`: +``` +host arbiter_db arbiter 64.50.188.128/32 scram-sha-256 +``` + +**Step 3:** Firewall: +```bash +sudo ufw allow from 64.50.188.128 to any port 5432 +``` + +**Step 4:** Restart: +```bash +sudo systemctl restart postgresql +``` + +### Dev VPS Bootstrap + +```bash +# System updates +sudo apt update && sudo apt upgrade -y + +# Install dependencies +sudo apt install -y nginx certbot python3-certbot-nginx curl git ufw + +# Firewall +sudo ufw allow OpenSSH +sudo ufw allow 'Nginx Full' +sudo ufw enable + +# Node.js 20 +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - +sudo apt install -y nodejs + +# PM2 +sudo npm install -g pm2 +``` + +### Nginx Configuration + +Create `/etc/nginx/sites-available/trinity`: +```nginx +server { + listen 80; + server_name trinity.firefrostgaming.com; + + location / { + proxy_pass http://localhost:3001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } +} +``` + +Enable: +```bash +sudo ln -s /etc/nginx/sites-available/trinity /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl restart nginx +``` + +### SSL Certificate + +```bash +sudo certbot --nginx -d trinity.firefrostgaming.com +``` + +### PM2 Configuration + +Create `ecosystem.config.js`: +```javascript +module.exports = { + apps: [{ + name: "trinity-console", + script: "./src/index.js", + watch: false, + max_memory_restart: "1G", + env: { + NODE_ENV: "production", + PORT: 3001 + } + }] +}; +``` + +### Environment Variables (`.env`) + +```env +# Database (Command Center) +DB_USER=arbiter +DB_HOST=63.143.34.217 +DB_NAME=arbiter_db +DB_PASSWORD=FireFrost2026!Arbiter +DB_PORT=5432 + +# Discord +DISCORD_CLIENT_ID=1330262498058670162 +DISCORD_CLIENT_SECRET=[from Vaultwarden] +DISCORD_BOT_TOKEN=[from Vaultwarden] +DISCORD_GUILD_ID=1286373938067198003 +DISCORD_ADMIN_WEBHOOK_URL=[create webhook in admin channel] + +# Stripe +STRIPE_SECRET_KEY=[from Vaultwarden] +STRIPE_WEBHOOK_SECRET=[from Vaultwarden] + +# Session +SESSION_SECRET=[from Vaultwarden] + +# Pterodactyl +PTERO_CLIENT_KEY=ptlc_NDkYX6yPPBHZacPmViFWtl4AvopzgxNcnHoQTOOtQEl + +# App +PORT=3001 +NODE_ENV=production +BASE_URL=https://trinity.firefrostgaming.com +``` + +--- + +## 12. Implementation Checklist + +### Phase 0: Infrastructure Setup + +- [ ] Create Cloudflare A record for `trinity` → 64.50.188.128 +- [ ] Update Command Center PostgreSQL for remote access +- [ ] Open UFW port 5432 for Dev VPS IP +- [ ] Bootstrap Dev VPS (Node, PM2, Nginx, Certbot) +- [ ] Run Certbot for SSL + +### Phase 1: Core Foundation + +- [ ] Run 001_rbac_tables.sql manually on Command Center +- [ ] Run 002_feature_flags.sql manually +- [ ] Run 003_auth_users.sql manually (with real Discord IDs) +- [ ] Create project structure on Dev VPS +- [ ] Implement `src/db.js` +- [ ] Implement `src/core/boot.js` +- [ ] Implement `src/core/modules/` (registry, loader, routes) +- [ ] Implement `src/core/events/index.js` +- [ ] Implement `src/core/navigation/index.js` +- [ ] Create `tailwind.config.js` and compile CSS +- [ ] Create `views/layout.ejs` +- [ ] Test boot sequence + +### Phase 2: RBAC & Auth + +- [ ] Implement `src/core/permissions/` (resolver, middleware, sync, routes) +- [ ] Implement `src/core/auth/` (strategy, middleware, webhook, routes, views) +- [ ] Configure Passport Discord strategy +- [ ] Test Discord OAuth flow +- [ ] Test pending user flow +- [ ] Test founder auto-approval + +### Phase 3: First Modules + +- [ ] Create Dashboard module +- [ ] Create System module (with About page) +- [ ] Test navigation building +- [ ] Test permission enforcement + +### Phase 4: Migration (Strangler Fig) + +- [ ] Migrate Servers module (includes Scheduler) +- [ ] Migrate Players module +- [ ] Migrate Financials module +- [ ] Build Tasks module (new) +- [ ] Continue with remaining modules + +--- + +## 13. Migration from Arbiter 3.5 + +### Current Arbiter Structure + +``` +/opt/arbiter-3.0/src/ +├── routes/admin/ +│ ├── dashboard.js +│ ├── players.js +│ ├── servers.js +│ ├── scheduler.js +│ └── financials.js +└── views/admin/ + ├── dashboard.ejs + ├── players.ejs + └── ... +``` + +### Migration Steps Per Module + +1. **Create module folder:** `src/modules/{name}/` +2. **Create module.json** with permissions +3. **Move routes:** Copy from `routes/admin/{name}.js` → `src/modules/{name}/routes.js` +4. **Update imports:** Change `../../views/admin/` → `./views/` +5. **Move views:** Copy from `views/admin/{name}.ejs` → `src/modules/{name}/views/` +6. **Add permission checks:** Wrap routes with `requirePermission()` +7. **Test in isolation** +8. **Remove old routes** once verified + +### Servers Module Example + +**`src/modules/servers/module.json`:** +```json +{ + "id": "servers", + "name": "Servers & Scheduling", + "description": "Game server status, whitelist sync, and restart automation", + "version": "1.0.0", + "author": "Trinity", + "dependencies": {}, + "icon": "server", + "nav": { + "section": "Infrastructure", + "position": 1 + }, + "permissions": [ + { + "key": "servers.view", + "name": "View Servers", + "description": "See server matrix and status" + }, + { + "key": "servers.manage", + "name": "Manage Servers", + "description": "Sync whitelists and toggle maintenance mode" + }, + { + "key": "servers.scheduler", + "name": "Manage Scheduler", + "description": "Deploy global restart schedules and nuke conflicts" + } + ], + "routes": "/servers" +} +``` + +--- + +## Emergency Contacts + +If Claude is unavailable: + +1. **Gemini** — Knows the entire architecture (paste this doc for context) +2. **GPT-4o** — Can implement from this guide +3. **New Claude session** — Start fresh with this document + +--- + +## Document History + +| Version | Date | Author | Changes | +|---------|------|--------|---------| +| 1.0 | 2026-04-05 | Claude (Chronicler #61) | Initial creation from 7 Gemini consultation rounds | + +--- + +**Fire + Frost + Foundation = Where Love Builds Legacy** 💙🔥❄️