feat: MokoGitea LicenseValidator — core DRM enforcement, cache table, task scheduler
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Generic: Project CI / Tests (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 7s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 13s
Universal: Auto Version Bump / Version Bump (push) Successful in 16s
Generic: Project CI / Lint & Validate (pull_request) Successful in 16s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 25s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 42s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 46s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 50s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 41s

This commit is contained in:
Jonathan Miller
2026-06-20 20:30:23 -05:00
parent 5a5a5713d6
commit 6bb1b43195
3 changed files with 433 additions and 0 deletions
@@ -215,3 +215,15 @@ INSERT IGNORE INTO `#__mokosuiteclient_retention_policies` (`id`, `content_type`
(4, 'inactive_users', 730, 'anonymize', 0, 'Anonymize users inactive for 2 years (disabled by default)'),
(5, 'closed_tickets', 365, 'anonymize', 0, 'Anonymize closed tickets older than 1 year (disabled by default)');
-- ============================================================
-- License Cache — stores MokoGitea validation results
-- ============================================================
CREATE TABLE IF NOT EXISTS `#__mokosuite_license_cache` (
`dlid_hash` CHAR(64) NOT NULL COMMENT 'SHA-256 of DLID (never store raw DLID)',
`response_data` TEXT NOT NULL COMMENT 'JSON validation response from MokoGitea',
`checked_at` DATETIME NOT NULL,
PRIMARY KEY (`dlid_hash`),
KEY `idx_checked` (`checked_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -0,0 +1,367 @@
<?php
/**
* @package MokoSuiteClient
* @subpackage plg_system_mokosuiteclient
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Plugin\System\MokoSuiteClient\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* MokoGitea License Validator — core DRM enforcement for the MokoSuite platform.
*
* Validates the site's DLID against MokoGitea, caches the result,
* and provides entitlement checking for all suite modules.
*
* Default Gitea server: git.mokoconsulting.tech
*
* @since 02.45.00
*/
final class LicenseValidator
{
/** @var string Default MokoGitea server address */
private const DEFAULT_GITEA_URL = 'https://git.mokoconsulting.tech';
/** @var int Cache TTL in seconds (24 hours) */
private const CACHE_TTL = 86400;
/** @var int Grace period in days after expiry before deactivation */
private const DEFAULT_GRACE_DAYS = 7;
/** @var object|null Cached license data for current request */
private static ?object $cachedLicense = null;
/**
* Validate the site's DLID against MokoGitea.
* Returns cached result if still valid; calls API if expired.
*/
public static function validate(bool $forceRefresh = false): object
{
if (self::$cachedLicense && !$forceRefresh) {
return self::$cachedLicense;
}
$db = Factory::getContainer()->get(DatabaseInterface::class);
$dlid = self::getDlid();
if (!$dlid) {
return self::$cachedLicense = (object) [
'valid' => false,
'status' => 'no_dlid',
'message' => 'No license key configured',
'entitlements'=> [],
];
}
// Check DB cache first
if (!$forceRefresh) {
$cached = self::getCachedResult($db, $dlid);
if ($cached) {
return self::$cachedLicense = $cached;
}
}
// Call MokoGitea API
$result = self::callGiteaApi($dlid);
// Cache the result
self::cacheResult($db, $dlid, $result);
return self::$cachedLicense = $result;
}
/**
* Check if the current license includes entitlement for a specific extension.
*
* @param string $extension Extension element name (e.g., 'com_mokosuite_crm', 'com_mokosuiterestaurant')
* @return bool
*/
public static function isEntitled(string $extension): bool
{
$license = self::validate();
if (!$license->valid) return false;
// Map extension names to repo identifiers
$repoMap = [
'com_mokosuite' => 'MokoSuite',
'com_mokosuite_crm' => 'MokoSuiteCRM',
'com_mokosuite_erp' => 'MokoSuiteERP',
'com_mokosuitechild' => 'MokoSuiteChild',
'com_mokosuitecreate' => 'MokoSuiteCreate',
'com_mokosuitenpo' => 'MokoSuiteNPO',
'com_mokosuitefield' => 'MokoSuiteField',
'com_mokosuitepos' => 'MokoSuitePOS',
'com_mokoshop' => 'MokoSuiteShop',
'com_mokosuitehrm' => 'MokoSuiteHRM',
'com_mokosuitemrp' => 'MokoSuiteMRP',
'com_mokosuiterestaurant' => 'MokoSuiteRestaurant',
];
$repo = $repoMap[$extension] ?? $extension;
$entitlements = $license->entitlements ?? [];
// Base is always entitled if license is valid
if ($repo === 'MokoSuite') return true;
return in_array($repo, $entitlements, true);
}
/**
* Get the full license status for admin display.
*/
public static function getStatus(): object
{
$license = self::validate();
return (object) [
'valid' => $license->valid ?? false,
'status' => $license->status ?? 'unknown',
'tier' => $license->tier ?? 'none',
'entitlements' => $license->entitlements ?? [],
'expires_at' => $license->expires_at ?? null,
'seats' => $license->seats ?? 0,
'seats_used' => $license->seats_used ?? 0,
'days_remaining'=> self::getDaysRemaining($license),
'in_grace' => self::isInGracePeriod($license),
'gitea_url' => self::getGiteaUrl(),
'dlid_configured' => (bool) self::getDlid(),
];
}
/**
* Get available seat count.
*/
public static function getAvailableSeats(): int
{
$license = self::validate();
$total = (int) ($license->seats ?? 0);
$used = (int) ($license->seats_used ?? 0);
if ($total === 0) return PHP_INT_MAX; // Unlimited seats
return max(0, $total - $used);
}
/**
* Report a heartbeat to MokoGitea (active installation check).
* Called by task scheduler daily.
*/
public static function heartbeat(): object
{
$dlid = self::getDlid();
if (!$dlid) return (object) ['success' => false, 'error' => 'No DLID'];
$giteaUrl = self::getGiteaUrl();
$siteUrl = \Joomla\CMS\Uri\Uri::root();
$joomlaVersion = (new \Joomla\CMS\Version())->getShortVersion();
// Count installed suite modules
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('element')
->from('#__extensions')
->where($db->quoteName('element') . ' LIKE ' . $db->quote('com_mokosuite%'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
->where($db->quoteName('enabled') . ' = 1'));
$installedModules = $db->loadColumn() ?: [];
$response = self::httpPost($giteaUrl . '/api/v1/licenses/heartbeat', [
'dlid' => $dlid,
'site_url' => $siteUrl,
'joomla_version' => $joomlaVersion,
'installed_modules' => $installedModules,
'php_version' => PHP_VERSION,
]);
return $response;
}
// ── Private methods ─────────────────────────────────
/**
* Get the configured DLID from component params.
*/
private static function getDlid(): string
{
try {
$params = Factory::getApplication()->getParams('com_mokosuite');
return trim($params->get('dlid', ''));
} catch (\Throwable $e) {
// Component not installed or params not available
try {
$db = Factory::getContainer()->get(DatabaseInterface::class);
$db->setQuery($db->getQuery(true)
->select('params')
->from('#__extensions')
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokosuite'))
->where($db->quoteName('type') . ' = ' . $db->quote('component')));
$paramsJson = $db->loadResult();
$params = json_decode($paramsJson ?: '{}', false);
return trim($params->dlid ?? '');
} catch (\Throwable $e2) {
return '';
}
}
}
/**
* Get the MokoGitea server URL from config.
*/
private static function getGiteaUrl(): string
{
try {
$params = Factory::getApplication()->getParams('com_mokosuite');
return rtrim($params->get('gitea_url', self::DEFAULT_GITEA_URL), '/');
} catch (\Throwable $e) {
return self::DEFAULT_GITEA_URL;
}
}
/**
* Call MokoGitea license validation API.
*/
private static function callGiteaApi(string $dlid): object
{
$giteaUrl = self::getGiteaUrl();
$response = self::httpGet($giteaUrl . '/api/v1/licenses/validate?dlid=' . urlencode($dlid));
if (isset($response->valid)) {
return (object) [
'valid' => (bool) $response->valid,
'status' => $response->status ?? 'unknown',
'tier' => $response->tier ?? '',
'entitlements' => $response->entitlements ?? $response->repo_scope ?? [],
'expires_at' => $response->expires_at ?? null,
'seats' => (int) ($response->seats ?? 0),
'seats_used' => (int) ($response->seats_used ?? 0),
'message' => $response->message ?? '',
];
}
// API error — use cached result if available, otherwise fail gracefully
return (object) [
'valid' => false,
'status' => 'api_error',
'message' => $response->error ?? 'Could not reach license server',
'entitlements' => [],
];
}
/**
* Get cached validation result from database.
*/
private static function getCachedResult(DatabaseInterface $db, string $dlid): ?object
{
$dlidHash = hash('sha256', $dlid);
try {
$db->setQuery($db->getQuery(true)
->select('response_data, checked_at')
->from('#__mokosuite_license_cache')
->where($db->quoteName('dlid_hash') . ' = ' . $db->quote($dlidHash))
->where('checked_at > DATE_SUB(NOW(), INTERVAL ' . (int) self::CACHE_TTL . ' SECOND)'));
$cached = $db->loadObject();
if ($cached && $cached->response_data) {
$data = json_decode($cached->response_data, false);
if ($data) return $data;
}
} catch (\Throwable $e) {
// Table may not exist yet — that's fine
}
return null;
}
/**
* Cache validation result in database.
*/
private static function cacheResult(DatabaseInterface $db, string $dlid, object $result): void
{
$dlidHash = hash('sha256', $dlid);
try {
// Upsert
$db->setQuery('REPLACE INTO #__mokosuite_license_cache (dlid_hash, response_data, checked_at) VALUES ('
. $db->quote($dlidHash) . ', '
. $db->quote(json_encode($result)) . ', '
. $db->quote(Factory::getDate()->toSql()) . ')');
$db->execute();
} catch (\Throwable $e) {
// Cache table may not exist — non-fatal
}
}
/**
* Calculate days remaining on license.
*/
private static function getDaysRemaining(object $license): ?int
{
if (empty($license->expires_at)) return null;
$now = new \DateTime('today');
$expiry = new \DateTime($license->expires_at);
$diff = (int) $now->diff($expiry)->format('%r%a');
return $diff;
}
/**
* Check if license is in grace period (expired but within grace window).
*/
private static function isInGracePeriod(object $license): bool
{
$days = self::getDaysRemaining($license);
if ($days === null || $days >= 0) return false;
$graceDays = self::DEFAULT_GRACE_DAYS;
try {
$graceDays = (int) Factory::getApplication()->getParams('com_mokosuite')->get('license_grace_days', self::DEFAULT_GRACE_DAYS);
} catch (\Throwable $e) {}
return abs($days) <= $graceDays;
}
/**
* HTTP GET helper.
*/
private static function httpGet(string $url): object
{
$response = file_get_contents($url, false, stream_context_create([
'http' => [
'method' => 'GET',
'header' => 'Accept: application/json',
'ignore_errors' => true,
'timeout' => 10,
],
]));
return json_decode($response ?: '{}', false) ?: (object) ['error' => 'No response'];
}
/**
* HTTP POST helper.
*/
private static function httpPost(string $url, array $data): object
{
$response = file_get_contents($url, false, stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nAccept: application/json",
'ignore_errors' => true,
'timeout' => 10,
'content' => json_encode($data),
],
]));
return json_decode($response ?: '{}', false) ?: (object) ['error' => 'No response'];
}
}
@@ -38,6 +38,14 @@ class TicketAutomation extends CMSPlugin implements SubscriberInterface
'langConstPrefix' => 'PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOCLOSE',
'method' => 'runAutoClose',
],
'mokosuiteclient.license.validate' => [
'langConstPrefix' => 'PLG_TASK_MOKOSUITECLIENT_LICENSE_VALIDATE',
'method' => 'runLicenseValidation',
],
'mokosuiteclient.license.heartbeat' => [
'langConstPrefix' => 'PLG_TASK_MOKOSUITECLIENT_LICENSE_HEARTBEAT',
'method' => 'runLicenseHeartbeat',
],
];
protected $autoloadLanguage = true;
@@ -310,4 +318,50 @@ class TicketAutomation extends CMSPlugin implements SubscriberInterface
return trim($textBody);
}
/**
* Daily license revalidation — refresh cached license status from MokoGitea.
* Recommended schedule: daily at 3:00 AM.
*/
private function runLicenseValidation(ExecuteTaskEvent $event): int
{
try {
$result = \Moko\Plugin\System\MokoSuiteClient\Helper\LicenseValidator::validate(true);
$status = $result->valid ? 'valid' : ($result->status ?? 'invalid');
$tier = $result->tier ?? 'none';
$entitlements = count($result->entitlements ?? []);
$this->logTask(sprintf(
'License validation: status=%s tier=%s entitlements=%d',
$status, $tier, $entitlements
));
if (!$result->valid) {
Log::add('License validation failed: ' . ($result->message ?? 'unknown'), Log::WARNING, 'mokosuite.license');
}
return Status::OK;
} catch (\Throwable $e) {
$this->logTask('License validation error: ' . $e->getMessage());
return Status::KNOCKOUT;
}
}
/**
* Daily heartbeat — report active installation to MokoGitea.
* Recommended schedule: daily at 4:00 AM.
*/
private function runLicenseHeartbeat(ExecuteTaskEvent $event): int
{
try {
$result = \Moko\Plugin\System\MokoSuiteClient\Helper\LicenseValidator::heartbeat();
$this->logTask('License heartbeat: ' . ($result->success ?? false ? 'sent' : ($result->error ?? 'failed')));
return Status::OK;
} catch (\Throwable $e) {
$this->logTask('License heartbeat error: ' . $e->getMessage());
return Status::KNOCKOUT;
}
}
}