diff --git a/CLAUDE.md b/CLAUDE.md index ff000b2..0508b8b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,14 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w No known bugs at the moment +### Version 0.1.1 + +No changes at the moment. + +### Version 0.2.0 + +No changes at the moment. + ## Technical Stack - **Language:** PHP 8.3.x @@ -153,3 +161,23 @@ When editing CLAUDE.md or other markdown files, follow these rules to avoid lint - StringEncoder uses XOR with expanded key for simple obfuscation (not encryption) - PHPUnit 11 uses PHP 8 attributes (`#[Test]`, `#[CoversClass]`) instead of annotations - OpenAPI spec (tmp/openapi.json) updated to v0.3.2 with signature header definitions + +### 2026-01-23 - Client Documentation + +**Completed:** + +- Created comprehensive `docs/client-implementation.md` documentation +- Documented all classes: LicenseClient, SecureLicenseClient, DTOs, Exceptions, Security classes +- Added integration guides for: Basic PHP, WordPress plugins, Laravel, Symfony +- Documented constructor parameters, method signatures, and return types +- Added complete exception hierarchy reference with error codes +- Included best practices section for production use +- Added API reference with endpoints and request/response formats +- Added troubleshooting section for common issues +- Updated README.md with documentation links section + +**Learnings:** + +- Client documentation complements server documentation for complete integration guide +- Integration examples for major PHP frameworks help adoption +- Error code mapping to exception classes aids programmatic error handling diff --git a/README.md b/README.md index 50e08c5..e1224df 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,13 @@ try { - **Per-License Keys**: Each license has a unique verification key - **Code Integrity**: Optional verification of source file integrity +## Documentation + +For detailed implementation guides, see: + +- [Client Implementation Guide](docs/client-implementation.md) - Complete guide for integrating this client into existing projects +- [Server Implementation Guide](docs/server-implementation.md) - How to set up response signing on the server + ## Testing Run the test suite with PHPUnit: diff --git a/docs/client-implementation.md b/docs/client-implementation.md new file mode 100644 index 0000000..07018a6 --- /dev/null +++ b/docs/client-implementation.md @@ -0,0 +1,1018 @@ +# Client-Side Implementation Guide + +This document provides a comprehensive guide for integrating the WooCommerce Licensed Product Client into existing PHP projects. It covers architecture, classes, configuration, and best practices. + +## Overview + +The `magdev/wc-licensed-product-client` library provides a complete PHP client for interacting with the WooCommerce Licensed Product REST API. It supports: + +- License validation, status checking, and activation +- Response signature verification (HMAC-SHA256) +- PSR-3 logging integration +- PSR-6 caching integration +- Comprehensive exception handling +- Optional code integrity verification + +## Requirements + +- **PHP:** 8.3 or higher +- **Dependencies:** + - `symfony/http-client` ^7.0 + - `psr/log` ^3.0 + - `psr/cache` ^3.0 + - `psr/http-client` ^1.0 + +## Installation + +```bash +composer require magdev/wc-licensed-product-client +``` + +## Architecture + +### Class Diagram + +```text +┌─────────────────────────────┐ +│ LicenseClientInterface │ +│ (Public API Contract) │ +├─────────────────────────────┤ +│ + validate() │ +│ + status() │ +│ + activate() │ +└─────────────┬───────────────┘ + │ implements + ├───────────────────────┐ + ▼ ▼ +┌─────────────────────┐ ┌──────────────────────┐ +│ LicenseClient │ │ SecureLicenseClient │ +│ (Basic Client) │ │ (With Signatures) │ +└─────────────────────┘ └──────────────────────┘ + │ │ + └───────────┬───────────┘ + ▼ + ┌─────────────────────┐ + │ DTOs │ + ├─────────────────────┤ + │ LicenseInfo │ + │ LicenseStatus │ + │ ActivationResult │ + │ LicenseState (enum) │ + └─────────────────────┘ +``` + +### Directory Structure + +```text +src/ +├── LicenseClientInterface.php # Public API contract +├── LicenseClient.php # Basic implementation +├── SecureLicenseClient.php # Secure implementation with signatures +├── Dto/ +│ ├── LicenseInfo.php # Validation response DTO +│ ├── LicenseStatus.php # Status response DTO + LicenseState enum +│ └── ActivationResult.php # Activation response DTO +├── Exception/ +│ ├── LicenseException.php # Base exception +│ ├── LicenseNotFoundException.php +│ ├── LicenseExpiredException.php +│ ├── LicenseRevokedException.php +│ ├── LicenseInactiveException.php +│ ├── LicenseInvalidException.php +│ ├── DomainMismatchException.php +│ ├── MaxActivationsReachedException.php +│ ├── ActivationFailedException.php +│ └── RateLimitExceededException.php +└── Security/ + ├── ResponseSignature.php # HMAC signature verification + ├── SignatureException.php # Signature verification error + ├── StringEncoder.php # XOR-based string obfuscation + ├── IntegrityChecker.php # Source file hash verification + └── IntegrityException.php # Integrity check failure +``` + +## Client Classes + +### LicenseClientInterface + +The public API contract that both client implementations follow: + +```php +interface LicenseClientInterface +{ + /** + * Validate a license key for a specific domain. + * + * @throws LicenseException When validation fails + */ + public function validate(string $licenseKey, string $domain): LicenseInfo; + + /** + * Get detailed status information for a license key. + * + * @throws LicenseException When status check fails + */ + public function status(string $licenseKey): LicenseStatus; + + /** + * Activate a license on a specific domain. + * + * @throws LicenseException When activation fails + */ + public function activate(string $licenseKey, string $domain): ActivationResult; +} +``` + +### LicenseClient (Basic) + +The basic client without signature verification. Use this when: + +- The server does not support response signing +- You're in a development/testing environment +- Security is handled at another layer + +**Constructor Parameters:** + +| Parameter | Type | Required | Default | Description | +| --------- | ---- | -------- | ------- | ----------- | +| `httpClient` | `HttpClientInterface` | Yes | - | Symfony HTTP client instance | +| `baseUrl` | `string` | Yes | - | WordPress site URL (e.g., `https://example.com`) | +| `logger` | `LoggerInterface\|null` | No | `NullLogger` | PSR-3 logger for debugging | +| `cache` | `CacheItemPoolInterface\|null` | No | `null` | PSR-6 cache for responses | +| `cacheTtl` | `int` | No | `300` | Cache TTL in seconds (5 minutes) | + +**Example:** + +```php +use Magdev\WcLicensedProductClient\LicenseClient; +use Symfony\Component\HttpClient\HttpClient; + +$client = new LicenseClient( + httpClient: HttpClient::create(), + baseUrl: 'https://your-wordpress-site.com', +); +``` + +### SecureLicenseClient (Recommended) + +The secure client with response signature verification. Use this for production environments. + +**Constructor Parameters:** + +| Parameter | Type | Required | Default | Description | +| --------- | ---- | -------- | ------- | ----------- | +| `httpClient` | `HttpClientInterface` | Yes | - | Symfony HTTP client instance | +| `baseUrl` | `string` | Yes | - | WordPress site URL | +| `serverSecret` | `string` | Yes | - | Shared secret (same as server) | +| `logger` | `LoggerInterface\|null` | No | `NullLogger` | PSR-3 logger | +| `cache` | `CacheItemPoolInterface\|null` | No | `null` | PSR-6 cache | +| `cacheTtl` | `int` | No | `300` | Cache TTL in seconds | +| `verifyIntegrity` | `bool` | No | `false` | Enable source file integrity checks | +| `encoder` | `StringEncoder\|null` | No | Default instance | Custom string encoder | + +**Example:** + +```php +use Magdev\WcLicensedProductClient\SecureLicenseClient; +use Symfony\Component\HttpClient\HttpClient; + +$client = new SecureLicenseClient( + httpClient: HttpClient::create(), + baseUrl: 'https://your-wordpress-site.com', + serverSecret: 'your-shared-server-secret-min-32-chars', +); +``` + +**Security Features:** + +1. **Response Signature Verification**: Every response is verified using HMAC-SHA256 +2. **Timestamp Validation**: Prevents replay attacks (5-minute tolerance) +3. **Per-License Key Derivation**: Each license gets a unique signing key +4. **Obfuscated API Paths**: API endpoint paths are XOR-encoded in source code +5. **Optional Integrity Checking**: Verify critical source files haven't been modified + +## Data Transfer Objects (DTOs) + +### LicenseInfo + +Returned by `validate()`. Contains license details for a valid license. + +```php +final readonly class LicenseInfo +{ + public function __construct( + public int $productId, // WooCommerce product ID + public ?\DateTimeImmutable $expiresAt, // null = lifetime license + public ?int $versionId, // Product version (if applicable) + ) {} + + public static function fromArray(array $data): self; + + public function isLifetime(): bool; // Returns true if no expiration +} +``` + +**Usage:** + +```php +$info = $client->validate('ABCD-1234-EFGH-5678', 'example.com'); + +echo "Product ID: " . $info->productId; +echo "Lifetime: " . ($info->isLifetime() ? 'Yes' : 'No'); + +if (!$info->isLifetime()) { + echo "Expires: " . $info->expiresAt->format('Y-m-d'); +} +``` + +### LicenseStatus + +Returned by `status()`. Contains comprehensive license information. + +```php +final readonly class LicenseStatus +{ + public function __construct( + public bool $valid, // Is license currently valid + public LicenseState $status, // Status enum value + public string $domain, // Bound domain + public ?\DateTimeImmutable $expiresAt, // Expiration date + public int $activationsCount, // Current activations + public int $maxActivations, // Maximum allowed + ) {} + + public static function fromArray(array $data): self; + + public function isLifetime(): bool; + + public function hasAvailableActivations(): bool; +} +``` + +**Usage:** + +```php +$status = $client->status('ABCD-1234-EFGH-5678'); + +echo "Valid: " . ($status->valid ? 'Yes' : 'No'); +echo "Status: " . $status->status->value; // active, inactive, expired, revoked +echo "Domain: " . $status->domain; +echo "Activations: " . $status->activationsCount . "/" . $status->maxActivations; + +if ($status->hasAvailableActivations()) { + echo "More activations available!"; +} +``` + +### LicenseState (Enum) + +Represents possible license states: + +```php +enum LicenseState: string +{ + case Active = 'active'; // License is active and valid + case Inactive = 'inactive'; // License exists but not activated + case Expired = 'expired'; // License has passed expiration date + case Revoked = 'revoked'; // License was manually revoked +} +``` + +### ActivationResult + +Returned by `activate()`. Contains activation outcome. + +```php +final readonly class ActivationResult +{ + public function __construct( + public bool $success, // Whether activation succeeded + public string $message, // Human-readable message + ) {} + + public static function fromArray(array $data): self; +} +``` + +**Usage:** + +```php +$result = $client->activate('ABCD-1234-EFGH-5678', 'newdomain.com'); + +if ($result->success) { + echo "Activated: " . $result->message; +} else { + echo "Failed: " . $result->message; +} +``` + +## Exception Hierarchy + +All exceptions extend `LicenseException` and include an `errorCode` property for programmatic handling. + +```text +LicenseException (base) +├── LicenseNotFoundException // error: license_not_found +├── LicenseExpiredException // error: license_expired +├── LicenseRevokedException // error: license_revoked +├── LicenseInactiveException // error: license_inactive +├── LicenseInvalidException // error: license_invalid +├── DomainMismatchException // error: domain_mismatch +├── MaxActivationsReachedException // error: max_activations_reached +├── ActivationFailedException // error: activation_failed +├── RateLimitExceededException // error: rate_limit_exceeded (has retryAfter) +└── Security\SignatureException // error: signature_invalid +└── Security\IntegrityException // error: integrity_check_failed +``` + +### Exception Properties + +**LicenseException (Base):** + +```php +class LicenseException extends \RuntimeException +{ + public readonly ?string $errorCode; // API error code for programmatic handling +} +``` + +**RateLimitExceededException:** + +```php +final class RateLimitExceededException extends LicenseException +{ + public readonly ?int $retryAfter; // Seconds until rate limit resets +} +``` + +**IntegrityException:** + +```php +final class IntegrityException extends LicenseException +{ + public readonly array $failures; // List of failed integrity checks +} +``` + +### Catching Exceptions + +```php +use Magdev\WcLicensedProductClient\Exception\{ + LicenseException, + LicenseNotFoundException, + LicenseExpiredException, + DomainMismatchException, + RateLimitExceededException, +}; +use Magdev\WcLicensedProductClient\Security\SignatureException; + +try { + $info = $client->validate($licenseKey, $domain); +} catch (SignatureException $e) { + // Response was tampered with or server secret mismatch + error_log("SECURITY: Response signature invalid"); + die("License verification failed. Please contact support."); +} catch (LicenseNotFoundException $e) { + echo "License key not found."; +} catch (LicenseExpiredException $e) { + echo "Your license has expired. Please renew."; +} catch (DomainMismatchException $e) { + echo "This license is not valid for this domain."; +} catch (RateLimitExceededException $e) { + echo "Too many requests. Try again in {$e->retryAfter} seconds."; +} catch (LicenseException $e) { + // Catch-all for any license error + echo "License error: " . $e->getMessage(); + error_log("License error [{$e->errorCode}]: " . $e->getMessage()); +} +``` + +## Security Classes + +### ResponseSignature + +Verifies HMAC-SHA256 signatures on API responses to prevent tampering. + +**Key Derivation:** + +Each license key derives a unique signing key from the server secret: + +```php +// HKDF-like key derivation (internal implementation) +$prk = hash_hmac('sha256', $licenseKey, $serverSecret, true); +$derivedKey = hash_hmac('sha256', $prk . "\x01", $serverSecret); +``` + +**Signature Format:** + +```text +signature = HMAC-SHA256( + key = derived_key, + message = timestamp + ":" + canonical_json(response_body) +) +``` + +Where: + +- `canonical_json` sorts keys alphabetically +- JSON uses `JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE` flags +- Result is hex-encoded (64 characters) + +**Response Headers:** + +| Header | Description | Example | +| ------ | ----------- | ------- | +| `X-License-Signature` | HMAC-SHA256 signature (hex) | `a1b2c3...` (64 chars) | +| `X-License-Timestamp` | Unix timestamp when signed | `1706000000` | + +**Custom Timestamp Tolerance:** + +```php +use Magdev\WcLicensedProductClient\Security\ResponseSignature; + +// Default is 300 seconds (5 minutes) +$signature = new ResponseSignature($secretKey, timestampTolerance: 600); // 10 minutes +``` + +### StringEncoder + +Simple XOR-based string obfuscation for hiding API paths in source code. + +**WARNING:** This is NOT encryption. It only prevents casual inspection. + +```php +use Magdev\WcLicensedProductClient\Security\StringEncoder; + +$encoder = new StringEncoder(); + +// Encode strings for embedding in source code +$encoded = $encoder->encode('/wp-json/wc-licensed-product/v1'); +// Result: "MR4xBi8rPDUcHhk2EQ0TICsvMBo9Mw0HJRE=" + +// Decode at runtime +$decoded = $encoder->decode('MR4xBi8rPDUcHhk2EQ0TICsvMBo9Mw0HJRE='); +// Result: "/wp-json/wc-licensed-product/v1" + +// Generate multiple encoded constants +$constants = $encoder->generateEncodedConstants([ + 'API_PATH' => '/wp-json/wc-licensed-product/v1', + 'VALIDATE' => 'validate', + 'STATUS' => 'status', +]); +``` + +**Custom Encoder Key:** + +```php +$encoder = new StringEncoder('my_custom_encoder_key'); +``` + +### IntegrityChecker + +Verifies that critical source files haven't been modified by comparing SHA256 hashes. + +```php +use Magdev\WcLicensedProductClient\Security\IntegrityChecker; + +// Define expected hashes (generated during build) +$expectedHashes = [ + 'src/SecureLicenseClient.php' => 'abc123...', + 'src/Security/ResponseSignature.php' => 'def456...', +]; + +$checker = new IntegrityChecker($expectedHashes, '/path/to/project'); + +// Verify all files +try { + $checker->verify(); + echo "All files intact"; +} catch (IntegrityException $e) { + echo "Tampered files: " . implode(', ', $e->failures); +} + +// Or check silently +if (!$checker->isValid()) { + die("Critical files have been modified!"); +} +``` + +**Generating Hashes During Build:** + +```php +$checker = new IntegrityChecker([], '/path/to/project'); + +$hashes = $checker->generateHashes([ + 'src/SecureLicenseClient.php', + 'src/Security/ResponseSignature.php', + 'src/Security/SignatureException.php', +]); + +// Output as PHP code for embedding +echo $checker->generateHashesAsPhpCode([ + 'src/SecureLicenseClient.php', + 'src/Security/ResponseSignature.php', +]); +``` + +## Caching + +Both clients support PSR-6 caching to reduce API calls. Cache keys use SHA256 hashes of license keys to avoid exposing sensitive data. + +**Cache Key Format:** + +```text +wc_license_{operation}_{sha256(license_key)}[_{sha256(domain)}] +``` + +**Example with Symfony Cache:** + +```php +use Symfony\Component\Cache\Adapter\FilesystemAdapter; + +$cache = new FilesystemAdapter( + namespace: 'license_client', + defaultLifetime: 300, + directory: '/tmp/license-cache' +); + +$client = new SecureLicenseClient( + httpClient: HttpClient::create(), + baseUrl: 'https://example.com', + serverSecret: 'your-secret', + cache: $cache, + cacheTtl: 600, // 10 minutes +); +``` + +**Cache Behavior:** + +- `validate()` and `status()` responses are cached +- `activate()` responses are NOT cached +- After `activate()`, related cache entries are automatically invalidated + +## Logging + +Both clients support PSR-3 logging for debugging and monitoring. + +**Log Levels Used:** + +| Level | When Used | +| ----- | --------- | +| `debug` | Cache hits, signature verification success | +| `info` | API calls, successful operations | +| `warning` | API error responses, missing signatures | +| `error` | Request failures, network errors | +| `critical` | Integrity check failures | + +**Example with Monolog:** + +```php +use Monolog\Logger; +use Monolog\Handler\StreamHandler; + +$logger = new Logger('license'); +$logger->pushHandler(new StreamHandler('/var/log/license.log', Logger::DEBUG)); + +$client = new SecureLicenseClient( + httpClient: HttpClient::create(), + baseUrl: 'https://example.com', + serverSecret: 'your-secret', + logger: $logger, +); +``` + +## Integration Guide + +### Basic Integration + +The simplest integration for existing PHP projects: + +```php +validate($licenseKey, $currentDomain); + + // Define constant for use throughout application + define('MY_PRODUCT_LICENSED', true); + define('MY_PRODUCT_ID', $info->productId); + +} catch (LicenseException $e) { + define('MY_PRODUCT_LICENSED', false); + define('MY_PRODUCT_ID', null); + + // Log the error but don't expose details to users + error_log("License validation failed: " . $e->getMessage()); +} +``` + +### WordPress Plugin Integration + +```php +client = new SecureLicenseClient( + httpClient: HttpClient::create(), + baseUrl: self::SERVER_URL, + serverSecret: self::SERVER_SECRET, + ); + } + + public function isLicenseValid(): bool + { + $licenseKey = get_option(self::LICENSE_OPTION); + + if (empty($licenseKey)) { + return false; + } + + // Use transient for caching + $cacheKey = 'my_plugin_license_valid_' . md5($licenseKey); + $cached = get_transient($cacheKey); + + if ($cached !== false) { + return $cached === 'valid'; + } + + try { + $domain = wp_parse_url(home_url(), PHP_URL_HOST); + $this->client->validate($licenseKey, $domain); + + set_transient($cacheKey, 'valid', HOUR_IN_SECONDS); + return true; + + } catch (LicenseException $e) { + set_transient($cacheKey, 'invalid', 5 * MINUTE_IN_SECONDS); + return false; + } + } + + public function activateLicense(string $licenseKey): array + { + try { + $domain = wp_parse_url(home_url(), PHP_URL_HOST); + $result = $this->client->activate($licenseKey, $domain); + + if ($result->success) { + update_option(self::LICENSE_OPTION, $licenseKey); + delete_transient('my_plugin_license_valid_' . md5($licenseKey)); + } + + return [ + 'success' => $result->success, + 'message' => $result->message, + ]; + + } catch (LicenseException $e) { + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } +} +``` + +### Laravel Integration + +**Service Provider:** + +```php +app->singleton(LicenseClientInterface::class, function ($app) { + return new SecureLicenseClient( + httpClient: HttpClient::create(), + baseUrl: config('services.license.url'), + serverSecret: config('services.license.secret'), + logger: $app->make(Logger::class), + cache: $app->make(CacheManager::class)->store('file')->getStore(), + ); + }); + } +} +``` + +**Configuration (config/services.php):** + +```php +return [ + // ... + 'license' => [ + 'url' => env('LICENSE_SERVER_URL'), + 'secret' => env('LICENSE_SERVER_SECRET'), + ], +]; +``` + +**Usage in Controller:** + +```php +licenseClient->validate( + $request->input('license_key'), + $request->getHost(), + ); + + return response()->json([ + 'valid' => true, + 'product_id' => $info->productId, + 'expires_at' => $info->expiresAt?->format('Y-m-d'), + ]); + + } catch (LicenseException $e) { + return response()->json([ + 'valid' => false, + 'error' => $e->errorCode, + 'message' => $e->getMessage(), + ], 403); + } + } +} +``` + +### Symfony Integration + +**Service Configuration (config/services.yaml):** + +```yaml +services: + Magdev\WcLicensedProductClient\LicenseClientInterface: + class: Magdev\WcLicensedProductClient\SecureLicenseClient + arguments: + $httpClient: '@Symfony\Contracts\HttpClient\HttpClientInterface' + $baseUrl: '%env(LICENSE_SERVER_URL)%' + $serverSecret: '%env(LICENSE_SERVER_SECRET)%' + $logger: '@logger' + $cache: '@cache.app' +``` + +## Best Practices + +### 1. Use the Secure Client in Production + +Always use `SecureLicenseClient` in production to verify response signatures: + +```php +// Development +$client = new LicenseClient(...); + +// Production +$client = new SecureLicenseClient(..., serverSecret: $secret); +``` + +### 2. Handle All Exception Types + +Don't catch just the base exception. Handle specific cases: + +```php +try { + $client->validate($key, $domain); +} catch (SignatureException $e) { + // Critical: possible tampering + notifyAdmin("Signature verification failed!"); + die("Security error"); +} catch (RateLimitExceededException $e) { + // Retry later + sleep($e->retryAfter ?? 60); + retry(); +} catch (LicenseExpiredException $e) { + // Prompt renewal + showRenewalForm(); +} catch (LicenseException $e) { + // Generic handling + showError($e->getMessage()); +} +``` + +### 3. Cache Validation Results + +Use caching to minimize API calls: + +```php +$cache = new FilesystemAdapter('licenses', 300); +$client = new SecureLicenseClient(..., cache: $cache, cacheTtl: 300); +``` + +### 4. Store Secrets Securely + +Never hardcode secrets in source code: + +```php +// Bad +$client = new SecureLicenseClient(..., serverSecret: 'abc123'); + +// Good +$client = new SecureLicenseClient(..., serverSecret: getenv('LICENSE_SECRET')); +``` + +### 5. Validate on Application Startup + +Check license validity early, not on every request: + +```php +// bootstrap.php +$isLicensed = checkLicense(); +define('APP_LICENSED', $isLicensed); + +// Later in code +if (APP_LICENSED) { + showPremiumFeatures(); +} +``` + +### 6. Log License Events + +Enable logging for debugging and audit trails: + +```php +$client = new SecureLicenseClient( + ..., + logger: new Logger('license'), +); +``` + +### 7. Implement Graceful Degradation + +Don't crash the entire application on license failure: + +```php +try { + $info = $client->validate($key, $domain); + enablePremiumFeatures($info); +} catch (LicenseException $e) { + // Fall back to free tier + enableFreeFeatures(); + logLicenseIssue($e); +} +``` + +## API Reference + +### API Endpoints + +The client communicates with these REST API endpoints: + +| Endpoint | Method | Description | +| -------- | ------ | ----------- | +| `/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 | + +### Request Format + +All requests use JSON bodies: + +```json +{ + "license_key": "ABCD-1234-EFGH-5678", + "domain": "example.com" +} +``` + +### Response Format + +**Successful Validation:** + +```json +{ + "valid": true, + "license": { + "product_id": 123, + "expires_at": "2027-01-21", + "version_id": null + } +} +``` + +**Error Response:** + +```json +{ + "valid": false, + "error": "license_expired", + "message": "This license has expired." +} +``` + +### Error Codes + +| Error Code | Exception Class | Description | +| ---------- | --------------- | ----------- | +| `license_not_found` | `LicenseNotFoundException` | License key doesn't exist | +| `license_expired` | `LicenseExpiredException` | License past expiration date | +| `license_revoked` | `LicenseRevokedException` | License manually revoked | +| `license_inactive` | `LicenseInactiveException` | License not activated | +| `license_invalid` | `LicenseInvalidException` | License invalid for operation | +| `domain_mismatch` | `DomainMismatchException` | Domain not authorized | +| `max_activations_reached` | `MaxActivationsReachedException` | Too many activations | +| `activation_failed` | `ActivationFailedException` | Server error during activation | +| `rate_limit_exceeded` | `RateLimitExceededException` | Too many requests | +| `signature_invalid` | `SignatureException` | Response signature invalid | +| `integrity_check_failed` | `IntegrityException` | Source files modified | + +## Troubleshooting + +### "Response is not signed by the server" + +- Server not configured with `WC_LICENSE_SERVER_SECRET` +- Using `SecureLicenseClient` with non-signing server +- **Solution:** Configure server secret or use `LicenseClient` + +### "Response signature verification failed" + +- Different secrets on server/client +- Clock skew > 5 minutes between server and client +- Response modified by proxy/cache +- **Solution:** Verify secrets match, sync clocks, disable response caching + +### Connection Timeouts + +- Network issues between client and license server +- **Solution:** Configure HTTP client timeout + +```php +$httpClient = HttpClient::create([ + 'timeout' => 30, + 'max_duration' => 60, +]); +``` + +### Rate Limiting + +- Too many requests from same IP +- **Solution:** Implement caching, handle `RateLimitExceededException` + +```php +catch (RateLimitExceededException $e) { + $waitSeconds = $e->retryAfter ?? 60; + // Implement exponential backoff +} +```