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>
57 KiB
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
- Project Overview
- Architecture Decisions
- Complete File Structure
- Database Migrations
- Core Engine Code
- RBAC System Code
- Feature Flags Code
- Authentication System
- Branding & Design System
- Example Module (Dashboard)
- Deployment Infrastructure
- Implementation Checklist
- Migration from Arbiter 3.5
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.readdirSyncon startup scanssrc/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)
- Develop on Dev VPS (64.50.188.128)
- Connect to Command Center's PostgreSQL (63.143.34.217)
- Migrate one module at a time
- Old routes coexist with new until verified
- Promote to Command Center when stable
- 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
-- 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
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
-- 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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
<!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
<!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
<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
/** @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
<!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:
<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:
<!-- 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:
<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:
<!-- 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:
<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
{
"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
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
<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
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
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:
sudo ufw allow from 64.50.188.128 to any port 5432
Step 4: Restart:
sudo systemctl restart postgresql
Dev VPS Bootstrap
# 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:
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:
sudo ln -s /etc/nginx/sites-available/trinity /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx
SSL Certificate
sudo certbot --nginx -d trinity.firefrostgaming.com
PM2 Configuration
Create ecosystem.config.js:
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)
# 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.jsand 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
- Create module folder:
src/modules/{name}/ - Create module.json with permissions
- Move routes: Copy from
routes/admin/{name}.js→src/modules/{name}/routes.js - Update imports: Change
../../views/admin/→./views/ - Move views: Copy from
views/admin/{name}.ejs→src/modules/{name}/views/ - Add permission checks: Wrap routes with
requirePermission() - Test in isolation
- Remove old routes once verified
Servers Module Example
src/modules/servers/module.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:
- Gemini — Knows the entire architecture (paste this doc for context)
- GPT-4o — Can implement from this guide
- 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 💙🔥❄️