feat: Migrate Arbiter and Modpack Version Checker to monorepo

WHAT WAS DONE:
- Migrated Arbiter (discord-oauth-arbiter) code to services/arbiter/
- Migrated Modpack Version Checker code to services/modpack-version-checker/
- Created .env.example for Arbiter with all required environment variables
- Moved systemd service file to services/arbiter/deploy/
- Organized directory structure per Gemini monorepo recommendations

WHY:
- Consolidate all service code in one repository
- Prepare for Gemini code review (Panel v1.12 compatibility check)
- Enable service-prefixed Git tagging (arbiter-v2.1.0, modpack-v1.0.0)
- Support npm workspaces for shared dependencies

SERVICES MIGRATED:
1. Arbiter (Discord OAuth bot) - Originally written by Gemini + Claude
   - Full source code from ops-manual docs/implementation/
   - Created comprehensive .env.example
   - Ready for Panel v1.12 compatibility verification

2. Modpack Version Checker (Python CLI tool)
   - Full source code from ops-manual docs/tasks/
   - Written for Panel v1.11, needs Gemini review for v1.12
   - Never had code review before

STILL TODO:
- Whitelist Manager - Pull from Billing VPS (38.68.14.188)
  - Currently deployed and running
  - Needs Panel v1.12 API compatibility fix (Task #86)
  - Requires SSH access to pull code

NEXT STEPS:
- Gemini code review for Panel v1.12 API compatibility
- Create package.json for each service
- Test npm workspaces integration
- Deploy after verification

FILES:
- services/arbiter/ (25 new files, full application)
- services/modpack-version-checker/ (21 new files, full application)

Signed-off-by: The Golden Chronicler <claude@firefrostgaming.com>
This commit is contained in:
Claude (The Golden Chronicler #50)
2026-03-31 21:52:42 +00:00
parent 4efdd44691
commit 04e9b407d5
47 changed files with 6366 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
# Arbiter Discord Bot - Environment Variables
# Copy this file to .env and fill in actual values
# Discord Bot Configuration
DISCORD_BOT_TOKEN=your_discord_bot_token_here
DISCORD_CLIENT_ID=your_discord_client_id_here
DISCORD_CLIENT_SECRET=your_discord_client_secret_here
GUILD_ID=your_discord_server_id_here
# Application Configuration
APP_URL=https://discord-bot.firefrostgaming.com
PORT=3000
NODE_ENV=production
# Admin Access (comma-separated Discord user IDs)
ADMIN_DISCORD_IDS=discord_id_1,discord_id_2,discord_id_3
# Ghost CMS Integration
CMS_URL=https://firefrostgaming.com
CMS_ADMIN_KEY=your_ghost_admin_api_key_here
# Paymenter Webhook Security
WEBHOOK_SECRET=your_paymenter_webhook_secret_here
# Session Security
SESSION_SECRET=generate_random_32_char_string_here
# Email Configuration (SMTP)
SMTP_HOST=smtp.example.com
SMTP_USER=your_email@firefrostgaming.com
SMTP_PASS=your_email_password_here
# Database (if applicable - check code)
# DATABASE_URL=postgresql://user:pass@host:port/dbname

View File

@@ -0,0 +1,176 @@
# Firefrost Arbiter - Changelog
All notable changes to The Arbiter will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.0.0] - 2026-03-30
**Major Release: OAuth Soft Gate System**
### Added
- **OAuth Subscriber Linking Flow**
- Email-based linking system with 24-hour token expiration
- Discord OAuth2 integration for automatic role assignment
- Ghost CMS integration to store Discord IDs
- Secure single-use cryptographic tokens (32-byte)
- **Manual Admin Interface**
- Web-based admin panel for Trinity members
- Search subscribers by email (queries Ghost CMS)
- Manual role assignment with required reason field
- Role removal functionality
- Audit log (last 50 actions with timestamps)
- Trinity-only access via Discord ID whitelist
- **Enhanced Webhook System**
- HMAC SHA256 signature verification
- Zod schema validation for all payloads
- Support for subscription events: created, upgraded, downgraded, cancelled
- Automatic email dispatch on subscription creation
- Intelligent role updates (strip old roles, assign new)
- **Security Measures**
- Rate limiting (100 requests/15min per IP)
- Session management with SQLite storage
- HTTPS enforcement via Nginx
- Admin authentication via Discord OAuth
- Webhook signature verification
- Input validation on all endpoints
- **Operational Features**
- Health check endpoint (`/health`) with version info
- Automated daily backups (4 AM, 7-day retention)
- SQLite databases for tokens and sessions
- Automated token cleanup (daily)
- Comprehensive logging for all operations
- Systemd service configuration
- **User Experience**
- 6 branded error pages (Pico.css dark theme)
- Success/error states for all flows
- Email notifications with plain text format
- Mobile-responsive admin interface
- **Documentation**
- Complete README (5,700 words)
- Deployment guide (3,800 words)
- Troubleshooting guide (3,200 words)
- Implementation summary (2,400 words)
### Changed
- Complete codebase rewrite from v1.0
- Modular architecture (14 source files vs 1-2 in v1.0)
- Enhanced Discord service with role management functions
- Improved error handling across all endpoints
### Technical Details
- **Dependencies Added**: better-sqlite3, nodemailer, @tryghost/admin-api, express-session, connect-sqlite3, express-rate-limit, zod
- **New Routes**: `/link`, `/auth/callback`, `/admin`, `/admin/login`, `/admin/callback`, `/admin/api/*`, `/webhook/billing`
- **Database**: SQLite (linking.db, sessions.db)
- **Email**: Nodemailer via Mailcow SMTP
- **Session Store**: SQLite-backed sessions
- **Architecture**: Express 4.x, Discord.js 14.x, Ghost Admin API 5.x
### Security
- All webhook payloads verified via HMAC
- All inputs validated via Zod schemas
- Rate limiting on public endpoints
- Admin access restricted to Discord ID whitelist
- Session cookies: httpOnly, SameSite, secure in production
- Automated token expiration and cleanup
### Deployment
- Target: Command Center (63.143.34.217)
- Domain: discord-bot.firefrostgaming.com
- Port: 3500 (proxied via Nginx)
- Service: arbiter.service (systemd)
- Backup: Automated daily at 4:00 AM CST
### Backward Compatibility
- Maintains all Arbiter 1.0 functionality
- Existing webhook endpoints continue to work
- Discord bot integration unchanged
- Holly's admin configuration preserved
### Contributors
- **Architecture**: Gemini AI (7-hour consultation)
- **Implementation**: Claude (Chronicler #49)
- **For**: Michael "Frostystyle" Krause, Meg "Gingerfury", Holly "unicorn20089"
- **Date**: March 30, 2026
---
## [1.0.0] - Date Unknown
**Initial Release**
### Features
- Basic Discord bot integration
- Simple webhook receiver
- Manual role assignment
- Admin configuration panel (Holly's setup)
- Direct Discord command-based role management
### Implementation
- Single-file architecture
- Basic webhook processing
- Manual intervention required for all subscribers
- No automation, no audit logging, no email notifications
### Status
- Served well for initial setup
- Foundation for Arbiter 2.0
- Retired: March 30, 2026 (replaced by v2.0.0)
---
## Version History Summary
| Version | Date | Description | Status |
|---------|------|-------------|--------|
| 1.0.0 | Unknown | Initial simple webhook system | Retired |
| 2.0.0 | 2026-03-30 | Complete OAuth soft gate | **Current** |
---
## Semantic Versioning Guide
**MAJOR.MINOR.PATCH**
- **MAJOR**: Breaking changes (e.g., 1.0 → 2.0)
- **MINOR**: New features, backward compatible (e.g., 2.0 → 2.1)
- **PATCH**: Bug fixes, backward compatible (e.g., 2.0.0 → 2.0.1)
### Examples of Future Changes:
**2.0.1** - Bug fix release
- Fix: Email delivery issue
- Fix: Session timeout edge case
**2.1.0** - Minor feature release
- Add: SMS notifications option
- Add: Export audit log to CSV
**3.0.0** - Major breaking release
- Change: Different database system
- Change: API endpoint restructure
- Remove: Deprecated features
---
## Links
- **Repository**: git.firefrostgaming.com/firefrost-gaming/firefrost-operations-manual
- **Implementation**: docs/implementation/discord-oauth-arbiter/
- **Consultation Archive**: docs/consultations/gemini-discord-oauth-2026-03-30/
- **Documentation**: README.md, DEPLOYMENT.md, TROUBLESHOOTING.md
---
**🔥❄️ Fire + Frost + Foundation = Where Love Builds Legacy 💙**
*Built for children not yet born.*

View File

@@ -0,0 +1,578 @@
# Firefrost Arbiter - Complete Deployment Guide
**Target Server:** Command Center (63.143.34.217, Dallas)
**Date:** March 30, 2026
**Prepared by:** Claude (Chronicler #49)
---
## 📋 Pre-Deployment Checklist
### Discord Configuration
- [ ] Discord Application created at discord.com/developers/applications
- [ ] Bot token generated and saved securely
- [ ] Client ID and Client Secret obtained
- [ ] Server Members Intent enabled
- [ ] Redirect URIs added:
- [ ] `https://discord-bot.firefrostgaming.com/auth/callback`
- [ ] `https://discord-bot.firefrostgaming.com/admin/callback`
- [ ] Bot invited to server with "Manage Roles" permission
- [ ] Bot role positioned ABOVE all subscription tier roles in hierarchy
### Ghost CMS Configuration
- [ ] Custom field `discord_id` created (Settings → Membership → Custom Fields)
- [ ] Custom Integration created: "Firefrost Arbiter"
- [ ] Admin API Key copied (format: `key_id:secret`)
### Server Configuration
- [ ] Node.js 18.x installed
- [ ] Nginx installed and running
- [ ] UFW firewall configured (ports 80, 443, 3500 if needed)
- [ ] Let's Encrypt SSL certificate obtained for `discord-bot.firefrostgaming.com`
- [ ] User `architect` exists with sudo privileges
### Credentials Prepared
- [ ] Discord Bot Token
- [ ] Discord Client ID
- [ ] Discord Client Secret
- [ ] Discord Guild ID (server ID)
- [ ] Trinity Discord IDs (Michael, Meg, Holly)
- [ ] Ghost CMS URL
- [ ] Ghost Admin API Key
- [ ] Mailcow SMTP password
- [ ] Paymenter webhook secret
- [ ] SESSION_SECRET generated (32-byte random)
---
## 🚀 Phase 1: Initial Setup
### Step 1: Connect to Server
```bash
ssh architect@63.143.34.217
```
### Step 2: Create Application Directory
```bash
cd /home/architect
mkdir -p arbiter
cd arbiter
```
### Step 3: Upload Application Files
**From your local machine:**
```bash
# If using git
git clone <repository-url> /home/architect/arbiter
# Or if uploading manually via scp
scp -r discord-oauth-implementation/* architect@63.143.34.217:/home/architect/arbiter/
```
### Step 4: Install Dependencies
```bash
cd /home/architect/arbiter
npm install
```
**Expected output:**
```
added 87 packages in 12s
```
### Step 5: Create Environment File
```bash
cp .env.example .env
nano .env
```
**Fill in ALL values:**
```bash
NODE_ENV=production
PORT=3500
APP_URL=https://discord-bot.firefrostgaming.com
SESSION_SECRET=<generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))">
DISCORD_BOT_TOKEN=<from Discord Developer Portal>
DISCORD_CLIENT_ID=<from Discord Developer Portal>
DISCORD_CLIENT_SECRET=<from Discord Developer Portal>
GUILD_ID=<your Discord server ID>
ADMIN_DISCORD_IDS=<michael_id>,<meg_id>,<holly_id>
CMS_URL=https://firefrostgaming.com
CMS_ADMIN_KEY=<from Ghost Integrations>
SMTP_HOST=38.68.14.188
SMTP_USER=noreply@firefrostgaming.com
SMTP_PASS=<from Mailcow>
WEBHOOK_SECRET=<from Paymenter>
```
**Generate SESSION_SECRET:**
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
**Save and exit:** `Ctrl+X`, `Y`, `Enter`
### Step 6: Configure Discord Role Mapping
```bash
nano config/roles.json
```
**Get Discord Role IDs:**
1. Go to Discord server
2. Settings → Roles
3. Right-click each role → Copy ID
**Fill in the file:**
```json
{
"awakened": "1234567890123456789",
"fire_elemental": "2345678901234567890",
"frost_elemental": "3456789012345678901",
"fire_knight": "4567890123456789012",
"frost_knight": "5678901234567890123",
"fire_master": "6789012345678901234",
"frost_master": "7890123456789012345",
"fire_legend": "8901234567890123456",
"frost_legend": "9012345678901234567",
"sovereign": "0123456789012345678"
}
```
**Save and exit**
### Step 7: Set Permissions
```bash
chmod 600 .env
chmod +x backup.sh
```
---
## 🌐 Phase 2: Nginx Configuration
### Step 1: Copy Nginx Config
```bash
sudo cp nginx.conf /etc/nginx/sites-available/arbiter
sudo ln -s /etc/nginx/sites-available/arbiter /etc/nginx/sites-enabled/
```
### Step 2: Test Nginx Configuration
```bash
sudo nginx -t
```
**Expected output:**
```
nginx: configuration file /etc/nginx/nginx.conf test is successful
```
### Step 3: Reload Nginx
```bash
sudo systemctl reload nginx
```
---
## ⚙️ Phase 3: Systemd Service Setup
### Step 1: Copy Service File
```bash
sudo cp arbiter.service /etc/systemd/system/
```
### Step 2: Reload Systemd
```bash
sudo systemctl daemon-reload
```
### Step 3: Enable Service (Start on Boot)
```bash
sudo systemctl enable arbiter
```
### Step 4: Start Service
```bash
sudo systemctl start arbiter
```
### Step 5: Check Status
```bash
sudo systemctl status arbiter
```
**Expected output:**
```
● arbiter.service - Firefrost Arbiter - Discord Role Management System
Loaded: loaded (/etc/systemd/system/arbiter.service; enabled)
Active: active (running) since Sun 2026-03-30 10:00:00 CDT; 5s ago
Main PID: 12345 (node)
Tasks: 11 (limit: 9830)
Memory: 45.2M
CGroup: /system.slice/arbiter.service
└─12345 /usr/bin/node src/index.js
Mar 30 10:00:00 command-center systemd[1]: Started Firefrost Arbiter.
Mar 30 10:00:00 command-center arbiter[12345]: [Server] Listening on port 3500
Mar 30 10:00:01 command-center arbiter[12345]: [Discord] Bot logged in as ArbiterBot#1234
Mar 30 10:00:01 command-center arbiter[12345]: [Database] Cleaned up 0 expired tokens.
```
**If status shows "failed":**
```bash
sudo journalctl -u arbiter -n 50
```
---
## ✅ Phase 4: Validation & Testing
### Step 1: Check Application Logs
```bash
sudo journalctl -u arbiter -f
```
**Look for:**
- `[Server] Listening on port 3500`
- `[Discord] Bot logged in as <BotName>`
- `[Database] Cleaned up X expired tokens`
**Press Ctrl+C to exit**
### Step 2: Test Health Endpoint
```bash
curl https://discord-bot.firefrostgaming.com/health
```
**Expected response:**
```json
{
"uptime": 123.456,
"discord": "ok",
"database": "ok",
"timestamp": "2026-03-30T15:00:00.000Z"
}
```
**If you get 502 Bad Gateway:**
- Check application is running: `sudo systemctl status arbiter`
- Check application logs: `sudo journalctl -u arbiter -n 50`
- Check Nginx is running: `sudo systemctl status nginx`
### Step 3: Test Webhook Reception (Local)
```bash
curl -X POST http://localhost:3500/webhook/billing \
-H "Content-Type: application/json" \
-d '{
"event": "subscription.created",
"customer_email": "test@firefrostgaming.com",
"customer_name": "Test User",
"tier": "awakened",
"subscription_id": "test_sub_123"
}'
```
**Check logs:**
```bash
sudo journalctl -u arbiter -n 20
```
**Look for:**
- `[Webhook] Received subscription.created for test@firefrostgaming.com`
- `[Webhook] Sent linking email to test@firefrostgaming.com`
**Check database:**
```bash
sqlite3 linking.db "SELECT * FROM link_tokens;"
```
Should show newly created token.
### Step 4: Test Admin OAuth Login
1. Visit `https://discord-bot.firefrostgaming.com/admin/login` in browser
2. Should redirect to Discord OAuth
3. Authorize with Trinity Discord account
4. Should redirect to admin panel
5. Verify search, assign functions work
### Step 5: End-to-End OAuth Test
**Create test member in Ghost CMS:**
1. Ghost Admin → Members → New Member
2. Email: `test@firefrostgaming.com`
3. Name: "Test User"
**Trigger webhook:**
```bash
curl -X POST http://localhost:3500/webhook/billing \
-H "Content-Type: application/json" \
-d '{
"event": "subscription.created",
"customer_email": "test@firefrostgaming.com",
"customer_name": "Test User",
"tier": "awakened",
"subscription_id": "test_001"
}'
```
**Check Mailcow logs for sent email:**
```bash
ssh root@38.68.14.188
docker logs -f --tail 50 mailcowdockerized_postfix-mailcow_1
```
**Copy linking URL from email (or get from database):**
```bash
sqlite3 linking.db "SELECT token FROM link_tokens WHERE email='test@firefrostgaming.com';"
```
**Build link:**
```
https://discord-bot.firefrostgaming.com/link?token=<token>
```
**Test flow:**
1. Visit link in browser
2. Should redirect to Discord OAuth
3. Authorize with test Discord account
4. Should show success page
5. Check Discord - test account should have "The Awakened" role
6. Check Ghost Admin - test member should have `discord_id` populated
---
## 💾 Phase 5: Backup Configuration
### Step 1: Create Backup Directory
```bash
mkdir -p /home/architect/backups/arbiter
chmod 700 /home/architect/backups/arbiter
```
### Step 2: Test Backup Script
```bash
cd /home/architect/arbiter
./backup.sh
```
**Check output:**
```bash
cat /home/architect/backups/arbiter/backup_log.txt
```
Should show:
```
--- Backup Started: 20260330_100000 ---
Backup completed successfully.
```
**Verify backup files exist:**
```bash
ls -lh /home/architect/backups/arbiter/
```
Should show:
```
-rw-r--r-- 1 architect architect 12K Mar 30 10:00 linking_20260330_100000.db
-rw-r--r-- 1 architect architect 4.0K Mar 30 10:00 sessions_20260330_100000.db
-rw------- 1 architect architect 892 Mar 30 10:00 env_20260330_100000.bak
-rw-r--r-- 1 architect architect 421 Mar 30 10:00 roles_20260330_100000.json
```
### Step 3: Schedule Daily Backups
```bash
crontab -e
```
**Add this line:**
```
0 4 * * * /home/architect/arbiter/backup.sh >> /home/architect/backups/arbiter/cron_error.log 2>&1
```
**Save and exit**
**Verify cron job:**
```bash
crontab -l
```
Should show the backup line.
---
## 🔗 Phase 6: Paymenter Integration
### Step 1: Configure Paymenter Webhook
1. Log in to Paymenter admin panel
2. Navigate to: System → Webhooks
3. Click "Add Webhook"
4. **URL:** `https://discord-bot.firefrostgaming.com/webhook/billing`
5. **Secret:** (use value from `.env` WEBHOOK_SECRET)
6. **Events:** Select:
- `subscription.created`
- `subscription.upgraded`
- `subscription.downgraded`
- `subscription.cancelled`
7. Save webhook
### Step 2: Test Paymenter Webhook
**From Paymenter admin:**
1. Find webhook in list
2. Click "Test Webhook"
3. Should show successful delivery
**Or manually trigger:**
```bash
curl -X POST https://discord-bot.firefrostgaming.com/webhook/billing \
-H "Content-Type: application/json" \
-H "x-signature: <generate_valid_hmac>" \
-d '{
"event": "subscription.created",
"customer_email": "real_customer@example.com",
"customer_name": "Real Customer",
"tier": "awakened",
"subscription_id": "sub_real_123"
}'
```
---
## 📊 Phase 7: Monitoring Setup
### Step 1: Set Up Log Rotation
```bash
sudo nano /etc/logrotate.d/arbiter
```
**Add:**
```
/var/log/nginx/arbiter-*.log {
daily
missingok
rotate 14
compress
delaycompress
notifempty
create 0640 www-data adm
sharedscripts
postrotate
systemctl reload nginx > /dev/null
endscript
}
```
### Step 2: Create Monitoring Script (Optional)
```bash
nano /home/architect/arbiter/monitor.sh
```
**Add:**
```bash
#!/bin/bash
STATUS=$(curl -s https://discord-bot.firefrostgaming.com/health | jq -r '.discord')
if [ "$STATUS" != "ok" ]; then
echo "Arbiter health check failed at $(date)" >> /home/architect/arbiter/monitor.log
sudo systemctl restart arbiter
fi
```
**Make executable:**
```bash
chmod +x /home/architect/arbiter/monitor.sh
```
**Schedule (every 5 minutes):**
```bash
crontab -e
```
**Add:**
```
*/5 * * * * /home/architect/arbiter/monitor.sh
```
---
## 🎉 Deployment Complete!
### Final Checklist
- [ ] Application running (`sudo systemctl status arbiter`)
- [ ] Health check returns "ok" for all services
- [ ] Test webhook received and logged
- [ ] Test OAuth flow completes successfully
- [ ] Admin panel accessible and functional
- [ ] Backups scheduled and tested
- [ ] Paymenter webhook configured
- [ ] Logs rotating properly
### Next Steps
1. **Monitor for 24 hours** before announcing to users
2. **Create test subscription** with real Paymenter flow
3. **Verify email delivery** reaches inbox (not spam)
4. **Test all subscription events** (upgrade, downgrade, cancel)
5. **Train Trinity members** on admin panel usage
6. **Update documentation** with any deployment-specific notes
### Rollback Plan (If Issues Occur)
```bash
# Stop service
sudo systemctl stop arbiter
# Disable service
sudo systemctl disable arbiter
# Remove Nginx config
sudo rm /etc/nginx/sites-enabled/arbiter
sudo systemctl reload nginx
# Application files remain in /home/architect/arbiter for debugging
```
---
## 📞 Support Contacts
**System Administrator:** Michael (The Wizard)
**Implementation Partner:** Claude (Chronicler #49)
**Architecture Consultant:** Gemini AI
**Documentation:** `/home/architect/arbiter/README.md`
**Troubleshooting:** `/home/architect/arbiter/TROUBLESHOOTING.md`
---
**🔥❄️ Deployment completed by Chronicler #49 on March 30, 2026 💙**

View File

@@ -0,0 +1,448 @@
# Firefrost Arbiter - Complete Implementation Summary
**Date:** March 30, 2026
**Time:** 10:18 AM CDT
**Prepared by:** Claude (Chronicler #49)
**Status:** READY TO DEPLOY
---
## 🎯 What This Is
A complete, production-ready Discord OAuth soft gate system that automates subscriber role assignment and provides a manual admin interface for Trinity members.
**Built in collaboration with:** Gemini AI (Architecture Consultant)
---
## 📦 What You Have
### Complete Application (24 Files)
**Location:** `/home/claude/discord-oauth-implementation/`
```
discord-oauth-implementation/
├── src/
│ ├── routes/
│ │ ├── webhook.js # Paymenter webhook handler
│ │ ├── oauth.js # User Discord linking flow
│ │ ├── admin.js # Admin panel routes
│ │ └── adminAuth.js # Admin OAuth login
│ ├── middleware/
│ │ ├── auth.js # Admin access control
│ │ ├── verifyWebhook.js # HMAC signature verification
│ │ └── validateWebhook.js # Zod schema validation
│ ├── utils/
│ │ └── templates.js # 6 HTML success/error pages
│ ├── views/
│ │ └── admin.html # Complete admin UI with JavaScript
│ ├── database.js # SQLite initialization + cleanup
│ ├── email.js # Nodemailer SMTP
│ ├── discordService.js # Bot client + role management
│ ├── cmsService.js # Ghost CMS integration
│ └── index.js # Main application entry
├── config/
│ └── roles.json # Tier → Discord Role ID mapping
├── docs/
│ ├── README.md # Complete project documentation
│ ├── DEPLOYMENT.md # Step-by-step deployment guide
│ └── TROUBLESHOOTING.md # Common issues + solutions
├── .env.example # Configuration template
├── package.json # Dependencies
├── backup.sh # Automated backup script
├── arbiter.service # Systemd service file
└── nginx.conf # Nginx reverse proxy config
```
### Complete Documentation (3 Files)
1. **README.md** (5,700 words)
- Project overview
- Architecture
- Installation instructions
- Configuration guides
- Testing procedures
- Maintenance tasks
2. **DEPLOYMENT.md** (3,800 words)
- 7-phase deployment process
- Pre-deployment checklist
- Step-by-step commands
- Validation procedures
- Rollback plan
3. **TROUBLESHOOTING.md** (3,200 words)
- 7 common issues with detailed solutions
- Emergency procedures
- Performance optimization
- Security concerns
- Tools reference
### Gemini Consultation Archive (8 Files)
**Location:** `/home/claude/firefrost-operations-manual/docs/consultations/gemini-discord-oauth-2026-03-30/`
- Complete technical discussion
- All architecture decisions
- Production-ready code
- 2,811 lines of consultation history
- Already committed to Gitea (commit `dbfc123`)
---
## 🚀 How to Deploy
### Quick Start (When You're Home)
```bash
# 1. Upload implementation to Command Center
scp -r discord-oauth-implementation/* architect@63.143.34.217:/home/architect/arbiter/
# 2. SSH to Command Center
ssh architect@63.143.34.217
# 3. Install dependencies
cd /home/architect/arbiter
npm install
# 4. Configure environment
cp .env.example .env
nano .env # Fill in all values
# 5. Configure roles
nano config/roles.json # Add Discord role IDs
# 6. Set up Nginx
sudo cp nginx.conf /etc/nginx/sites-available/arbiter
sudo ln -s /etc/nginx/sites-available/arbiter /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
# 7. Set up systemd
sudo cp arbiter.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable arbiter
sudo systemctl start arbiter
# 8. Verify
curl https://discord-bot.firefrostgaming.com/health
```
**Full detailed instructions:** See `DEPLOYMENT.md`
---
## 🔑 What You Need Before Deploying
### From Discord Developer Portal
- [ ] Bot Token
- [ ] Client ID
- [ ] Client Secret
- [ ] Guild ID (server ID)
- [ ] All 10 Discord Role IDs
- [ ] Trinity Discord IDs (Michael, Meg, Holly)
### From Ghost CMS
- [ ] Custom field `discord_id` created
- [ ] Admin API Key
### From Mailcow
- [ ] SMTP password for `noreply@firefrostgaming.com`
### From Paymenter
- [ ] Webhook secret
### Generate New
- [ ] SESSION_SECRET (32-byte random hex)
---
## 🎨 Features Implemented
### For Subscribers (Automated)
- ✅ Receive subscription → get linking email
- ✅ Click link → Discord OAuth → role assigned automatically
- ✅ 24-hour token expiration
- ✅ Beautiful error pages for all scenarios
- ✅ Ghost CMS updated with Discord ID
### For Admins (Manual)
- ✅ Search subscribers by email
- ✅ Manually assign/remove roles
- ✅ View audit log of all actions
- ✅ Protected by Discord OAuth (Trinity-only)
- ✅ Clean Pico.css UI (dark mode)
### Security Measures
- ✅ Webhook signature verification (HMAC SHA256)
- ✅ Input validation (Zod schemas)
- ✅ Rate limiting (100 req/15min per IP)
- ✅ Secure sessions (httpOnly, SameSite)
- ✅ Admin whitelist (Discord ID check)
- ✅ HTTPS enforcement
- ✅ Automated token cleanup
### Operational
- ✅ Automated daily backups (4 AM)
- ✅ Health check endpoint
- ✅ Systemd service (auto-restart)
- ✅ Comprehensive logging
- ✅ Database maintenance (auto-cleanup)
---
## 📊 Architecture Decisions
### 1. Soft Gate (Option C)
**Why:** Maintains high conversion rates, industry standard, no friction at checkout
**Alternatives Considered:** Hard gate (require Discord before purchase), Hybrid (Discord optional)
### 2. Integrated Admin Interface (Option A)
**Why:** Shares Discord client, no duplication, simpler deployment
**Alternatives Considered:** Separate admin tool/service
### 3. SQLite for State
**Why:** Appropriate scale, persistence, no extra infrastructure
**Alternatives Considered:** Redis, PostgreSQL, in-memory
### 4. Plain Text Email
**Why:** Better spam filtering, simpler maintenance
**Alternatives Considered:** HTML email with branding
### 5. 4:00 AM Backup Schedule
**Why:** Lowest activity window, SQLite .backup command is safe during reads
**Timing Rationale:** 3-6 AM lowest activity, 4 AM is middle
---
## 📈 What Happens Next
### Phase 1: Local Testing (Optional)
1. Set `APP_URL=http://localhost:3500` in `.env`
2. Run `npm run dev`
3. Test webhook with curl
4. Test OAuth flow
5. Test admin panel
### Phase 2: Production Deployment
1. Follow `DEPLOYMENT.md` step-by-step
2. Deploy to Command Center
3. Configure all services
4. Validate with test subscription
### Phase 3: Soft Launch
1. Monitor for 24 hours
2. Test with real subscription
3. Train Trinity on admin panel
4. Announce to community
### Phase 4: Ongoing Maintenance
1. Monitor logs daily (first week)
2. Check backups working
3. Review audit logs weekly
4. Update documentation as needed
---
## 🔧 Technologies Used
**Runtime & Framework:**
- Node.js 18.x
- Express 4.x
**Database:**
- SQLite (better-sqlite3)
- connect-sqlite3 (session store)
**Discord:**
- discord.js 14.x
**CMS:**
- @tryghost/admin-api (Ghost 5.x)
**Email:**
- Nodemailer
**Security:**
- express-rate-limit
- Zod (validation)
- Crypto (HMAC, tokens)
**Session:**
- express-session
**Reverse Proxy:**
- Nginx + Let's Encrypt
**UI:**
- Pico.css (classless CSS framework)
---
## 💡 Key Learnings from Gemini
### Technical
1. **Soft gates work** - Don't force Discord before purchase
2. **Don't separate prematurely** - Monoliths are good at small scale
3. **SQLite is underrated** - Perfect for this use case
4. **Plain text email** - Better deliverability than HTML
5. **Rate limiting is essential** - Even low-traffic apps need it
### Operational
1. **4 AM backups** - Middle of lowest activity window
2. **SQLite .backup is safe** - Can run while app is active
3. **chmod 700 backups** - Protect secrets in backed-up .env
4. **Trust proxy matters** - Required for sessions behind Nginx
5. **Role hierarchy is critical** - Bot role must be ABOVE subscriber roles
---
## 📝 Post-Deployment Tasks
### Immediate (Within 24 Hours)
- [ ] Verify health check green
- [ ] Test complete OAuth flow with real subscription
- [ ] Check email delivery (inbox, not spam)
- [ ] Verify Ghost CMS updates correctly
- [ ] Confirm Discord roles assign correctly
- [ ] Test admin panel with all Trinity members
### Within 1 Week
- [ ] Monitor logs for errors
- [ ] Review first backup success
- [ ] Test all subscription events (create, upgrade, downgrade, cancel)
- [ ] Document any deployment-specific notes
- [ ] Update operations manual
### Ongoing
- [ ] Weekly audit log review
- [ ] Monthly backup restore test
- [ ] Quarterly dependency updates
- [ ] Update documentation as system evolves
---
## 🆘 If Something Goes Wrong
### Application Won't Start
1. Check logs: `sudo journalctl -u arbiter -n 100`
2. Common causes: missing .env, syntax error, port in use
3. See TROUBLESHOOTING.md "Application Won't Start"
### Webhooks Failing
1. Check webhook signature in Paymenter matches .env
2. Check health endpoint: `/health`
3. See TROUBLESHOOTING.md "Webhook signature verification failed"
### Roles Not Assigning
1. Check bot role hierarchy in Discord
2. Verify role IDs in `config/roles.json` are correct
3. See TROUBLESHOOTING.md "Bot missing permissions"
### Emergency Rollback
```bash
sudo systemctl stop arbiter
sudo systemctl disable arbiter
sudo rm /etc/nginx/sites-enabled/arbiter
sudo systemctl reload nginx
```
**Full troubleshooting:** See `TROUBLESHOOTING.md`
---
## 📞 Support Resources
**Documentation:**
- `/discord-oauth-implementation/README.md` - Complete overview
- `/discord-oauth-implementation/DEPLOYMENT.md` - Step-by-step deployment
- `/discord-oauth-implementation/TROUBLESHOOTING.md` - Common issues
**Gemini Consultation Archive:**
- `/firefrost-operations-manual/docs/consultations/gemini-discord-oauth-2026-03-30/`
- Complete technical discussion
- All architecture decisions
- Already in Gitea (commit `dbfc123`)
**Implementation Partner:**
- Claude (Chronicler #49)
**Architecture Consultant:**
- Gemini AI
---
## ✅ Quality Checklist
**Code Quality:**
- ✅ Production-ready code
- ✅ Error handling on all external calls
- ✅ Input validation on all user input
- ✅ Security best practices followed
- ✅ Logging for debugging
- ✅ Comments explaining complex logic
**Documentation:**
- ✅ Complete README
- ✅ Step-by-step deployment guide
- ✅ Troubleshooting for 7+ common issues
- ✅ Inline code comments
- ✅ Configuration examples
- ✅ Architecture decisions documented
**Testing:**
- ✅ Local testing procedure provided
- ✅ Production testing checklist included
- ✅ Health check endpoint
- ✅ Manual testing commands provided
**Operations:**
- ✅ Automated backups configured
- ✅ Systemd service file
- ✅ Log rotation consideration
- ✅ Monitoring recommendations
- ✅ Rollback procedure
---
## 🎉 You're Ready!
**Everything is prepared.** When you're home and ready:
1. Read `DEPLOYMENT.md` start to finish
2. Gather all credentials (checklist in DEPLOYMENT.md)
3. Follow the 7-phase deployment process
4. Validate with test subscription
5. Monitor for 24 hours
6. Go live!
**Estimated deployment time:** 2-3 hours (including validation)
---
## 💙 Final Notes
This implementation represents ~7 hours of consultation with Gemini AI, resulting in:
- **2,000+ lines of production code**
- **12,000+ words of documentation**
- **5 major architecture decisions**
- **24 complete files ready to deploy**
**Built with care for:**
- Subscribers (seamless experience)
- Trinity (powerful admin tools)
- Future maintainers (comprehensive docs)
- The community ("for children not yet born")
**This is sustainable infrastructure.** It will serve Firefrost Gaming for years.
---
**🔥❄️ Fire + Frost + Foundation = Where Love Builds Legacy 💙**
**Prepared by Chronicler #49 on March 30, 2026 at 10:18 AM CDT**

465
services/arbiter/README.md Normal file
View File

@@ -0,0 +1,465 @@
# Firefrost Arbiter
**Discord Role Management and Subscription OAuth Gateway**
A centralized Node.js/Express service for managing Discord community roles, authenticating users via OAuth2, and processing subscription webhooks from Paymenter billing platform.
---
## 🎯 Purpose
Firefrost Arbiter automates the entire subscription-to-Discord-role workflow:
1. **User subscribes** via Paymenter (billing system)
2. **Webhook fires** to Arbiter
3. **Email sent** with secure 24-hour linking URL
4. **User clicks link** → Discord OAuth → Role assigned automatically
5. **Ghost CMS updated** with Discord ID for future reference
**Admin Interface** allows Trinity members to manually assign/remove roles, search subscribers, and view audit logs.
---
## 🏗️ Architecture Overview
### Components
- **Webhook Gateway**: Receives subscription events from Paymenter, generates secure single-use tokens, dispatches notification emails via SMTP
- **OAuth2 Linking**: Authenticates users via Discord, updates Ghost CMS member metadata, automatically assigns Discord server roles based on subscription tiers
- **Admin Dashboard**: Protected by Discord OAuth (restricted to specific User IDs), allows staff to manually assign roles, view audit logs, and search CMS records
- **State Management**: Utilizes local SQLite databases (`linking.db` and `sessions.db`) for lightweight, persistent data storage
### Tech Stack
- **Runtime**: Node.js 18.x+
- **Framework**: Express 4.x
- **Database**: SQLite (better-sqlite3)
- **Discord**: discord.js 14.x
- **CMS**: Ghost 5.x (Admin API)
- **Email**: Nodemailer (SMTP)
- **Session**: express-session + connect-sqlite3
- **Security**: express-rate-limit, Zod validation, HMAC webhook verification
---
## 📋 Prerequisites
Before installation, ensure you have:
- **Node.js 18.x or higher** installed
- **A Discord Application** with Bot User created ([Discord Developer Portal](https://discord.com/developers/applications))
- Server Members Intent enabled
- Bot invited to your Discord server with "Manage Roles" permission
- Bot role positioned ABOVE all subscription tier roles in role hierarchy
- **Ghost CMS 5.x** with Admin API access
- Custom field `discord_id` created in Ghost Admin
- Integration created for Admin API key
- **SMTP Server** for outgoing mail (Mailcow, Gmail, SendGrid, etc.)
- **Nginx** for reverse proxy and SSL termination
- **SSL Certificate** (Let's Encrypt recommended)
---
## 🚀 Installation
### 1. Clone and Install Dependencies
```bash
cd /home/architect
git clone <repository-url> arbiter
cd arbiter
npm install
```
### 2. Configure Environment Variables
```bash
cp .env.example .env
nano .env
```
Fill in all required values:
- Discord credentials (bot token, client ID/secret, guild ID)
- Ghost CMS URL and Admin API key
- SMTP server details
- Admin Discord IDs (comma-separated, no spaces)
- Generate SESSION_SECRET: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`
- Webhook secret from Paymenter
### 3. Configure Discord Role Mapping
Edit `config/roles.json` with your Discord role IDs:
```bash
nano config/roles.json
```
Get role IDs: Right-click role in Discord → Copy ID
### 4. Set Up Nginx Reverse Proxy
```bash
sudo cp nginx.conf /etc/nginx/sites-available/arbiter
sudo ln -s /etc/nginx/sites-available/arbiter /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
### 5. Set Up Systemd Service
```bash
sudo cp arbiter.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable arbiter
sudo systemctl start arbiter
```
### 6. Verify Installation
Check service status:
```bash
sudo systemctl status arbiter
```
Check logs:
```bash
sudo journalctl -u arbiter -f
```
Visit health check:
```bash
curl https://discord-bot.firefrostgaming.com/health
```
Should return:
```json
{
"uptime": 123.456,
"discord": "ok",
"database": "ok",
"timestamp": "2026-03-30T15:00:00.000Z"
}
```
### 7. Set Up Automated Backups
```bash
chmod +x backup.sh
crontab -e
```
Add this line (runs daily at 4:00 AM):
```
0 4 * * * /home/architect/arbiter/backup.sh >> /home/architect/backups/arbiter/cron_error.log 2>&1
```
Create backup directory:
```bash
mkdir -p /home/architect/backups/arbiter
chmod 700 /home/architect/backups/arbiter
```
---
## 🔧 Configuration
### Discord Developer Portal Setup
1. Go to [Discord Developer Portal](https://discord.com/developers/applications)
2. Create New Application (or select existing)
3. **Bot Tab**:
- Generate bot token (save to `.env` as `DISCORD_BOT_TOKEN`)
- Enable "Server Members Intent"
4. **OAuth2 → General**:
- Add Redirect URIs:
- `http://localhost:3500/auth/callback` (testing)
- `https://discord-bot.firefrostgaming.com/auth/callback` (production)
- `https://discord-bot.firefrostgaming.com/admin/callback` (admin login)
5. **OAuth2 → URL Generator**:
- Scopes: `bot`
- Permissions: `Manage Roles`
- Copy generated URL and invite bot to server
**CRITICAL**: In Discord Server Settings → Roles, drag the bot's role ABOVE all subscription tier roles!
### Ghost CMS Setup
1. **Create Custom Field**:
- Navigate to: Settings → Membership → Custom Fields
- Add field: `discord_id` (type: Text)
2. **Generate Admin API Key**:
- Navigate to: Settings → Integrations → Add Custom Integration
- Name: "Firefrost Arbiter"
- Copy Admin API Key (format: `key_id:secret`)
- Save to `.env` as `CMS_ADMIN_KEY`
### Paymenter Webhook Configuration
1. In Paymenter admin panel, navigate to Webhooks
2. Add new webhook:
- URL: `https://discord-bot.firefrostgaming.com/webhook/billing`
- Secret: (generate secure random string, save to `.env` as `WEBHOOK_SECRET`)
- Events: `subscription.created`, `subscription.upgraded`, `subscription.downgraded`, `subscription.cancelled`
---
## 📖 Usage
### For Subscribers (Automated Flow)
1. User subscribes via Paymenter
2. User receives email with secure linking URL
3. User clicks link → redirected to Discord OAuth
4. User authorizes → role automatically assigned
5. User sees new channels in Discord immediately
### For Admins (Manual Assignment)
1. Visit `https://discord-bot.firefrostgaming.com/admin/login`
2. Authenticate via Discord
3. **Search user** by email (from Ghost CMS)
4. **Assign role** or remove all roles
5. **Provide reason** (logged to audit trail)
6. View **audit log** of all manual actions
---
## 🧪 Testing
### Local Testing Setup
1. Set `APP_URL=http://localhost:3500` in `.env`
2. Add `http://localhost:3500/auth/callback` to Discord redirect URIs
3. Run in development mode:
```bash
npm run dev
```
### Test Webhook Reception
```bash
curl -X POST http://localhost:3500/webhook/billing \
-H "Content-Type: application/json" \
-H "x-signature: test_signature" \
-d '{
"event": "subscription.created",
"customer_email": "test@example.com",
"customer_name": "Test User",
"tier": "awakened",
"subscription_id": "test_sub_123"
}'
```
### Test OAuth Flow
1. Trigger webhook (above) to generate token
2. Check database: `sqlite3 linking.db "SELECT * FROM link_tokens;"`
3. Check email sent (Mailcow logs)
4. Click link in email
5. Complete Discord OAuth
6. Verify role assigned in Discord
7. Verify Ghost CMS updated: Ghost Admin → Members → search email
### Test Admin Panel
1. Visit `/admin/login`
2. Authenticate with Discord
3. Search for test user by email
4. Assign/remove role
5. Check audit log displays action
---
## 📁 Project Structure
```
arbiter/
├── src/
│ ├── routes/
│ │ ├── webhook.js # Paymenter webhook handler
│ │ ├── oauth.js # User Discord linking flow
│ │ ├── admin.js # Admin panel routes
│ │ └── adminAuth.js # Admin OAuth login
│ ├── middleware/
│ │ ├── auth.js # Admin access control
│ │ ├── verifyWebhook.js # HMAC signature verification
│ │ └── validateWebhook.js # Zod schema validation
│ ├── utils/
│ │ └── templates.js # HTML success/error pages
│ ├── views/
│ │ └── admin.html # Admin panel UI
│ ├── database.js # SQLite initialization
│ ├── email.js # Nodemailer SMTP
│ ├── discordService.js # Bot client + role management
│ ├── cmsService.js # Ghost CMS integration
│ └── index.js # Main application entry
├── config/
│ └── roles.json # Tier → Discord Role ID mapping
├── .env # Environment variables (not in git)
├── .env.example # Template for .env
├── package.json # Dependencies
├── backup.sh # Automated backup script
├── arbiter.service # Systemd service file
└── nginx.conf # Nginx reverse proxy config
```
---
## 🔐 Security
- **Webhook Verification**: HMAC SHA256 signature validation
- **Input Validation**: Zod schemas for all webhook payloads
- **Rate Limiting**: 100 requests per 15 minutes per IP
- **Session Security**: httpOnly, SameSite cookies
- **Admin Access Control**: Discord ID whitelist via environment variable
- **HTTPS Enforcement**: Nginx SSL termination with HSTS headers
- **Secure Tokens**: 32-byte cryptographically random tokens (64 hex chars)
- **Token Expiration**: 24-hour automatic expiry
- **Database Permissions**: `chmod 700` on backup directory
---
## 🛠️ Maintenance
### View Logs
```bash
# Application logs
sudo journalctl -u arbiter -f
# Nginx access logs
sudo tail -f /var/log/nginx/arbiter-access.log
# Nginx error logs
sudo tail -f /var/log/nginx/arbiter-error.log
# Backup logs
tail -f /home/architect/backups/arbiter/backup_log.txt
```
### Restart Service
```bash
sudo systemctl restart arbiter
```
### Update Application
```bash
cd /home/architect/arbiter
git pull
npm install
sudo systemctl restart arbiter
```
### Database Maintenance
```bash
# View link tokens
sqlite3 linking.db "SELECT * FROM link_tokens WHERE used = 0;"
# View audit logs
sqlite3 linking.db "SELECT * FROM audit_logs ORDER BY timestamp DESC LIMIT 10;"
# Manual cleanup of expired tokens
sqlite3 linking.db "DELETE FROM link_tokens WHERE created_at < datetime('now', '-1 day');"
```
---
## 🚨 Troubleshooting
See [TROUBLESHOOTING.md](TROUBLESHOOTING.md) for detailed solutions to common issues.
Quick reference:
- Invalid redirect URI → Check Discord Developer Portal OAuth settings
- Bot missing permissions → Check role hierarchy in Discord
- Session not persisting → Check `trust proxy` setting in code
- Ghost API 401 → Verify Admin API key format
- Database locked → Increase timeout in database.js
- Email not sending → Check SMTP credentials and port 587 firewall rule
---
## 📦 Backup & Restore
### Backup Procedure
Automated daily at 4:00 AM via cron. Manual backup:
```bash
./backup.sh
```
Backs up:
- `linking.db` (tokens and audit logs)
- `sessions.db` (admin sessions)
- `.env` (configuration with secrets)
- `config/roles.json` (tier mappings)
Retention: 7 days
### Restore Procedure
```bash
# Stop service
sudo systemctl stop arbiter
# Move corrupted database
mv linking.db linking.db.corrupt
# Restore from backup
cp /home/architect/backups/arbiter/linking_YYYYMMDD_HHMMSS.db linking.db
# Start service
sudo systemctl start arbiter
```
Verify restored backup:
```bash
sqlite3 /home/architect/backups/arbiter/linking_20260330_040000.db "SELECT count(*) FROM link_tokens;"
```
---
## 📚 Documentation
- [DEPLOYMENT.md](DEPLOYMENT.md) - Complete deployment guide
- [TROUBLESHOOTING.md](TROUBLESHOOTING.md) - Common issues and solutions
- [API.md](API.md) - API endpoint documentation (if created)
---
## 🤝 Contributing
This is a private system for Firefrost Gaming. For internal team members:
1. Create feature branch
2. Test locally
3. Commit with detailed messages
4. Deploy to staging first
5. Monitor logs before production rollout
---
## 📝 License
Private - Firefrost Gaming Internal Use Only
---
## 👥 Team
**Built by:**
- Michael "Frostystyle" Krause (The Wizard) - Technical Lead
- Claude (Chronicler #49) - Implementation Partner
- Gemini AI - Architecture Consultant
**For:** Firefrost Gaming Community
**Date:** March 30, 2026
---
**🔥❄️ Fire + Frost + Foundation = Where Love Builds Legacy 💙**

View File

@@ -0,0 +1,666 @@
# Firefrost Arbiter - Troubleshooting Guide
**Last Updated:** March 30, 2026
**Prepared by:** Claude (Chronicler #49) + Gemini AI
---
## 🔍 Quick Diagnostics
### Check Service Status
```bash
sudo systemctl status arbiter
```
### View Recent Logs
```bash
sudo journalctl -u arbiter -n 50
```
### Follow Live Logs
```bash
sudo journalctl -u arbiter -f
```
### Check Health Endpoint
```bash
curl https://discord-bot.firefrostgaming.com/health
```
---
## 🚨 Common Issues & Solutions
### 1. "Invalid redirect URI" in Discord OAuth
**Symptom:** When clicking linking URL or admin login, Discord shows "Invalid Redirect URI" error.
**Cause:** The redirect URI in your `.env` file doesn't exactly match what's registered in the Discord Developer Portal.
**Solution:**
1. Check `.env` file:
```bash
cat .env | grep APP_URL
```
Should show: `APP_URL=https://discord-bot.firefrostgaming.com` (no trailing slash)
2. Go to Discord Developer Portal → OAuth2 → General
3. Verify exact URIs are registered:
- `https://discord-bot.firefrostgaming.com/auth/callback`
- `https://discord-bot.firefrostgaming.com/admin/callback`
4. **Important:** Check for:
- Trailing slashes (don't include them)
- `http` vs `https` mismatch
- `www` vs non-www
- Typos in domain
5. If you changed the URI, wait 5-10 minutes for Discord to propagate
6. Restart the application:
```bash
sudo systemctl restart arbiter
```
---
### 2. "Bot missing permissions" when assigning roles
**Symptom:** Logs show "Failed to assign role" or "Missing Permissions" error when trying to assign Discord roles.
**Cause:** Either the bot wasn't invited with the correct permissions, or the bot's role is positioned below the roles it's trying to assign.
**Solution:**
**Check 1: Bot Has "Manage Roles" Permission**
1. Go to Discord Server → Settings → Roles
2. Find the bot's role (usually named after the bot)
3. Verify "Manage Roles" permission is enabled
4. If not, enable it
**Check 2: Role Hierarchy (Most Common Issue)**
1. Go to Discord Server → Settings → Roles
2. Find the bot's role in the list
3. **Drag it ABOVE all subscription tier roles**
4. The bot can only assign roles that are below its own role
Example correct hierarchy:
```
1. Owner (you)
2. Admin
3. [Bot Role] ← MUST BE HERE
4. Sovereign
5. Fire Legend
6. Frost Legend
... (all other subscriber roles)
```
**Check 3: Re-invite Bot with Correct Permissions**
If role hierarchy is correct but still failing:
1. Go to Discord Developer Portal → OAuth2 → URL Generator
2. Select scopes: `bot`
3. Select permissions: `Manage Roles` (minimum)
4. Copy generated URL
5. Visit URL and re-authorize bot (this updates permissions)
**Test:**
```bash
# Check if bot can see roles
sudo journalctl -u arbiter -n 100 | grep "Role ID"
```
---
### 3. "Session not persisting" across requests
**Symptom:** Admin panel logs you out immediately after login, or every page reload requires re-authentication.
**Cause:** Session cookies not being saved properly, usually due to reverse proxy configuration.
**Solution:**
**Check 1: Express Trust Proxy Setting**
Verify in `src/index.js`:
```javascript
app.set('trust proxy', 1);
```
This line MUST be present before session middleware.
**Check 2: Nginx Proxy Headers**
Edit Nginx config:
```bash
sudo nano /etc/nginx/sites-available/arbiter
```
Verify these headers exist in the `location /` block:
```nginx
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;
```
**Check 3: Cookie Settings for Development**
If testing on `http://localhost`, update `src/index.js`:
```javascript
cookie: {
secure: process.env.NODE_ENV === 'production', // false for localhost
httpOnly: true,
maxAge: 1000 * 60 * 60 * 24 * 7
}
```
**Check 4: SESSION_SECRET is Set**
```bash
grep SESSION_SECRET .env
```
Should show a 64-character hex string.
**Restart after changes:**
```bash
sudo systemctl restart arbiter
sudo systemctl reload nginx
```
---
### 4. "Ghost API 401 error"
**Symptom:** Logs show "Ghost API 401 Unauthorized" when trying to search users or update members.
**Cause:** Invalid or incorrectly formatted Admin API key.
**Solution:**
**Check 1: API Key Format**
```bash
cat .env | grep CMS_ADMIN_KEY
```
Should be in format: `key_id:secret` (with the colon)
Example:
```
CMS_ADMIN_KEY=65f8a1b2c3d4e5f6:a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6
```
**Check 2: Integration Still Exists**
1. Go to Ghost Admin → Settings → Integrations
2. Find "Firefrost Arbiter" integration
3. Verify it's not deleted or disabled
4. If missing, create new integration and update `.env`
**Check 3: Ghost URL is Correct**
```bash
cat .env | grep CMS_URL
```
Should match your Ghost installation URL exactly (no trailing slash).
**Check 4: Test API Key Manually**
```bash
curl -H "Authorization: Ghost <your_admin_key>" \
"https://firefrostgaming.com/ghost/api/admin/members/"
```
Should return JSON with member list. If 401, key is invalid.
**After fixing:**
```bash
sudo systemctl restart arbiter
```
---
### 5. "Database locked" errors
**Symptom:** Logs show "SQLITE_BUSY: database is locked" when multiple webhooks arrive simultaneously.
**Cause:** SQLite locks the database during writes. If multiple webhooks arrive at exactly the same time, one may fail.
**Solution:**
**Option 1: Increase Timeout (Recommended)**
Edit `src/database.js`:
```javascript
const Database = require('better-sqlite3');
const db = new Database('linking.db', { timeout: 5000 });
```
This gives SQLite 5 seconds to wait for locks to clear.
**Option 2: Add WAL Mode (Write-Ahead Logging)**
Edit `src/database.js`, add after database creation:
```javascript
db.pragma('journal_mode = WAL');
```
WAL mode allows concurrent reads and writes.
**Option 3: Retry Logic (For Critical Operations)**
In `src/routes/webhook.js`, wrap database operations:
```javascript
let retries = 3;
while (retries > 0) {
try {
stmt.run(token, customer_email, tier, subscription_id);
break;
} catch (error) {
if (error.code === 'SQLITE_BUSY' && retries > 1) {
retries--;
await new Promise(resolve => setTimeout(resolve, 100));
} else {
throw error;
}
}
}
```
**After changes:**
```bash
sudo systemctl restart arbiter
```
---
### 6. "Email not sending"
**Symptom:** Webhook processes successfully but subscriber never receives linking email.
**Cause:** SMTP connection issue, firewall blocking port 587, or incorrect credentials.
**Solution:**
**Check 1: SMTP Credentials**
```bash
cat .env | grep SMTP
```
Verify:
- `SMTP_HOST=38.68.14.188`
- `SMTP_USER=noreply@firefrostgaming.com`
- `SMTP_PASS=<correct password>`
**Check 2: Port 587 is Open**
From Command Center:
```bash
telnet 38.68.14.188 587
```
Should connect. If "Connection refused":
```bash
sudo ufw allow 587
```
**Check 3: Test SMTP Manually**
```bash
node -e "
const nodemailer = require('nodemailer');
const t = nodemailer.createTransport({
host: '38.68.14.188',
port: 587,
secure: false,
auth: { user: 'noreply@firefrostgaming.com', pass: 'YOUR_PASSWORD' }
});
t.sendMail({
from: 'noreply@firefrostgaming.com',
to: 'your_email@example.com',
subject: 'Test',
text: 'Testing SMTP'
}).then(() => console.log('Sent!')).catch(console.error);
"
```
**Check 4: Mailcow Logs**
SSH to Billing VPS:
```bash
ssh root@38.68.14.188
docker logs -f mailcowdockerized_postfix-mailcow_1 | grep noreply
```
Look for errors or rejections.
**Check 5: Spam Folder**
Check if email landed in spam/junk folder.
**Check 6: DKIM/SPF Records**
Verify DNS records are set up correctly (should be done already, but worth checking if delivery is failing).
---
### 7. "Webhook signature verification failed"
**Symptom:** Paymenter sends webhook but application logs "Invalid webhook signature" and returns 401.
**Cause:** `WEBHOOK_SECRET` in `.env` doesn't match the secret configured in Paymenter.
**Solution:**
**Check 1: Secrets Match**
```bash
cat .env | grep WEBHOOK_SECRET
```
Compare to Paymenter webhook configuration:
1. Paymenter Admin → System → Webhooks
2. Find Arbiter webhook
3. Check secret field
They must match exactly.
**Check 2: Header Name**
Verify Paymenter sends signature in `x-signature` header.
Edit `src/middleware/verifyWebhook.js` if needed:
```javascript
const signature = req.headers['x-signature']; // or 'x-paymenter-signature' or whatever Paymenter uses
```
**Check 3: Signature Algorithm**
Verify Paymenter uses HMAC SHA256. If different, update `src/middleware/verifyWebhook.js`:
```javascript
const expectedSignature = crypto
.createHmac('sha256', secret) // or 'sha1', 'md5', etc.
.update(payload)
.digest('hex');
```
**Check 4: Payload Format**
Paymenter might stringify the JSON differently. Add debug logging:
```javascript
console.log('Received signature:', signature);
console.log('Payload:', payload);
console.log('Expected signature:', expectedSignature);
```
**Temporary Bypass (Testing Only):**
To test without signature verification (NOT for production):
```javascript
// In src/routes/webhook.js, temporarily comment out:
// router.post('/billing', verifyBillingWebhook, validateBillingPayload, async (req, res) => {
router.post('/billing', validateBillingPayload, async (req, res) => {
```
**After fixing:**
```bash
sudo systemctl restart arbiter
```
---
## 🔥 Emergency Procedures
### Application Won't Start
**Symptom:** `systemctl status arbiter` shows "failed" status.
**Diagnosis:**
```bash
sudo journalctl -u arbiter -n 100
```
Look for:
- Missing `.env` file
- Syntax errors in code
- Missing dependencies
- Port 3500 already in use
**Solutions:**
**Port in use:**
```bash
sudo lsof -i :3500
sudo kill -9 <PID>
sudo systemctl start arbiter
```
**Missing dependencies:**
```bash
cd /home/architect/arbiter
npm install
sudo systemctl restart arbiter
```
**Syntax errors:**
Fix the reported file and line number, then:
```bash
sudo systemctl restart arbiter
```
---
### Database Corruption
**Symptom:** Application crashes with "database disk image is malformed" error.
**Solution:**
```bash
# Stop application
sudo systemctl stop arbiter
# Check database integrity
sqlite3 linking.db "PRAGMA integrity_check;"
```
**If corrupted:**
```bash
# Restore from backup (see DEPLOYMENT.md Phase 5)
mv linking.db linking.db.corrupt
cp /home/architect/backups/arbiter/linking_YYYYMMDD_HHMMSS.db linking.db
# Restart application
sudo systemctl start arbiter
```
---
### All Webhooks Suddenly Failing
**Symptom:** Every webhook returns 500 error, but application is running.
**Check 1: Disk Space**
```bash
df -h
```
If `/` is at 100%, clear space:
```bash
# Clean old logs
sudo journalctl --vacuum-time=7d
# Clean old backups
find /home/architect/backups/arbiter -type f -mtime +7 -delete
```
**Check 2: Memory Usage**
```bash
free -h
```
If out of memory:
```bash
sudo systemctl restart arbiter
```
**Check 3: Discord Bot Disconnected**
```bash
curl http://localhost:3500/health
```
If `discord: "down"`:
```bash
sudo systemctl restart arbiter
```
---
## 📊 Performance Issues
### Slow Response Times
**Check 1: Database Size**
```bash
ls -lh linking.db sessions.db
```
If >100MB, consider cleanup:
```bash
sqlite3 linking.db "DELETE FROM link_tokens WHERE used = 1 AND created_at < datetime('now', '-30 days');"
sqlite3 linking.db "VACUUM;"
```
**Check 2: High CPU Usage**
```bash
top
```
If `node` process is using >80% CPU consistently, check for:
- Infinite loops in code
- Too many concurrent webhooks
- Discord API rate limiting (bot trying to reconnect repeatedly)
**Check 3: Rate Limiting Too Strict**
If users report frequent "Too many requests" errors:
Edit `src/index.js`:
```javascript
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 200, // Increase from 100
// ...
});
```
---
## 🔐 Security Concerns
### Suspicious Database Entries
**Check for unusual tokens:**
```bash
sqlite3 linking.db "SELECT email, tier, created_at FROM link_tokens WHERE used = 0 ORDER BY created_at DESC LIMIT 20;"
```
**Check audit log for unauthorized actions:**
```bash
sqlite3 linking.db "SELECT * FROM audit_logs ORDER BY timestamp DESC LIMIT 20;"
```
**If compromised:**
1. Change all secrets in `.env`
2. Rotate Discord bot token
3. Regenerate Ghost Admin API key
4. Clear all unused tokens:
```bash
sqlite3 linking.db "DELETE FROM link_tokens WHERE used = 0;"
```
5. Force all admin re-authentication:
```bash
rm sessions.db
```
6. Restart application
---
## 📞 Getting Help
**Before asking for help, collect:**
1. Service status:
```bash
sudo systemctl status arbiter > /tmp/arbiter-status.txt
```
2. Recent logs:
```bash
sudo journalctl -u arbiter -n 200 > /tmp/arbiter-logs.txt
```
3. Configuration (sanitized):
```bash
cat .env | sed 's/=.*/=REDACTED/' > /tmp/arbiter-config.txt
```
4. Health check output:
```bash
curl https://discord-bot.firefrostgaming.com/health > /tmp/arbiter-health.txt
```
5. Database stats:
```bash
sqlite3 linking.db "SELECT COUNT(*) FROM link_tokens;" > /tmp/arbiter-db-stats.txt
sqlite3 linking.db "SELECT COUNT(*) FROM audit_logs;" >> /tmp/arbiter-db-stats.txt
```
**Share these files (remove any actual secrets first) when requesting support.**
---
## 🛠️ Tools & Commands Reference
### Restart Everything
```bash
sudo systemctl restart arbiter
sudo systemctl reload nginx
```
### View All Environment Variables
```bash
cat .env
```
### Check Which Process is Using Port 3500
```bash
sudo lsof -i :3500
```
### Test Database Connection
```bash
sqlite3 linking.db "SELECT 1;"
```
### Force Regenerate Sessions Database
```bash
sudo systemctl stop arbiter
rm sessions.db
sudo systemctl start arbiter
```
### Manually Cleanup Old Tokens
```bash
sqlite3 linking.db "DELETE FROM link_tokens WHERE created_at < datetime('now', '-1 day');"
```
### Export Audit Logs to CSV
```bash
sqlite3 -header -csv linking.db "SELECT * FROM audit_logs ORDER BY timestamp DESC;" > audit_export.csv
```
---
**🔥❄️ When in doubt, check the logs first. Most issues reveal themselves there. 💙**

1
services/arbiter/VERSION Normal file
View File

@@ -0,0 +1 @@
2.0.0

View File

@@ -0,0 +1,33 @@
#!/bin/bash
# Firefrost Arbiter - Automated Backup Script
# Location: /home/architect/arbiter/backup.sh
# Scheduled: Daily at 4:00 AM CST via crontab
APP_DIR="/home/architect/arbiter"
BACKUP_DIR="/home/architect/backups/arbiter"
DATE=$(date +%Y%m%d_%H%M%S)
LOG_FILE="$BACKUP_DIR/backup_log.txt"
# Ensure the backup directory exists
mkdir -p "$BACKUP_DIR"
echo "--- Backup Started: $DATE ---" >> "$LOG_FILE"
# Safely backup databases using SQLite native backup
if ! sqlite3 "$APP_DIR/linking.db" ".backup '$BACKUP_DIR/linking_$DATE.db'"; then
echo "ERROR: Failed to backup linking.db" >> "$LOG_FILE"
exit 1
fi
if ! sqlite3 "$APP_DIR/sessions.db" ".backup '$BACKUP_DIR/sessions_$DATE.db'"; then
echo "ERROR: Failed to backup sessions.db" >> "$LOG_FILE"
exit 1
fi
# Copy config and environment files
cp "$APP_DIR/.env" "$BACKUP_DIR/env_$DATE.bak"
cp "$APP_DIR/config/roles.json" "$BACKUP_DIR/roles_$DATE.json"
# Delete backups older than 7 days to save disk space
find "$BACKUP_DIR" -type f -mtime +7 -delete
echo "Backup completed successfully." >> "$LOG_FILE"

View File

@@ -0,0 +1,12 @@
{
"awakened": "REPLACE_WITH_DISCORD_ROLE_ID",
"fire_elemental": "REPLACE_WITH_DISCORD_ROLE_ID",
"frost_elemental": "REPLACE_WITH_DISCORD_ROLE_ID",
"fire_knight": "REPLACE_WITH_DISCORD_ROLE_ID",
"frost_knight": "REPLACE_WITH_DISCORD_ROLE_ID",
"fire_master": "REPLACE_WITH_DISCORD_ROLE_ID",
"frost_master": "REPLACE_WITH_DISCORD_ROLE_ID",
"fire_legend": "REPLACE_WITH_DISCORD_ROLE_ID",
"frost_legend": "REPLACE_WITH_DISCORD_ROLE_ID",
"sovereign": "REPLACE_WITH_DISCORD_ROLE_ID"
}

View File

@@ -0,0 +1,24 @@
[Unit]
Description=Firefrost Arbiter - Discord Role Management System
After=network.target
[Service]
Type=simple
User=architect
WorkingDirectory=/home/architect/arbiter
ExecStart=/usr/bin/node src/index.js
Restart=on-failure
RestartSec=10
EnvironmentFile=/home/architect/arbiter/.env
# Security
NoNewPrivileges=true
PrivateTmp=true
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=arbiter
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,64 @@
# Firefrost Arbiter - Nginx Configuration
# Location: /etc/nginx/sites-available/arbiter
# Enable with: sudo ln -s /etc/nginx/sites-available/arbiter /etc/nginx/sites-enabled/
# Then: sudo nginx -t && sudo systemctl reload nginx
# HTTP -> HTTPS Redirect
server {
listen 80;
server_name discord-bot.firefrostgaming.com;
return 301 https://$host$request_uri;
}
# HTTPS Server Block
server {
listen 443 ssl http2;
server_name discord-bot.firefrostgaming.com;
# SSL Configuration (Let's Encrypt)
ssl_certificate /etc/letsencrypt/live/discord-bot.firefrostgaming.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/discord-bot.firefrostgaming.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# Security Headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Access Logs
access_log /var/log/nginx/arbiter-access.log;
error_log /var/log/nginx/arbiter-error.log;
# Proxy Configuration
location / {
proxy_pass http://127.0.0.1:3500;
proxy_http_version 1.1;
# WebSocket Support (for Discord.js if needed)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
# Proxy Headers
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;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Cache Control
proxy_cache_bypass $http_upgrade;
}
# Health Check Endpoint (optional monitoring)
location /health {
proxy_pass http://127.0.0.1:3500/health;
access_log off;
}
}

View File

@@ -0,0 +1,31 @@
{
"name": "firefrost-arbiter",
"version": "2.0.0",
"description": "Discord Role Management and Subscription OAuth Gateway for Firefrost Gaming",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js"
},
"keywords": ["discord", "oauth", "subscriptions", "ghost-cms", "webhook"],
"author": "Firefrost Gaming",
"license": "UNLICENSED",
"dependencies": {
"@tryghost/admin-api": "^1.13.8",
"better-sqlite3": "^9.4.3",
"connect-sqlite3": "^0.9.15",
"discord.js": "^14.14.1",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-rate-limit": "^7.1.5",
"express-session": "^1.18.0",
"nodemailer": "^6.9.13",
"zod": "^3.22.4"
},
"devDependencies": {
"nodemon": "^3.1.0"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

@@ -0,0 +1,57 @@
// src/cmsService.js
// Ghost CMS Admin API integration for member management
const api = require('@tryghost/admin-api');
const cms = new api({
url: process.env.CMS_URL,
key: process.env.CMS_ADMIN_KEY,
version: 'v5.0'
});
/**
* Find a Ghost member by their email address
* @param {string} email - Email address to search for
* @returns {Promise<Object>} - Ghost member object
* @throws {Error} - If member not found
*/
async function findMemberByEmail(email) {
// We use the browse method with a filter to find the exact match
const members = await cms.members.browse({ filter: `email:'${email}'` });
if (members.length === 0) {
throw new Error('Member not found');
}
// Return the first match
return members[0];
}
/**
* Update a Ghost member's discord_id custom field
* @param {string} email - Member's email address
* @param {string} discordId - Discord user ID (snowflake)
* @returns {Promise<Object>} - Updated member object
*/
async function updateMemberDiscordId(email, discordId) {
const members = await cms.members.browse({ filter: `email:'${email}'` });
if (members.length === 0) {
throw new Error('Member not found in CMS');
}
const updated = await cms.members.edit({
id: members[0].id,
custom_fields: [
{ name: 'discord_id', value: discordId }
]
});
console.log(`[Ghost] Updated discord_id for ${email}`);
return updated;
}
module.exports = {
findMemberByEmail,
updateMemberDiscordId
};

View File

@@ -0,0 +1,46 @@
// src/database.js
// SQLite database initialization and maintenance for Firefrost Arbiter
const Database = require('better-sqlite3');
const db = new Database('linking.db');
// Create tables if they don't exist
db.exec(`
CREATE TABLE IF NOT EXISTS link_tokens (
token TEXT PRIMARY KEY,
email TEXT NOT NULL,
tier TEXT NOT NULL,
subscription_id TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
used INTEGER DEFAULT 0
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
admin_id TEXT NOT NULL,
target_user TEXT NOT NULL,
action TEXT NOT NULL,
reason TEXT NOT NULL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Cleanup function - removes tokens older than 24 hours
function cleanupExpiredTokens() {
const stmt = db.prepare(`
DELETE FROM link_tokens
WHERE created_at < datetime('now', '-1 day')
`);
const info = stmt.run();
console.log(`[Database] Cleaned up ${info.changes} expired tokens.`);
}
// Run cleanup once every 24 hours (86400000 ms)
setInterval(cleanupExpiredTokens, 86400000);
// Run cleanup on startup to clear any that expired while app was down
cleanupExpiredTokens();
module.exports = db;

View File

@@ -0,0 +1,104 @@
// src/discordService.js
// Discord bot client initialization and role management functions
const { Client, GatewayIntentBits } = require('discord.js');
const rolesConfig = require('../config/roles.json');
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers]
});
// Initialize the Discord bot login
client.login(process.env.DISCORD_BOT_TOKEN);
client.on('ready', () => {
console.log(`[Discord] Bot logged in as ${client.user.tag}`);
});
/**
* Assign a Discord role to a user based on their subscription tier
* @param {string} userId - Discord user ID (snowflake)
* @param {string} tier - Subscription tier name (e.g., 'awakened', 'fire_elemental')
* @returns {Promise<boolean>} - Success status
*/
async function assignDiscordRole(userId, tier) {
try {
const guild = client.guilds.cache.get(process.env.GUILD_ID);
if (!guild) throw new Error('Guild not found.');
// Fetch the member. If they aren't in the server, this throws an error.
const member = await guild.members.fetch(userId);
const roleId = rolesConfig[tier];
if (!roleId) throw new Error(`No role mapping found for tier: ${tier}`);
const role = guild.roles.cache.get(roleId);
if (!role) throw new Error(`Role ID ${roleId} not found in server.`);
await member.roles.add(role);
console.log(`[Discord] Assigned role ${tier} to user ${userId}`);
return true;
} catch (error) {
console.error(`[Discord] Failed to assign role to ${userId}:`, error.message);
return false;
}
}
/**
* Remove all subscription roles from a user (used for cancellations or before upgrades)
* @param {string} userId - Discord user ID (snowflake)
* @returns {Promise<boolean>} - Success status
*/
async function removeAllSubscriptionRoles(userId) {
try {
const guild = client.guilds.cache.get(process.env.GUILD_ID);
if (!guild) throw new Error('Guild not found.');
const member = await guild.members.fetch(userId);
// Extract all role IDs from the config
const allRoleIds = Object.values(rolesConfig);
// discord.js allows removing an array of role IDs at once
await member.roles.remove(allRoleIds);
console.log(`[Discord] Removed all subscription roles from ${userId}`);
return true;
} catch (error) {
console.error(`[Discord] Failed to remove roles for ${userId}:`, error.message);
throw error;
}
}
/**
* Update subscription roles (remove old, add new) - used for tier changes
* @param {string} userId - Discord user ID
* @param {string|null} newTier - New tier name, or null for cancellation
*/
async function updateSubscriptionRoles(userId, newTier = null) {
try {
const guild = client.guilds.cache.get(process.env.GUILD_ID);
const member = await guild.members.fetch(userId);
// 1. Remove ALL possible subscription roles
const allRoleIds = Object.values(rolesConfig);
await member.roles.remove(allRoleIds);
// 2. Add the new role (if not cancelled)
if (newTier && rolesConfig[newTier]) {
const newRole = guild.roles.cache.get(rolesConfig[newTier]);
if (newRole) await member.roles.add(newRole);
}
console.log(`[Discord] Updated roles for ${userId} to ${newTier || 'none'}`);
} catch (error) {
console.error(`[Discord] Role update failed for ${userId}:`, error);
}
}
module.exports = {
client,
rolesConfig,
assignDiscordRole,
removeAllSubscriptionRoles,
updateSubscriptionRoles
};

View File

@@ -0,0 +1,49 @@
// src/email.js
// Email service using Nodemailer for subscription linking notifications
const nodemailer = require('nodemailer');
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: 587,
secure: false, // Use STARTTLS
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
}
});
/**
* Send Discord linking email to subscriber
* @param {string} name - Customer name
* @param {string} email - Customer email address
* @param {string} token - Secure linking token
* @returns {Promise} - Nodemailer send result
*/
async function sendLinkingEmail(name, email, token) {
const link = `${process.env.APP_URL}/link?token=${token}`;
const textBody = `Hi ${name},
Thanks for subscribing to Firefrost Gaming!
To access your game servers, please connect your Discord account:
${link}
This link expires in 24 hours. Once connected, you'll see your server channels in Discord with IPs pinned at the top.
Questions? Join us in Discord: https://firefrostgaming.com/discord
- The Firefrost Team
🔥❄️`;
return transporter.sendMail({
from: `"Firefrost Gaming" <${process.env.SMTP_USER}>`,
to: email,
subject: 'Welcome to Firefrost Gaming! 🔥❄️ One More Step...',
text: textBody
});
}
module.exports = { sendLinkingEmail };

View File

@@ -0,0 +1,101 @@
// Firefrost Arbiter v2.0.0
// Discord Role Management & OAuth Gateway
// Built: March 30, 2026
const VERSION = '2.0.0';
require('dotenv').config();
const express = require('express');
const session = require('express-session');
const SQLiteStore = require('connect-sqlite3')(session);
const rateLimit = require('express-rate-limit');
const { client } = require('./discordService');
const db = require('./database');
const app = express();
const PORT = process.env.PORT || 3500;
// Trust reverse proxy (Nginx) for secure cookies
app.set('trust proxy', 1);
// Middleware - Body Parsers
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Middleware - Session Configuration
app.use(session({
store: new SQLiteStore({ db: 'sessions.db', dir: './' }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production', // true if HTTPS
httpOnly: true,
maxAge: 1000 * 60 * 60 * 24 * 7 // 1 week
}
}));
// Middleware - Rate Limiting
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per window
message: 'Too many requests from this IP, please try again after 15 minutes',
standardHeaders: true,
legacyHeaders: false,
});
// Apply rate limiting to specific routes
app.use('/auth', apiLimiter);
app.use('/webhook', apiLimiter);
app.use('/admin/api', apiLimiter);
// Routes
app.use('/webhook', require('./routes/webhook'));
app.use('/auth', require('./routes/oauth'));
app.use('/admin', require('./routes/adminAuth'));
app.use('/admin', require('./routes/admin'));
// Health Check Endpoint
app.get('/health', async (req, res) => {
let dbStatus = 'down';
try {
db.prepare('SELECT 1').get();
dbStatus = 'ok';
} catch (e) {
console.error('[Health] Database check failed:', e);
}
const status = {
uptime: process.uptime(),
discord: client.isReady() ? 'ok' : 'down',
database: dbStatus,
timestamp: new Date().toISOString()
};
const httpStatus = (status.discord === 'ok' && status.database === 'ok') ? 200 : 503;
res.status(httpStatus).json(status);
});
// Root endpoint
app.get('/', (req, res) => {
res.send('Firefrost Arbiter - Discord Role Management System');
});
// Start Server
app.listen(PORT, () => {
console.log(`[Server] Listening on port ${PORT}`);
console.log(`[Server] Environment: ${process.env.NODE_ENV || 'development'}`);
console.log(`[Server] Health check: http://localhost:${PORT}/health`);
});
// Discord Bot Ready Event
client.on('ready', () => {
console.log(`[Discord] Bot ready as ${client.user.tag}`);
});
// Graceful Shutdown
process.on('SIGTERM', () => {
console.log('[Server] SIGTERM received, shutting down gracefully...');
client.destroy();
process.exit(0);
});

View File

@@ -0,0 +1,27 @@
// src/middleware/auth.js
// Authentication middleware for admin panel access control
/**
* Require admin authentication - checks if logged-in user is in Trinity whitelist
* @param {Object} req - Express request
* @param {Object} res - Express response
* @param {Function} next - Express next function
*/
function requireAdmin(req, res, next) {
// This assumes your existing OAuth flow stores the logged-in user's ID in a session
const userId = req.session?.discordId;
if (!userId) {
return res.redirect('/admin/login');
}
const adminIds = process.env.ADMIN_DISCORD_IDS.split(',');
if (adminIds.includes(userId)) {
return next();
}
return res.status(403).send('Forbidden: You do not have admin access.');
}
module.exports = { requireAdmin };

View File

@@ -0,0 +1,33 @@
// src/middleware/validateWebhook.js
// Zod-based payload validation for Paymenter webhooks
const { z } = require('zod');
const webhookSchema = z.object({
event: z.string(),
customer_email: z.string().email(),
customer_name: z.string().optional(),
tier: z.string(),
product_id: z.string().optional(),
subscription_id: z.string().optional(),
discord_id: z.string().optional().nullable()
});
/**
* Validate webhook payload structure using Zod
* @param {Object} req - Express request
* @param {Object} res - Express response
* @param {Function} next - Express next function
*/
function validateBillingPayload(req, res, next) {
try {
req.body = webhookSchema.parse(req.body);
next();
} catch (error) {
// Log the validation error for debugging, but return 400
console.error('[Webhook] Validation Error:', error.errors);
return res.status(400).json({ error: 'Invalid payload structure' });
}
}
module.exports = validateBillingPayload;

View File

@@ -0,0 +1,35 @@
// src/middleware/verifyWebhook.js
// HMAC SHA256 webhook signature verification for Paymenter webhooks
const crypto = require('crypto');
/**
* Verify webhook signature to prevent unauthorized requests
* @param {Object} req - Express request
* @param {Object} res - Express response
* @param {Function} next - Express next function
*/
function verifyBillingWebhook(req, res, next) {
const signature = req.headers['x-signature']; // Check your provider's exact header name
const payload = JSON.stringify(req.body);
const secret = process.env.WEBHOOK_SECRET;
if (!signature || !secret) {
console.error('[Webhook] Missing signature or secret');
return res.status(401).json({ error: 'Invalid webhook signature' });
}
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) {
console.error('[Webhook] Signature verification failed');
return res.status(401).json({ error: 'Invalid webhook signature' });
}
next();
}
module.exports = verifyBillingWebhook;

View File

@@ -0,0 +1,79 @@
// src/routes/admin.js
// Admin panel routes for manual role assignment and audit logs
const express = require('express');
const path = require('path');
const router = express.Router();
const { requireAdmin } = require('../middleware/auth');
const db = require('../database');
const { assignDiscordRole, removeAllSubscriptionRoles, rolesConfig } = require('../discordService');
const { findMemberByEmail } = require('../cmsService');
// Apply admin protection to all routes
router.use(requireAdmin);
// 1. Render the admin UI
router.get('/', (req, res) => {
res.sendFile(path.join(__dirname, '../views/admin.html'));
});
// 2. Get tier list for dropdown population
router.get('/api/tiers', (req, res) => {
res.json(rolesConfig);
});
// 3. Search API endpoint - find user by email
router.get('/api/search', async (req, res) => {
const { email } = req.query;
try {
const cmsUser = await findMemberByEmail(email);
res.json(cmsUser);
} catch (error) {
console.error('[Admin] Search failed:', error);
res.status(404).json({ error: 'User not found' });
}
});
// 4. Manual role assignment endpoint
router.post('/api/assign', async (req, res) => {
const { targetDiscordId, action, tier, reason } = req.body;
const adminId = req.session.discordId;
try {
if (action === 'add') {
await assignDiscordRole(targetDiscordId, tier);
} else if (action === 'remove_all') {
await removeAllSubscriptionRoles(targetDiscordId);
}
// Log the action to audit log
const stmt = db.prepare(`
INSERT INTO audit_logs (admin_id, target_user, action, reason)
VALUES (?, ?, ?, ?)
`);
stmt.run(adminId, targetDiscordId, `${action}_${tier || 'all'}`, reason);
console.log(`[Admin] ${adminId} performed ${action} on ${targetDiscordId}`);
res.json({ success: true, message: 'Action completed and logged.' });
} catch (error) {
console.error('[Admin] Assignment failed:', error);
res.status(500).json({ error: error.message });
}
});
// 5. Audit log retrieval endpoint
router.get('/api/audit-log', async (req, res) => {
try {
const stmt = db.prepare(`
SELECT * FROM audit_logs
ORDER BY timestamp DESC LIMIT 50
`);
const logs = stmt.all();
res.json(logs);
} catch (error) {
console.error('[Admin] Failed to fetch audit logs:', error);
res.status(500).json({ error: 'Failed to fetch logs' });
}
});
module.exports = router;

View File

@@ -0,0 +1,55 @@
// src/routes/adminAuth.js
// Discord OAuth authentication for admin panel access
const express = require('express');
const router = express.Router();
// Admin login - redirect to Discord OAuth
router.get('/login', (req, res) => {
const redirectUri = encodeURIComponent(`${process.env.APP_URL}/admin/callback`);
res.redirect(`https://discord.com/api/oauth2/authorize?client_id=${process.env.DISCORD_CLIENT_ID}&redirect_uri=${redirectUri}&response_type=code&scope=identify`);
});
// OAuth callback - set session and redirect to dashboard
router.get('/callback', async (req, res) => {
const { code } = req.query;
try {
// Exchange code for Discord access token
const tokenRes = await fetch('https://discord.com/api/oauth2/token', {
method: 'POST',
body: new URLSearchParams({
client_id: process.env.DISCORD_CLIENT_ID,
client_secret: process.env.DISCORD_CLIENT_SECRET,
code,
grant_type: 'authorization_code',
redirect_uri: `${process.env.APP_URL}/admin/callback`,
}),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
const tokenData = await tokenRes.json();
// Get Discord user profile
const userRes = await fetch('https://discord.com/api/users/@me', {
headers: { authorization: `Bearer ${tokenData.access_token}` },
});
const userData = await userRes.json();
// Set session
req.session.discordId = userData.id;
console.log(`[Admin Auth] ${userData.username} logged in`);
res.redirect('/admin');
} catch (error) {
console.error('[Admin Auth] Login failed:', error);
res.status(500).send('Admin login failed. Please try again.');
}
});
// Logout - destroy session
router.get('/logout', (req, res) => {
req.session.destroy();
res.redirect('/');
});
module.exports = router;

View File

@@ -0,0 +1,92 @@
// src/routes/oauth.js
// Discord OAuth linking flow for subscription users
const express = require('express');
const db = require('../database');
const { updateMemberDiscordId } = require('../cmsService');
const { assignDiscordRole } = require('../discordService');
const templates = require('../utils/templates');
const router = express.Router();
// 1. The entry point from the email link
router.get('/link', (req, res) => {
const { token } = req.query;
const tokenData = db.prepare(`
SELECT * FROM link_tokens
WHERE token = ? AND used = 0 AND created_at >= datetime('now', '-1 day')
`).get(token);
if (!tokenData) {
// Check if token exists but is expired or used
const expiredToken = db.prepare('SELECT * FROM link_tokens WHERE token = ?').get(token);
if (expiredToken && expiredToken.used === 1) {
return res.status(400).send(templates.getUsedPage());
}
if (expiredToken) {
return res.status(400).send(templates.getExpiredPage());
}
return res.status(400).send(templates.getInvalidPage());
}
const redirectUri = encodeURIComponent(`${process.env.APP_URL}/auth/callback`);
const authUrl = `https://discord.com/api/oauth2/authorize?client_id=${process.env.DISCORD_CLIENT_ID}&redirect_uri=${redirectUri}&response_type=code&scope=identify&state=${token}`;
res.redirect(authUrl);
});
// 2. The callback from Discord OAuth
router.get('/callback', async (req, res) => {
const { code, state: token } = req.query;
const tokenData = db.prepare('SELECT * FROM link_tokens WHERE token = ? AND used = 0').get(token);
if (!tokenData) {
return res.status(400).send(templates.getInvalidPage());
}
try {
// Exchange code for Discord access token
const tokenRes = await fetch('https://discord.com/api/oauth2/token', {
method: 'POST',
body: new URLSearchParams({
client_id: process.env.DISCORD_CLIENT_ID,
client_secret: process.env.DISCORD_CLIENT_SECRET,
code,
grant_type: 'authorization_code',
redirect_uri: `${process.env.APP_URL}/auth/callback`,
}),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
const oauthData = await tokenRes.json();
// Get Discord user profile
const userRes = await fetch('https://discord.com/api/users/@me', {
headers: { authorization: `Bearer ${oauthData.access_token}` },
});
const userData = await userRes.json();
// Update Ghost CMS with Discord ID
await updateMemberDiscordId(tokenData.email, userData.id);
// Assign Discord role
const roleAssigned = await assignDiscordRole(userData.id, tokenData.tier);
if (!roleAssigned) {
// User not in server
return res.status(400).send(templates.getNotInServerPage());
}
// Mark token as used
db.prepare('UPDATE link_tokens SET used = 1 WHERE token = ?').run(token);
console.log(`[OAuth] Successfully linked ${userData.id} to ${tokenData.email}`);
res.send(templates.getSuccessPage());
} catch (error) {
console.error('[OAuth] Callback Error:', error);
res.status(500).send(templates.getServerErrPage());
}
});
module.exports = router;

View File

@@ -0,0 +1,62 @@
// src/routes/webhook.js
// Paymenter webhook handler for subscription events
const express = require('express');
const crypto = require('crypto');
const db = require('../database');
const { sendLinkingEmail } = require('../email');
const { assignDiscordRole, updateSubscriptionRoles } = require('../discordService');
const verifyBillingWebhook = require('../middleware/verifyWebhook');
const validateBillingPayload = require('../middleware/validateWebhook');
const router = express.Router();
router.post('/billing', verifyBillingWebhook, validateBillingPayload, async (req, res) => {
const { event, discord_id, tier, customer_email, customer_name, subscription_id } = req.body;
console.log(`[Webhook] Received ${event} for ${customer_email}`);
try {
if (event === 'subscription.created') {
if (discord_id) {
// User already linked - assign role immediately
await assignDiscordRole(discord_id, tier);
return res.status(200).json({ message: 'Role assigned' });
} else {
// User not linked yet - generate token and send email
const token = crypto.randomBytes(32).toString('hex');
const stmt = db.prepare(`
INSERT INTO link_tokens (token, email, tier, subscription_id)
VALUES (?, ?, ?, ?)
`);
stmt.run(token, customer_email, tier, subscription_id);
await sendLinkingEmail(customer_name || 'Subscriber', customer_email, token);
console.log(`[Webhook] Sent linking email to ${customer_email}`);
return res.status(200).json({ message: 'Email sent' });
}
}
if (event === 'subscription.upgraded' || event === 'subscription.downgraded') {
if (discord_id) {
await updateSubscriptionRoles(discord_id, tier);
return res.status(200).json({ message: 'Roles updated' });
}
}
if (event === 'subscription.cancelled') {
if (discord_id) {
await updateSubscriptionRoles(discord_id, null); // Remove all roles
return res.status(200).json({ message: 'Roles removed' });
}
}
res.status(200).json({ message: 'Event acknowledged' });
} catch (error) {
console.error('[Webhook] Processing failed:', error);
return res.status(500).json({ error: 'Internal error' });
}
});
module.exports = router;

View File

@@ -0,0 +1,65 @@
// src/utils/templates.js
// HTML templates for user-facing success and error pages
const baseHtml = (title, content) => `
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${title} - Firefrost Gaming</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css">
</head>
<body>
<main class="container" style="text-align: center; margin-top: 10vh;">
<article>
${content}
</article>
</main>
</body>
</html>`;
const getSuccessPage = () => baseHtml('Success', `
<h1>🔥 Account Linked Successfully! ❄️</h1>
<p>Your Discord account has been connected and your roles are assigned.</p>
<p>You can close this window and head back to Discord to see your new channels!</p>
`);
const getExpiredPage = () => baseHtml('Link Expired', `
<h2 style="color: #ffb703;">⏳ This Link Has Expired</h2>
<p>For security, linking URLs expire after 24 hours.</p>
<p>Please log in to the website to request a new Discord linking email, or contact support.</p>
`);
const getInvalidPage = () => baseHtml('Invalid Link', `
<h2 style="color: #e63946;">❌ Invalid Link</h2>
<p>We couldn't recognize this secure token. The URL might be malformed or incomplete.</p>
<p>Please make sure you copied the entire link from your email.</p>
`);
const getUsedPage = () => baseHtml('Already Linked', `
<h2 style="color: #8eca91;">✅ Already Linked</h2>
<p>This specific token has already been used to link an account.</p>
<p>If you do not see your roles in Discord, please open a support ticket.</p>
`);
const getServerErrPage = () => baseHtml('System Error', `
<h2 style="color: #e63946;">⚠️ System Error</h2>
<p>Something went wrong communicating with the Discord or CMS servers.</p>
<p>Please try clicking the link in your email again in a few minutes.</p>
`);
const getNotInServerPage = () => baseHtml('Join Server First', `
<h2 style="color: #ffb703;">👋 One Quick Thing...</h2>
<p>It looks like you aren't in our Discord server yet!</p>
<p>Please <a href="https://firefrostgaming.com/discord">click here to join the server</a>, then click the secure link in your email again to receive your roles.</p>
`);
module.exports = {
getSuccessPage,
getExpiredPage,
getInvalidPage,
getUsedPage,
getServerErrPage,
getNotInServerPage
};

View File

@@ -0,0 +1,188 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin Panel - Firefrost Gaming</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css">
</head>
<body>
<main class="container">
<nav>
<ul><li><strong>🔥❄️ Firefrost Admin Panel</strong></li></ul>
<ul><li><a href="/admin/logout">Logout</a></li></ul>
</nav>
<!-- Search User Section -->
<section>
<h2>Search User</h2>
<form id="searchForm">
<input type="email" id="searchEmail" placeholder="Enter subscriber email from Ghost CMS" required>
<button type="submit">Search</button>
</form>
<div id="searchResults"></div>
</section>
<!-- Manual Role Assignment Section -->
<section>
<h2>Manual Role Assignment</h2>
<form id="assignForm">
<input type="text" id="targetId" placeholder="Discord User ID (snowflake)" required>
<select id="action" required>
<option value="" disabled selected>Select Action...</option>
<option value="add">Add Role</option>
<option value="remove_all">Remove All Subscription Roles</option>
</select>
<select id="tier" required>
<!-- Populated dynamically by JavaScript -->
</select>
<input type="text" id="reason" placeholder="Reason (e.g., 'Support ticket #123', 'Refund')" required>
<button type="submit">Execute & Log</button>
</form>
</section>
<!-- Audit Log Section -->
<section>
<h2>Recent Actions (Audit Log)</h2>
<figure>
<table role="grid">
<thead>
<tr>
<th scope="col">Timestamp</th>
<th scope="col">Admin ID</th>
<th scope="col">Target User</th>
<th scope="col">Action</th>
<th scope="col">Reason</th>
</tr>
</thead>
<tbody id="auditLogs">
<tr><td colspan="5">Loading...</td></tr>
</tbody>
</table>
</figure>
</section>
</main>
<script>
// --- 1. Load Tier Dropdown ---
async function loadTiers() {
try {
const response = await fetch('/admin/api/tiers');
const tiers = await response.json();
const tierSelect = document.getElementById('tier');
tierSelect.innerHTML = Object.keys(tiers).map(tierKey =>
`<option value="${tierKey}">${tierKey.replace(/_/g, ' ').toUpperCase()}</option>`
).join('');
} catch (error) {
console.error('Failed to load tiers:', error);
}
}
// --- 2. Search Functionality ---
document.getElementById('searchForm').addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('searchEmail').value;
const resultsDiv = document.getElementById('searchResults');
resultsDiv.innerHTML = '<em>Searching...</em>';
try {
const response = await fetch(`/admin/api/search?email=${encodeURIComponent(email)}`);
if (!response.ok) throw new Error('User not found in CMS.');
const user = await response.json();
// Assuming Ghost CMS custom field is named 'discord_id'
const discordId = user.labels?.find(l => l.name === 'discord_id')?.value ||
user.custom_fields?.find(f => f.name === 'discord_id')?.value ||
'Not Linked';
resultsDiv.innerHTML = `
<article>
<p><strong>Name:</strong> ${user.name || 'Unknown'}</p>
<p><strong>Email:</strong> ${user.email}</p>
<p><strong>Discord ID:</strong> ${discordId}</p>
</article>
`;
// Auto-fill assignment form if Discord ID exists
if (discordId !== 'Not Linked') {
document.getElementById('targetId').value = discordId;
}
} catch (error) {
resultsDiv.innerHTML = `<p style="color: #ff6b6b;">${error.message}</p>`;
}
});
// --- 3. Role Assignment Functionality ---
document.getElementById('assignForm').addEventListener('submit', async (e) => {
e.preventDefault();
const submitBtn = e.target.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.textContent = 'Processing...';
const payload = {
targetDiscordId: document.getElementById('targetId').value,
action: document.getElementById('action').value,
tier: document.getElementById('tier').value,
reason: document.getElementById('reason').value
};
try {
const response = await fetch('/admin/api/assign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || 'Assignment failed');
alert('✅ Success: ' + result.message);
e.target.reset();
loadAuditLogs(); // Refresh logs
} catch (error) {
alert('❌ Error: ' + error.message);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Execute & Log';
}
});
// --- 4. Audit Log Display ---
async function loadAuditLogs() {
const logContainer = document.getElementById('auditLogs');
try {
const response = await fetch('/admin/api/audit-log');
const logs = await response.json();
if (logs.length === 0) {
logContainer.innerHTML = '<tr><td colspan="5">No audit logs yet.</td></tr>';
return;
}
logContainer.innerHTML = logs.map(log => `
<tr>
<td>${new Date(log.timestamp).toLocaleString()}</td>
<td>${log.admin_id}</td>
<td>${log.target_user}</td>
<td>${log.action}</td>
<td>${log.reason}</td>
</tr>
`).join('');
} catch (error) {
logContainer.innerHTML = '<tr><td colspan="5">Failed to load logs.</td></tr>';
}
}
// Initialize on page load
loadTiers();
loadAuditLogs();
</script>
</body>
</html>

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Firefrost Gaming
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,183 @@
#!/bin/bash
# Auto-generated script to create all modpack-version-checker files
BASE_DIR="/home/claude/firefrost-operations-manual/docs/tasks/modpack-version-checker/code/modpack-version-checker"
cd "$BASE_DIR"
# Already created: src/modpack_checker/__init__.py, src/modpack_checker/config.py
# Create database.py
cat > src/modpack_checker/database.py << 'EOF'
"""SQLite database layer using SQLAlchemy 2.0 ORM."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import List, Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, create_engine, select
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship
class _Base(DeclarativeBase):
pass
class _ModpackRow(_Base):
__tablename__ = "modpacks"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
curseforge_id: Mapped[int] = mapped_column(Integer, unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
current_version: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
last_checked: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
notification_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
history: Mapped[List["_CheckHistoryRow"]] = relationship(
back_populates="modpack", cascade="all, delete-orphan"
)
class _CheckHistoryRow(_Base):
__tablename__ = "check_history"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
modpack_id: Mapped[int] = mapped_column(ForeignKey("modpacks.id"), nullable=False)
checked_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
version_found: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
notification_sent: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
modpack: Mapped["_ModpackRow"] = relationship(back_populates="history")
@dataclass
class Modpack:
id: int
curseforge_id: int
name: str
current_version: Optional[str]
last_checked: Optional[datetime]
notification_enabled: bool
@classmethod
def _from_row(cls, row: _ModpackRow) -> "Modpack":
return cls(
id=row.id,
curseforge_id=row.curseforge_id,
name=row.name,
current_version=row.current_version,
last_checked=row.last_checked,
notification_enabled=row.notification_enabled,
)
@dataclass
class CheckHistory:
id: int
modpack_id: int
checked_at: datetime
version_found: Optional[str]
notification_sent: bool
@classmethod
def _from_row(cls, row: _CheckHistoryRow) -> "CheckHistory":
return cls(
id=row.id,
modpack_id=row.modpack_id,
checked_at=row.checked_at,
version_found=row.version_found,
notification_sent=row.notification_sent,
)
class Database:
def __init__(self, db_path: str) -> None:
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
self.engine = create_engine(f"sqlite:///{db_path}", echo=False)
_Base.metadata.create_all(self.engine)
def add_modpack(self, curseforge_id: int, name: str) -> Modpack:
with Session(self.engine) as session:
existing = session.scalar(
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
)
if existing is not None:
raise ValueError(f"Modpack ID {curseforge_id} is already being tracked.")
row = _ModpackRow(curseforge_id=curseforge_id, name=name, notification_enabled=True)
session.add(row)
session.commit()
return Modpack._from_row(row)
def remove_modpack(self, curseforge_id: int) -> bool:
with Session(self.engine) as session:
row = session.scalar(
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
)
if row is None:
return False
session.delete(row)
session.commit()
return True
def update_version(self, curseforge_id: int, version: str, notification_sent: bool = False) -> None:
with Session(self.engine) as session:
row = session.scalar(
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
)
if row is None:
raise ValueError(f"Modpack {curseforge_id} not found in database.")
row.current_version = version
row.last_checked = datetime.utcnow()
session.add(
_CheckHistoryRow(
modpack_id=row.id,
checked_at=datetime.utcnow(),
version_found=version,
notification_sent=notification_sent,
)
)
session.commit()
def toggle_notifications(self, curseforge_id: int, enabled: bool) -> bool:
with Session(self.engine) as session:
row = session.scalar(
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
)
if row is None:
return False
row.notification_enabled = enabled
session.commit()
return True
def get_modpack(self, curseforge_id: int) -> Optional[Modpack]:
with Session(self.engine) as session:
row = session.scalar(
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
)
return Modpack._from_row(row) if row else None
def get_all_modpacks(self) -> List[Modpack]:
with Session(self.engine) as session:
rows = session.scalars(select(_ModpackRow)).all()
return [Modpack._from_row(r) for r in rows]
def get_check_history(self, curseforge_id: int, limit: int = 10) -> List[CheckHistory]:
with Session(self.engine) as session:
modpack_row = session.scalar(
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
)
if modpack_row is None:
return []
rows = session.scalars(
select(_CheckHistoryRow)
.where(_CheckHistoryRow.modpack_id == modpack_row.id)
.order_by(_CheckHistoryRow.checked_at.desc())
.limit(limit)
).all()
return [CheckHistory._from_row(r) for r in rows]
EOF
echo "Created database.py"

View File

@@ -0,0 +1,228 @@
# CLI Reference
Full reference for all `modpack-checker` commands and flags.
---
## Global Flags
| Flag | Description |
|---|---|
| `--version` | Show version and exit |
| `-h`, `--help` | Show help for any command |
---
## config
Manage configuration settings stored in `~/.config/modpack-checker/config.json`.
### config set-key
```
modpack-checker config set-key API_KEY
```
Save and validate a CurseForge API key. The key is tested against the API immediately; a warning is shown if validation fails but the key is still saved.
**Get a free key at:** https://console.curseforge.com
---
### config set-webhook
```
modpack-checker config set-webhook WEBHOOK_URL
```
Save a Discord webhook URL. A test embed is sent immediately to confirm the webhook works.
---
### config set-interval
```
modpack-checker config set-interval HOURS
```
Set how many hours the scheduler waits between checks. Accepts values 1168.
**Default:** 6
---
### config show
```
modpack-checker config show
```
Display the current configuration. The API key is masked (first 4 / last 4 characters shown).
---
## add
```
modpack-checker add MODPACK_ID
```
Add a CurseForge modpack to the watch list. The modpack name is fetched from the API and stored locally. Run `check` afterward to record the initial version.
**Arguments:**
| Argument | Type | Description |
|---|---|---|
| `MODPACK_ID` | int | CurseForge project ID (from the modpack's URL) |
**Example:**
```bash
modpack-checker add 238222 # All The Mods 9
```
---
## remove
```
modpack-checker remove MODPACK_ID
```
Remove a modpack and all its check history. Prompts for confirmation.
**Arguments:**
| Argument | Type | Description |
|---|---|---|
| `MODPACK_ID` | int | CurseForge project ID |
---
## list
```
modpack-checker list
```
Display all watched modpacks in a table showing:
- CurseForge ID
- Name
- Last known version
- Last check timestamp
- Whether Discord alerts are enabled
---
## check
```
modpack-checker check [OPTIONS]
```
Check watched modpacks against the CurseForge API and report on their status.
**Options:**
| Flag | Description |
|---|---|
| `--id INTEGER`, `-m INTEGER` | Check only this modpack ID |
| `--notify` / `--no-notify` | Send Discord notifications (default: on) |
**Output:**
- `✓` — up to date
- `↑` — update available (version shown)
- `→` — initial version recorded (first check)
- `✗` — API error
---
## status
```
modpack-checker status MODPACK_ID [OPTIONS]
```
Show a detailed panel for one modpack plus its check history.
**Arguments:**
| Argument | Type | Description |
|---|---|---|
| `MODPACK_ID` | int | CurseForge project ID |
**Options:**
| Flag | Default | Description |
|---|---|---|
| `--limit INTEGER`, `-n INTEGER` | 10 | Number of history entries to display |
---
## notifications
```
modpack-checker notifications MODPACK_ID [--enable | --disable]
```
Toggle Discord alerts for a specific modpack without removing it from the watch list.
**Options:**
| Flag | Description |
|---|---|
| `--enable` | Enable notifications (default) |
| `--disable` | Suppress notifications for this modpack |
---
## schedule
```
modpack-checker schedule [OPTIONS]
```
Start a blocking background process that checks for updates on a repeating interval. Requires the `[scheduler]` extra (`pip install "modpack-version-checker[scheduler]"`).
**Options:**
| Flag | Description |
|---|---|
| `--hours INTEGER`, `-h INTEGER` | Override the configured interval |
Runs a check immediately on startup, then repeats at the configured interval. Press `Ctrl-C` to stop.
---
## Configuration File
Location: `~/.config/modpack-checker/config.json`
```json
{
"curseforge_api_key": "your-key-here",
"discord_webhook_url": "https://discord.com/api/webhooks/...",
"database_path": "/home/user/.config/modpack-checker/modpacks.db",
"check_interval_hours": 6,
"notification_on_update": true
}
```
---
## Database
Location: `~/.config/modpack-checker/modpacks.db` (SQLite)
The database is managed automatically. To reset completely:
```bash
rm ~/.config/modpack-checker/modpacks.db
```
---
## Exit Codes
| Code | Meaning |
|---|---|
| `0` | Success |
| `1` | Error (API failure, modpack not found, missing config, etc.) |

View File

@@ -0,0 +1,166 @@
# Installation Guide
## Requirements
- Python 3.9 or newer
- pip (comes with Python)
- A free CurseForge API key
---
## Step 1 — Install Python
Verify Python is installed:
```bash
python3 --version
# Should show: Python 3.9.x or newer
```
If not installed, download from [python.org](https://www.python.org/downloads/).
---
## Step 2 — Install Modpack Version Checker
### Option A: pip (recommended)
```bash
pip install modpack-version-checker
```
### Option B: Install with scheduler support
```bash
pip install "modpack-version-checker[scheduler]"
```
This adds APScheduler for the `modpack-checker schedule` background daemon command.
### Option C: Install from source
```bash
git clone https://github.com/firefrostgaming/modpack-version-checker.git
cd modpack-version-checker
pip install -e ".[scheduler]"
```
---
## Step 3 — Get a CurseForge API Key (free)
1. Go to [console.curseforge.com](https://console.curseforge.com)
2. Log in or create a free account
3. Click **Create API Key**
4. Copy the key
---
## Step 4 — Configure
```bash
modpack-checker config set-key YOUR_API_KEY_HERE
```
The key is stored in `~/.config/modpack-checker/config.json`.
---
## Step 5 (Optional) — Set Up Discord Notifications
1. In your Discord server, go to **Channel Settings → Integrations → Webhooks**
2. Click **New Webhook** and copy the URL
3. Run:
```bash
modpack-checker config set-webhook https://discord.com/api/webhooks/YOUR_WEBHOOK_URL
```
A test message will be sent to confirm it works.
---
## Step 6 — Add Your First Modpack
Find your modpack's CurseForge project ID in the URL:
`https://www.curseforge.com/minecraft/modpacks/all-the-mods-9` → go to the page, the ID is in the sidebar.
```bash
modpack-checker add 238222 # All The Mods 9
modpack-checker add 361392 # RLCraft
```
---
## Step 7 — Check for Updates
```bash
modpack-checker check
```
---
## Background Scheduler (Optional)
Run continuous checks automatically:
```bash
# Check every 6 hours (default)
modpack-checker schedule
# Check every 12 hours
modpack-checker schedule --hours 12
```
To run as a Linux systemd service, create `/etc/systemd/system/modpack-checker.service`:
```ini
[Unit]
Description=Modpack Version Checker
After=network.target
[Service]
Type=simple
User=YOUR_USERNAME
ExecStart=/usr/local/bin/modpack-checker schedule --hours 6
Restart=on-failure
[Install]
WantedBy=multi-user.target
```
Then:
```bash
sudo systemctl enable --now modpack-checker
```
---
## Uninstall
```bash
pip uninstall modpack-version-checker
# Remove config and database (optional)
rm -rf ~/.config/modpack-checker/
```
---
## Troubleshooting
**`modpack-checker: command not found`**
- Make sure pip's script directory is in your PATH
- Try: `python3 -m modpack_checker.cli`
**`Invalid API key`**
- Double-check the key at [console.curseforge.com](https://console.curseforge.com)
- Ensure there are no extra spaces when setting it
**`Connection failed`**
- Check your internet connection
- CurseForge API may be temporarily down; try again in a few minutes
**`Modpack shows Unknown`**
- Verify the project ID is correct by checking the CurseForge page
- Some older modpacks have no files listed via the API

View File

@@ -0,0 +1,91 @@
# Modpack Version Checker
**Monitor CurseForge modpack versions and get instantly notified when updates are released.**
Stop manually checking CurseForge every day. Modpack Version Checker tracks your modpacks and fires a Discord alert the moment a new version drops — saving you 20+ minutes of daily maintenance.
---
## Features
- **Multi-modpack tracking** — watch as many packs as you need in a single database
- **Discord notifications** — rich embeds with old/new version info sent automatically
- **Version history** — full log of every check and what version was found
- **Per-modpack notification control** — silence specific packs without removing them
- **Built-in scheduler** — runs in the background and checks on a configurable interval
- **Manual override** — force a check any time with `modpack-checker check`
- **Graceful error handling** — API downtime shows clear messages, never crashes
---
## Quick Start
```bash
# 1. Install
pip install modpack-version-checker
# 2. Set your CurseForge API key (free at console.curseforge.com)
modpack-checker config set-key YOUR_API_KEY
# 3. Add a modpack (use its CurseForge project ID)
modpack-checker add 238222 # All The Mods 9
# 4. Check for updates
modpack-checker check
```
---
## Installation
See [INSTALLATION.md](INSTALLATION.md) for full setup instructions including optional Discord notifications and background scheduling.
---
## Commands
| Command | Description |
|---|---|
| `modpack-checker add <id>` | Add a modpack to the watch list |
| `modpack-checker remove <id>` | Remove a modpack from the watch list |
| `modpack-checker list` | Show all watched modpacks and versions |
| `modpack-checker check` | Check all modpacks for updates now |
| `modpack-checker check --id <id>` | Check a single modpack |
| `modpack-checker status <id>` | Show detailed info + check history |
| `modpack-checker notifications <id> --enable/--disable` | Toggle alerts per modpack |
| `modpack-checker schedule` | Start background scheduler |
| `modpack-checker config set-key <key>` | Save CurseForge API key |
| `modpack-checker config set-webhook <url>` | Save Discord webhook URL |
| `modpack-checker config set-interval <hours>` | Set check interval |
| `modpack-checker config show` | Display current configuration |
See [API.md](API.md) for full command reference with all flags.
---
## Pricing
| Tier | Price | Features |
|---|---|---|
| Standard | $9.99 | All features listed above |
One-time purchase. No subscriptions. Available on [BuiltByBit](https://builtbybit.com).
---
## Requirements
- Python 3.9 or newer
- A free CurseForge API key ([get one here](https://console.curseforge.com))
- Linux, macOS, or Windows
---
## Support
- Discord: [Firefrost Gaming Support Server]
- Response time: within 48 hours
---
*Built by Firefrost Gaming*

View File

@@ -0,0 +1,16 @@
# Core runtime dependencies
requests>=2.28.0
click>=8.1.0
rich>=13.0.0
pydantic>=2.0.0
sqlalchemy>=2.0.0
# Optional: background scheduler
# apscheduler>=3.10.0
# Development / testing
# pytest>=7.0.0
# pytest-cov>=4.0.0
# pytest-mock>=3.10.0
# responses>=0.23.0
# black>=23.0.0

View File

@@ -0,0 +1,15 @@
[tool:pytest]
testpaths = tests
pythonpath = src
addopts = --tb=short -q
[coverage:run]
source = modpack_checker
[coverage:report]
show_missing = True
skip_covered = False
[flake8]
max-line-length = 100
exclude = .git,__pycache__,build,dist

View File

@@ -0,0 +1,61 @@
"""Package setup for Modpack Version Checker."""
from pathlib import Path
from setuptools import find_packages, setup
long_description = (Path(__file__).parent / "docs" / "README.md").read_text(
encoding="utf-8"
)
setup(
name="modpack-version-checker",
version="1.0.0",
author="Firefrost Gaming",
author_email="support@firefrostgaming.com",
description="Monitor CurseForge modpack versions and get notified of updates",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://firefrostgaming.com",
license="MIT",
packages=find_packages(where="src"),
package_dir={"": "src"},
python_requires=">=3.9",
install_requires=[
"requests>=2.28.0",
"click>=8.1.0",
"rich>=13.0.0",
"pydantic>=2.0.0",
"sqlalchemy>=2.0.0",
],
extras_require={
"scheduler": ["apscheduler>=3.10.0"],
"dev": [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
"pytest-mock>=3.10.0",
"responses>=0.23.0",
"black>=23.0.0",
],
},
entry_points={
"console_scripts": [
"modpack-checker=modpack_checker.cli:main",
],
},
classifiers=[
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Games/Entertainment",
"Topic :: System :: Monitoring",
],
keywords="minecraft modpack curseforge version checker monitor",
)

View File

@@ -0,0 +1,4 @@
"""Modpack Version Checker - Monitor CurseForge modpack updates."""
__version__ = "1.0.0"
__author__ = "Firefrost Gaming"

View File

@@ -0,0 +1,565 @@
"""Click-based CLI — all user-facing commands."""
from __future__ import annotations
import sys
from typing import Optional
import click
from rich import box
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from . import __version__
from .config import Config
from .curseforge import (
CurseForgeAuthError,
CurseForgeClient,
CurseForgeError,
CurseForgeNotFoundError,
)
from .database import Database
from .notifier import DiscordNotifier, NotificationError
console = Console()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _load_db(cfg: Config) -> Database:
return Database(cfg.database_path)
def _require_client(cfg: Config) -> CurseForgeClient:
"""Return a configured API client, or exit with a helpful message."""
if not cfg.curseforge_api_key:
console.print("[red]Error:[/red] No CurseForge API key configured.")
console.print(
"Set one with: [bold]modpack-checker config set-key YOUR_KEY[/bold]"
)
console.print(
"Get a free key at: [dim]https://console.curseforge.com[/dim]"
)
sys.exit(1)
return CurseForgeClient(cfg.curseforge_api_key)
# ---------------------------------------------------------------------------
# Root group
# ---------------------------------------------------------------------------
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
@click.version_option(version=__version__, prog_name="modpack-checker")
def cli() -> None:
"""Modpack Version Checker — monitor CurseForge modpacks for updates.
\b
Quick start:
modpack-checker config set-key YOUR_CURSEFORGE_KEY
modpack-checker add 238222
modpack-checker check
"""
# ---------------------------------------------------------------------------
# config sub-group
# ---------------------------------------------------------------------------
@cli.group("config")
def config_group() -> None:
"""Manage configuration (API key, webhook URL, etc.)."""
@config_group.command("set-key")
@click.argument("api_key")
def config_set_key(api_key: str) -> None:
"""Save your CurseForge API key and validate it."""
cfg = Config.load()
cfg.curseforge_api_key = api_key.strip()
cfg.save()
client = CurseForgeClient(api_key)
with console.status("Validating API key with CurseForge…"):
valid = client.validate_api_key()
if valid:
console.print("[green]✓[/green] API key saved and validated.")
else:
console.print(
"[yellow]⚠[/yellow] API key saved, but validation failed. "
"Double-check at [dim]https://console.curseforge.com[/dim]"
)
@config_group.command("set-webhook")
@click.argument("webhook_url")
def config_set_webhook(webhook_url: str) -> None:
"""Save a Discord webhook URL and send a test message."""
cfg = Config.load()
cfg.discord_webhook_url = webhook_url.strip()
cfg.save()
notifier = DiscordNotifier(webhook_url)
try:
with console.status("Testing webhook…"):
notifier.test()
console.print("[green]✓[/green] Webhook saved and test message sent.")
except NotificationError as exc:
console.print(f"[yellow]⚠[/yellow] Webhook saved, but test failed: {exc}")
@config_group.command("set-interval")
@click.argument("hours", type=int)
def config_set_interval(hours: int) -> None:
"""Set how often the scheduler checks for updates (in hours, 1168)."""
if not 1 <= hours <= 168:
console.print("[red]Error:[/red] Interval must be between 1 and 168 hours.")
sys.exit(1)
cfg = Config.load()
cfg.check_interval_hours = hours
cfg.save()
console.print(f"[green]✓[/green] Check interval set to {hours} hour(s).")
@config_group.command("show")
def config_show() -> None:
"""Display the current configuration."""
cfg = Config.load()
key = cfg.curseforge_api_key
if key and len(key) > 8:
masked = f"{key[:4]}{'*' * (len(key) - 8)}{key[-4:]}"
elif key:
masked = "****"
else:
masked = "[red]Not configured[/red]"
table = Table(title="Configuration", box=box.ROUNDED, show_header=False)
table.add_column("Setting", style="cyan", min_width=22)
table.add_column("Value")
table.add_row("CurseForge API Key", masked)
table.add_row(
"Discord Webhook",
cfg.discord_webhook_url or "[dim]Not configured[/dim]",
)
table.add_row("Database", cfg.database_path)
table.add_row("Check Interval", f"{cfg.check_interval_hours} hour(s)")
table.add_row(
"Notifications",
"[green]On[/green]" if cfg.notification_on_update else "[red]Off[/red]",
)
console.print(table)
# ---------------------------------------------------------------------------
# add
# ---------------------------------------------------------------------------
@cli.command()
@click.argument("modpack_id", type=int)
def add(modpack_id: int) -> None:
"""Add a modpack to the watch list by its CurseForge project ID.
\b
Example:
modpack-checker add 238222 # All The Mods 9
"""
cfg = Config.load()
client = _require_client(cfg)
db = _load_db(cfg)
with console.status(f"Looking up modpack {modpack_id} on CurseForge…"):
try:
name = client.get_mod_name(modpack_id)
except CurseForgeNotFoundError:
console.print(
f"[red]Error:[/red] No modpack found with ID {modpack_id} on CurseForge."
)
sys.exit(1)
except CurseForgeAuthError as exc:
console.print(f"[red]Auth error:[/red] {exc}")
sys.exit(1)
except CurseForgeError as exc:
console.print(f"[red]API error:[/red] {exc}")
sys.exit(1)
try:
db.add_modpack(modpack_id, name)
except ValueError:
console.print(
f"[yellow]Already tracked:[/yellow] [bold]{name}[/bold] (ID: {modpack_id})"
)
return
console.print(
f"[green]✓[/green] Now tracking [bold]{name}[/bold] (ID: {modpack_id})"
)
console.print("Run [bold]modpack-checker check[/bold] to fetch the current version.")
# ---------------------------------------------------------------------------
# remove
# ---------------------------------------------------------------------------
@cli.command()
@click.argument("modpack_id", type=int)
@click.confirmation_option(prompt="Remove this modpack and all its history?")
def remove(modpack_id: int) -> None:
"""Remove a modpack from the watch list."""
cfg = Config.load()
db = _load_db(cfg)
modpack = db.get_modpack(modpack_id)
if modpack is None:
console.print(
f"[red]Error:[/red] Modpack ID {modpack_id} is not in your watch list."
)
sys.exit(1)
db.remove_modpack(modpack_id)
console.print(f"[green]✓[/green] Removed [bold]{modpack.name}[/bold].")
# ---------------------------------------------------------------------------
# list
# ---------------------------------------------------------------------------
@cli.command(name="list")
def list_modpacks() -> None:
"""Show all watched modpacks and their last known versions."""
cfg = Config.load()
db = _load_db(cfg)
modpacks = db.get_all_modpacks()
if not modpacks:
console.print("[dim]Watch list is empty.[/dim]")
console.print(
"Add a modpack with: [bold]modpack-checker add <curseforge-id>[/bold]"
)
return
table = Table(
title=f"Watched Modpacks ({len(modpacks)})",
box=box.ROUNDED,
)
table.add_column("CF ID", style="cyan", justify="right", no_wrap=True)
table.add_column("Name", style="bold white")
table.add_column("Current Version", style="green")
table.add_column("Last Checked", style="dim")
table.add_column("Alerts", justify="center")
for mp in modpacks:
last_checked = (
mp.last_checked.strftime("%Y-%m-%d %H:%M")
if mp.last_checked
else "[dim]Never[/dim]"
)
alerts = "[green]✓[/green]" if mp.notification_enabled else "[red]✗[/red]"
version = mp.current_version or "[dim]—[/dim]"
table.add_row(str(mp.curseforge_id), mp.name, version, last_checked, alerts)
console.print(table)
# ---------------------------------------------------------------------------
# check
# ---------------------------------------------------------------------------
@cli.command()
@click.option(
"--id", "-m", "modpack_id", type=int, default=None,
help="Check a single modpack by CurseForge ID.",
)
@click.option(
"--notify/--no-notify", default=True,
help="Send Discord notifications for updates found (default: on).",
)
def check(modpack_id: Optional[int], notify: bool) -> None:
"""Check all (or one) watched modpack(s) for updates."""
cfg = Config.load()
client = _require_client(cfg)
db = _load_db(cfg)
if modpack_id is not None:
target = db.get_modpack(modpack_id)
if target is None:
console.print(
f"[red]Error:[/red] Modpack ID {modpack_id} is not in your watch list."
)
sys.exit(1)
modpacks = [target]
else:
modpacks = db.get_all_modpacks()
if not modpacks:
console.print("[dim]No modpacks to check.[/dim]")
console.print(
"Add one with: [bold]modpack-checker add <curseforge-id>[/bold]"
)
return
notifier: Optional[DiscordNotifier] = None
if notify and cfg.discord_webhook_url and cfg.notification_on_update:
notifier = DiscordNotifier(cfg.discord_webhook_url)
updates = 0
errors = 0
for mp in modpacks:
with console.status(f"Checking [bold]{mp.name}[/bold]…"):
try:
file_obj = client.get_latest_file(mp.curseforge_id)
except CurseForgeNotFoundError:
console.print(
f" [red]✗[/red] {mp.name}: not found on CurseForge "
f"(ID: {mp.curseforge_id})"
)
errors += 1
continue
except CurseForgeError as exc:
console.print(f" [red]✗[/red] {mp.name}: API error — {exc}")
errors += 1
continue
if file_obj is None:
console.print(f" [yellow]⚠[/yellow] {mp.name}: no files found on CurseForge.")
errors += 1
continue
new_version = client.extract_version(file_obj)
notification_sent = False
if mp.current_version == new_version:
line = f"[green]✓[/green] {mp.name}: up to date ([bold]{new_version}[/bold])"
elif mp.current_version is None:
line = (
f"[cyan]→[/cyan] {mp.name}: "
f"initial version recorded as [bold]{new_version}[/bold]"
)
else:
updates += 1
line = (
f"[yellow]↑[/yellow] {mp.name}: "
f"[dim]{mp.current_version}[/dim] → [bold green]{new_version}[/bold green]"
)
if notifier and mp.notification_enabled:
try:
notifier.send_update(
mp.name, mp.curseforge_id, mp.current_version, new_version
)
notification_sent = True
line += " [dim](notified)[/dim]"
except NotificationError as exc:
line += f" [red](notification failed: {exc})[/red]"
db.update_version(mp.curseforge_id, new_version, notification_sent)
console.print(f" {line}")
# Summary line
console.print()
parts = []
if updates:
parts.append(f"[yellow]{updates} update(s) found[/yellow]")
if errors:
parts.append(f"[red]{errors} error(s)[/red]")
if not updates and not errors:
parts.append("[green]All modpacks are up to date.[/green]")
console.print(" ".join(parts))
# ---------------------------------------------------------------------------
# status
# ---------------------------------------------------------------------------
@cli.command()
@click.argument("modpack_id", type=int)
@click.option("--limit", "-n", default=10, show_default=True, help="History entries to show.")
def status(modpack_id: int, limit: int) -> None:
"""Show detailed status and check history for a modpack."""
cfg = Config.load()
db = _load_db(cfg)
mp = db.get_modpack(modpack_id)
if mp is None:
console.print(
f"[red]Error:[/red] Modpack ID {modpack_id} is not in your watch list."
)
sys.exit(1)
last_checked = (
mp.last_checked.strftime("%Y-%m-%d %H:%M UTC") if mp.last_checked else "Never"
)
notif_str = "[green]Enabled[/green]" if mp.notification_enabled else "[red]Disabled[/red]"
console.print(
Panel(
f"[bold white]{mp.name}[/bold white]\n"
f"CurseForge ID : [cyan]{mp.curseforge_id}[/cyan]\n"
f"Version : [green]{mp.current_version or 'Not checked yet'}[/green]\n"
f"Last Checked : [dim]{last_checked}[/dim]\n"
f"Notifications : {notif_str}",
title="Modpack Status",
border_style="cyan",
)
)
history = db.get_check_history(modpack_id, limit=limit)
if not history:
console.print("[dim]No check history yet.[/dim]")
return
table = Table(title=f"Check History (last {limit})", box=box.SIMPLE)
table.add_column("Timestamp", style="dim")
table.add_column("Version", style="green")
table.add_column("Notified", justify="center")
for entry in history:
notified = "[green]✓[/green]" if entry.notification_sent else "[dim]—[/dim]"
table.add_row(
entry.checked_at.strftime("%Y-%m-%d %H:%M"),
entry.version_found or "[dim]Unknown[/dim]",
notified,
)
console.print(table)
# ---------------------------------------------------------------------------
# notifications (toggle per-modpack alerts)
# ---------------------------------------------------------------------------
@cli.command()
@click.argument("modpack_id", type=int)
@click.option(
"--enable/--disable",
default=True,
help="Enable or disable Discord alerts for this modpack.",
)
def notifications(modpack_id: int, enable: bool) -> None:
"""Enable or disable Discord notifications for a specific modpack."""
cfg = Config.load()
db = _load_db(cfg)
mp = db.get_modpack(modpack_id)
if mp is None:
console.print(
f"[red]Error:[/red] Modpack ID {modpack_id} is not in your watch list."
)
sys.exit(1)
db.toggle_notifications(modpack_id, enable)
state = "[green]enabled[/green]" if enable else "[red]disabled[/red]"
console.print(
f"[green]✓[/green] Notifications {state} for [bold]{mp.name}[/bold]."
)
# ---------------------------------------------------------------------------
# schedule (background daemon)
# ---------------------------------------------------------------------------
@cli.command()
@click.option(
"--hours", "-h", "hours", type=int, default=None,
help="Override the configured check interval (hours).",
)
def schedule(hours: Optional[int]) -> None:
"""Run continuous background checks on a configurable interval.
Requires the [scheduler] extra: pip install modpack-version-checker[scheduler]
"""
try:
from apscheduler.schedulers.blocking import BlockingScheduler
except ImportError:
console.print("[red]Error:[/red] APScheduler is not installed.")
console.print(
"Install it with: [bold]pip install modpack-version-checker[scheduler][/bold]"
)
sys.exit(1)
cfg = Config.load()
interval = hours or cfg.check_interval_hours
def _run_check() -> None:
"""Inner function executed by the scheduler."""
client = _require_client(cfg)
db = _load_db(cfg)
modpacks = db.get_all_modpacks()
notifier: Optional[DiscordNotifier] = None
if cfg.discord_webhook_url and cfg.notification_on_update:
notifier = DiscordNotifier(cfg.discord_webhook_url)
for mp in modpacks:
try:
file_obj = client.get_latest_file(mp.curseforge_id)
if file_obj is None:
continue
new_version = client.extract_version(file_obj)
notification_sent = False
if (
mp.current_version is not None
and mp.current_version != new_version
and notifier
and mp.notification_enabled
):
try:
notifier.send_update(
mp.name, mp.curseforge_id, mp.current_version, new_version
)
notification_sent = True
except NotificationError:
pass
db.update_version(mp.curseforge_id, new_version, notification_sent)
except CurseForgeError:
pass # Log silently in daemon mode; don't crash the scheduler
scheduler = BlockingScheduler()
scheduler.add_job(_run_check, "interval", hours=interval)
console.print(
Panel(
f"Checking every [bold]{interval}[/bold] hour(s).\n"
"Press [bold]Ctrl-C[/bold] to stop.",
title="Modpack Checker — Scheduler Running",
border_style="green",
)
)
# Run immediately so the user gets instant feedback
_run_check()
try:
scheduler.start()
except KeyboardInterrupt:
console.print("\n[dim]Scheduler stopped.[/dim]")
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
cli()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,46 @@
"""Configuration management for Modpack Version Checker."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Optional
from pydantic import BaseModel, Field
CONFIG_DIR = Path.home() / ".config" / "modpack-checker"
CONFIG_FILE = CONFIG_DIR / "config.json"
DEFAULT_DB_PATH = str(CONFIG_DIR / "modpacks.db")
class Config(BaseModel):
"""Application configuration, persisted to ~/.config/modpack-checker/config.json."""
curseforge_api_key: str = ""
discord_webhook_url: Optional[str] = None
database_path: str = DEFAULT_DB_PATH
check_interval_hours: int = Field(default=6, ge=1, le=168)
notification_on_update: bool = True
@classmethod
def load(cls) -> "Config":
"""Load configuration from disk, returning defaults if not present."""
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE) as f:
data = json.load(f)
return cls(**data)
except (json.JSONDecodeError, ValueError):
# Corrupted config — fall back to defaults silently
return cls()
return cls()
def save(self) -> None:
"""Persist configuration to disk."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
with open(CONFIG_FILE, "w") as f:
json.dump(self.model_dump(), f, indent=2)
def is_configured(self) -> bool:
"""Return True if the minimum required config (API key) is present."""
return bool(self.curseforge_api_key)

View File

@@ -0,0 +1,192 @@
"""CurseForge API v1 client with error handling and retry logic."""
from __future__ import annotations
import time
from typing import Any, Dict, Optional
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
# ---------------------------------------------------------------------------
# Custom exceptions
# ---------------------------------------------------------------------------
class CurseForgeError(Exception):
"""Base exception for all CurseForge API errors."""
class CurseForgeAuthError(CurseForgeError):
"""API key is missing, invalid, or lacks required permissions."""
class CurseForgeNotFoundError(CurseForgeError):
"""The requested modpack or file does not exist."""
class CurseForgeRateLimitError(CurseForgeError):
"""The API rate limit has been exceeded (429)."""
# ---------------------------------------------------------------------------
# Client
# ---------------------------------------------------------------------------
class CurseForgeClient:
"""Thin wrapper around the CurseForge REST API v1.
Handles authentication, retries on transient errors, and translates
HTTP status codes into typed exceptions so callers never see raw
requests exceptions.
"""
BASE_URL = "https://api.curseforge.com"
_MINECRAFT_GAME_ID = 432 # used for API key validation
def __init__(self, api_key: str, timeout: int = 15) -> None:
self.api_key = api_key
self.timeout = timeout
self._session = requests.Session()
self._session.headers.update(
{
"x-api-key": api_key,
"Accept": "application/json",
"User-Agent": "ModpackVersionChecker/1.0 (Firefrost Gaming)",
}
)
# Retry on connection errors and server-side 5xx — never on 4xx
retry = Retry(
total=3,
backoff_factor=1.0,
status_forcelist=[500, 502, 503, 504],
allowed_methods=["GET"],
raise_on_status=False,
)
self._session.mount("https://", HTTPAdapter(max_retries=retry))
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
"""Perform a GET request and return parsed JSON.
Raises:
CurseForgeAuthError: on 401 / 403
CurseForgeNotFoundError: on 404
CurseForgeRateLimitError: on 429
CurseForgeError: on connection failure, timeout, or other HTTP error
"""
url = f"{self.BASE_URL}{path}"
try:
response = self._session.get(url, params=params, timeout=self.timeout)
except requests.ConnectionError as exc:
raise CurseForgeError(
f"Could not connect to CurseForge API. Check your internet connection."
) from exc
except requests.Timeout:
raise CurseForgeError(
f"CurseForge API timed out after {self.timeout}s. Try again later."
)
if response.status_code == 401:
raise CurseForgeAuthError(
"Invalid API key. Verify your key at https://console.curseforge.com"
)
if response.status_code == 403:
raise CurseForgeAuthError(
"API key lacks permission for this resource. "
"Ensure your key has 'Mods' read access."
)
if response.status_code == 404:
raise CurseForgeNotFoundError(f"Resource not found: {path}")
if response.status_code == 429:
raise CurseForgeRateLimitError(
"CurseForge rate limit exceeded (100 req/min). "
"Wait a moment and try again."
)
try:
response.raise_for_status()
except requests.HTTPError as exc:
raise CurseForgeError(
f"Unexpected API error ({response.status_code}): {response.text[:200]}"
) from exc
return response.json()
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def validate_api_key(self) -> bool:
"""Return True if the current API key is valid."""
try:
self._get(f"/v1/games/{self._MINECRAFT_GAME_ID}")
return True
except (CurseForgeAuthError, CurseForgeError):
return False
def get_mod(self, mod_id: int) -> Dict[str, Any]:
"""Fetch full modpack metadata by CurseForge project ID.
Raises:
CurseForgeNotFoundError: if the project ID does not exist.
"""
data = self._get(f"/v1/mods/{mod_id}")
return data["data"]
def get_mod_name(self, mod_id: int) -> str:
"""Return the display name for a modpack."""
mod = self.get_mod(mod_id)
return mod.get("name", f"Modpack {mod_id}")
def get_latest_file(self, mod_id: int) -> Optional[Dict[str, Any]]:
"""Return the most recent file object for a modpack.
Prefers the ``latestFiles`` field on the mod object (single request).
Falls back to querying the files endpoint if that is empty.
Returns None if no files are found.
"""
mod = self.get_mod(mod_id)
# Fast path: latestFiles is populated by CurseForge automatically
latest_files = mod.get("latestFiles", [])
if latest_files:
# Sort by fileDate descending to get the newest release
latest_files_sorted = sorted(
latest_files,
key=lambda f: f.get("fileDate", ""),
reverse=True,
)
return latest_files_sorted[0]
# Slow path: query the files endpoint
data = self._get(
f"/v1/mods/{mod_id}/files",
params={"pageSize": 1, "sortOrder": "desc"},
)
files = data.get("data", [])
return files[0] if files else None
def extract_version(self, file_obj: Dict[str, Any]) -> str:
"""Extract a human-readable version string from a CurseForge file object.
Priority: displayName → fileName → file ID fallback.
"""
display_name = (file_obj.get("displayName") or "").strip()
if display_name:
return display_name
file_name = (file_obj.get("fileName") or "").strip()
if file_name:
return file_name
return f"File ID {file_obj.get('id', 'unknown')}"

View File

@@ -0,0 +1,225 @@
"""SQLite database layer using SQLAlchemy 2.0 ORM."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import List, Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, create_engine, select
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship
# ---------------------------------------------------------------------------
# ORM models (internal — not exposed outside this module)
# ---------------------------------------------------------------------------
class _Base(DeclarativeBase):
pass
class _ModpackRow(_Base):
__tablename__ = "modpacks"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
curseforge_id: Mapped[int] = mapped_column(Integer, unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
current_version: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
last_checked: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
notification_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
history: Mapped[List["_CheckHistoryRow"]] = relationship(
back_populates="modpack", cascade="all, delete-orphan"
)
class _CheckHistoryRow(_Base):
__tablename__ = "check_history"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
modpack_id: Mapped[int] = mapped_column(ForeignKey("modpacks.id"), nullable=False)
checked_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
version_found: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
notification_sent: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
modpack: Mapped["_ModpackRow"] = relationship(back_populates="history")
# ---------------------------------------------------------------------------
# Public dataclasses (detached, safe to use outside a session)
# ---------------------------------------------------------------------------
@dataclass
class Modpack:
"""Read-only snapshot of a tracked modpack."""
id: int
curseforge_id: int
name: str
current_version: Optional[str]
last_checked: Optional[datetime]
notification_enabled: bool
@classmethod
def _from_row(cls, row: _ModpackRow) -> "Modpack":
return cls(
id=row.id,
curseforge_id=row.curseforge_id,
name=row.name,
current_version=row.current_version,
last_checked=row.last_checked,
notification_enabled=row.notification_enabled,
)
@dataclass
class CheckHistory:
"""Read-only snapshot of a single version-check record."""
id: int
modpack_id: int
checked_at: datetime
version_found: Optional[str]
notification_sent: bool
@classmethod
def _from_row(cls, row: _CheckHistoryRow) -> "CheckHistory":
return cls(
id=row.id,
modpack_id=row.modpack_id,
checked_at=row.checked_at,
version_found=row.version_found,
notification_sent=row.notification_sent,
)
# ---------------------------------------------------------------------------
# Database façade
# ---------------------------------------------------------------------------
class Database:
"""All database operations for the modpack tracker."""
def __init__(self, db_path: str) -> None:
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
self.engine = create_engine(f"sqlite:///{db_path}", echo=False)
_Base.metadata.create_all(self.engine)
# --- write operations ---------------------------------------------------
def add_modpack(self, curseforge_id: int, name: str) -> Modpack:
"""Add a new modpack to the watch list.
Raises:
ValueError: if the modpack is already being tracked.
"""
with Session(self.engine) as session:
existing = session.scalar(
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
)
if existing is not None:
raise ValueError(
f"Modpack ID {curseforge_id} is already being tracked."
)
row = _ModpackRow(
curseforge_id=curseforge_id,
name=name,
notification_enabled=True,
)
session.add(row)
session.commit()
return Modpack._from_row(row)
def remove_modpack(self, curseforge_id: int) -> bool:
"""Remove a modpack and its history. Returns False if not found."""
with Session(self.engine) as session:
row = session.scalar(
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
)
if row is None:
return False
session.delete(row)
session.commit()
return True
def update_version(
self,
curseforge_id: int,
version: str,
notification_sent: bool = False,
) -> None:
"""Record a new version check result, updating the cached version.
Raises:
ValueError: if the modpack is not in the database.
"""
with Session(self.engine) as session:
row = session.scalar(
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
)
if row is None:
raise ValueError(f"Modpack {curseforge_id} not found in database.")
row.current_version = version
row.last_checked = datetime.utcnow()
session.add(
_CheckHistoryRow(
modpack_id=row.id,
checked_at=datetime.utcnow(),
version_found=version,
notification_sent=notification_sent,
)
)
session.commit()
def toggle_notifications(self, curseforge_id: int, enabled: bool) -> bool:
"""Enable or disable Discord notifications for a modpack.
Returns False if modpack not found.
"""
with Session(self.engine) as session:
row = session.scalar(
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
)
if row is None:
return False
row.notification_enabled = enabled
session.commit()
return True
# --- read operations ----------------------------------------------------
def get_modpack(self, curseforge_id: int) -> Optional[Modpack]:
"""Return a single modpack, or None if not tracked."""
with Session(self.engine) as session:
row = session.scalar(
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
)
return Modpack._from_row(row) if row else None
def get_all_modpacks(self) -> List[Modpack]:
"""Return all tracked modpacks."""
with Session(self.engine) as session:
rows = session.scalars(select(_ModpackRow)).all()
return [Modpack._from_row(r) for r in rows]
def get_check_history(
self, curseforge_id: int, limit: int = 10
) -> List[CheckHistory]:
"""Return recent check history for a modpack, newest first."""
with Session(self.engine) as session:
modpack_row = session.scalar(
select(_ModpackRow).where(_ModpackRow.curseforge_id == curseforge_id)
)
if modpack_row is None:
return []
rows = session.scalars(
select(_CheckHistoryRow)
.where(_CheckHistoryRow.modpack_id == modpack_row.id)
.order_by(_CheckHistoryRow.checked_at.desc())
.limit(limit)
).all()
return [CheckHistory._from_row(r) for r in rows]

View File

@@ -0,0 +1,122 @@
"""Notification delivery — Discord webhooks and generic HTTP webhooks."""
from __future__ import annotations
from typing import Optional
import requests
class NotificationError(Exception):
"""Raised when a notification cannot be delivered."""
class DiscordNotifier:
"""Send modpack update alerts to a Discord channel via webhook.
Embeds are used so the messages look polished without additional
configuration on the user's side.
"""
_EMBED_COLOR_UPDATE = 0xF5A623 # amber — update available
_EMBED_COLOR_TEST = 0x43B581 # green — test / OK
def __init__(self, webhook_url: str, timeout: int = 10) -> None:
self.webhook_url = webhook_url
self.timeout = timeout
# ------------------------------------------------------------------
# Public helpers
# ------------------------------------------------------------------
def send_update(
self,
modpack_name: str,
curseforge_id: int,
old_version: Optional[str],
new_version: str,
) -> None:
"""Post an update-available embed to Discord.
Raises:
NotificationError: if the webhook request fails.
"""
embed = {
"title": "Modpack Update Available",
"color": self._EMBED_COLOR_UPDATE,
"url": f"https://www.curseforge.com/minecraft/modpacks/{curseforge_id}",
"fields": [
{
"name": "Modpack",
"value": modpack_name,
"inline": True,
},
{
"name": "CurseForge ID",
"value": str(curseforge_id),
"inline": True,
},
{
"name": "Previous Version",
"value": old_version or "Unknown",
"inline": False,
},
{
"name": "New Version",
"value": new_version,
"inline": False,
},
],
"footer": {
"text": "Modpack Version Checker \u2022 Firefrost Gaming",
},
}
self._post({"embeds": [embed]})
def test(self) -> None:
"""Send a simple test message to verify the webhook URL works.
Raises:
NotificationError: if the request fails.
"""
self._post(
{
"embeds": [
{
"title": "Webhook Connected",
"description": (
"Modpack Version Checker notifications are working correctly."
),
"color": self._EMBED_COLOR_TEST,
"footer": {"text": "Modpack Version Checker \u2022 Firefrost Gaming"},
}
]
}
)
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
def _post(self, payload: dict) -> None:
try:
response = requests.post(
self.webhook_url,
json=payload,
timeout=self.timeout,
)
# Discord returns 204 No Content on success
if response.status_code not in (200, 204):
raise NotificationError(
f"Discord returned HTTP {response.status_code}: {response.text[:200]}"
)
except requests.ConnectionError as exc:
raise NotificationError(
"Could not reach Discord. Check your internet connection."
) from exc
except requests.Timeout:
raise NotificationError(
f"Discord webhook timed out after {self.timeout}s."
)
except requests.RequestException as exc:
raise NotificationError(f"Webhook request failed: {exc}") from exc

View File

@@ -0,0 +1,11 @@
"""Shared pytest fixtures."""
import pytest
from modpack_checker.database import Database
@pytest.fixture
def db(tmp_path):
"""In-memory-equivalent SQLite database backed by a temp directory."""
return Database(str(tmp_path / "test.db"))

View File

@@ -0,0 +1,339 @@
"""Tests for cli.py using Click's test runner."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
import responses as responses_lib
from click.testing import CliRunner
from modpack_checker.cli import cli
from modpack_checker.curseforge import CurseForgeNotFoundError
@pytest.fixture
def runner():
return CliRunner()
@pytest.fixture
def mock_cfg(tmp_path):
"""Return a Config-like mock backed by a real temp database."""
cfg = MagicMock()
cfg.curseforge_api_key = "test-api-key"
cfg.database_path = str(tmp_path / "test.db")
cfg.discord_webhook_url = None
cfg.notification_on_update = True
cfg.check_interval_hours = 6
return cfg
# ---------------------------------------------------------------------------
# Root / help
# ---------------------------------------------------------------------------
def test_help(runner):
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0
assert "Modpack Version Checker" in result.output
def test_version(runner):
result = runner.invoke(cli, ["--version"])
assert result.exit_code == 0
assert "1.0.0" in result.output
# ---------------------------------------------------------------------------
# config show
# ---------------------------------------------------------------------------
def test_config_show(runner, mock_cfg):
mock_cfg.curseforge_api_key = "abcd1234efgh5678"
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["config", "show"])
assert result.exit_code == 0
assert "abcd" in result.output # first 4 chars
assert "5678" in result.output # last 4 chars
def test_config_show_no_key(runner, mock_cfg):
mock_cfg.curseforge_api_key = ""
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["config", "show"])
assert result.exit_code == 0
assert "Not configured" in result.output
# ---------------------------------------------------------------------------
# list
# ---------------------------------------------------------------------------
def test_list_empty(runner, mock_cfg):
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["list"])
assert result.exit_code == 0
assert "empty" in result.output.lower()
def test_list_with_modpacks(runner, mock_cfg, tmp_path):
from modpack_checker.database import Database
db = Database(str(tmp_path / "test.db"))
db.add_modpack(111, "Pack Alpha")
db.add_modpack(222, "Pack Beta")
mock_cfg.database_path = str(tmp_path / "test.db")
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["list"])
assert result.exit_code == 0
assert "Pack Alpha" in result.output
assert "Pack Beta" in result.output
# ---------------------------------------------------------------------------
# add
# ---------------------------------------------------------------------------
def test_add_requires_api_key(runner, mock_cfg):
mock_cfg.curseforge_api_key = ""
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["add", "12345"])
assert result.exit_code == 1
assert "API key" in result.output
@responses_lib.activate
def test_add_success(runner, mock_cfg):
responses_lib.add(
responses_lib.GET,
"https://api.curseforge.com/v1/mods/12345",
json={"data": {"id": 12345, "name": "Test Modpack", "latestFiles": []}},
status=200,
)
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["add", "12345"])
assert result.exit_code == 0
assert "Test Modpack" in result.output
assert "tracking" in result.output.lower()
@responses_lib.activate
def test_add_not_found(runner, mock_cfg):
responses_lib.add(
responses_lib.GET,
"https://api.curseforge.com/v1/mods/99999",
status=404,
)
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["add", "99999"])
assert result.exit_code == 1
assert "99999" in result.output
@responses_lib.activate
def test_add_duplicate_shows_warning(runner, mock_cfg, tmp_path):
responses_lib.add(
responses_lib.GET,
"https://api.curseforge.com/v1/mods/12345",
json={"data": {"id": 12345, "name": "Test Modpack", "latestFiles": []}},
status=200,
)
from modpack_checker.database import Database
db = Database(str(tmp_path / "test.db"))
db.add_modpack(12345, "Test Modpack")
mock_cfg.database_path = str(tmp_path / "test.db")
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["add", "12345"])
assert "already tracked" in result.output.lower()
# ---------------------------------------------------------------------------
# remove
# ---------------------------------------------------------------------------
def test_remove_not_in_list(runner, mock_cfg):
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["remove", "99999"], input="y\n")
assert result.exit_code == 1
assert "not in your watch list" in result.output
def test_remove_success(runner, mock_cfg, tmp_path):
from modpack_checker.database import Database
db = Database(str(tmp_path / "test.db"))
db.add_modpack(12345, "Test Pack")
mock_cfg.database_path = str(tmp_path / "test.db")
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["remove", "12345"], input="y\n")
assert result.exit_code == 0
assert "Removed" in result.output
def test_remove_aborted(runner, mock_cfg, tmp_path):
from modpack_checker.database import Database
db = Database(str(tmp_path / "test.db"))
db.add_modpack(12345, "Test Pack")
mock_cfg.database_path = str(tmp_path / "test.db")
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["remove", "12345"], input="n\n")
# Aborted — pack should still exist
assert db.get_modpack(12345) is not None
# ---------------------------------------------------------------------------
# check
# ---------------------------------------------------------------------------
def test_check_empty_list(runner, mock_cfg):
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["check"])
assert result.exit_code == 0
assert "No modpacks" in result.output
@responses_lib.activate
def test_check_up_to_date(runner, mock_cfg, tmp_path):
from modpack_checker.database import Database
db = Database(str(tmp_path / "test.db"))
db.add_modpack(12345, "Test Pack")
db.update_version(12345, "1.0.0")
responses_lib.add(
responses_lib.GET,
"https://api.curseforge.com/v1/mods/12345",
json={
"data": {
"id": 12345,
"name": "Test Pack",
"latestFiles": [
{
"id": 1,
"displayName": "1.0.0",
"fileName": "pack-1.0.0.zip",
"fileDate": "2026-01-01T00:00:00Z",
}
],
}
},
status=200,
)
mock_cfg.database_path = str(tmp_path / "test.db")
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["check", "--no-notify"])
assert result.exit_code == 0
assert "up to date" in result.output.lower()
@responses_lib.activate
def test_check_update_available(runner, mock_cfg, tmp_path):
from modpack_checker.database import Database
db = Database(str(tmp_path / "test.db"))
db.add_modpack(12345, "Test Pack")
db.update_version(12345, "1.0.0")
responses_lib.add(
responses_lib.GET,
"https://api.curseforge.com/v1/mods/12345",
json={
"data": {
"id": 12345,
"name": "Test Pack",
"latestFiles": [
{
"id": 2,
"displayName": "1.1.0",
"fileName": "pack-1.1.0.zip",
"fileDate": "2026-02-01T00:00:00Z",
}
],
}
},
status=200,
)
mock_cfg.database_path = str(tmp_path / "test.db")
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["check", "--no-notify"])
assert result.exit_code == 0
assert "1.1.0" in result.output
# ---------------------------------------------------------------------------
# status
# ---------------------------------------------------------------------------
def test_status_not_found(runner, mock_cfg):
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["status", "99999"])
assert result.exit_code == 1
assert "not in your watch list" in result.output
def test_status_shows_info(runner, mock_cfg, tmp_path):
from modpack_checker.database import Database
db = Database(str(tmp_path / "test.db"))
db.add_modpack(12345, "Test Pack")
db.update_version(12345, "2.0.0")
mock_cfg.database_path = str(tmp_path / "test.db")
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["status", "12345"])
assert result.exit_code == 0
assert "Test Pack" in result.output
assert "2.0.0" in result.output
# ---------------------------------------------------------------------------
# notifications command
# ---------------------------------------------------------------------------
def test_notifications_disable(runner, mock_cfg, tmp_path):
from modpack_checker.database import Database
db = Database(str(tmp_path / "test.db"))
db.add_modpack(12345, "Test Pack")
mock_cfg.database_path = str(tmp_path / "test.db")
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["notifications", "12345", "--disable"])
assert result.exit_code == 0
assert "disabled" in result.output
assert db.get_modpack(12345).notification_enabled is False
def test_notifications_enable(runner, mock_cfg, tmp_path):
from modpack_checker.database import Database
db = Database(str(tmp_path / "test.db"))
db.add_modpack(12345, "Test Pack")
db.toggle_notifications(12345, False)
mock_cfg.database_path = str(tmp_path / "test.db")
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["notifications", "12345", "--enable"])
assert result.exit_code == 0
assert "enabled" in result.output
assert db.get_modpack(12345).notification_enabled is True
def test_notifications_missing_modpack(runner, mock_cfg):
with patch("modpack_checker.cli.Config.load", return_value=mock_cfg):
result = runner.invoke(cli, ["notifications", "99999", "--enable"])
assert result.exit_code == 1

View File

@@ -0,0 +1,72 @@
"""Tests for config.py."""
import json
from pathlib import Path
from unittest.mock import patch
import pytest
from modpack_checker.config import Config
def test_default_values():
cfg = Config()
assert cfg.curseforge_api_key == ""
assert cfg.discord_webhook_url is None
assert cfg.check_interval_hours == 6
assert cfg.notification_on_update is True
def test_is_configured_without_key():
assert Config().is_configured() is False
def test_is_configured_with_key():
assert Config(curseforge_api_key="abc123").is_configured() is True
def test_save_and_load_round_trip(tmp_path):
config_dir = tmp_path / ".config" / "modpack-checker"
config_file = config_dir / "config.json"
with patch("modpack_checker.config.CONFIG_DIR", config_dir), \
patch("modpack_checker.config.CONFIG_FILE", config_file):
original = Config(
curseforge_api_key="test-key-xyz",
check_interval_hours=12,
notification_on_update=False,
)
original.save()
loaded = Config.load()
assert loaded.curseforge_api_key == "test-key-xyz"
assert loaded.check_interval_hours == 12
assert loaded.notification_on_update is False
def test_load_returns_defaults_when_file_missing(tmp_path):
config_file = tmp_path / "nonexistent.json"
with patch("modpack_checker.config.CONFIG_FILE", config_file):
cfg = Config.load()
assert cfg.curseforge_api_key == ""
def test_load_returns_defaults_on_corrupted_file(tmp_path):
config_dir = tmp_path / ".config" / "modpack-checker"
config_dir.mkdir(parents=True)
config_file = config_dir / "config.json"
config_file.write_text("{ this is not valid json }")
with patch("modpack_checker.config.CONFIG_DIR", config_dir), \
patch("modpack_checker.config.CONFIG_FILE", config_file):
cfg = Config.load()
assert cfg.curseforge_api_key == ""
def test_interval_bounds():
with pytest.raises(Exception):
Config(check_interval_hours=0)
with pytest.raises(Exception):
Config(check_interval_hours=169)

View File

@@ -0,0 +1,227 @@
"""Tests for curseforge.py."""
import pytest
import responses as responses_lib
from modpack_checker.curseforge import (
CurseForgeAuthError,
CurseForgeClient,
CurseForgeError,
CurseForgeNotFoundError,
CurseForgeRateLimitError,
)
BASE = "https://api.curseforge.com"
@pytest.fixture
def client():
return CurseForgeClient("test-api-key", timeout=5)
# ---------------------------------------------------------------------------
# get_mod
# ---------------------------------------------------------------------------
@responses_lib.activate
def test_get_mod_success(client):
responses_lib.add(
responses_lib.GET,
f"{BASE}/v1/mods/123456",
json={"data": {"id": 123456, "name": "Test Pack", "latestFiles": []}},
status=200,
)
mod = client.get_mod(123456)
assert mod["name"] == "Test Pack"
@responses_lib.activate
def test_get_mod_not_found(client):
responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/999", status=404)
with pytest.raises(CurseForgeNotFoundError):
client.get_mod(999)
@responses_lib.activate
def test_get_mod_invalid_key_401(client):
responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/123", status=401)
with pytest.raises(CurseForgeAuthError, match="Invalid API key"):
client.get_mod(123)
@responses_lib.activate
def test_get_mod_forbidden_403(client):
responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/123", status=403)
with pytest.raises(CurseForgeAuthError, match="permission"):
client.get_mod(123)
@responses_lib.activate
def test_get_mod_rate_limit_429(client):
responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/123", status=429)
with pytest.raises(CurseForgeRateLimitError):
client.get_mod(123)
@responses_lib.activate
def test_get_mod_server_error(client):
# responses library doesn't retry by default in tests; just test the exception
responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/123", status=500)
responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/123", status=500)
responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/123", status=500)
responses_lib.add(responses_lib.GET, f"{BASE}/v1/mods/123", status=500)
with pytest.raises(CurseForgeError):
client.get_mod(123)
# ---------------------------------------------------------------------------
# get_mod_name
# ---------------------------------------------------------------------------
@responses_lib.activate
def test_get_mod_name(client):
responses_lib.add(
responses_lib.GET,
f"{BASE}/v1/mods/100",
json={"data": {"id": 100, "name": "All The Mods 9", "latestFiles": []}},
status=200,
)
assert client.get_mod_name(100) == "All The Mods 9"
@responses_lib.activate
def test_get_mod_name_fallback(client):
"""If 'name' key is missing, return generic fallback."""
responses_lib.add(
responses_lib.GET,
f"{BASE}/v1/mods/100",
json={"data": {"id": 100, "latestFiles": []}},
status=200,
)
assert client.get_mod_name(100) == "Modpack 100"
# ---------------------------------------------------------------------------
# get_latest_file
# ---------------------------------------------------------------------------
@responses_lib.activate
def test_get_latest_file_uses_latest_files(client):
"""get_latest_file should prefer the latestFiles field (fast path)."""
responses_lib.add(
responses_lib.GET,
f"{BASE}/v1/mods/200",
json={
"data": {
"id": 200,
"name": "Pack",
"latestFiles": [
{
"id": 9001,
"displayName": "Pack 2.0.0",
"fileName": "pack-2.0.0.zip",
"fileDate": "2026-01-15T00:00:00Z",
},
{
"id": 9000,
"displayName": "Pack 1.0.0",
"fileName": "pack-1.0.0.zip",
"fileDate": "2025-12-01T00:00:00Z",
},
],
}
},
status=200,
)
file_obj = client.get_latest_file(200)
assert file_obj["displayName"] == "Pack 2.0.0"
@responses_lib.activate
def test_get_latest_file_fallback_files_endpoint(client):
"""Falls back to the /files endpoint when latestFiles is empty."""
responses_lib.add(
responses_lib.GET,
f"{BASE}/v1/mods/300",
json={"data": {"id": 300, "name": "Pack", "latestFiles": []}},
status=200,
)
responses_lib.add(
responses_lib.GET,
f"{BASE}/v1/mods/300/files",
json={
"data": [
{"id": 8000, "displayName": "Pack 3.0.0", "fileName": "pack-3.0.0.zip"}
]
},
status=200,
)
file_obj = client.get_latest_file(300)
assert file_obj["displayName"] == "Pack 3.0.0"
@responses_lib.activate
def test_get_latest_file_no_files_returns_none(client):
responses_lib.add(
responses_lib.GET,
f"{BASE}/v1/mods/400",
json={"data": {"id": 400, "name": "Pack", "latestFiles": []}},
status=200,
)
responses_lib.add(
responses_lib.GET,
f"{BASE}/v1/mods/400/files",
json={"data": []},
status=200,
)
assert client.get_latest_file(400) is None
# ---------------------------------------------------------------------------
# extract_version
# ---------------------------------------------------------------------------
def test_extract_version_prefers_display_name(client):
file_obj = {"displayName": "ATM9 1.2.3", "fileName": "atm9-1.2.3.zip", "id": 1}
assert client.extract_version(file_obj) == "ATM9 1.2.3"
def test_extract_version_falls_back_to_filename(client):
file_obj = {"displayName": "", "fileName": "pack-1.0.0.zip", "id": 1}
assert client.extract_version(file_obj) == "pack-1.0.0.zip"
def test_extract_version_last_resort_file_id(client):
file_obj = {"displayName": "", "fileName": "", "id": 9999}
assert client.extract_version(file_obj) == "File ID 9999"
def test_extract_version_strips_whitespace(client):
file_obj = {"displayName": " Pack 1.0 ", "fileName": "pack.zip", "id": 1}
assert client.extract_version(file_obj) == "Pack 1.0"
# ---------------------------------------------------------------------------
# validate_api_key
# ---------------------------------------------------------------------------
@responses_lib.activate
def test_validate_api_key_success(client):
responses_lib.add(
responses_lib.GET,
f"{BASE}/v1/games/432",
json={"data": {"id": 432, "name": "Minecraft"}},
status=200,
)
assert client.validate_api_key() is True
@responses_lib.activate
def test_validate_api_key_failure(client):
responses_lib.add(responses_lib.GET, f"{BASE}/v1/games/432", status=401)
assert client.validate_api_key() is False

View File

@@ -0,0 +1,174 @@
"""Tests for database.py."""
from datetime import datetime
import pytest
from modpack_checker.database import Database
# ---------------------------------------------------------------------------
# add_modpack
# ---------------------------------------------------------------------------
def test_add_modpack_returns_correct_fields(db):
mp = db.add_modpack(12345, "Test Pack")
assert mp.curseforge_id == 12345
assert mp.name == "Test Pack"
assert mp.current_version is None
assert mp.last_checked is None
assert mp.notification_enabled is True
def test_add_modpack_duplicate_raises(db):
db.add_modpack(12345, "Test Pack")
with pytest.raises(ValueError, match="already being tracked"):
db.add_modpack(12345, "Test Pack Again")
# ---------------------------------------------------------------------------
# remove_modpack
# ---------------------------------------------------------------------------
def test_remove_modpack_returns_true(db):
db.add_modpack(12345, "Test Pack")
assert db.remove_modpack(12345) is True
def test_remove_modpack_missing_returns_false(db):
assert db.remove_modpack(99999) is False
def test_remove_modpack_deletes_record(db):
db.add_modpack(12345, "Test Pack")
db.remove_modpack(12345)
assert db.get_modpack(12345) is None
# ---------------------------------------------------------------------------
# get_modpack
# ---------------------------------------------------------------------------
def test_get_modpack_found(db):
db.add_modpack(111, "Pack A")
mp = db.get_modpack(111)
assert mp is not None
assert mp.name == "Pack A"
def test_get_modpack_not_found(db):
assert db.get_modpack(999) is None
# ---------------------------------------------------------------------------
# get_all_modpacks
# ---------------------------------------------------------------------------
def test_get_all_modpacks_empty(db):
assert db.get_all_modpacks() == []
def test_get_all_modpacks_multiple(db):
db.add_modpack(1, "Pack A")
db.add_modpack(2, "Pack B")
db.add_modpack(3, "Pack C")
modpacks = db.get_all_modpacks()
assert len(modpacks) == 3
ids = {mp.curseforge_id for mp in modpacks}
assert ids == {1, 2, 3}
# ---------------------------------------------------------------------------
# update_version
# ---------------------------------------------------------------------------
def test_update_version_sets_fields(db):
db.add_modpack(12345, "Test Pack")
db.update_version(12345, "1.2.3", notification_sent=True)
mp = db.get_modpack(12345)
assert mp.current_version == "1.2.3"
assert mp.last_checked is not None
def test_update_version_nonexistent_raises(db):
with pytest.raises(ValueError, match="not found"):
db.update_version(99999, "1.0.0")
def test_update_version_multiple_times(db):
db.add_modpack(12345, "Test Pack")
db.update_version(12345, "1.0.0")
db.update_version(12345, "1.1.0")
mp = db.get_modpack(12345)
assert mp.current_version == "1.1.0"
# ---------------------------------------------------------------------------
# get_check_history
# ---------------------------------------------------------------------------
def test_check_history_newest_first(db):
db.add_modpack(12345, "Test Pack")
db.update_version(12345, "1.0.0", notification_sent=False)
db.update_version(12345, "1.1.0", notification_sent=True)
history = db.get_check_history(12345, limit=10)
assert len(history) == 2
assert history[0].version_found == "1.1.0"
assert history[0].notification_sent is True
assert history[1].version_found == "1.0.0"
def test_check_history_limit(db):
db.add_modpack(12345, "Test Pack")
for i in range(5):
db.update_version(12345, f"1.{i}.0")
history = db.get_check_history(12345, limit=3)
assert len(history) == 3
def test_check_history_missing_modpack(db):
assert db.get_check_history(99999) == []
# ---------------------------------------------------------------------------
# toggle_notifications
# ---------------------------------------------------------------------------
def test_toggle_notifications_disable(db):
db.add_modpack(12345, "Test Pack")
result = db.toggle_notifications(12345, False)
assert result is True
mp = db.get_modpack(12345)
assert mp.notification_enabled is False
def test_toggle_notifications_re_enable(db):
db.add_modpack(12345, "Test Pack")
db.toggle_notifications(12345, False)
db.toggle_notifications(12345, True)
assert db.get_modpack(12345).notification_enabled is True
def test_toggle_notifications_missing(db):
assert db.toggle_notifications(99999, True) is False
# ---------------------------------------------------------------------------
# cascade delete
# ---------------------------------------------------------------------------
def test_remove_modpack_also_removes_history(db):
db.add_modpack(12345, "Test Pack")
db.update_version(12345, "1.0.0")
db.update_version(12345, "1.1.0")
db.remove_modpack(12345)
# History should be gone (cascade delete)
assert db.get_check_history(12345) == []

View File

@@ -0,0 +1,83 @@
"""Tests for notifier.py."""
import pytest
import responses as responses_lib
from modpack_checker.notifier import DiscordNotifier, NotificationError
WEBHOOK_URL = "https://discord.com/api/webhooks/123456/abcdef"
@pytest.fixture
def notifier():
return DiscordNotifier(WEBHOOK_URL, timeout=5)
# ---------------------------------------------------------------------------
# send_update
# ---------------------------------------------------------------------------
@responses_lib.activate
def test_send_update_success(notifier):
responses_lib.add(responses_lib.POST, WEBHOOK_URL, status=204)
# Should not raise
notifier.send_update("Test Pack", 12345, "1.0.0", "1.1.0")
@responses_lib.activate
def test_send_update_initial_version(notifier):
"""old_version=None should be handled gracefully."""
responses_lib.add(responses_lib.POST, WEBHOOK_URL, status=204)
notifier.send_update("Test Pack", 12345, None, "1.0.0")
@responses_lib.activate
def test_send_update_bad_response_raises(notifier):
responses_lib.add(responses_lib.POST, WEBHOOK_URL, status=400, body="Bad Request")
with pytest.raises(NotificationError, match="HTTP 400"):
notifier.send_update("Test Pack", 12345, "1.0.0", "1.1.0")
@responses_lib.activate
def test_send_update_unauthorized_raises(notifier):
responses_lib.add(responses_lib.POST, WEBHOOK_URL, status=401)
with pytest.raises(NotificationError):
notifier.send_update("Test Pack", 12345, "1.0.0", "1.1.0")
# ---------------------------------------------------------------------------
# test
# ---------------------------------------------------------------------------
@responses_lib.activate
def test_test_webhook_success(notifier):
responses_lib.add(responses_lib.POST, WEBHOOK_URL, status=204)
notifier.test() # Should not raise
@responses_lib.activate
def test_test_webhook_failure_raises(notifier):
responses_lib.add(responses_lib.POST, WEBHOOK_URL, status=404)
with pytest.raises(NotificationError):
notifier.test()
# ---------------------------------------------------------------------------
# embed structure
# ---------------------------------------------------------------------------
@responses_lib.activate
def test_send_update_embed_contains_modpack_name(notifier):
"""Verify the correct embed payload is sent to Discord."""
responses_lib.add(responses_lib.POST, WEBHOOK_URL, status=204)
notifier.send_update("All The Mods 9", 238222, "0.2.0", "0.3.0")
assert len(responses_lib.calls) == 1
raw_body = responses_lib.calls[0].request.body
payload = raw_body.decode("utf-8") if isinstance(raw_body, bytes) else raw_body
assert "All The Mods 9" in payload
assert "238222" in payload
assert "0.3.0" in payload