From 04e9b407d5dda47e2ac40ff04bbd247e7fdd64d4 Mon Sep 17 00:00:00 2001 From: "Claude (The Golden Chronicler #50)" Date: Tue, 31 Mar 2026 21:52:42 +0000 Subject: [PATCH] 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 --- services/arbiter/.env.example | 34 + services/arbiter/CHANGELOG.md | 176 +++++ services/arbiter/DEPLOYMENT.md | 578 +++++++++++++++ services/arbiter/IMPLEMENTATION-SUMMARY.md | 448 ++++++++++++ services/arbiter/README.md | 465 ++++++++++++ services/arbiter/TROUBLESHOOTING.md | 666 ++++++++++++++++++ services/arbiter/VERSION | 1 + services/arbiter/backup.sh | 33 + services/arbiter/config/roles.json | 12 + services/arbiter/deploy/arbiter.service | 24 + services/arbiter/nginx.conf | 64 ++ services/arbiter/package.json | 31 + services/arbiter/src/cmsService.js | 57 ++ services/arbiter/src/database.js | 46 ++ services/arbiter/src/discordService.js | 104 +++ services/arbiter/src/email.js | 49 ++ services/arbiter/src/index.js | 101 +++ services/arbiter/src/middleware/auth.js | 27 + .../arbiter/src/middleware/validateWebhook.js | 33 + .../arbiter/src/middleware/verifyWebhook.js | 35 + services/arbiter/src/routes/admin.js | 79 +++ services/arbiter/src/routes/adminAuth.js | 55 ++ services/arbiter/src/routes/oauth.js | 92 +++ services/arbiter/src/routes/webhook.js | 62 ++ services/arbiter/src/utils/templates.js | 65 ++ services/arbiter/src/views/admin.html | 188 +++++ services/modpack-version-checker/LICENSE | 21 + .../create_all_files.sh | 183 +++++ services/modpack-version-checker/docs/API.md | 228 ++++++ .../docs/INSTALLATION.md | 166 +++++ .../modpack-version-checker/docs/README.md | 91 +++ .../modpack-version-checker/requirements.txt | 16 + services/modpack-version-checker/setup.cfg | 15 + services/modpack-version-checker/setup.py | 61 ++ .../src/modpack_checker/__init__.py | 4 + .../src/modpack_checker/cli.py | 565 +++++++++++++++ .../src/modpack_checker/config.py | 46 ++ .../src/modpack_checker/curseforge.py | 192 +++++ .../src/modpack_checker/database.py | 225 ++++++ .../src/modpack_checker/notifier.py | 122 ++++ .../modpack-version-checker/tests/__init__.py | 0 .../modpack-version-checker/tests/conftest.py | 11 + .../modpack-version-checker/tests/test_cli.py | 339 +++++++++ .../tests/test_config.py | 72 ++ .../tests/test_curseforge.py | 227 ++++++ .../tests/test_database.py | 174 +++++ .../tests/test_notifier.py | 83 +++ 47 files changed, 6366 insertions(+) create mode 100644 services/arbiter/.env.example create mode 100644 services/arbiter/CHANGELOG.md create mode 100644 services/arbiter/DEPLOYMENT.md create mode 100644 services/arbiter/IMPLEMENTATION-SUMMARY.md create mode 100644 services/arbiter/README.md create mode 100644 services/arbiter/TROUBLESHOOTING.md create mode 100644 services/arbiter/VERSION create mode 100644 services/arbiter/backup.sh create mode 100644 services/arbiter/config/roles.json create mode 100644 services/arbiter/deploy/arbiter.service create mode 100644 services/arbiter/nginx.conf create mode 100644 services/arbiter/package.json create mode 100644 services/arbiter/src/cmsService.js create mode 100644 services/arbiter/src/database.js create mode 100644 services/arbiter/src/discordService.js create mode 100644 services/arbiter/src/email.js create mode 100644 services/arbiter/src/index.js create mode 100644 services/arbiter/src/middleware/auth.js create mode 100644 services/arbiter/src/middleware/validateWebhook.js create mode 100644 services/arbiter/src/middleware/verifyWebhook.js create mode 100644 services/arbiter/src/routes/admin.js create mode 100644 services/arbiter/src/routes/adminAuth.js create mode 100644 services/arbiter/src/routes/oauth.js create mode 100644 services/arbiter/src/routes/webhook.js create mode 100644 services/arbiter/src/utils/templates.js create mode 100644 services/arbiter/src/views/admin.html create mode 100644 services/modpack-version-checker/LICENSE create mode 100755 services/modpack-version-checker/create_all_files.sh create mode 100644 services/modpack-version-checker/docs/API.md create mode 100644 services/modpack-version-checker/docs/INSTALLATION.md create mode 100644 services/modpack-version-checker/docs/README.md create mode 100644 services/modpack-version-checker/requirements.txt create mode 100644 services/modpack-version-checker/setup.cfg create mode 100644 services/modpack-version-checker/setup.py create mode 100644 services/modpack-version-checker/src/modpack_checker/__init__.py create mode 100644 services/modpack-version-checker/src/modpack_checker/cli.py create mode 100644 services/modpack-version-checker/src/modpack_checker/config.py create mode 100644 services/modpack-version-checker/src/modpack_checker/curseforge.py create mode 100644 services/modpack-version-checker/src/modpack_checker/database.py create mode 100644 services/modpack-version-checker/src/modpack_checker/notifier.py create mode 100644 services/modpack-version-checker/tests/__init__.py create mode 100644 services/modpack-version-checker/tests/conftest.py create mode 100644 services/modpack-version-checker/tests/test_cli.py create mode 100644 services/modpack-version-checker/tests/test_config.py create mode 100644 services/modpack-version-checker/tests/test_curseforge.py create mode 100644 services/modpack-version-checker/tests/test_database.py create mode 100644 services/modpack-version-checker/tests/test_notifier.py diff --git a/services/arbiter/.env.example b/services/arbiter/.env.example new file mode 100644 index 0000000..857606f --- /dev/null +++ b/services/arbiter/.env.example @@ -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 diff --git a/services/arbiter/CHANGELOG.md b/services/arbiter/CHANGELOG.md new file mode 100644 index 0000000..e6bf8e6 --- /dev/null +++ b/services/arbiter/CHANGELOG.md @@ -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.* diff --git a/services/arbiter/DEPLOYMENT.md b/services/arbiter/DEPLOYMENT.md new file mode 100644 index 0000000..f762706 --- /dev/null +++ b/services/arbiter/DEPLOYMENT.md @@ -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 /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= + +DISCORD_BOT_TOKEN= +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +GUILD_ID= + +ADMIN_DISCORD_IDS=,, + +CMS_URL=https://firefrostgaming.com +CMS_ADMIN_KEY= + +SMTP_HOST=38.68.14.188 +SMTP_USER=noreply@firefrostgaming.com +SMTP_PASS= + +WEBHOOK_SECRET= +``` + +**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 ` +- `[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= +``` + +**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: " \ + -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 πŸ’™** diff --git a/services/arbiter/IMPLEMENTATION-SUMMARY.md b/services/arbiter/IMPLEMENTATION-SUMMARY.md new file mode 100644 index 0000000..a7b75b8 --- /dev/null +++ b/services/arbiter/IMPLEMENTATION-SUMMARY.md @@ -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** diff --git a/services/arbiter/README.md b/services/arbiter/README.md new file mode 100644 index 0000000..fc16433 --- /dev/null +++ b/services/arbiter/README.md @@ -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 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 πŸ’™** diff --git a/services/arbiter/TROUBLESHOOTING.md b/services/arbiter/TROUBLESHOOTING.md new file mode 100644 index 0000000..1ebc9e5 --- /dev/null +++ b/services/arbiter/TROUBLESHOOTING.md @@ -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 " \ + "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=` + +**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 +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. πŸ’™** diff --git a/services/arbiter/VERSION b/services/arbiter/VERSION new file mode 100644 index 0000000..227cea2 --- /dev/null +++ b/services/arbiter/VERSION @@ -0,0 +1 @@ +2.0.0 diff --git a/services/arbiter/backup.sh b/services/arbiter/backup.sh new file mode 100644 index 0000000..9d427d5 --- /dev/null +++ b/services/arbiter/backup.sh @@ -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" diff --git a/services/arbiter/config/roles.json b/services/arbiter/config/roles.json new file mode 100644 index 0000000..1912b18 --- /dev/null +++ b/services/arbiter/config/roles.json @@ -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" +} diff --git a/services/arbiter/deploy/arbiter.service b/services/arbiter/deploy/arbiter.service new file mode 100644 index 0000000..1fb7a88 --- /dev/null +++ b/services/arbiter/deploy/arbiter.service @@ -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 diff --git a/services/arbiter/nginx.conf b/services/arbiter/nginx.conf new file mode 100644 index 0000000..6a46493 --- /dev/null +++ b/services/arbiter/nginx.conf @@ -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; + } +} diff --git a/services/arbiter/package.json b/services/arbiter/package.json new file mode 100644 index 0000000..1ad74e1 --- /dev/null +++ b/services/arbiter/package.json @@ -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" + } +} diff --git a/services/arbiter/src/cmsService.js b/services/arbiter/src/cmsService.js new file mode 100644 index 0000000..06faf8f --- /dev/null +++ b/services/arbiter/src/cmsService.js @@ -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} - 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} - 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 +}; diff --git a/services/arbiter/src/database.js b/services/arbiter/src/database.js new file mode 100644 index 0000000..7d2dcda --- /dev/null +++ b/services/arbiter/src/database.js @@ -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; diff --git a/services/arbiter/src/discordService.js b/services/arbiter/src/discordService.js new file mode 100644 index 0000000..0e3069e --- /dev/null +++ b/services/arbiter/src/discordService.js @@ -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} - 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} - 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 +}; diff --git a/services/arbiter/src/email.js b/services/arbiter/src/email.js new file mode 100644 index 0000000..f13c6f7 --- /dev/null +++ b/services/arbiter/src/email.js @@ -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 }; diff --git a/services/arbiter/src/index.js b/services/arbiter/src/index.js new file mode 100644 index 0000000..51bd32a --- /dev/null +++ b/services/arbiter/src/index.js @@ -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); +}); diff --git a/services/arbiter/src/middleware/auth.js b/services/arbiter/src/middleware/auth.js new file mode 100644 index 0000000..4b8c82a --- /dev/null +++ b/services/arbiter/src/middleware/auth.js @@ -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 }; diff --git a/services/arbiter/src/middleware/validateWebhook.js b/services/arbiter/src/middleware/validateWebhook.js new file mode 100644 index 0000000..4a0a2a1 --- /dev/null +++ b/services/arbiter/src/middleware/validateWebhook.js @@ -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; diff --git a/services/arbiter/src/middleware/verifyWebhook.js b/services/arbiter/src/middleware/verifyWebhook.js new file mode 100644 index 0000000..55c3bee --- /dev/null +++ b/services/arbiter/src/middleware/verifyWebhook.js @@ -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; diff --git a/services/arbiter/src/routes/admin.js b/services/arbiter/src/routes/admin.js new file mode 100644 index 0000000..8e11d1d --- /dev/null +++ b/services/arbiter/src/routes/admin.js @@ -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; diff --git a/services/arbiter/src/routes/adminAuth.js b/services/arbiter/src/routes/adminAuth.js new file mode 100644 index 0000000..080c96f --- /dev/null +++ b/services/arbiter/src/routes/adminAuth.js @@ -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; diff --git a/services/arbiter/src/routes/oauth.js b/services/arbiter/src/routes/oauth.js new file mode 100644 index 0000000..3405a6f --- /dev/null +++ b/services/arbiter/src/routes/oauth.js @@ -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; diff --git a/services/arbiter/src/routes/webhook.js b/services/arbiter/src/routes/webhook.js new file mode 100644 index 0000000..586c71c --- /dev/null +++ b/services/arbiter/src/routes/webhook.js @@ -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; diff --git a/services/arbiter/src/utils/templates.js b/services/arbiter/src/utils/templates.js new file mode 100644 index 0000000..e199dc7 --- /dev/null +++ b/services/arbiter/src/utils/templates.js @@ -0,0 +1,65 @@ +// src/utils/templates.js +// HTML templates for user-facing success and error pages + +const baseHtml = (title, content) => ` + + + + + + ${title} - Firefrost Gaming + + + +
+
+ ${content} +
+
+ +`; + +const getSuccessPage = () => baseHtml('Success', ` +

πŸ”₯ Account Linked Successfully! ❄️

+

Your Discord account has been connected and your roles are assigned.

+

You can close this window and head back to Discord to see your new channels!

+`); + +const getExpiredPage = () => baseHtml('Link Expired', ` +

⏳ This Link Has Expired

+

For security, linking URLs expire after 24 hours.

+

Please log in to the website to request a new Discord linking email, or contact support.

+`); + +const getInvalidPage = () => baseHtml('Invalid Link', ` +

❌ Invalid Link

+

We couldn't recognize this secure token. The URL might be malformed or incomplete.

+

Please make sure you copied the entire link from your email.

+`); + +const getUsedPage = () => baseHtml('Already Linked', ` +

βœ… Already Linked

+

This specific token has already been used to link an account.

+

If you do not see your roles in Discord, please open a support ticket.

+`); + +const getServerErrPage = () => baseHtml('System Error', ` +

⚠️ System Error

+

Something went wrong communicating with the Discord or CMS servers.

+

Please try clicking the link in your email again in a few minutes.

+`); + +const getNotInServerPage = () => baseHtml('Join Server First', ` +

πŸ‘‹ One Quick Thing...

+

It looks like you aren't in our Discord server yet!

+

Please click here to join the server, then click the secure link in your email again to receive your roles.

+`); + +module.exports = { + getSuccessPage, + getExpiredPage, + getInvalidPage, + getUsedPage, + getServerErrPage, + getNotInServerPage +}; diff --git a/services/arbiter/src/views/admin.html b/services/arbiter/src/views/admin.html new file mode 100644 index 0000000..7d04dc2 --- /dev/null +++ b/services/arbiter/src/views/admin.html @@ -0,0 +1,188 @@ + + + + + + Admin Panel - Firefrost Gaming + + + +
+ + + +
+

Search User

+
+ + +
+
+
+ + +
+

Manual Role Assignment

+
+ + + + + + + + +
+
+ + +
+

Recent Actions (Audit Log)

+
+ + + + + + + + + + + + + +
TimestampAdmin IDTarget UserActionReason
Loading...
+
+
+
+ + + + diff --git a/services/modpack-version-checker/LICENSE b/services/modpack-version-checker/LICENSE new file mode 100644 index 0000000..039ce79 --- /dev/null +++ b/services/modpack-version-checker/LICENSE @@ -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. diff --git a/services/modpack-version-checker/create_all_files.sh b/services/modpack-version-checker/create_all_files.sh new file mode 100755 index 0000000..210ca0f --- /dev/null +++ b/services/modpack-version-checker/create_all_files.sh @@ -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" + diff --git a/services/modpack-version-checker/docs/API.md b/services/modpack-version-checker/docs/API.md new file mode 100644 index 0000000..7e9abd7 --- /dev/null +++ b/services/modpack-version-checker/docs/API.md @@ -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 1–168. + +**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.) | diff --git a/services/modpack-version-checker/docs/INSTALLATION.md b/services/modpack-version-checker/docs/INSTALLATION.md new file mode 100644 index 0000000..eb59a92 --- /dev/null +++ b/services/modpack-version-checker/docs/INSTALLATION.md @@ -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 diff --git a/services/modpack-version-checker/docs/README.md b/services/modpack-version-checker/docs/README.md new file mode 100644 index 0000000..979f9cd --- /dev/null +++ b/services/modpack-version-checker/docs/README.md @@ -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 ` | Add a modpack to the watch list | +| `modpack-checker remove ` | 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 ` | Check a single modpack | +| `modpack-checker status ` | Show detailed info + check history | +| `modpack-checker notifications --enable/--disable` | Toggle alerts per modpack | +| `modpack-checker schedule` | Start background scheduler | +| `modpack-checker config set-key ` | Save CurseForge API key | +| `modpack-checker config set-webhook ` | Save Discord webhook URL | +| `modpack-checker config set-interval ` | 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* diff --git a/services/modpack-version-checker/requirements.txt b/services/modpack-version-checker/requirements.txt new file mode 100644 index 0000000..86d48c1 --- /dev/null +++ b/services/modpack-version-checker/requirements.txt @@ -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 diff --git a/services/modpack-version-checker/setup.cfg b/services/modpack-version-checker/setup.cfg new file mode 100644 index 0000000..d2e4956 --- /dev/null +++ b/services/modpack-version-checker/setup.cfg @@ -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 diff --git a/services/modpack-version-checker/setup.py b/services/modpack-version-checker/setup.py new file mode 100644 index 0000000..ae17b92 --- /dev/null +++ b/services/modpack-version-checker/setup.py @@ -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", +) diff --git a/services/modpack-version-checker/src/modpack_checker/__init__.py b/services/modpack-version-checker/src/modpack_checker/__init__.py new file mode 100644 index 0000000..7905747 --- /dev/null +++ b/services/modpack-version-checker/src/modpack_checker/__init__.py @@ -0,0 +1,4 @@ +"""Modpack Version Checker - Monitor CurseForge modpack updates.""" + +__version__ = "1.0.0" +__author__ = "Firefrost Gaming" diff --git a/services/modpack-version-checker/src/modpack_checker/cli.py b/services/modpack-version-checker/src/modpack_checker/cli.py new file mode 100644 index 0000000..42d18c8 --- /dev/null +++ b/services/modpack-version-checker/src/modpack_checker/cli.py @@ -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, 1–168).""" + 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 [/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 [/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() diff --git a/services/modpack-version-checker/src/modpack_checker/config.py b/services/modpack-version-checker/src/modpack_checker/config.py new file mode 100644 index 0000000..bfebf66 --- /dev/null +++ b/services/modpack-version-checker/src/modpack_checker/config.py @@ -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) diff --git a/services/modpack-version-checker/src/modpack_checker/curseforge.py b/services/modpack-version-checker/src/modpack_checker/curseforge.py new file mode 100644 index 0000000..32f48f3 --- /dev/null +++ b/services/modpack-version-checker/src/modpack_checker/curseforge.py @@ -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')}" diff --git a/services/modpack-version-checker/src/modpack_checker/database.py b/services/modpack-version-checker/src/modpack_checker/database.py new file mode 100644 index 0000000..265e204 --- /dev/null +++ b/services/modpack-version-checker/src/modpack_checker/database.py @@ -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] diff --git a/services/modpack-version-checker/src/modpack_checker/notifier.py b/services/modpack-version-checker/src/modpack_checker/notifier.py new file mode 100644 index 0000000..ef3135b --- /dev/null +++ b/services/modpack-version-checker/src/modpack_checker/notifier.py @@ -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 diff --git a/services/modpack-version-checker/tests/__init__.py b/services/modpack-version-checker/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/modpack-version-checker/tests/conftest.py b/services/modpack-version-checker/tests/conftest.py new file mode 100644 index 0000000..6f8f8d6 --- /dev/null +++ b/services/modpack-version-checker/tests/conftest.py @@ -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")) diff --git a/services/modpack-version-checker/tests/test_cli.py b/services/modpack-version-checker/tests/test_cli.py new file mode 100644 index 0000000..f8242ee --- /dev/null +++ b/services/modpack-version-checker/tests/test_cli.py @@ -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 diff --git a/services/modpack-version-checker/tests/test_config.py b/services/modpack-version-checker/tests/test_config.py new file mode 100644 index 0000000..2cf9ca6 --- /dev/null +++ b/services/modpack-version-checker/tests/test_config.py @@ -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) diff --git a/services/modpack-version-checker/tests/test_curseforge.py b/services/modpack-version-checker/tests/test_curseforge.py new file mode 100644 index 0000000..4633118 --- /dev/null +++ b/services/modpack-version-checker/tests/test_curseforge.py @@ -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 diff --git a/services/modpack-version-checker/tests/test_database.py b/services/modpack-version-checker/tests/test_database.py new file mode 100644 index 0000000..7b7d239 --- /dev/null +++ b/services/modpack-version-checker/tests/test_database.py @@ -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) == [] diff --git a/services/modpack-version-checker/tests/test_notifier.py b/services/modpack-version-checker/tests/test_notifier.py new file mode 100644 index 0000000..c1bc71c --- /dev/null +++ b/services/modpack-version-checker/tests/test_notifier.py @@ -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