diff --git a/CHANGELOG.md b/CHANGELOG.md index 228a637..f1148b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.1] - 2026-01-27 + +### Added + +- `checkForUpdates()` method for checking plugin updates +- `UpdateInfo` DTO for update check responses +- `ProductNotFoundException` for `product_not_found` error handling +- `/update-check` endpoint support aligned with remote OpenAPI spec (v0.4.0) + +### Changed + +- Updated local `openapi.json` to match remote specification (now v0.4.0) +- Added "Plugin Updates" tag to OpenAPI specification + ## [0.2.0] - 2026-01-26 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 6087cb6..b6744be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,10 +29,6 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w No known bugs at the moment -### Version 0.2.1 - -No pending tasks at the moment. - ### Version 0.3.0 No pending tasks at the moment. @@ -237,3 +233,27 @@ When editing CLAUDE.md or other markdown files, follow these rules to avoid lint - Version tagging with `git tag -a` creates annotated tags with messages - CHANGELOG.md follows Keep a Changelog format (MD024 duplicate headings are expected) - Roadmap in CLAUDE.md should be updated after each release to reflect next versions + +### 2026-01-27 - Version 0.2.1 (Update Check Endpoint) + +**Completed:** + +- Compared local `openapi.json` against remote server specification +- Discovered new `/update-check` endpoint in remote spec +- Added `UpdateInfo` DTO for update check responses +- Added `ProductNotFoundException` for `product_not_found` error code +- Implemented `checkForUpdates()` method in `LicenseClientInterface` +- Implemented `checkForUpdates()` in both `LicenseClient` and `SecureLicenseClient` +- Updated local `openapi.json` with `/update-check` endpoint (now v0.4.0) +- Updated `LicenseException::fromApiResponse()` to handle `product_not_found` +- Added encoded constant `ENCODED_UPDATE_CHECK` to `SecureLicenseClient` +- Updated documentation: README.md and docs/client-implementation.md +- All 66 tests pass + +**Learnings:** + +- Remote OpenAPI spec at server repo may have newer endpoints than local copy +- Update check endpoint returns WordPress-compatible plugin info (tested, requires, requires_php) +- `UpdateInfo` DTO supports both `download_url` and `package` fields (WordPress compatibility) +- Package hash format uses `sha256:hexstring` prefix for integrity verification +- StringEncoder can generate encoded constants for obfuscated endpoint names diff --git a/README.md b/README.md index e1224df..92a6af1 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ composer require magdev/wc-licensed-product-client - License validation against domains - License activation on domains - License status checking +- Plugin update checking - Comprehensive exception handling - Code integrity verification - Built on Symfony HttpClient @@ -52,6 +53,12 @@ echo "Status: " . $status->status->value; // Activate a license $result = $client->activate('ABCD-1234-EFGH-5678', 'example.com'); echo "Activated: " . ($result->success ? 'Yes' : 'No'); + +// Check for updates +$updateInfo = $client->checkForUpdates('ABCD-1234-EFGH-5678', 'example.com', 'my-plugin', '1.0.0'); +if ($updateInfo->updateAvailable) { + echo "New version available: " . $updateInfo->version; +} ``` ### With Logging @@ -160,6 +167,7 @@ This client interacts with the following WooCommerce Licensed Product API endpoi - **POST /validate** - Validate a license key for a specific domain - **POST /status** - Get detailed license status information - **POST /activate** - Activate a license on a domain +- **POST /update-check** - Check for plugin updates ## License diff --git a/docs/client-implementation.md b/docs/client-implementation.md index 07018a6..0ebebb6 100644 --- a/docs/client-implementation.md +++ b/docs/client-implementation.md @@ -40,6 +40,7 @@ composer require magdev/wc-licensed-product-client │ + validate() │ │ + status() │ │ + activate() │ +│ + checkForUpdates() │ └─────────────┬───────────────┘ │ implements ├───────────────────────┐ @@ -57,6 +58,7 @@ composer require magdev/wc-licensed-product-client │ LicenseInfo │ │ LicenseStatus │ │ ActivationResult │ + │ UpdateInfo │ │ LicenseState (enum) │ └─────────────────────┘ ``` @@ -71,7 +73,8 @@ src/ ├── Dto/ │ ├── LicenseInfo.php # Validation response DTO │ ├── LicenseStatus.php # Status response DTO + LicenseState enum -│ └── ActivationResult.php # Activation response DTO +│ ├── ActivationResult.php # Activation response DTO +│ └── UpdateInfo.php # Update check response DTO ├── Exception/ │ ├── LicenseException.php # Base exception │ ├── LicenseNotFoundException.php @@ -82,6 +85,7 @@ src/ │ ├── DomainMismatchException.php │ ├── MaxActivationsReachedException.php │ ├── ActivationFailedException.php +│ ├── ProductNotFoundException.php │ └── RateLimitExceededException.php └── Security/ ├── ResponseSignature.php # HMAC signature verification @@ -120,6 +124,18 @@ interface LicenseClientInterface * @throws LicenseException When activation fails */ public function activate(string $licenseKey, string $domain): ActivationResult; + + /** + * Check for available plugin updates. + * + * @throws LicenseException When update check fails + */ + public function checkForUpdates( + string $licenseKey, + string $domain, + ?string $pluginSlug = null, + ?string $currentVersion = null, + ): UpdateInfo; } ``` @@ -306,6 +322,57 @@ if ($result->success) { } ``` +### UpdateInfo + +Returned by `checkForUpdates()`. Contains plugin update information. + +```php +final readonly class UpdateInfo +{ + public function __construct( + public bool $updateAvailable, // Whether an update is available + public ?string $version, // Latest available version + public ?string $slug, // Plugin slug for WordPress + public ?string $plugin, // Plugin basename (slug/slug.php) + public ?string $downloadUrl, // Secure download URL + public ?\DateTimeImmutable $lastUpdated,// Date of the latest release + public ?string $tested, // Highest WordPress version tested + public ?string $requires, // Minimum WordPress version + public ?string $requiresPhp, // Minimum PHP version + public ?string $changelog, // Release notes + public ?string $packageHash, // SHA256 hash for integrity + public ?string $name, // Product name + public ?string $homepage, // Product homepage URL + public ?array $icons, // Plugin icons for WordPress admin + public ?array $sections, // Content sections for plugin info + ) {} + + public static function fromArray(array $data): self; + + public function hasValidPackageHash(): bool; // Check package hash format +} +``` + +**Usage:** + +```php +$updateInfo = $client->checkForUpdates( + 'ABCD-1234-EFGH-5678', + 'example.com', + 'my-plugin', + '1.0.0', +); + +if ($updateInfo->updateAvailable) { + echo "New version available: " . $updateInfo->version; + echo "Download URL: " . $updateInfo->downloadUrl; + + if ($updateInfo->hasValidPackageHash()) { + echo "Package hash: " . $updateInfo->packageHash; + } +} +``` + ## Exception Hierarchy All exceptions extend `LicenseException` and include an `errorCode` property for programmatic handling. @@ -320,8 +387,9 @@ LicenseException (base) ├── DomainMismatchException // error: domain_mismatch ├── MaxActivationsReachedException // error: max_activations_reached ├── ActivationFailedException // error: activation_failed +├── ProductNotFoundException // error: product_not_found ├── RateLimitExceededException // error: rate_limit_exceeded (has retryAfter) -└── Security\SignatureException // error: signature_invalid +├── Security\SignatureException // error: signature_invalid └── Security\IntegrityException // error: integrity_check_failed ``` @@ -925,6 +993,7 @@ The client communicates with these REST API endpoints: | `/wp-json/wc-licensed-product/v1/validate` | POST | Validate license for domain | | `/wp-json/wc-licensed-product/v1/status` | POST | Get license status details | | `/wp-json/wc-licensed-product/v1/activate` | POST | Activate license on domain | +| `/wp-json/wc-licensed-product/v1/update-check` | POST | Check for plugin updates | ### Request Format @@ -974,6 +1043,7 @@ All requests use JSON bodies: | `domain_mismatch` | `DomainMismatchException` | Domain not authorized | | `max_activations_reached` | `MaxActivationsReachedException` | Too many activations | | `activation_failed` | `ActivationFailedException` | Server error during activation | +| `product_not_found` | `ProductNotFoundException` | Licensed product doesn't exist | | `rate_limit_exceeded` | `RateLimitExceededException` | Too many requests | | `signature_invalid` | `SignatureException` | Response signature invalid | | `integrity_check_failed` | `IntegrityException` | Source files modified | diff --git a/src/Dto/UpdateInfo.php b/src/Dto/UpdateInfo.php new file mode 100644 index 0000000..f253cb7 --- /dev/null +++ b/src/Dto/UpdateInfo.php @@ -0,0 +1,110 @@ +|null $icons Plugin icons for WordPress admin + * @param array|null $sections Content sections for plugin info modal + */ + public function __construct( + public bool $updateAvailable, + public ?string $version = null, + public ?string $slug = null, + public ?string $plugin = null, + public ?string $downloadUrl = null, + public ?\DateTimeImmutable $lastUpdated = null, + public ?string $tested = null, + public ?string $requires = null, + public ?string $requiresPhp = null, + public ?string $changelog = null, + public ?string $packageHash = null, + public ?string $name = null, + public ?string $homepage = null, + public ?array $icons = null, + public ?array $sections = null, + ) { + } + + public static function fromArray(array $data): self + { + if (!isset($data['update_available']) || !is_bool($data['update_available'])) { + throw new \InvalidArgumentException('Invalid response: missing or invalid update_available'); + } + + $lastUpdated = null; + if (isset($data['last_updated']) && $data['last_updated'] !== null) { + try { + $lastUpdated = new \DateTimeImmutable($data['last_updated']); + } catch (\Exception $e) { + throw new \InvalidArgumentException( + 'Invalid response: invalid date format for last_updated', + 0, + $e + ); + } + } + + $icons = null; + if (isset($data['icons']) && is_array($data['icons'])) { + $icons = $data['icons']; + } + + $sections = null; + if (isset($data['sections']) && is_array($data['sections'])) { + $sections = $data['sections']; + } + + return new self( + updateAvailable: $data['update_available'], + version: isset($data['version']) && is_string($data['version']) ? $data['version'] : null, + slug: isset($data['slug']) && is_string($data['slug']) ? $data['slug'] : null, + plugin: isset($data['plugin']) && is_string($data['plugin']) ? $data['plugin'] : null, + downloadUrl: isset($data['download_url']) && is_string($data['download_url']) ? $data['download_url'] : ( + isset($data['package']) && is_string($data['package']) ? $data['package'] : null + ), + lastUpdated: $lastUpdated, + tested: isset($data['tested']) && is_string($data['tested']) ? $data['tested'] : null, + requires: isset($data['requires']) && is_string($data['requires']) ? $data['requires'] : null, + requiresPhp: isset($data['requires_php']) && is_string($data['requires_php']) ? $data['requires_php'] : null, + changelog: isset($data['changelog']) && is_string($data['changelog']) ? $data['changelog'] : null, + packageHash: isset($data['package_hash']) && is_string($data['package_hash']) ? $data['package_hash'] : null, + name: isset($data['name']) && is_string($data['name']) ? $data['name'] : null, + homepage: isset($data['homepage']) && is_string($data['homepage']) ? $data['homepage'] : null, + icons: $icons, + sections: $sections, + ); + } + + /** + * Check if the package hash is valid against the expected format. + */ + public function hasValidPackageHash(): bool + { + if ($this->packageHash === null) { + return false; + } + + // Package hash format: "sha256:hexstring" + return preg_match('/^sha256:[a-f0-9]{64}$/i', $this->packageHash) === 1; + } +} diff --git a/src/Exception/LicenseException.php b/src/Exception/LicenseException.php index bd88ddb..a91d7c6 100644 --- a/src/Exception/LicenseException.php +++ b/src/Exception/LicenseException.php @@ -29,6 +29,7 @@ class LicenseException extends \RuntimeException 'domain_mismatch' => new DomainMismatchException($message, $errorCode, $httpCode), 'max_activations_reached' => new MaxActivationsReachedException($message, $errorCode, $httpCode), 'activation_failed' => new ActivationFailedException($message, $errorCode, $httpCode), + 'product_not_found' => new ProductNotFoundException($message, $errorCode, $httpCode), 'rate_limit_exceeded' => new RateLimitExceededException( $message, $errorCode, diff --git a/src/Exception/ProductNotFoundException.php b/src/Exception/ProductNotFoundException.php new file mode 100644 index 0000000..c334e4b --- /dev/null +++ b/src/Exception/ProductNotFoundException.php @@ -0,0 +1,9 @@ +buildCacheKey('update-check', $licenseKey, $domain); + + if ($this->cache !== null) { + $item = $this->cache->getItem($cacheKey); + if ($item->isHit()) { + $this->logger->debug('Update check cache hit', ['domain' => $domain]); + return $item->get(); + } + } + + $this->logger->info('Checking for updates', ['domain' => $domain]); + + $payload = [ + 'license_key' => $licenseKey, + 'domain' => $domain, + ]; + + if ($pluginSlug !== null) { + $payload['plugin_slug'] = $pluginSlug; + } + + if ($currentVersion !== null) { + $payload['current_version'] = $currentVersion; + } + + $response = $this->request('update-check', $payload); + + $result = UpdateInfo::fromArray($response); + + if ($this->cache !== null) { + $item = $this->cache->getItem($cacheKey); + $item->set($result); + $item->expiresAfter($this->cacheTtl); + $this->cache->save($item); + } + + $this->logger->info('Update check completed', [ + 'domain' => $domain, + 'update_available' => $result->updateAvailable, + 'version' => $result->version, + ]); + + return $result; + } + /** * @throws LicenseException */ diff --git a/src/LicenseClientInterface.php b/src/LicenseClientInterface.php index 0cde6c6..5e257da 100644 --- a/src/LicenseClientInterface.php +++ b/src/LicenseClientInterface.php @@ -7,6 +7,7 @@ namespace Magdev\WcLicensedProductClient; use Magdev\WcLicensedProductClient\Dto\ActivationResult; use Magdev\WcLicensedProductClient\Dto\LicenseInfo; use Magdev\WcLicensedProductClient\Dto\LicenseStatus; +use Magdev\WcLicensedProductClient\Dto\UpdateInfo; use Magdev\WcLicensedProductClient\Exception\LicenseException; interface LicenseClientInterface @@ -31,4 +32,21 @@ interface LicenseClientInterface * @throws LicenseException When activation fails */ public function activate(string $licenseKey, string $domain): ActivationResult; + + /** + * Check for available plugin updates. + * + * @param string $licenseKey The license key + * @param string $domain The domain the plugin is installed on + * @param string|null $pluginSlug Optional plugin slug for identification + * @param string|null $currentVersion Currently installed version for comparison + * + * @throws LicenseException When update check fails + */ + public function checkForUpdates( + string $licenseKey, + string $domain, + ?string $pluginSlug = null, + ?string $currentVersion = null, + ): UpdateInfo; } diff --git a/src/SecureLicenseClient.php b/src/SecureLicenseClient.php index 250a212..6050247 100644 --- a/src/SecureLicenseClient.php +++ b/src/SecureLicenseClient.php @@ -7,6 +7,7 @@ namespace Magdev\WcLicensedProductClient; use Magdev\WcLicensedProductClient\Dto\ActivationResult; use Magdev\WcLicensedProductClient\Dto\LicenseInfo; use Magdev\WcLicensedProductClient\Dto\LicenseStatus; +use Magdev\WcLicensedProductClient\Dto\UpdateInfo; use Magdev\WcLicensedProductClient\Exception\LicenseException; use Magdev\WcLicensedProductClient\Security\IntegrityChecker; use Magdev\WcLicensedProductClient\Security\IntegrityException; @@ -52,6 +53,7 @@ final class SecureLicenseClient implements LicenseClientInterface private const ENCODED_VALIDATE = 'Egs4Oz4HMgE='; // validate private const ENCODED_STATUS = 'NwgqKAcZ'; // status private const ENCODED_ACTIVATE = 'Jggxfg4MEws='; // activate + private const ENCODED_UPDATE_CHECK = 'Z8E5+5jwTPWoY64b'; // update-check public function __construct( private readonly HttpClientInterface $httpClient, @@ -173,6 +175,58 @@ final class SecureLicenseClient implements LicenseClientInterface return $result; } + public function checkForUpdates( + string $licenseKey, + string $domain, + ?string $pluginSlug = null, + ?string $currentVersion = null, + ): UpdateInfo { + $cacheKey = $this->buildCacheKey('update-check', $licenseKey, $domain); + + if ($this->cache !== null) { + $item = $this->cache->getItem($cacheKey); + if ($item->isHit()) { + $this->logger->debug('Update check cache hit', ['domain' => $domain]); + return $item->get(); + } + } + + $this->logger->info('Checking for updates', ['domain' => $domain]); + + $payload = [ + 'license_key' => $licenseKey, + 'domain' => $domain, + ]; + + if ($pluginSlug !== null) { + $payload['plugin_slug'] = $pluginSlug; + } + + if ($currentVersion !== null) { + $payload['current_version'] = $currentVersion; + } + + $endpoint = $this->encoder->decode(self::ENCODED_UPDATE_CHECK); + $response = $this->secureRequest($endpoint, $payload, $licenseKey); + + $result = UpdateInfo::fromArray($response); + + if ($this->cache !== null) { + $item = $this->cache->getItem($cacheKey); + $item->set($result); + $item->expiresAfter($this->cacheTtl); + $this->cache->save($item); + } + + $this->logger->info('Update check completed', [ + 'domain' => $domain, + 'update_available' => $result->updateAvailable, + 'version' => $result->version, + ]); + + return $result; + } + /** * @throws LicenseException * @throws SignatureException diff --git a/tmp/openapi.json b/tmp/openapi.json index 09dd4a6..b2f703d 100644 --- a/tmp/openapi.json +++ b/tmp/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "WooCommerce Licensed Product API", "description": "REST API for validating and managing software licenses bound to domains. This API allows external applications to validate license keys, check license status, and activate licenses on specific domains.\n\n## Response Signing (Optional)\n\nWhen the server is configured with `WC_LICENSE_SERVER_SECRET`, all API responses include cryptographic signatures for tamper protection:\n\n- `X-License-Signature`: HMAC-SHA256 signature of the response\n- `X-License-Timestamp`: Unix timestamp when the response was generated\n\nSignature verification prevents man-in-the-middle attacks and ensures response integrity. Use the `magdev/wc-licensed-product-client` library's `SecureLicenseClient` class to automatically verify signatures.", - "version": "0.3.2", + "version": "0.4.0", "contact": { "name": "Marco Graetsch", "url": "https://src.bundespruefstelle.ch/magdev", @@ -332,6 +332,148 @@ } } } + }, + "/update-check": { + "post": { + "operationId": "checkForUpdates", + "summary": "Check for plugin updates", + "description": "Checks if a newer version of the licensed product is available. Returns WordPress-compatible update information that can be used to integrate with WordPress's native plugin update system.", + "tags": ["Plugin Updates"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCheckRequest" + }, + "example": { + "license_key": "ABCD-1234-EFGH-5678", + "domain": "example.com", + "plugin_slug": "my-licensed-plugin", + "current_version": "1.0.0" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/UpdateCheckRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Update check completed successfully", + "headers": { + "X-License-Signature": { + "$ref": "#/components/headers/X-License-Signature" + }, + "X-License-Timestamp": { + "$ref": "#/components/headers/X-License-Timestamp" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCheckResponse" + }, + "examples": { + "update_available": { + "summary": "Update is available", + "value": { + "success": true, + "update_available": true, + "version": "1.2.0", + "slug": "my-licensed-plugin", + "plugin": "my-licensed-plugin/my-licensed-plugin.php", + "download_url": "https://example.com/license-download/123-456-abc123", + "package": "https://example.com/license-download/123-456-abc123", + "last_updated": "2026-01-27", + "tested": "6.7", + "requires": "6.0", + "requires_php": "8.3", + "changelog": "## 1.2.0\n- New feature added\n- Bug fixes", + "package_hash": "sha256:abc123def456...", + "name": "My Licensed Plugin", + "homepage": "https://example.com/product/my-plugin" + } + }, + "no_update": { + "summary": "No update available", + "value": { + "success": true, + "update_available": false, + "version": "1.0.0" + } + } + } + } + } + }, + "403": { + "description": "License validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "license_invalid": { + "summary": "License is not valid", + "value": { + "success": false, + "update_available": false, + "error": "license_invalid", + "message": "License validation failed." + } + }, + "domain_mismatch": { + "summary": "Domain mismatch", + "value": { + "success": false, + "update_available": false, + "error": "domain_mismatch", + "message": "This license is not valid for this domain." + } + } + } + } + } + }, + "404": { + "description": "License or product not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "license_not_found": { + "summary": "License not found", + "value": { + "success": false, + "update_available": false, + "error": "license_not_found", + "message": "License not found." + } + }, + "product_not_found": { + "summary": "Product not found", + "value": { + "success": false, + "update_available": false, + "error": "product_not_found", + "message": "Licensed product not found." + } + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimitExceeded" + } + } + } } }, "components": { @@ -473,6 +615,130 @@ } } }, + "UpdateCheckRequest": { + "type": "object", + "required": ["license_key", "domain"], + "properties": { + "license_key": { + "type": "string", + "description": "The license key to validate (format: XXXX-XXXX-XXXX-XXXX)", + "maxLength": 64, + "example": "ABCD-1234-EFGH-5678" + }, + "domain": { + "type": "string", + "description": "The domain the plugin is installed on", + "maxLength": 255, + "example": "example.com" + }, + "plugin_slug": { + "type": "string", + "description": "The plugin slug (optional, for identification)", + "example": "my-licensed-plugin" + }, + "current_version": { + "type": "string", + "description": "Currently installed version for comparison", + "example": "1.0.0" + } + } + }, + "UpdateCheckResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the request was successful" + }, + "update_available": { + "type": "boolean", + "description": "Whether an update is available" + }, + "version": { + "type": "string", + "description": "Latest available version" + }, + "slug": { + "type": "string", + "description": "Plugin slug for WordPress" + }, + "plugin": { + "type": "string", + "description": "Plugin basename (slug/slug.php)" + }, + "download_url": { + "type": "string", + "format": "uri", + "description": "Secure download URL for the update package" + }, + "package": { + "type": "string", + "format": "uri", + "description": "Alias for download_url (WordPress compatibility)" + }, + "last_updated": { + "type": "string", + "format": "date", + "description": "Date of the latest release" + }, + "tested": { + "type": "string", + "description": "Highest WordPress version tested with" + }, + "requires": { + "type": "string", + "description": "Minimum required WordPress version" + }, + "requires_php": { + "type": "string", + "description": "Minimum required PHP version" + }, + "changelog": { + "type": "string", + "description": "Release notes/changelog for the update" + }, + "package_hash": { + "type": "string", + "description": "SHA256 hash of the package for integrity verification", + "example": "sha256:abc123..." + }, + "name": { + "type": "string", + "description": "Product name" + }, + "homepage": { + "type": "string", + "format": "uri", + "description": "Product homepage URL" + }, + "icons": { + "type": "object", + "description": "Plugin icons for WordPress admin", + "properties": { + "1x": { + "type": "string", + "format": "uri" + }, + "2x": { + "type": "string", + "format": "uri" + } + } + }, + "sections": { + "type": "object", + "description": "Content sections for plugin info modal", + "properties": { + "description": { + "type": "string" + }, + "changelog": { + "type": "string" + } + } + } + } + }, "ErrorResponse": { "type": "object", "properties": { @@ -577,6 +843,10 @@ { "name": "License Activation", "description": "Activate licenses on domains" + }, + { + "name": "Plugin Updates", + "description": "Check for plugin updates" } ] }