Zero-Trust + Webhook approach: - New users land in 'pending' state - Founders (Michael, Meg, Holly) pre-seeded as 'approved' - Discord webhook alerts admin channel on new login - Gatekeeper middleware traps pending users - Branded 'Access Pending' page New files: - migrations/003_auth_users.sql (users table + founder seeds) - src/core/auth/webhook.js (Discord notification) - src/core/auth/strategy.js (OAuth callback with upsert) - src/core/auth/middleware.js (requireActiveUser gatekeeper) - src/core/auth/views/pending.ejs (locked gate view) Architecture is now fully airtight. Signed-off-by: Claude (Chronicler #61) <claude@firefrostgaming.com>
10 KiB
Gemini Response: Trinity Console 2.0 — Round 6 (User Onboarding)
Date: April 5, 2026
From: Gemini (Architectural Partner)
To: Michael (The Wizard) + Claude (Chronicler #61)
Re: User onboarding, pending state, founder seeding
Never apologize for finding edge cases, Michael! This is exactly why we do this. Finding the gaps before you deploy is the hallmark of a great architect. You just saved yourself a massive headache down the line.
Tell Meg she is very welcome, and that we are about to design a very pretty "Locked Gate" for this exact scenario.
Here is the strategic breakdown of the auth flow, followed by the exact code to implement it.
🌟 The Recommended Approach: "Zero-Trust + Webhook" (Option A + Seed)
For a tight-knit team of 3 growing to 10, Option A (Pending State) combined with Pre-Seeded Founders and a Discord Webhook is the absolute best path.
Here is why:
- Zero-Trust Security: If a random player finds
trinity.firefrostgaming.comand logs in, they are immediately trapped in a "Pending Access" screen. They see nothing, and they can do nothing. - Frictionless for Founders: Michael, Meg, and Holly are pre-seeded in the database. When you log in, the system recognizes your Discord IDs, instantly grants your approved status and roles, and drops you into the Dashboard. You will never see the pending screen.
- Instant RV-Friendly Alerts: When a new staff member logs in, the Arbiter fires a webhook directly to your private Discord admin channel. You get a ping on your phone, you tap the link, and you approve them from the Trinity Console. No checking logs required.
1. The Database Migration (Users & Founders)
We need to formalize the users table so we can track status and last_login, and seed the founders so you don't lock yourselves out.
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 the IDs with your actual Discord IDs!)
INSERT INTO users (discord_id, username, status) VALUES
('YOUR_DISCORD_ID', 'Frostystyle', 'approved'),
('MEGS_DISCORD_ID', 'Gingerfury', 'approved'),
('HOLLYS_DISCORD_ID', 'Holly', 'approved')
ON CONFLICT (discord_id) DO UPDATE SET status = 'approved';
-- 3. Auto-assign the roles to the founders
-- This safely looks up the role IDs by name so we don't hardcode integers
INSERT INTO user_roles (user_id, role_id, assigned_by)
SELECT 'YOUR_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;
2. The Discord Webhook Alerter
This is a lightweight utility to ping your admin channel when someone hits the gate.
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, // Firefrost Fire Hex roughly converted to decimal
thumbnail: {
url: `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`
},
fields: [
{ name: "Discord ID", value: `\`${user.id}\``, inline: true }
],
// Link directly to the user management module
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 };
3. The OAuth Callback Logic
When Passport (or your custom Discord OAuth flow) returns the profile, we process the Upsert and check if they are new.
src/core/auth/strategy.js (Snippet of your callback logic)
const db = require('../../db');
const { notifyAdminOfPendingUser } = require('./webhook');
// Inside your Discord Strategy verify callback:
async function verifyDiscordLogin(accessToken, refreshToken, profile, done) {
const { id, username, avatar } = profile;
try {
// Upsert the user and return the system status.
// The 'xmax' trick in Postgres tells us if the row was INSERTED (0) or UPDATED (>0).
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];
// Construct the session user object
const sessionUser = {
id: id,
discord_id: id,
username: username,
avatar: avatar,
status: dbUser.status
};
// If they are brand new AND pending (meaning they weren't seeded)
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);
}
}
4. The "Gatekeeper" Middleware
This middleware sits after your auth check, but before the RBAC module permission checks. It catches anyone stuck in the pending state and traps them.
src/core/auth/middleware.js
function requireActiveUser(req, res, next) {
// 1. Ensure they are logged in at all
if (!req.user) {
return res.redirect('/auth/login');
}
// 2. The Gatekeeper Check
if (req.user.status === 'pending') {
// If it's an HTMX request, we don't want to load a full page inside a div
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>
`);
}
// Trap them in the pending view
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 });
}
// 3. User is 'approved', proceed to RBAC checks
next();
}
module.exports = { requireActiveUser };
Implementation Note: Apply app.use(requireActiveUser) globally right after your passport session initialization, so it protects all /dashboard, /system, /modules, etc.
5. The "Locked Gate" View (Branded)
This is what a random user (or a new staff member) sees when they log in. Meg's design language applies perfectly here to make it look official.
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" xmlns="http://www.w3.org/2000/svg"><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>
The Final Checklist
- ✅ The
userstable handles status - ✅ The Founders are explicitly seeded to bypass the gate
- ✅ Random players hit the Gatekeeper middleware
- ✅ The Webhook alerts you immediately
Now the architecture is fully airtight. You can officially start building!
Response received April 5, 2026