From 9f513a819e8218a0e8e16f0be8f7edbf0f30245e Mon Sep 17 00:00:00 2001 From: magdev Date: Fri, 23 Jan 2026 16:45:59 +0100 Subject: [PATCH] Update server implementation documentation - Add complete API endpoints reference with request/response formats - Add recursive key sorting for nested objects in signatures - Add comprehensive error codes table with HTTP status codes - Add rate limiting implementation with configurable limits - Add complete WordPress plugin example with all handlers - Add security sections: HTTPS, input sanitization, caching conflicts - Update PHP version requirement to 8.3 for consistency - Expand troubleshooting section with more scenarios Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 2 +- docs/server-implementation.md | 788 +++++++++++++++++++++++++++++----- 2 files changed, 676 insertions(+), 114 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0508b8b..e9550a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ No known bugs at the moment ### Version 0.1.1 -No changes at the moment. +No changes at the moment ### Version 0.2.0 diff --git a/docs/server-implementation.md b/docs/server-implementation.md index f7fde97..b2f453c 100644 --- a/docs/server-implementation.md +++ b/docs/server-implementation.md @@ -1,25 +1,28 @@ -# Server-Side Response Signing Implementation +# Server-Side Implementation Guide -This document describes how to implement response signing on the server side (e.g., in the WooCommerce Licensed Product plugin) to work with the `SecureLicenseClient`. +This document describes how to implement the WooCommerce Licensed Product REST API with response signing. The API allows external applications to validate, check status, and activate software licenses. ## Overview -The security model works as follows: +The license server provides: -1. Server generates a unique signature for each response using HMAC-SHA256 -2. Signature includes a timestamp to prevent replay attacks -3. Client verifies the signature using a shared secret -4. Invalid signatures cause the client to reject the response +1. **REST API endpoints** for license operations +2. **Response signing** using HMAC-SHA256 for tamper protection +3. **Rate limiting** to prevent abuse +4. **Comprehensive error responses** for programmatic handling -This prevents attackers from: +### Security Model + +The signature system prevents attackers from: - Faking valid license responses -- Replaying old responses -- Tampering with response data +- Replaying old responses (timestamp validation) +- Tampering with response data in transit ## Requirements -- PHP 7.4+ (8.0+ recommended) +- PHP 8.3 or higher (to match client requirements) +- WordPress 6.0+ with WooCommerce - A server secret stored securely (not in version control) ## Server Configuration @@ -29,7 +32,7 @@ This prevents attackers from: Add a secret key to your WordPress configuration: ```php -// wp-config.php or secure configuration file +// wp-config.php define('WC_LICENSE_SERVER_SECRET', 'your-secure-random-string-min-32-chars'); ``` @@ -45,7 +48,143 @@ php -r "echo bin2hex(random_bytes(32));" **IMPORTANT:** Never commit this secret to version control! -## Implementation +### 2. Configure Rate Limiting (Optional) + +```php +// wp-config.php +define('WC_LICENSE_RATE_LIMIT', 30); // Requests per window +define('WC_LICENSE_RATE_WINDOW', 60); // Window in seconds +``` + +## API Endpoints + +Base URL: `{site_url}/wp-json/wc-licensed-product/v1` + +### POST /validate + +Validate a license key for a specific domain. + +**Request:** + +```json +{ + "license_key": "ABCD-1234-EFGH-5678", + "domain": "example.com" +} +``` + +**Success Response (200):** + +```json +{ + "valid": true, + "license": { + "product_id": 123, + "expires_at": "2027-01-21", + "version_id": null + } +} +``` + +**Error Response (403):** + +```json +{ + "valid": false, + "error": "license_expired", + "message": "This license has expired." +} +``` + +### POST /status + +Get detailed license status information. + +**Request:** + +```json +{ + "license_key": "ABCD-1234-EFGH-5678" +} +``` + +**Success Response (200):** + +```json +{ + "valid": true, + "status": "active", + "domain": "example.com", + "expires_at": "2027-01-21", + "activations_count": 1, + "max_activations": 3 +} +``` + +### POST /activate + +Activate a license on a specific domain. + +**Request:** + +```json +{ + "license_key": "ABCD-1234-EFGH-5678", + "domain": "newdomain.com" +} +``` + +**Success Response (200):** + +```json +{ + "success": true, + "message": "License activated successfully." +} +``` + +**Error Response (403):** + +```json +{ + "success": false, + "error": "max_activations_reached", + "message": "Maximum number of activations reached." +} +``` + +## Error Codes + +The API uses consistent error codes for programmatic handling: + +| Error Code | HTTP Status | Description | +| ---------- | ----------- | ----------- | +| `license_not_found` | 404 | License key doesn't exist | +| `license_expired` | 403 | License past expiration date | +| `license_revoked` | 403 | License manually revoked by admin | +| `license_inactive` | 403 | License not yet activated | +| `license_invalid` | 403 | License invalid for requested operation | +| `domain_mismatch` | 403 | License not authorized for this domain | +| `max_activations_reached` | 403 | Maximum activations limit exceeded | +| `activation_failed` | 500 | Server error during activation | +| `rate_limit_exceeded` | 429 | Too many requests | + +### Rate Limit Response + +When rate limit is exceeded, include `retry_after`: + +```json +{ + "success": false, + "error": "rate_limit_exceeded", + "message": "Too many requests. Please try again later.", + "retry_after": 45 +} +``` + +Also include the `Retry-After` HTTP header. + +## Response Signing Implementation ### Key Derivation @@ -68,6 +207,31 @@ function derive_signing_key(string $licenseKey, string $serverSecret): string } ``` +### Recursive Key Sorting + +**IMPORTANT:** Response data must have keys sorted recursively for consistent signatures: + +```php +/** + * Recursively sort array keys for consistent JSON output. + * + * @param array $data The data to sort + * @return array Sorted data + */ +function sort_keys_recursive(array $data): array +{ + ksort($data); + + foreach ($data as $key => $value) { + if (is_array($value)) { + $data[$key] = sort_keys_recursive($value); + } + } + + return $data; +} +``` + ### Response Signing Sign every API response before sending: @@ -86,11 +250,11 @@ function sign_response(array $responseData, string $licenseKey, string $serverSe $timestamp = time(); $signingKey = derive_signing_key($licenseKey, $serverSecret); - // Sort keys for consistent ordering - ksort($responseData); + // Sort keys recursively for consistent ordering + $sortedData = sort_keys_recursive($responseData); // Build signature payload - $jsonBody = json_encode($responseData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $jsonBody = json_encode($sortedData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $payload = $timestamp . ':' . $jsonBody; // Generate HMAC signature @@ -103,81 +267,356 @@ function sign_response(array $responseData, string $licenseKey, string $serverSe } ``` -### WordPress REST API Integration +### Signature Algorithm -Example integration with WooCommerce REST API: - -```php -/** - * Add signature headers to license API responses. - */ -add_filter('rest_post_dispatch', function($response, $server, $request) { - // Only sign license API responses - if (!str_starts_with($request->get_route(), '/wc-licensed-product/v1/')) { - return $response; - } - - // Get the response data - $data = $response->get_data(); - - // Get the license key from the request - $licenseKey = $request->get_param('license_key'); - - if (empty($licenseKey) || !is_array($data)) { - return $response; - } - - // Sign the response - $serverSecret = defined('WC_LICENSE_SERVER_SECRET') - ? WC_LICENSE_SERVER_SECRET - : ''; - - if (empty($serverSecret)) { - // Log warning: server secret not configured - return $response; - } - - $signatureHeaders = sign_response($data, $licenseKey, $serverSecret); - - // Add headers to response - foreach ($signatureHeaders as $name => $value) { - $response->header($name, $value); - } - - return $response; -}, 10, 3); +```text +signature = HMAC-SHA256( + key = derive_signing_key(license_key, server_secret), + message = timestamp + ":" + canonical_json(response_body) +) ``` -### Complete WordPress Plugin Example +Where: + +- `derive_signing_key` uses HKDF-like derivation +- `canonical_json` sorts keys recursively, uses `JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE` +- Result is hex-encoded (64 characters) + +### Response Headers + +Every signed response includes: + +| Header | Description | Example | +| ------ | ----------- | ------- | +| `X-License-Signature` | HMAC-SHA256 signature (hex) | `a1b2c3d4...` (64 chars) | +| `X-License-Timestamp` | Unix timestamp when signed | `1706000000` | + +## Complete WordPress Plugin Implementation ```php serverSecret = defined('WC_LICENSE_SERVER_SECRET') ? WC_LICENSE_SERVER_SECRET : ''; + + $this->rateLimit = defined('WC_LICENSE_RATE_LIMIT') + ? (int) WC_LICENSE_RATE_LIMIT + : 30; + + $this->rateWindow = defined('WC_LICENSE_RATE_WINDOW') + ? (int) WC_LICENSE_RATE_WINDOW + : 60; } public function register(): void { + add_action('rest_api_init', [$this, 'registerRoutes']); add_filter('rest_post_dispatch', [$this, 'signResponse'], 10, 3); } - public function signResponse($response, $server, $request) + public function registerRoutes(): void { + register_rest_route(self::API_NAMESPACE, '/validate', [ + 'methods' => 'POST', + 'callback' => [$this, 'handleValidate'], + 'permission_callback' => [$this, 'checkRateLimit'], + 'args' => $this->getValidateArgs(), + ]); + + register_rest_route(self::API_NAMESPACE, '/status', [ + 'methods' => 'POST', + 'callback' => [$this, 'handleStatus'], + 'permission_callback' => [$this, 'checkRateLimit'], + 'args' => $this->getStatusArgs(), + ]); + + register_rest_route(self::API_NAMESPACE, '/activate', [ + 'methods' => 'POST', + 'callback' => [$this, 'handleActivate'], + 'permission_callback' => [$this, 'checkRateLimit'], + 'args' => $this->getActivateArgs(), + ]); + } + + // ========================================================================= + // Request Validation + // ========================================================================= + + private function getValidateArgs(): 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'], + ], + ]; + } + + private function getStatusArgs(): array + { + return [ + 'license_key' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => [$this, 'validateLicenseKeyFormat'], + ], + ]; + } + + private function getActivateArgs(): array + { + return $this->getValidateArgs(); // Same as validate + } + + public function validateLicenseKeyFormat($value): bool + { + return is_string($value) && strlen($value) <= 64 && strlen($value) >= 8; + } + + public function validateDomainFormat($value): bool + { + return is_string($value) && strlen($value) <= 255 && strlen($value) >= 1; + } + + // ========================================================================= + // Rate Limiting + // ========================================================================= + + public function checkRateLimit(\WP_REST_Request $request): bool|\WP_Error + { + $ip = $this->getClientIp(); + $key = 'license_rate_' . md5($ip); + + $data = get_transient($key); + + if ($data === false) { + // First request in window + set_transient($key, ['count' => 1, 'start' => time()], $this->rateWindow); + return true; + } + + if ($data['count'] >= $this->rateLimit) { + $retryAfter = $this->rateWindow - (time() - $data['start']); + + return new \WP_Error( + 'rate_limit_exceeded', + 'Too many requests. Please try again later.', + [ + 'status' => 429, + 'retry_after' => max(1, $retryAfter), + ] + ); + } + + // Increment counter + $data['count']++; + set_transient($key, $data, $this->rateWindow - (time() - $data['start'])); + + return true; + } + + private function getClientIp(): string + { + $headers = [ + 'HTTP_CF_CONNECTING_IP', // Cloudflare + 'HTTP_X_FORWARDED_FOR', // Proxy + 'HTTP_X_REAL_IP', // Nginx + 'REMOTE_ADDR', // Direct + ]; + + foreach ($headers as $header) { + if (!empty($_SERVER[$header])) { + $ip = $_SERVER[$header]; + // Handle comma-separated list (X-Forwarded-For) + if (str_contains($ip, ',')) { + $ip = trim(explode(',', $ip)[0]); + } + if (filter_var($ip, FILTER_VALIDATE_IP)) { + return $ip; + } + } + } + + return '0.0.0.0'; + } + + // ========================================================================= + // API Handlers + // ========================================================================= + + public function handleValidate(\WP_REST_Request $request): \WP_REST_Response|\WP_Error + { + $licenseKey = $request->get_param('license_key'); + $domain = $request->get_param('domain'); + + // TODO: Replace with your license lookup logic + $license = $this->findLicense($licenseKey); + + if ($license === null) { + return $this->errorResponse('license_not_found', 'License key not found.', 404); + } + + if ($license['status'] === 'revoked') { + return $this->errorResponse('license_revoked', 'This license has been revoked.', 403); + } + + if ($license['status'] === 'expired' || $this->isExpired($license)) { + return $this->errorResponse('license_expired', 'This license has expired.', 403); + } + + if ($license['status'] === 'inactive') { + return $this->errorResponse('license_inactive', 'This license is inactive.', 403); + } + + if (!empty($license['domain']) && $license['domain'] !== $domain) { + return $this->errorResponse('domain_mismatch', 'This license is not valid for this domain.', 403); + } + + return new \WP_REST_Response([ + 'valid' => true, + 'license' => [ + 'product_id' => $license['product_id'], + 'expires_at' => $license['expires_at'], + 'version_id' => $license['version_id'] ?? null, + ], + ], 200); + } + + public function handleStatus(\WP_REST_Request $request): \WP_REST_Response|\WP_Error + { + $licenseKey = $request->get_param('license_key'); + + $license = $this->findLicense($licenseKey); + + if ($license === null) { + return $this->errorResponse('license_not_found', 'License key not found.', 404); + } + + $status = $license['status']; + if ($status === 'active' && $this->isExpired($license)) { + $status = 'expired'; + } + + return new \WP_REST_Response([ + 'valid' => $status === 'active', + 'status' => $status, + 'domain' => $license['domain'] ?? '', + 'expires_at' => $license['expires_at'], + 'activations_count' => $license['activations_count'] ?? 0, + 'max_activations' => $license['max_activations'] ?? 1, + ], 200); + } + + public function handleActivate(\WP_REST_Request $request): \WP_REST_Response|\WP_Error + { + $licenseKey = $request->get_param('license_key'); + $domain = $request->get_param('domain'); + + $license = $this->findLicense($licenseKey); + + if ($license === null) { + return $this->errorResponse('license_not_found', 'License key not found.', 404); + } + + if ($license['status'] === 'revoked') { + return $this->errorResponse('license_invalid', 'This license is not valid.', 403); + } + + if ($this->isExpired($license)) { + return $this->errorResponse('license_invalid', 'This license has expired.', 403); + } + + // Check if already activated on this domain + if ($license['domain'] === $domain) { + return new \WP_REST_Response([ + 'success' => true, + 'message' => 'License is already activated for this domain.', + ], 200); + } + + // Check activation limit + $activations = $license['activations_count'] ?? 0; + $maxActivations = $license['max_activations'] ?? 1; + + if ($activations >= $maxActivations && $license['domain'] !== $domain) { + return $this->errorResponse( + 'max_activations_reached', + 'Maximum number of activations reached.', + 403 + ); + } + + // TODO: Replace with your activation logic + $activated = $this->activateLicense($licenseKey, $domain); + + if (!$activated) { + return $this->errorResponse('activation_failed', 'Failed to activate license.', 500); + } + + return new \WP_REST_Response([ + 'success' => true, + 'message' => 'License activated successfully.', + ], 200); + } + + private function errorResponse(string $code, string $message, int $status): \WP_REST_Response + { + $data = [ + 'valid' => false, + 'success' => false, + 'error' => $code, + 'message' => $message, + ]; + + if ($code === 'rate_limit_exceeded') { + $data['retry_after'] = $this->rateWindow; + } + + return new \WP_REST_Response($data, $status); + } + + // ========================================================================= + // Response Signing + // ========================================================================= + + public function signResponse( + \WP_REST_Response $response, + \WP_REST_Server $server, + \WP_REST_Request $request + ): \WP_REST_Response { if (!$this->shouldSign($request)) { return $response; } @@ -198,13 +637,11 @@ class ResponseSigner return $response; } - private function shouldSign($request): bool + private function shouldSign(\WP_REST_Request $request): bool { $route = $request->get_route(); - return str_starts_with($route, '/wc-licensed-product/v1/validate') - || str_starts_with($route, '/wc-licensed-product/v1/status') - || str_starts_with($route, '/wc-licensed-product/v1/activate'); + return str_starts_with($route, '/' . self::API_NAMESPACE . '/'); } private function createSignatureHeaders(array $data, string $licenseKey): array @@ -212,9 +649,11 @@ class ResponseSigner $timestamp = time(); $signingKey = $this->deriveKey($licenseKey); - ksort($data); + // Sort keys recursively for consistent ordering + $sortedData = $this->sortKeysRecursive($data); + $payload = $timestamp . ':' . json_encode( - $data, + $sortedData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); @@ -224,46 +663,84 @@ class ResponseSigner ]; } + private function sortKeysRecursive(array $data): array + { + ksort($data); + + foreach ($data as $key => $value) { + if (is_array($value)) { + $data[$key] = $this->sortKeysRecursive($value); + } + } + + return $data; + } + private function deriveKey(string $licenseKey): string { $prk = hash_hmac('sha256', $licenseKey, $this->serverSecret, true); return hash_hmac('sha256', $prk . "\x01", $this->serverSecret); } + + // ========================================================================= + // License Storage (Replace with your implementation) + // ========================================================================= + + /** + * Find a license by key. + * + * TODO: Replace with your database/WooCommerce lookup + * + * @return array|null License data or null if not found + */ + private function findLicense(string $licenseKey): ?array + { + // Example structure - replace with actual database lookup + // return [ + // 'license_key' => 'ABCD-1234-EFGH-5678', + // 'product_id' => 123, + // 'status' => 'active', // active, inactive, expired, revoked + // 'domain' => 'example.com', + // 'expires_at' => '2027-01-21', // or null for lifetime + // 'version_id' => null, + // 'activations_count' => 1, + // 'max_activations' => 3, + // ]; + + return null; // Replace with actual implementation + } + + /** + * Activate a license on a domain. + * + * TODO: Replace with your database update logic + */ + private function activateLicense(string $licenseKey, string $domain): bool + { + // Update license record with new domain + // Increment activations_count + // Set status to 'active' + + return false; // Replace with actual implementation + } + + private function isExpired(?array $license): bool + { + if ($license === null || empty($license['expires_at'])) { + return false; // Lifetime license + } + + return strtotime($license['expires_at']) < time(); + } } -// Initialize -add_action('init', function() { - (new ResponseSigner())->register(); +// Initialize plugin +add_action('plugins_loaded', function () { + (new LicenseApi())->register(); }); ``` -## Response Format - -### Headers - -Every signed response includes: - -| Header | Description | Example | -| -------- | ------------- | --------- | -| `X-License-Signature` | HMAC-SHA256 signature (hex) | `a1b2c3d4...` (64 chars) | -| `X-License-Timestamp` | Unix timestamp when signed | `1706000000` | - -### Signature Algorithm - -```text -signature = HMAC-SHA256( - key = derive_signing_key(license_key, server_secret), - message = timestamp + ":" + canonical_json(response_body) -) -``` - -Where: - -- `derive_signing_key` uses HKDF-like derivation (see above) -- `canonical_json` sorts keys alphabetically, no escaping of slashes/unicode -- Result is hex-encoded (64 characters) - ## Testing ### Verify Signing Works @@ -291,6 +768,7 @@ echo "X-License-Timestamp: " . $headers['X-License-Timestamp'] . "\n"; ```php use Magdev\WcLicensedProductClient\SecureLicenseClient; +use Magdev\WcLicensedProductClient\Security\SignatureException; use Symfony\Component\HttpClient\HttpClient; $client = new SecureLicenseClient( @@ -307,6 +785,18 @@ try { } ``` +### Verify Signature Manually + +```bash +# Using curl to test the API +curl -X POST https://your-site.com/wp-json/wc-licensed-product/v1/validate \ + -H "Content-Type: application/json" \ + -d '{"license_key":"ABCD-1234-EFGH-5678","domain":"example.com"}' \ + -i + +# Check for X-License-Signature and X-License-Timestamp headers +``` + ## Security Considerations ### Timestamp Tolerance @@ -320,6 +810,8 @@ Adjust if needed: ```php // Client-side: custom tolerance +use Magdev\WcLicensedProductClient\Security\ResponseSignature; + $signature = new ResponseSignature($key, timestampTolerance: 600); // 10 minutes ``` @@ -344,19 +836,42 @@ $secrets = [ $response->header('X-License-Signature-Version', 'v2'); ``` -### Error Responses +### Sign Error Responses Sign error responses too! Otherwise attackers could craft fake error messages: ```php -// Sign both success and error responses -$errorData = [ - 'valid' => false, - 'error' => 'license_expired', - 'message' => 'This license has expired.', -]; +// Both success and error responses are signed by the filter +// No additional code needed - the signResponse filter handles all responses +``` -$headers = sign_response($errorData, $licenseKey, $serverSecret); +### HTTPS Required + +Always serve the API over HTTPS: + +```php +// In your plugin, enforce HTTPS +add_action('rest_api_init', function() { + if (!is_ssl() && !defined('WP_DEBUG') || !WP_DEBUG) { + wp_die('License API requires HTTPS'); + } +}); +``` + +### Input Sanitization + +Always sanitize and validate input: + +```php +// The WordPress REST API args handle this automatically +// But add additional validation as needed +$licenseKey = sanitize_text_field($request->get_param('license_key')); +$domain = sanitize_text_field($request->get_param('domain')); + +// Validate format +if (!preg_match('/^[A-Z0-9\-]{8,64}$/i', $licenseKey)) { + return new WP_Error('invalid_license_key', 'Invalid license key format'); +} ``` ## Troubleshooting @@ -372,22 +887,69 @@ $headers = sign_response($errorData, $licenseKey, $serverSecret); - Different secrets on server/client - Clock skew > 5 minutes - Response body modified after signing (e.g., by caching plugin) -- JSON encoding differences (check `ksort` and flags) +- JSON encoding differences (check recursive `ksort` and flags) +- Nested objects not sorted consistently ### Debugging Enable detailed logging: ```php -// Server-side -error_log('Signing response for: ' . $licenseKey); -error_log('Timestamp: ' . $timestamp); -error_log('Payload: ' . $payload); -error_log('Signature: ' . $signature); +// Server-side debugging +add_filter('rest_post_dispatch', function($response, $server, $request) { + if (!str_starts_with($request->get_route(), '/wc-licensed-product/v1/')) { + return $response; + } + $data = $response->get_data(); + $sortedData = sort_keys_recursive($data); + $json = json_encode($sortedData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + error_log('License API Debug:'); + error_log('Route: ' . $request->get_route()); + error_log('License Key: ' . $request->get_param('license_key')); + error_log('Sorted JSON: ' . $json); + error_log('Timestamp: ' . time()); + + return $response; +}, 5, 3); +``` + +```php // Client-side: use a PSR-3 logger $client = new SecureLicenseClient( // ... logger: new YourDebugLogger(), ); ``` + +### Rate Limit Issues + +If legitimate users hit rate limits: + +```php +// Increase limits in wp-config.php +define('WC_LICENSE_RATE_LIMIT', 60); // 60 requests +define('WC_LICENSE_RATE_WINDOW', 60); // per minute + +// Or implement per-license key rate limiting instead of per-IP +$key = 'license_rate_' . md5($licenseKey); +``` + +### Caching Conflicts + +If a caching plugin modifies responses: + +```php +// Exclude license API from caching +add_action('rest_api_init', function() { + if (str_contains($_SERVER['REQUEST_URI'], '/wc-licensed-product/')) { + // Disable page caching + define('DONOTCACHEPAGE', true); + + // Set no-cache headers + header('Cache-Control: no-store, no-cache, must-revalidate'); + header('Pragma: no-cache'); + } +}); +```