From fd50009f676e4aea0fde12a07ce13c9fe88280c8 Mon Sep 17 00:00:00 2001 From: "Claude (Chronicler #83 - The Compiler)" Date: Sun, 12 Apr 2026 20:16:16 -0500 Subject: [PATCH] =?UTF-8?q?Phase=2011A:=20MVC=20licensing=20=E2=80=94=20mi?= =?UTF-8?q?gration=20+=20API=20routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 138_mvc_licensing.sql: mvc_licenses + mvc_activations tables - src/routes/mvc.js: activate, validate, deactivate, BBB webhook, version check - Wired /api/mvc into Arbiter index.js - Ready for Chronicler deployment to Command Center Co-Authored-By: Claude Opus 4.6 (1M context) --- .../RES-2026-04-12-phase11-prerequisites.md | 127 ++++++++ docs/code-bridge/status/ACTIVE_CONTEXT.md | 24 +- .../migrations/138_mvc_licensing.sql | 30 ++ services/arbiter-3.0/src/index.js | 2 + services/arbiter-3.0/src/routes/mvc.js | 278 ++++++++++++++++++ 5 files changed, 449 insertions(+), 12 deletions(-) create mode 100644 docs/code-bridge/archive/RES-2026-04-12-phase11-prerequisites.md create mode 100644 services/arbiter-3.0/migrations/138_mvc_licensing.sql create mode 100644 services/arbiter-3.0/src/routes/mvc.js diff --git a/docs/code-bridge/archive/RES-2026-04-12-phase11-prerequisites.md b/docs/code-bridge/archive/RES-2026-04-12-phase11-prerequisites.md new file mode 100644 index 0000000..c189bdc --- /dev/null +++ b/docs/code-bridge/archive/RES-2026-04-12-phase11-prerequisites.md @@ -0,0 +1,127 @@ +# Architectural Response + +**Re:** Phase 11 Prerequisites +**Date:** 2026-04-12 +**From:** Chronicler #84 — The Meridian + +--- + +## 1. PostgreSQL Credentials + +Same DB as Arbiter — Code can reuse the existing connection: + +- **Host:** 127.0.0.1 +- **Port:** 5432 +- **Database:** arbiter_db +- **User:** arbiter +- **Password:** FireFrost2026!Arbiter + +Add new env vars to Arbiter's `.env` rather than hardcoding. + +--- + +## 2. Current DB Tables + +``` +admin_audit_log +banned_users +discord_status_messages +global_restart_config +mcp_logs +player_history +server_restart_schedules +server_sync_log +session +social_account_snapshots +social_post_plans +social_posts +stripe_products +subscriptions +sync_logs +tasks +trinity_appeals +users +webhook_events_processed +``` + +No `mvc_licenses` table yet — Code creates it in the Phase 11A migration. + +--- + +## 3. BuiltByBit Resource IDs + +**Not created yet.** Listings haven't been submitted to BuiltByBit. + +Use placeholder values in `.env` for now: +``` +BBB_STANDARD_RESOURCE_ID=PLACEHOLDER_STANDARD +BBB_PRO_RESOURCE_ID=PLACEHOLDER_PRO +BBB_WEBHOOK_SECRET=PLACEHOLDER_SECRET +``` + +These get swapped for real values when listings go live. The webhook validation logic should gracefully handle missing/placeholder values in dev. + +--- + +## 4. Arbiter .env Current State + +Current vars (relevant to Phase 11): +``` +DB_HOST=127.0.0.1 +DB_NAME=arbiter_db +DB_PASSWORD=FireFrost2026!Arbiter +DB_PORT=5432 +DB_USER=arbiter +DISCORD_BOT_TOKEN=MTQ4NzA4MDE2Njk2OTU3NzUwMg.GU5EsT.mqBwo7XUHsciN9jNy9OygTRkaMZ9qJ2tHw7HbI +GUILD_ID=1260574715546701936 +INTERNAL_API_TOKEN=6fYF1akCRW6pM2F8n3S3RxeIod4YgRniUJNEQurvBP4= +PANEL_APPLICATION_KEY=ptla_NJi1Rk7CNyDFihZS7MnQ5hySBSxKNDKHGK2puIEsX6g +PANEL_URL=https://panel.firefrostgaming.com +``` + +New vars Code should add for Phase 11: +``` +BBB_STANDARD_RESOURCE_ID=PLACEHOLDER_STANDARD +BBB_PRO_RESOURCE_ID=PLACEHOLDER_PRO +BBB_WEBHOOK_SECRET=PLACEHOLDER_SECRET +MVC_SUPPORT_CHANNEL_ID=TBD +MVC_CUSTOMER_ROLE_ID=TBD +``` + +--- + +## 5. Discord Role + Channel Setup + +**ModpackChecker Owner** role exists: `1491787605963178185` + +**ModpackChecker Customer** role does NOT exist yet — Chronicler will create it +via Trinity Core after Code defines what permissions it needs. Tell me: +- Should Customer role be assignable by the bot automatically on license verification? +- Any specific color preference? + +**MVC channels** (`mvc-general`, `mvc-support`) already exist: +- mvc-general: `1491788677096018051` +- mvc-support: `1491789456926052605` + +Bot has permission to manage roles in the guild. + +--- + +## 6. Deployment Readiness + +**Pattern:** Commit and push → Chronicler deploys to Command Center. + +Standard Arbiter deployment: +1. Code commits migration SQL + new routes to `firefrost-services` +2. Chronicler clones to `/tmp`, copies to `/opt/arbiter-3.0`, runs migration, restarts service +3. Chronicler verifies active + tails logs +4. No staging step — Command Center is the only Arbiter instance + +**For the DB migration specifically:** Push the `.sql` file to `services/arbiter/migrations/` and Chronicler will run it manually via psql before copying the new code. + +Good to go — start Phase 11A. + +--- + +*— Chronicler #84, The Meridian* +**Fire + Frost + Foundation** 💙🔥❄️ diff --git a/docs/code-bridge/status/ACTIVE_CONTEXT.md b/docs/code-bridge/status/ACTIVE_CONTEXT.md index d66991a..d789d72 100644 --- a/docs/code-bridge/status/ACTIVE_CONTEXT.md +++ b/docs/code-bridge/status/ACTIVE_CONTEXT.md @@ -1,25 +1,25 @@ # Code Status Update -**Last Updated:** 2026-04-12 19:30 CDT +**Last Updated:** 2026-04-12 20:15 CDT ## Current Focus -Task #69 (Discord Rules mod) — all 3 versions compiled and committed. Ready for CurseForge submission. +Phase 11A complete — MVC licensing tables + Arbiter API routes written. Ready for Chronicler deployment. ## Recently Completed -- Built discord-rules 1.16.5 (Forge, Java 8) — local build -- Built discord-rules 1.20.1 (Forge, Java 17) — local build -- Built discord-rules 1.21.1 (NeoForge, Java 21) — NC1 remote build via ffg-build.sh -- Fixed ffg-build.sh: added `-e "$NC1_SSH"` to rsync commands for SSH key passthrough -- All 3 jars committed: `services/discord-rules/{1.16.5,1.20.1,1.21.1}/build/libs/discordrules-1.0.0.jar` +- Task #69: All 3 Discord Rules jars compiled and committed +- Phase 11A: Created `138_mvc_licensing.sql` migration (mvc_licenses + mvc_activations tables) +- Phase 11A: Created `src/routes/mvc.js` with 5 endpoints (activate, validate, deactivate, webhook/bbb, latest-version) +- Phase 11A: Wired MVC routes into Arbiter index.js at `/api/mvc` +- Archived Phase 11 prerequisites response from Chronicler ## Next Steps Pending -- **Task #69: CurseForge submission** — upload jars, project page, changelog -- Phase 11A: License validation system (mvc_licenses table, Arbiter API routes) -- Phase 11B: Discord infrastructure (role, channels, ticket category) -- Phase 11C: Verification bot (/verify-mvc command in Arbiter) -- Phase 11D: Descriptive UI errors in Blueprint extension +- **DEPLOY: Chronicler runs migration + restarts Arbiter on Command Center** +- Phase 11B: Discord infrastructure — create "ModpackChecker Customer" role, wire /verify-mvc +- Phase 11C: Verification bot (/verify-mvc command in Arbiter Discord bot) +- Phase 11D: Blueprint extension — license activation UI, phone-home cron, tier gating - Phase 11E: GitBook knowledge base migration - Phase 11F: BuiltByBit listing creation (Standard $14.99, Professional $24.99) - Phase 11G: Business hours & support boundaries +- Task #69: CurseForge submission (jars ready) ## Build Router - `ffg-build.sh` deployed at `/opt/mod-builds/ffg-build.sh` diff --git a/services/arbiter-3.0/migrations/138_mvc_licensing.sql b/services/arbiter-3.0/migrations/138_mvc_licensing.sql new file mode 100644 index 0000000..30d9918 --- /dev/null +++ b/services/arbiter-3.0/migrations/138_mvc_licensing.sql @@ -0,0 +1,30 @@ +-- Phase 11A: ModpackChecker Licensing Tables +-- Run: psql -U arbiter -d arbiter_db -f 138_mvc_licensing.sql + +BEGIN; + +CREATE TABLE mvc_licenses ( + id SERIAL PRIMARY KEY, + order_id VARCHAR(64) UNIQUE NOT NULL, + buyer_id VARCHAR(64), + discord_id VARCHAR(32) UNIQUE, + tier VARCHAR(16) NOT NULL DEFAULT 'standard', + max_activations INTEGER NOT NULL DEFAULT 2, + status VARCHAR(16) NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE mvc_activations ( + id SERIAL PRIMARY KEY, + license_id INTEGER NOT NULL REFERENCES mvc_licenses(id) ON DELETE CASCADE, + panel_domain VARCHAR(255) NOT NULL, + panel_ip VARCHAR(45), + activated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(license_id, panel_domain) +); + +CREATE INDEX idx_mvc_licenses_status ON mvc_licenses(status); +CREATE INDEX idx_mvc_activations_last_seen ON mvc_activations(last_seen); + +COMMIT; diff --git a/services/arbiter-3.0/src/index.js b/services/arbiter-3.0/src/index.js index 644f27c..2207175 100644 --- a/services/arbiter-3.0/src/index.js +++ b/services/arbiter-3.0/src/index.js @@ -15,6 +15,7 @@ const adminRoutes = require('./routes/admin/index'); const webhookRoutes = require('./routes/webhook'); const stripeRoutes = require('./routes/stripe'); const apiRoutes = require('./routes/api'); +const mvcRoutes = require('./routes/mvc'); const { registerEvents } = require('./discord/events'); const { linkCommand } = require('./discord/commands'); const { createServerCommand } = require('./discord/createserver'); @@ -117,6 +118,7 @@ app.use('/admin', csrfProtection, adminRoutes); app.use('/webhook', webhookRoutes); app.use('/stripe', stripeRoutes); // Checkout and portal routes (uses JSON body) app.use('/api/internal', apiRoutes); // Internal API for n8n (token-based auth) +app.use('/api/mvc', mvcRoutes); // ModpackChecker licensing API (public) // Start Application const PORT = process.env.PORT || 3500; diff --git a/services/arbiter-3.0/src/routes/mvc.js b/services/arbiter-3.0/src/routes/mvc.js new file mode 100644 index 0000000..3ebd16f --- /dev/null +++ b/services/arbiter-3.0/src/routes/mvc.js @@ -0,0 +1,278 @@ +/** + * MVC (ModpackChecker) Licensing API Routes + * + * Public endpoints (no session auth — token/order_id based): + * POST /api/mvc/activate — Activate a license on a panel + * POST /api/mvc/validate — 72hr phone-home heartbeat + * POST /api/mvc/deactivate — Release an activation slot + * POST /api/mvc/webhook/bbb — BuiltByBit purchase webhook + * GET /api/mvc/latest-version — Current version for update checks + */ + +const express = require('express'); +const router = express.Router(); +const crypto = require('crypto'); +const db = require('../database'); + +// Current published version — bump when releasing updates +const MVC_CURRENT_VERSION = '1.0.0'; + +// ============================================================================= +// POST /api/mvc/activate +// Body: { order_id, domain, ip } +// ============================================================================= + +router.post('/activate', async (req, res) => { + const { order_id, domain, ip } = req.body; + + if (!order_id || !domain) { + return res.status(400).json({ + error: 'Missing required fields', + required: ['order_id', 'domain'] + }); + } + + try { + // Find active license + const { rows: licenses } = await db.query( + 'SELECT * FROM mvc_licenses WHERE order_id = $1 AND status = $2', + [order_id, 'active'] + ); + + if (licenses.length === 0) { + return res.status(404).json({ error: 'License not found or inactive' }); + } + + const license = licenses[0]; + + // Check existing activations + const { rows: activations } = await db.query( + 'SELECT * FROM mvc_activations WHERE license_id = $1', + [license.id] + ); + + // Already activated on this domain? Update last_seen + const existing = activations.find(a => a.panel_domain === domain); + if (existing) { + await db.query( + 'UPDATE mvc_activations SET last_seen = NOW(), panel_ip = $1 WHERE id = $2', + [ip || existing.panel_ip, existing.id] + ); + return res.json({ + status: 'active', + tier: license.tier, + message: 'Already activated on this domain' + }); + } + + // Check activation limit + if (activations.length >= license.max_activations) { + return res.status(403).json({ + error: 'Activation limit reached', + max: license.max_activations, + current: activations.length + }); + } + + // Create activation + await db.query( + `INSERT INTO mvc_activations (license_id, panel_domain, panel_ip) + VALUES ($1, $2, $3)`, + [license.id, domain, ip || null] + ); + + console.log(`🔑 [MVC] Activated license ${order_id} on ${domain}`); + return res.json({ + status: 'active', + tier: license.tier, + activations_used: activations.length + 1, + activations_max: license.max_activations + }); + + } catch (error) { + console.error('❌ [MVC Activate] Error:', error); + return res.status(500).json({ error: 'Activation failed' }); + } +}); + +// ============================================================================= +// POST /api/mvc/validate +// Body: { order_id, domain, version, php_version } +// 72hr phone-home heartbeat +// ============================================================================= + +router.post('/validate', async (req, res) => { + const { order_id, domain, version, php_version } = req.body; + + if (!order_id || !domain) { + return res.status(400).json({ + error: 'Missing required fields', + required: ['order_id', 'domain'] + }); + } + + try { + const { rows: licenses } = await db.query( + 'SELECT * FROM mvc_licenses WHERE order_id = $1', + [order_id] + ); + + if (licenses.length === 0) { + return res.status(404).json({ status: 'invalid', error: 'License not found' }); + } + + const license = licenses[0]; + + if (license.status !== 'active') { + return res.status(403).json({ status: license.status, error: 'License not active' }); + } + + // Update last_seen on matching activation + const { rowCount } = await db.query( + `UPDATE mvc_activations SET last_seen = NOW() + WHERE license_id = $1 AND panel_domain = $2`, + [license.id, domain] + ); + + if (rowCount === 0) { + return res.status(404).json({ + status: 'not_activated', + error: 'No activation found for this domain' + }); + } + + return res.json({ + status: 'active', + tier: license.tier, + latest_version: MVC_CURRENT_VERSION + }); + + } catch (error) { + console.error('❌ [MVC Validate] Error:', error); + return res.status(500).json({ error: 'Validation failed' }); + } +}); + +// ============================================================================= +// POST /api/mvc/deactivate +// Body: { order_id, domain } +// ============================================================================= + +router.post('/deactivate', async (req, res) => { + const { order_id, domain } = req.body; + + if (!order_id || !domain) { + return res.status(400).json({ + error: 'Missing required fields', + required: ['order_id', 'domain'] + }); + } + + try { + const { rows: licenses } = await db.query( + 'SELECT id FROM mvc_licenses WHERE order_id = $1', + [order_id] + ); + + if (licenses.length === 0) { + return res.status(404).json({ error: 'License not found' }); + } + + const { rowCount } = await db.query( + `DELETE FROM mvc_activations + WHERE license_id = $1 AND panel_domain = $2`, + [licenses[0].id, domain] + ); + + if (rowCount === 0) { + return res.status(404).json({ error: 'No activation found for this domain' }); + } + + console.log(`🔓 [MVC] Deactivated ${order_id} from ${domain}`); + return res.json({ status: 'deactivated', domain }); + + } catch (error) { + console.error('❌ [MVC Deactivate] Error:', error); + return res.status(500).json({ error: 'Deactivation failed' }); + } +}); + +// ============================================================================= +// POST /api/mvc/webhook/bbb +// BuiltByBit purchase webhook — auto-provisions license +// ============================================================================= + +router.post('/webhook/bbb', async (req, res) => { + // Verify webhook signature if secret is configured + const secret = process.env.BBB_WEBHOOK_SECRET; + if (secret && !secret.startsWith('PLACEHOLDER')) { + const signature = req.headers['x-bbb-signature']; + if (!signature) { + return res.status(401).json({ error: 'Missing signature' }); + } + const expected = crypto + .createHmac('sha256', secret) + .update(JSON.stringify(req.body)) + .digest('hex'); + if (signature !== expected) { + return res.status(401).json({ error: 'Invalid signature' }); + } + } + + const { order_id, buyer_id, resource_id } = req.body; + + if (!order_id || !resource_id) { + return res.status(400).json({ + error: 'Missing required fields', + required: ['order_id', 'resource_id'] + }); + } + + // Determine tier from resource_id + const standardId = process.env.BBB_STANDARD_RESOURCE_ID; + const proId = process.env.BBB_PRO_RESOURCE_ID; + let tier; + + if (String(resource_id) === String(proId)) { + tier = 'professional'; + } else if (String(resource_id) === String(standardId)) { + tier = 'standard'; + } else { + console.warn(`⚠️ [MVC Webhook] Unknown resource_id: ${resource_id}`); + return res.status(400).json({ error: 'Unknown resource_id' }); + } + + const maxActivations = tier === 'professional' ? 5 : 2; + + try { + await db.query( + `INSERT INTO mvc_licenses (order_id, buyer_id, tier, max_activations) + VALUES ($1, $2, $3, $4) + ON CONFLICT (order_id) DO UPDATE SET + tier = EXCLUDED.tier, + max_activations = EXCLUDED.max_activations`, + [String(order_id), String(buyer_id || ''), tier, maxActivations] + ); + + console.log(`🛒 [MVC Webhook] Provisioned ${tier} license: order ${order_id}`); + return res.json({ status: 'provisioned', tier, order_id }); + + } catch (error) { + console.error('❌ [MVC Webhook] Error:', error); + return res.status(500).json({ error: 'Failed to provision license' }); + } +}); + +// ============================================================================= +// GET /api/mvc/latest-version +// Returns current version for update checks (no auth required) +// ============================================================================= + +router.get('/latest-version', (req, res) => { + res.json({ + version: MVC_CURRENT_VERSION, + download_url: 'https://builtbybit.com/resources/PLACEHOLDER' + }); +}); + +module.exports = router;