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:
Claude (Chronicler #83 - The Compiler)
2026-04-12 20:16:16 -05:00
parent 2d6d4aeee7
commit fd50009f67
5 changed files with 449 additions and 12 deletions

View 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;

View File

@@ -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;

View 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;