Files
firefrost-operations-manual/docs/implementation/trinity-console-2-implementation-guide.md
Claude (Chronicler #61) ae55b7d1e2 docs: Trinity Console 2.0 Complete Implementation Guide
MAJOR: Single-source cold-start handoff document (1,776 lines)

Any AI or developer can implement Trinity Console 2.0 from this
document alone. Consolidates all 7 Gemini consultation rounds into
one comprehensive guide.

Contents:
1. Project Overview & Brand Colors
2. Architecture Decisions (12 modules, RBAC, versioning)
3. Complete File Structure
4. Database Migrations (001-003)
5. Core Engine Code (boot, loader, registry, routes, events, nav)
6. RBAC System (resolver, middleware, sync, routes)
7. Feature Flags (cache, middleware, routes)
8. Authentication (Discord OAuth, pending state, webhooks)
9. Branding & Design System (Tailwind, layout, components)
10. Example Module (Dashboard)
11. Deployment Infrastructure (Dev VPS, PostgreSQL, Nginx, SSL, PM2)
12. Implementation Checklist (4 phases)
13. Migration from Arbiter 3.5 (Strangler Fig steps)

Emergency handoff: Gemini knows this architecture, can pick up if
Claude unavailable.

Establishes pattern for future project documentation.

Signed-off-by: Claude (Chronicler #61) <claude@firefrostgaming.com>
2026-04-05 12:17:37 +00:00

1777 lines
57 KiB
Markdown

# 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(`
<div class="p-4 bg-fire-dim border border-fire text-white rounded">
<strong>Access Denied:</strong> You do not have permission to access this resource.
</div>
`);
}
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(`
<div class="p-4 bg-gold-dim border border-gold text-white rounded">
<strong>Service Unavailable:</strong> ${msg}
</div>
`);
}
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(`
<div class="p-4 bg-fire-dim border border-fire text-white rounded">
Account pending authorization.
</div>
`);
}
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
<!DOCTYPE html>
<html lang="en" class="bg-void text-gray-300">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login | Trinity Console</title>
<link rel="stylesheet" href="/css/output.css">
</head>
<body class="flex items-center justify-center min-h-screen relative overflow-hidden">
<div class="absolute top-1/4 left-1/4 w-96 h-96 bg-fire/20 rounded-full blur-[100px] pointer-events-none"></div>
<div class="absolute bottom-1/4 right-1/4 w-96 h-96 bg-frost/20 rounded-full blur-[100px] pointer-events-none"></div>
<div class="z-10 w-full max-w-md bg-void-surface border border-void-border rounded-2xl shadow-2xl p-8 text-center relative overflow-hidden">
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-fire via-arcane to-frost"></div>
<h1 class="text-3xl font-bold text-white mb-2">Trinity Console</h1>
<p class="text-gray-400 text-sm mb-8">Command Center for Firefrost Gaming</p>
<a href="/auth/discord" class="inline-flex w-full justify-center items-center gap-3 bg-[#5865F2] hover:bg-[#4752C4] text-white font-medium py-3 px-4 rounded-lg transition-colors">
<svg class="w-5 h-5 fill-current" viewBox="0 0 127.14 96.36" xmlns="http://www.w3.org/2000/svg"><path d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.31,60,73.31,53s5-12.74,11.43-12.74S96.33,46,96.22,53,91.08,65.69,84.69,65.69Z"/></svg>
Sign in with Discord
</a>
</div>
</body>
</html>
```
### `src/core/auth/views/pending.ejs`
```html
<!DOCTYPE html>
<html lang="en" class="bg-void text-gray-300">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Access Pending | Trinity Console</title>
<link rel="stylesheet" href="/css/output.css">
</head>
<body class="flex items-center justify-center min-h-screen relative overflow-hidden">
<div class="absolute inset-0 bg-arcane/5 blur-[150px] pointer-events-none"></div>
<div class="z-10 w-full max-w-md bg-void-surface border border-void-border rounded-2xl shadow-2xl p-8 text-center relative overflow-hidden">
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-arcane to-frost"></div>
<div class="w-16 h-16 rounded-full bg-arcane-dim border border-arcane/50 flex items-center justify-center mx-auto mb-6 shadow-[0_0_20px_rgba(168,85,247,0.3)]">
<svg class="w-8 h-8 text-arcane" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>
</div>
<h1 class="text-2xl font-bold text-white mb-2">Access Pending</h1>
<p class="text-gray-400 text-sm mb-6">Your Discord identity (<strong class="text-gray-200"><%= user.username %></strong>) has been verified, but your clearance level has not yet been assigned by the Trinity.</p>
<div class="bg-void border border-void-border rounded-lg p-4 mb-6">
<p class="text-xs text-arcane font-mono">STATUS: WAITING_FOR_WIZARD</p>
</div>
<p class="text-xs text-gray-500 mb-6">An alert has been dispatched to the administrators. You will be able to access the console once your profile is approved.</p>
<a href="/auth/logout" class="inline-flex w-full justify-center items-center gap-2 border border-void-border hover:bg-void-hover text-gray-300 font-medium py-2.5 px-4 rounded-lg transition-colors">
Return to Login
</a>
</div>
</body>
</html>
```
### `src/core/auth/views/profile.ejs`
```html
<div class="max-w-4xl mx-auto space-y-6">
<div class="bg-void-surface border border-void-border rounded-xl p-8 shadow-lg flex items-center gap-6 relative overflow-hidden">
<div class="absolute -right-20 -top-20 w-64 h-64 bg-arcane/10 rounded-full blur-3xl pointer-events-none"></div>
<img class="h-24 w-24 rounded-full border-2 border-arcane object-cover shadow-[0_0_15px_rgba(168,85,247,0.4)]"
src="https://cdn.discordapp.com/avatars/<%= user.discord_id %>/<%= user.avatar %>.png"
alt="Avatar">
<div>
<h1 class="text-3xl font-bold text-white"><%= user.username %></h1>
<p class="text-gray-400 mt-1 flex items-center gap-2">
<span class="inline-block w-2 h-2 rounded-full bg-frost"></span>
Connected via Discord
</p>
<div class="mt-3">
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold bg-arcane-dim text-arcane border border-arcane/20">
<%= roleName %>
</span>
</div>
</div>
</div>
<div class="bg-void-surface border border-void-border rounded-xl p-6 shadow-lg">
<h2 class="text-xl font-bold text-white mb-4 border-b border-void-border pb-2">Active Permissions</h2>
<p class="text-sm text-gray-400 mb-6">These are the specific clearance levels assigned to you by the Trinity.</p>
<% if (permissions && permissions.length > 0) { %>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
<% permissions.forEach(perm => { %>
<div class="bg-void p-3 border border-void-border rounded text-sm text-gray-300 font-mono flex items-center gap-2">
<div class="w-1.5 h-1.5 rounded-full bg-fire"></div>
<%= perm %>
</div>
<% }) %>
</div>
<% } else { %>
<div class="text-center py-8 bg-void border border-dashed border-void-border rounded-lg">
<p class="text-gray-500">No explicit permissions assigned to this profile.</p>
</div>
<% } %>
</div>
</div>
```
---
## 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
<!DOCTYPE html>
<html lang="en" class="bg-void text-gray-300">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Trinity Console | Firefrost</title>
<link rel="stylesheet" href="/css/output.css">
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://unpkg.com/lucide@latest"></script>
</head>
<body class="flex h-screen overflow-hidden selection:bg-arcane selection:text-white" x-data="{ sidebarOpen: false, userMenuOpen: false }">
<!-- Mobile sidebar overlay -->
<div x-show="sidebarOpen" class="fixed inset-0 z-40 bg-void/80 backdrop-blur-sm lg:hidden" @click="sidebarOpen = false"></div>
<!-- Sidebar -->
<aside :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'" class="fixed inset-y-0 left-0 z-50 w-64 bg-void-surface border-r border-void-border transition-transform duration-300 lg:static lg:translate-x-0 flex flex-col">
<div class="flex items-center justify-center h-16 border-b border-void-border bg-void">
<span class="text-xl font-bold tracking-wider text-transparent bg-clip-text bg-gradient-to-r from-fire to-frost">
TRINITY CONSOLE
</span>
</div>
<nav class="flex-1 overflow-y-auto p-4 space-y-6 scrollbar-thin scrollbar-thumb-void-border">
<% Object.keys(navigation).forEach(section => { %>
<div>
<h3 class="px-3 text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2"><%= section %></h3>
<ul class="space-y-1">
<% navigation[section].forEach(item => { %>
<li>
<a href="<%= item.path %>" class="flex items-center justify-between px-3 py-2 text-sm font-medium rounded-md hover:bg-void-hover hover:text-white transition-colors group">
<div class="flex items-center">
<i data-lucide="<%= item.icon %>" class="w-5 h-5 mr-3 text-gray-400 group-hover:text-frost"></i>
<%= item.name %>
</div>
<% if (item.badge) { %>
<span class="bg-fire-dim text-fire text-xs py-0.5 px-2 rounded-full"><%= item.badge %></span>
<% } %>
</a>
</li>
<% }) %>
</ul>
</div>
<% }) %>
</nav>
</aside>
<!-- Main content area -->
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
<!-- Header -->
<header class="flex items-center justify-between h-16 px-4 sm:px-6 bg-void-surface border-b border-void-border">
<button @click="sidebarOpen = true" class="lg:hidden text-gray-400 hover:text-white">
<i data-lucide="menu" class="w-6 h-6"></i>
</button>
<div class="flex-1"></div>
<!-- User dropdown -->
<div class="relative ml-3">
<button @click="userMenuOpen = !userMenuOpen" @click.away="userMenuOpen = false" class="flex items-center space-x-3 focus:outline-none">
<div class="text-right hidden sm:block">
<p class="text-sm font-medium text-white"><%= user.username %></p>
<p class="text-xs text-arcane"><%= user.role_name || 'Member' %></p>
</div>
<img class="h-9 w-9 rounded-full border border-arcane p-0.5 object-cover"
src="https://cdn.discordapp.com/avatars/<%= user.discord_id %>/<%= user.avatar %>.png"
alt="Avatar">
</button>
<div x-show="userMenuOpen" x-transition class="absolute right-0 mt-2 w-48 bg-void-surface border border-void-border rounded-md shadow-lg py-1 z-50">
<a href="/profile" class="block px-4 py-2 text-sm text-gray-300 hover:bg-void-hover hover:text-white">My Profile</a>
<hr class="border-void-border my-1">
<a href="/auth/logout" class="block px-4 py-2 text-sm text-fire hover:bg-fire-dim hover:text-fire-hover">Sign Out</a>
</div>
</div>
</header>
<!-- Page content -->
<main class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8 relative" id="main-content">
<%- body %>
</main>
</div>
<script>lucide.createIcons();</script>
</body>
</html>
```
### Component Reference
**Cards:**
```html
<div class="bg-void-surface border border-void-border rounded-xl p-6 shadow-lg">
<h2 class="text-lg font-bold text-white mb-4">Card Title</h2>
<!-- Content -->
</div>
```
**Buttons:**
```html
<!-- Primary (Fire) -->
<button class="bg-fire hover:bg-fire-hover text-white font-medium py-2 px-4 rounded-md transition-colors shadow-[0_0_15px_rgba(255,107,53,0.3)]">Action</button>
<!-- Secondary (Frost) -->
<button class="bg-frost hover:bg-frost-hover text-void font-bold py-2 px-4 rounded-md transition-colors">Action</button>
<!-- Outline (Arcane) -->
<button class="border border-arcane text-arcane hover:bg-arcane hover:text-white font-medium py-2 px-4 rounded-md transition-all">Action</button>
<!-- Disabled -->
<button class="bg-gray-700 text-gray-400 cursor-not-allowed font-medium py-2 px-4 rounded-md" disabled>Processing...</button>
```
**Form Inputs:**
```html
<div>
<label class="block text-sm font-medium text-gray-400 mb-1">Label</label>
<input type="text" class="w-full bg-void border border-void-border rounded-md text-white px-3 py-2 focus:ring-2 focus:ring-frost focus:border-transparent transition-all">
</div>
```
**Alerts:**
```html
<!-- Error -->
<div class="bg-fire-dim border-l-4 border-fire p-4 rounded-r-md">
<p class="text-fire-hover text-sm font-medium">Error message here.</p>
</div>
<!-- Success -->
<div class="bg-frost-dim border-l-4 border-frost p-4 rounded-r-md">
<p class="text-frost text-sm font-medium">Success message here.</p>
</div>
```
**Badges:**
```html
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-frost-dim text-frost">Online</span>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-arcane-dim text-arcane">Admin</span>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-fire-dim text-fire">Offline</span>
```
---
## 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
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-3xl font-bold text-white">Welcome back, <%= user.username %></h1>
<span class="text-sm text-gray-400">The Frostwall holds strong.</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="bg-void-surface border border-void-border rounded-xl p-6 shadow-lg">
<div class="flex items-center justify-between">
<h2 class="text-sm font-medium text-gray-400">Servers Online</h2>
<i data-lucide="server" class="w-5 h-5 text-frost"></i>
</div>
<p class="text-3xl font-bold text-frost mt-2">12 / 12</p>
</div>
<div class="bg-void-surface border border-void-border rounded-xl p-6 shadow-lg">
<div class="flex items-center justify-between">
<h2 class="text-sm font-medium text-gray-400">Active Subscribers</h2>
<i data-lucide="users" class="w-5 h-5 text-fire"></i>
</div>
<p class="text-3xl font-bold text-fire mt-2">47</p>
</div>
<div class="bg-void-surface border border-void-border rounded-xl p-6 shadow-lg">
<div class="flex items-center justify-between">
<h2 class="text-sm font-medium text-gray-400">Monthly Revenue</h2>
<i data-lucide="dollar-sign" class="w-5 h-5 text-gold"></i>
</div>
<p class="text-3xl font-bold text-gold mt-2">$385</p>
</div>
</div>
</div>
```
### `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** 💙🔥❄️