Phase 11A: MVC licensing — migration + API routes
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
2d6d4aeee7
commit
fd50009f67
127
docs/code-bridge/archive/RES-2026-04-12-phase11-prerequisites.md
Normal file
127
docs/code-bridge/archive/RES-2026-04-12-phase11-prerequisites.md
Normal file
@@ -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** 💙🔥❄️
|
||||
@@ -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`
|
||||
|
||||
30
services/arbiter-3.0/migrations/138_mvc_licensing.sql
Normal file
30
services/arbiter-3.0/migrations/138_mvc_licensing.sql
Normal file
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
278
services/arbiter-3.0/src/routes/mvc.js
Normal file
278
services/arbiter-3.0/src/routes/mvc.js
Normal file
@@ -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;
|
||||
Reference in New Issue
Block a user