3 Commits

Author SHA1 Message Date
56abe8a97c Add update-check endpoint documentation (v0.2.2)
- Add /update-check endpoint documentation to server-implementation.md
- Add product_not_found error code to error codes table
- Add handleUpdateCheck() handler example in WordPress plugin
- Add findProduct() method stub for product lookups
- Verified client implementation aligns with server documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 11:56:47 +01:00
760e1e752a Add update-check endpoint support (v0.2.1)
Implement /update-check endpoint aligned with remote OpenAPI spec:
- Add checkForUpdates() method to LicenseClientInterface
- Add UpdateInfo DTO for update check responses
- Add ProductNotFoundException for product_not_found error
- Update local openapi.json to v0.4.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 20:52:12 +01:00
5e4b5a970f Update session history with v0.2.0 release notes
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 16:54:37 +01:00
12 changed files with 860 additions and 7 deletions

View File

@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.2.2] - 2026-01-28
### Added
- `/update-check` endpoint documentation in server-implementation.md
- `product_not_found` error code to error codes table
- `handleUpdateCheck()` handler example in WordPress plugin implementation
- `findProduct()` method stub for product lookups
### Changed
- Verified client implementation aligns with updated server documentation
- All signature algorithms, key derivation, and JSON canonicalization match server
## [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

View File

@@ -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.
@@ -222,3 +218,62 @@ When editing CLAUDE.md or other markdown files, follow these rules to avoid lint
- Recursive key sorting is essential for nested objects like the `license` object in validate responses
- When updating cryptographic implementations, both client and server documentation must be aligned
- The remote server documentation URL was 404 - local `docs/server-implementation.md` is the source of truth
### 2026-01-26 - Version 0.2.0 Release
**Completed:**
- Released version 0.2.0 with security improvements and server alignment
- Created annotated git tag `v0.2.0`
- Updated CHANGELOG.md with all changes since 0.1.0
- Updated roadmap for versions 0.2.1 and 0.3.0
**Learnings:**
- 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
### 2026-01-28 - Version 0.2.2 (Server Documentation Alignment)
**Completed:**
- Fetched and compared remote server-implementation.md against client implementation
- Verified client signature algorithm matches server exactly (HKDF, recursive key sorting, JSON flags)
- Added `/update-check` endpoint documentation to `docs/server-implementation.md`
- Added `product_not_found` error code to error codes table
- Added `handleUpdateCheck()` handler example in WordPress plugin implementation
- Added `findProduct()` method stub for product lookups
- Added `getUpdateCheckArgs()` for request validation
- All 66 tests pass
**Learnings:**
- Client implementation was already fully aligned with server documentation
- Server-implementation.md needed update-check endpoint documentation (added in v0.2.1 client but not server docs)
- Documentation alignment is as important as code alignment for maintainability
- Remote server docs fetched successfully via WebFetch tool

View File

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

View File

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

View File

@@ -153,6 +153,64 @@ Activate a license on a specific domain.
}
```
### POST /update-check
Check for plugin updates for a licensed product.
**Request:**
```json
{
"license_key": "ABCD-1234-EFGH-5678",
"domain": "example.com",
"plugin_slug": "my-licensed-plugin",
"current_version": "1.0.0"
}
```
**Success Response (200) - Update Available:**
```json
{
"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"
}
```
**Success Response (200) - No Update:**
```json
{
"success": true,
"update_available": false,
"version": "1.0.0"
}
```
**Error Response (404):**
```json
{
"success": false,
"update_available": false,
"error": "product_not_found",
"message": "Licensed product not found."
}
```
## Error Codes
The API uses consistent error codes for programmatic handling:
@@ -167,6 +225,7 @@ The API uses consistent error codes for programmatic handling:
| `domain_mismatch` | 403 | License not authorized for this domain |
| `max_activations_reached` | 403 | Maximum activations limit exceeded |
| `activation_failed` | 500 | Server error during activation |
| `product_not_found` | 404 | Licensed product not found |
| `rate_limit_exceeded` | 429 | Too many requests |
### Rate Limit Response
@@ -366,6 +425,13 @@ final class LicenseApi
'permission_callback' => [$this, 'checkRateLimit'],
'args' => $this->getActivateArgs(),
]);
register_rest_route(self::API_NAMESPACE, '/update-check', [
'methods' => 'POST',
'callback' => [$this, 'handleUpdateCheck'],
'permission_callback' => [$this, 'checkRateLimit'],
'args' => $this->getUpdateCheckArgs(),
]);
}
// =========================================================================
@@ -407,6 +473,34 @@ final class LicenseApi
return $this->getValidateArgs(); // Same as validate
}
private function getUpdateCheckArgs(): array
{
return [
'license_key' => [
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => [$this, 'validateLicenseKeyFormat'],
],
'domain' => [
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => [$this, 'validateDomainFormat'],
],
'plugin_slug' => [
'required' => false,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
],
'current_version' => [
'required' => false,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
],
];
}
public function validateLicenseKeyFormat($value): bool
{
return is_string($value) && strlen($value) <= 64 && strlen($value) >= 8;
@@ -598,6 +692,62 @@ final class LicenseApi
], 200);
}
public function handleUpdateCheck(\WP_REST_Request $request): \WP_REST_Response|\WP_Error
{
$licenseKey = $request->get_param('license_key');
$domain = $request->get_param('domain');
$pluginSlug = $request->get_param('plugin_slug');
$currentVersion = $request->get_param('current_version');
$license = $this->findLicense($licenseKey);
if ($license === null) {
return $this->errorResponse('license_not_found', 'License key not found.', 404);
}
if ($license['status'] !== 'active' || $this->isExpired($license)) {
return $this->errorResponse('license_invalid', 'License validation failed.', 403);
}
if (!empty($license['domain']) && $license['domain'] !== $domain) {
return $this->errorResponse('domain_mismatch', 'This license is not valid for this domain.', 403);
}
// TODO: Replace with your product lookup logic
$product = $this->findProduct($license['product_id']);
if ($product === null) {
return $this->errorResponse('product_not_found', 'Licensed product not found.', 404);
}
$latestVersion = $product['version'] ?? '1.0.0';
$updateAvailable = $currentVersion !== null
&& version_compare($currentVersion, $latestVersion, '<');
$response = [
'success' => true,
'update_available' => $updateAvailable,
'version' => $latestVersion,
];
if ($updateAvailable) {
$response['slug'] = $product['slug'] ?? $pluginSlug;
$response['plugin'] = ($product['slug'] ?? $pluginSlug) . '/' . ($product['slug'] ?? $pluginSlug) . '.php';
$response['download_url'] = $product['download_url'] ?? null;
$response['package'] = $product['download_url'] ?? null;
$response['last_updated'] = $product['last_updated'] ?? null;
$response['tested'] = $product['tested'] ?? null;
$response['requires'] = $product['requires'] ?? null;
$response['requires_php'] = $product['requires_php'] ?? null;
$response['changelog'] = $product['changelog'] ?? null;
$response['package_hash'] = $product['package_hash'] ?? null;
$response['name'] = $product['name'] ?? null;
$response['homepage'] = $product['homepage'] ?? null;
}
return new \WP_REST_Response($response, 200);
}
private function errorResponse(string $code, string $message, int $status): \WP_REST_Response
{
$data = [
@@ -732,6 +882,34 @@ final class LicenseApi
return false; // Replace with actual implementation
}
/**
* Find a product by ID.
*
* TODO: Replace with your WooCommerce product lookup
*
* @return array|null Product data or null if not found
*/
private function findProduct(int $productId): ?array
{
// Example structure - replace with actual database lookup
// return [
// 'product_id' => 123,
// 'name' => 'My Licensed Plugin',
// 'slug' => 'my-licensed-plugin',
// 'version' => '1.2.0',
// 'download_url' => 'https://example.com/license-download/123-abc',
// 'last_updated' => '2026-01-27',
// 'tested' => '6.7',
// 'requires' => '6.0',
// 'requires_php' => '8.3',
// 'changelog' => '## 1.2.0\n- New feature\n- Bug fixes',
// 'package_hash' => 'sha256:abc123...',
// 'homepage' => 'https://example.com/product/my-plugin',
// ];
return null; // Replace with actual implementation
}
private function isExpired(?array $license): bool
{
if ($license === null || empty($license['expires_at'])) {

110
src/Dto/UpdateInfo.php Normal file
View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Magdev\WcLicensedProductClient\Dto;
/**
* Represents update information returned by the update-check endpoint.
*/
final readonly class UpdateInfo
{
/**
* @param bool $updateAvailable Whether an update is available
* @param string|null $version Latest available version
* @param string|null $slug Plugin slug for WordPress
* @param string|null $plugin Plugin basename (slug/slug.php)
* @param string|null $downloadUrl Secure download URL for the update package
* @param \DateTimeImmutable|null $lastUpdated Date of the latest release
* @param string|null $tested Highest WordPress version tested with
* @param string|null $requires Minimum required WordPress version
* @param string|null $requiresPhp Minimum required PHP version
* @param string|null $changelog Release notes/changelog for the update
* @param string|null $packageHash SHA256 hash of the package for integrity verification
* @param string|null $name Product name
* @param string|null $homepage Product homepage URL
* @param array<string, string>|null $icons Plugin icons for WordPress admin
* @param array<string, string>|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;
}
}

View File

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

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Magdev\WcLicensedProductClient\Exception;
final class ProductNotFoundException extends LicenseException
{
}

View File

@@ -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 Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerInterface;
@@ -144,6 +145,57 @@ final class LicenseClient 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;
}
$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
*/

View File

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

View File

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

View File

@@ -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"
}
]
}