Files
firefrost-services/services/arbiter-3.0/src/index.js
Claude (Chronicler #62) 1a97e82ec8 feat(arbiter): implement Task #87 - Lifecycle handlers with Discord role sync
WHAT THIS ADDS:
- Discord role sync on new subscriptions (checkout.session.completed)
- Discord role removal on chargebacks (charge.dispute.created)
- Grace period expiration job (hourly cron check)
- Automatic downgrade to Awakened when grace period expires

NEW FILES:
- src/services/discordRoleSync.js - Role add/remove/sync functions
- src/sync/graceExpiration.js - Grace period expiration processor

MODIFIED FILES:
- src/routes/stripe.js - Added role sync calls to webhook handlers
- src/discord/events.js - Initialize role sync service on bot ready
- src/sync/cron.js - Added grace period check to hourly job
- src/index.js - Import discordRoleSync service

PHILOSOPHY:
'We Don't Kick People Out' - expired grace periods downgrade to
permanent Awakened tier (tier 1, lifetime). Users keep community
access, just lose premium perks.

ROLE MAPPING (tier_level -> role key):
1=the-awakened, 2=fire-elemental, 3=frost-elemental,
4=fire-knight, 5=frost-knight, 6=fire-master, 7=frost-master,
8=fire-legend, 9=frost-legend, 10=the-sovereign

CHARGEBACKS:
- Immediate role removal
- Added to banned_users table
- Full audit logging

Signed-off-by: Claude (Chronicler #62) <claude@firefrostgaming.com>
2026-04-05 14:25:41 +00:00

147 lines
4.6 KiB
JavaScript

require('dotenv').config();
const express = require('express');
const expressLayouts = require('express-ejs-layouts');
const session = require('express-session');
const PgSession = require('connect-pg-simple')(session);
const passport = require('passport');
const DiscordStrategy = require('passport-discord').Strategy;
const { Client, GatewayIntentBits, REST, Routes } = require('discord.js');
const csrf = require('csurf');
const cors = require('cors');
const { Pool } = require('pg');
const authRoutes = require('./routes/auth');
const adminRoutes = require('./routes/admin/index');
const webhookRoutes = require('./routes/webhook');
const stripeRoutes = require('./routes/stripe');
const { registerEvents } = require('./discord/events');
const { linkCommand } = require('./discord/commands');
const { initCron } = require('./sync/cron');
const discordRoleSync = require('./services/discordRoleSync');
// PostgreSQL connection pool for sessions
const pgPool = new Pool({
user: process.env.DB_USER,
host: process.env.DB_HOST,
database: process.env.DB_NAME,
password: process.env.DB_PASSWORD,
port: process.env.DB_PORT || 5432
});
// Initialize Discord Client
const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers] });
registerEvents(client);
// Passport Configuration
passport.serializeUser((user, done) => done(null, user));
passport.deserializeUser((obj, done) => done(null, obj));
passport.use(new DiscordStrategy({
clientID: process.env.DISCORD_CLIENT_ID,
clientSecret: process.env.DISCORD_CLIENT_SECRET,
callbackURL: process.env.REDIRECT_URI,
scope: ['identify']
}, (accessToken, refreshToken, profile, done) => {
return done(null, profile);
}));
// Initialize Express App
const app = express();
app.set('trust proxy', 1);
app.set('view engine', 'ejs');
app.set('views', __dirname + '/views');
// Enable proper layout rendering with express-ejs-layouts
app.use(expressLayouts);
app.set('layout', 'layout'); // Default layout is views/layout.ejs
// HTMX Middleware: Disable layout wrapper for HTMX AJAX requests
// HTMX sends HX-Request header - these requests need raw HTML fragments, not full layout
app.use((req, res, next) => {
if (req.headers['hx-request']) {
res.locals.layout = false;
}
next();
});
// CRITICAL: Stripe webhook needs raw body BEFORE express.json() middleware
// Mounted at /webhooks/stripe to avoid conflict with /stripe checkout mount
app.use('/webhooks/stripe', stripeRoutes);
// Body parsing middleware (comes AFTER webhook route)
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Make Discord client accessible to routes
app.locals.client = client;
app.use(session({
store: new PgSession({
pool: pgPool,
tableName: 'session',
createTableIfMissing: true
}),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
maxAge: 7 * 24 * 60 * 60 * 1000
}
}));
app.use(passport.initialize());
app.use(passport.session());
// Health Check
app.get('/health', (req, res) => {
res.json({
status: 'online',
uptime: process.uptime(),
bot: client.user?.tag || 'not ready'
});
});
// Root redirect to admin
app.get('/', (req, res) => res.redirect('/admin'));
// CSRF Protection (session-based)
const csrfProtection = csrf({ cookie: false });
// Register Routes
app.use('/auth', authRoutes);
app.use('/admin', csrfProtection, adminRoutes);
app.use('/webhook', webhookRoutes);
app.use('/stripe', stripeRoutes); // Checkout and portal routes (uses JSON body)
// Start Application
const PORT = process.env.PORT || 3500;
app.listen(PORT, () => {
console.log(`🌐 Express server running on port ${PORT}`);
console.log(`📍 Admin panel: https://discord-bot.firefrostgaming.com/admin`);
client.login(process.env.DISCORD_BOT_TOKEN);
});
// Register Slash Commands
const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_BOT_TOKEN);
(async () => {
try {
console.log('Refreshing application (/) commands.');
await rest.put(
Routes.applicationGuildCommands(process.env.DISCORD_CLIENT_ID, process.env.GUILD_ID),
{ body: [linkCommand.toJSON()] },
);
console.log('✅ Successfully reloaded application (/) commands.');
} catch (error) {
console.error('Failed to register slash commands:', error);
}
})();
// Initialize Hourly Cron Job
initCron();
console.log('✅ Hourly sync cron initialized.');
// Error handling
process.on('unhandledRejection', error => {
console.error('❌ Unhandled promise rejection:', error);
});