diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1ec2df58..090b7693 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,9 @@
- License key support via Joomla's native Update Sites download key system (dlid)
- Update server URL migrated from static XML to MokoGitea's dynamic update feed endpoint
- Legacy static update site URLs auto-migrated to dynamic endpoint on install/update
+- Persistent admin warning when no license key is configured in Update Sites
+- Daily heartbeat validation of license key against MokoGitea — warns if key is invalid or expired
+- Stale/duplicate update site cleanup on install/update (removes old static URL entries and orphaned records)
### Removed
- Static `updates.xml` — update feed is now generated dynamically by MokoGitea from git releases
diff --git a/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php b/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php
index 58bad838..626896b6 100644
--- a/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php
+++ b/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php
@@ -1000,6 +1000,7 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
return;
}
+ $this->warnMissingLicenseKey();
$this->enforceAdminRestrictions();
$this->protectPlugin();
}
@@ -3926,6 +3927,124 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface
// ------------------------------------------------------------------
// Heartbeat (called from onExtensionAfterSave)
+ // ------------------------------------------------------------------
+ // License key check (called from onAfterRoute)
+ // ------------------------------------------------------------------
+
+ /**
+ * Show a persistent admin warning if no license key is set on the
+ * MokoWaaS update site.
+ *
+ * Checks the extra_query column in #__update_sites for a dlid value.
+ * Also validates the key against MokoGitea on a heartbeat interval
+ * (once per day) and warns if the key is invalid or expired.
+ *
+ * @return void
+ *
+ * @since 02.30.00
+ */
+ protected function warnMissingLicenseKey(): void
+ {
+ // Only show to master users
+ if (!$this->isMasterUser())
+ {
+ return;
+ }
+
+ try
+ {
+ $db = Factory::getDbo();
+
+ $query = $db->getQuery(true)
+ ->select($db->quoteName('extra_query'))
+ ->from($db->quoteName('#__update_sites'))
+ ->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoWaaS%')
+ . ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoWaaS%') . ')')
+ ->setLimit(1);
+ $db->setQuery($query);
+ $extraQuery = (string) $db->loadResult();
+
+ if (empty($extraQuery) || strpos($extraQuery, 'dlid=') === false)
+ {
+ $this->app->enqueueMessage(
+ 'MokoWaaS License Key Required — '
+ . 'No download key is configured. Updates will not be available until a valid license key is entered. '
+ . 'Go to System → Update Sites '
+ . 'and enter your license key (MOKO-XXXX-XXXX-XXXX-XXXX) in the Download Key field for the MokoWaaS update site.',
+ 'warning'
+ );
+
+ return;
+ }
+
+ // Extract the key value from extra_query
+ parse_str($extraQuery, $parsed);
+ $licenseKey = $parsed['dlid'] ?? '';
+
+ if (empty($licenseKey))
+ {
+ return;
+ }
+
+ // Heartbeat validation — check once per day
+ $session = Factory::getSession();
+ $lastCheck = (int) $session->get('mokowaas.license_check', 0);
+ $now = time();
+
+ if (($now - $lastCheck) < 86400)
+ {
+ // Show cached warning if key was invalid last check
+ if ($session->get('mokowaas.license_invalid', false))
+ {
+ $this->app->enqueueMessage(
+ 'MokoWaaS License Key Invalid — '
+ . 'Your license key could not be validated. Please verify your key in '
+ . 'System → Update Sites.',
+ 'error'
+ );
+ }
+
+ return;
+ }
+
+ // Validate against MokoGitea
+ $session->set('mokowaas.license_check', $now);
+
+ $validateUrl = 'https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/updates.xml'
+ . '?dlid=' . urlencode($licenseKey)
+ . '&domain=' . urlencode(Uri::root());
+
+ $ch = curl_init($validateUrl);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_TIMEOUT, 10);
+ curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
+ curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
+ $response = curl_exec($ch);
+ $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ // Empty or non-200 means invalid key
+ $isValid = ($httpCode === 200 && $response && strpos($response, '') !== false);
+
+ $session->set('mokowaas.license_invalid', !$isValid);
+
+ if (!$isValid)
+ {
+ $this->app->enqueueMessage(
+ 'MokoWaaS License Key Invalid — '
+ . 'Your license key could not be validated. Updates will not be available. '
+ . 'Please verify your key in '
+ . 'System → Update Sites.',
+ 'error'
+ );
+ }
+ }
+ catch (\Throwable $e)
+ {
+ // Silent — license check is non-critical
+ }
+ }
+
// ------------------------------------------------------------------
/**
diff --git a/src/script.php b/src/script.php
index a022b809..03eace5a 100644
--- a/src/script.php
+++ b/src/script.php
@@ -45,6 +45,9 @@ class Pkg_MokowaasInstallerScript
// Mark MokoWaaS extensions as protected (prevents disable/uninstall at framework level)
$this->protectExtensions();
+ // Clean up stale/duplicate update sites
+ $this->cleanupStaleUpdateSites();
+
// Trigger heartbeat registration
$this->sendHeartbeat();
}
@@ -218,6 +221,92 @@ class Pkg_MokowaasInstallerScript
}
}
+ /**
+ * Remove stale and duplicate MokoWaaS update site entries.
+ *
+ * Keeps only the package-level update site pointing to the dynamic
+ * MokoGitea endpoint. Removes plugin-level entries, old static URLs,
+ * and orphaned #__updates rows tied to deleted update sites.
+ *
+ * @return void
+ *
+ * @since 02.30.00
+ */
+ private function cleanupStaleUpdateSites(): void
+ {
+ try
+ {
+ $db = Factory::getDbo();
+ $dynamicUrl = 'https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS/updates.xml';
+
+ // Find all MokoWaaS update sites
+ $query = $db->getQuery(true)
+ ->select($db->quoteName(['update_site_id', 'location']))
+ ->from($db->quoteName('#__update_sites'))
+ ->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoWaaS%')
+ . ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoWaaS%') . ')');
+ $db->setQuery($query);
+ $sites = $db->loadObjectList();
+
+ $keepId = null;
+ $removeIds = [];
+
+ foreach ($sites as $site)
+ {
+ if ($site->location === $dynamicUrl && $keepId === null)
+ {
+ $keepId = (int) $site->update_site_id;
+ }
+ else
+ {
+ $removeIds[] = (int) $site->update_site_id;
+ }
+ }
+
+ if (empty($removeIds))
+ {
+ return;
+ }
+
+ $idList = implode(',', $removeIds);
+
+ // Remove orphaned #__updates rows
+ $db->setQuery(
+ $db->getQuery(true)
+ ->delete($db->quoteName('#__updates'))
+ ->where($db->quoteName('update_site_id') . ' IN (' . $idList . ')')
+ )->execute();
+
+ // Remove link rows
+ $db->setQuery(
+ $db->getQuery(true)
+ ->delete($db->quoteName('#__update_sites_extensions'))
+ ->where($db->quoteName('update_site_id') . ' IN (' . $idList . ')')
+ )->execute();
+
+ // Remove stale update sites
+ $db->setQuery(
+ $db->getQuery(true)
+ ->delete($db->quoteName('#__update_sites'))
+ ->where($db->quoteName('update_site_id') . ' IN (' . $idList . ')')
+ )->execute();
+
+ $count = count($removeIds);
+
+ if ($count > 0)
+ {
+ Factory::getApplication()->enqueueMessage(
+ sprintf('Cleaned up %d stale MokoWaaS update site(s).', $count),
+ 'message'
+ );
+ }
+ }
+ catch (\Throwable $e)
+ {
+ Log::add('Error cleaning up stale update sites: ' . $e->getMessage(), Log::WARNING, 'jerror');
+ }
+ }
+
/**
* Ensure the MokoWaaS update server entry stays enabled and points
* to the correct dynamic endpoint with the license key attached.