# License System

The Pro license layer — activation, status persistence, server-side checks, OTP confirmation, plugin updates, and the weekly cron refresh. This is **paid-feature gating + plugin update transport** and every change is security-sensitive.

## Context

Pro is a commercial plugin. Without an active license, Pro should:

- Allow installation (no install-time gate)
- Show clear admin notices prompting activation
- Block plugin auto-updates from the store API (`api.wpdeveloper.com`)
- (Today) allow widgets to render — feature gating is at the update-transport level, not at the render path

The license stack is wholly admin-side. Front-end render paths never instantiate `Manager`. They read the cached status string from `LicenseStore` if they need to know whether the license is valid (today only the License/* code itself reads status; widgets do not).

The implementation is derived from Easy Digital Downloads' Software Licensing pattern (you'll see `edd_sl_*` cache keys and `edd_action` request body in code).

## Verified facts

### Surface

| File                                              | Role                                                                                            |
| ------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `includes/Classes/License/Manager.php`            | Singleton lifecycle controller. Internal version `'2.1.0'` (line 53). Entry: `Manager::get_instance($args)` |
| `includes/Classes/License/LicenseStore.php`       | All WP option / transient I/O. Encapsulates DB key naming + legacy-key migration                |
| `includes/Classes/License/Api.php`                | Unified transport — registers either REST routes or AJAX actions based on `api` config          |
| `includes/Classes/License/Updater.php`            | EDD-derived plugin updater (v1.9.2 per file header). Hooks `pre_set_site_transient_update_plugins`, `plugins_api`, etc. |
| `includes/Classes/License/CronChecker.php`        | Weekly status-refresh cron. Only schedules when license is already active                       |
| `includes/Classes/License/Contracts/BaseApi.php`  | Base class for `Api`                                                                            |

Namespace: `Essential_Addons_Elementor\Pro\Classes\License`.

### Bootstrap call site

`includes/Traits/Core.php:115-141` — `plugin_licensing()`:

```php
public function plugin_licensing() {
    if ( is_admin() ) {                                  // ← admin-only
        LicenseManager::get_instance( [
            'plugin_file'    => EAEL_PRO_PLUGIN_FILE,
            'version'        => EAEL_PRO_PLUGIN_VERSION, // 6.8.1
            'item_id'        => EAEL_SL_ITEM_ID,         // 4372
            'item_name'      => EAEL_SL_ITEM_NAME,       // 'Essential Addons for Elementor'
            'item_slug'      => EAEL_SL_ITEM_SLUG,       // 'essential-addons-elementor'
            'textdomain'     => 'essential-addons-elementor',
            'db_prefix'      => EAEL_SL_ITEM_SLUG,
            'page_slug'      => 'eael-settings',
            'scripts_handle' => 'eael-admin-dashboard',
            'screen_id'      => [ "toplevel_page_eael-settings" ],
            'api'            => 'ajax',
            'ajax'           => [
                'textdomain'    => 'essential-addons-elementor',
                'action_prefix' => 'essential-addons-elementor'
            ],
            'migrate_from'   => [
                'license' => 'essential-addons-elementor-license-key',
                'status'  => 'essential-addons-elementor-license-status'
            ]
        ] );
    }
}
```

The `is_admin()` gate means `Manager` is **never** instantiated on front-end requests. Any code path that needs to know "is the license valid?" outside admin must read directly from option/transient storage — see `LicenseStore` keys below.

Store URL is `EAEL_STORE_URL` = `https://api.wpdeveloper.com/` (entry file constant), though `storeURL` is omitted from the args above (defaults internal to Manager).

### Manager constructor — what gets wired up

`Manager::__construct( array $args )` (line 134+):

1. Validates that all required args are present (throws `Exception` if missing).
2. Computes `failed_request_cache_key` = `'wpdeveloper_sl_failed_http_' . md5( $storeURL )`.
3. Instantiates `LicenseStore( $db_prefix )` and calls `maybe_migrate_error_key()`.
4. If `migrate_from` is set, calls `LicenseStore::maybe_migrate_from( $map )` — copies legacy option keys to new namespaced keys.
5. If `dev_mode` is true, adds `http_request_host_is_external` filter (scoped to `allow_store_host()` — only the store URL is whitelisted).
6. Registers `admin_notices` and `admin_enqueue_scripts` (priority 999).
7. If `api` is set, instantiates `new Api( $this )` which registers either REST routes or `wp_ajax_*` actions.
8. Registers `add_action( 'init', [ $this, 'plugin_updater' ] )` — Updater wires into the WP update transient lifecycle.
9. If `weekly_check` arg is true, instantiates `new CronChecker( $this )`. **Note: current Core.php config does not pass `weekly_check`, so CronChecker is not active in production today.**
10. If `action_links` arg is true, hooks `plugin_action_links_<plugin-basename>`. **Not active by default.**

### Singleton entry — `get_instance()`

Manager is a singleton: `private static $_instance = null;` (line 54). The first call to `get_instance( $args )` instantiates; subsequent calls return the same instance. **`get_instance()` with no args after first call** returns the existing instance — that's how Pro's other admin code accesses it.

There is **no** `Manager::instance()` shorthand and **no** `is_active()` helper. The canonical license-active check is:

```php
$status = LicenseManager::get_instance()->get_store()->get_status();
if ( 'valid' === $status ) {
    // license-gated logic
}
```

`Manager::get_status()` and `Manager::set_status()` exist (lines 868, 879) but are **deprecated since 2.0.0** — call through `get_store()` directly.

### LicenseStore — option / transient keys

`LicenseStore::__construct( string $db_prefix )` prefixes all keys. With `db_prefix = 'essential-addons-elementor'`, the actual key names are:

| Key                                                                 | Type      | Stores                                          | Method                          |
| ------------------------------------------------------------------- | --------- | ----------------------------------------------- | ------------------------------- |
| `essential-addons-elementor_license`                                | option    | License key string                              | `get_license()`, `update_license()` |
| `essential-addons-elementor_license_status`                         | option    | Status string (`'valid'`, `'invalid'`, etc.)    | `get_status()`, `set_status()`  |
| `essential-addons-elementor_license_data`                           | transient | Full response object from store (cached)        | `get_license_data()`, `set_license_data()` |
| `essential-addons-elementor_license_data_error`                     | option    | Last error array `[ 'code' => ..., 'message' => ... ]` | `get_error()`, `set_error()` |
| `essential-addons-elementor_license_migrated`                       | option    | `bool` — once legacy keys migrated              | `maybe_migrate_from()`          |

**Legacy keys** (migrated by `migrate_from` arg, then deleted):

| Old key (Lite-era / older Pro)                                      | New key                                                  |
| ------------------------------------------------------------------- | -------------------------------------------------------- |
| `essential-addons-elementor-license-key`                            | `essential-addons-elementor_license`                     |
| `essential-addons-elementor-license-status`                         | `essential-addons-elementor_license_status`              |

Migration runs once per site — guarded by the `_license_migrated` flag. Safe to no-op on subsequent boots.

The `_license_data` value is a **transient**, not an option. It has a TTL. When the TTL expires, `check()` triggers a fresh remote call. This is the actual mechanism for "the license used to work but suddenly Pro says it's not valid" — the cached transient expired and the upstream check failed.

### Lifecycle methods

| Method               | Remote action (`edd_action`)       | Side effects                                                                       |
| -------------------- | ---------------------------------- | ---------------------------------------------------------------------------------- |
| `activate( $args )`  | `activate_license`                 | Stores license + response, schedules cron, fires `wpdeveloper_licensing_activated`. May return `required_otp` response (no store write yet — caller must `submit_otp`) |
| `submit_otp( $args )`| `activate_license_by_otp`          | Stores license + response, schedules cron, fires `wpdeveloper_licensing_activated` |
| `resend_otp( $args )`| `resend_otp_for_license`           | Returns response, no store write                                                   |
| `deactivate()`       | `deactivate_license`               | Calls `purge_all()` on store, unschedules cron, fires `wpdeveloper_licensing_deactivated` |
| `check()`            | `check_license`                    | Returns cached `_license_data` if present (TTL not expired); else fresh remote call + cache. Fires `wpdeveloper_licensing_checked` on success |
| `delete_license()`   | (none — local only)                | Wipes all store keys                                                               |

### `remote_post()` — the safe transport

`Manager::remote_post( $action, $args = [] )` (line 490) is the single funnel for all store communication:

1. **Returns `WP_Error('empty_license', …)`** if `$this->license` is empty.
2. **Backoff check** — calls `request_recently_failed()`. If true, returns `WP_Error('request_backoff', …)` without hitting the network. The failure cache key is `wpdeveloper_sl_failed_http_<md5($storeURL)>`.
3. Merges defaults with `$args`:
   - `sdk_version` = `$this->_version` (current `'2.1.0'`)
   - `edd_action` = `$action`
   - `license` = `$this->license`
   - `item_id` = `$this->item_id`
   - `item_name` = `rawurlencode( $this->item_name )`
   - `url` = `home_url()`
   - `version` = `$this->version`
   - `environment` = `wp_get_environment_type()` (defaults `'production'`)
4. **Filterable args** — `apply_filters( 'wpdeveloper_licensing_api_request_args', $args, $action, $this )`.
5. **Security re-enforcement** — `edd_action`, `license`, `item_id`, `url` are **re-overwritten after the filter** so no third-party callback can tamper:
   ```php
   $args['edd_action'] = $action;
   $args['license']    = $this->license;
   $args['item_id']    = $this->item_id;
   $args['url']        = home_url();
   ```
6. `wp_safe_remote_post()` (not `wp_remote_post`) with:
   - `timeout` = `15`
   - `sslverify` = `true` unless `dev_mode`
7. Status code check — anything other than 200 → log failure + set store error + return `WP_Error`.
8. On success — `clear_failed_request()`, decode JSON, return object. If JSON decode fails, set error + return WP_Error.
9. Failures fire `do_action( 'wpdeveloper_licensing_error', $response, $action, $this )`.

**Key invariants for editors of `remote_post()`:**

- Never remove the post-filter re-enforcement of security-critical keys
- Never bypass `request_recently_failed()` to "force a retry" — the backoff is the only thing preventing the site from DDoS'ing the store after a network hiccup
- Never increase the timeout above 15s without a measured reason — the store endpoint should respond under 5s in steady state

### Hidden license key in display

`Manager::hide_license_key( string $_license )` (line 351) masks the middle of the key — first 5 chars + asterisks + last 5 chars. Use this for any UI/admin-notice display of the license. Used in `get_license_data()` to populate `hidden_license_key` field.

### API transport — AJAX (production) and REST (alternate)

`Api::register()` (in `Api.php`):

```php
if ( 'rest' === $this->api_type ) {
    add_action( 'rest_api_init', [ $this, 'routes' ] );
} elseif ( 'ajax' === $this->api_type ) {
    add_action( "wp_ajax_{$this->action_prefix}/license/activate",        [ $this, 'activate' ] );
    add_action( "wp_ajax_{$this->action_prefix}/license/deactivate",      [ $this, 'deactivate' ] );
    add_action( "wp_ajax_{$this->action_prefix}/license/submit-otp",      [ $this, 'submit_otp' ] );
    add_action( "wp_ajax_{$this->action_prefix}/license/resend-otp",      [ $this, 'resend_otp' ] );
    add_action( "wp_ajax_{$this->action_prefix}/license/get-license",     [ $this, 'get_license' ] );
    add_action( "wp_ajax_{$this->action_prefix}/license/delete-license",  [ $this, 'delete_license_action' ] );
    // …more
}
```

With `action_prefix = 'essential-addons-elementor'`, the actual AJAX action names are:

- `wp_ajax_essential-addons-elementor/license/activate`
- `wp_ajax_essential-addons-elementor/license/deactivate`
- `wp_ajax_essential-addons-elementor/license/submit-otp`
- `wp_ajax_essential-addons-elementor/license/resend-otp`
- `wp_ajax_essential-addons-elementor/license/get-license`
- `wp_ajax_essential-addons-elementor/license/delete-license`

**No `wp_ajax_nopriv_` variants** — all license actions require a logged-in admin (this is correct; license changes are privileged operations).

### Updater — EDD-derived plugin updates

`Updater.php` is a near-verbatim copy of EDD's Software Licensing updater (`v1.9.2` per file docblock). It registers:

- `pre_set_site_transient_update_plugins` filter — injects update info into WP's plugin-update transient
- `plugins_api` filter — provides plugin info for the "View details" modal
- `upgrader_pre_download` filter — gates the actual download URL
- `admin_init` for "fake" update checks

Updater uses its **own** `failed_request_cache_key` = `'edd_sl_failed_http_' . md5( $api_url )`. This is a separate failure cache from Manager's — they don't share backoff state.

Key flags:

- `$wp_override` — defaults `false`. If `true`, WP override mode (uses different filter). Never flip without explicit user opt-in.
- `$beta` — opts into beta updates. Controlled by Manager `args['beta']`. No admin UI to toggle in current code.

### CronChecker — weekly refresh (not active by default)

`CronChecker::__construct( Manager $manager )`:

- Hook name: `$db_prefix . '_weekly_license_check'` → `essential-addons-elementor_weekly_license_check`
- Recurrence: registers `wpdeveloper_weekly` schedule (`WEEK_IN_SECONDS`), display `'Once Weekly'`. Namespaced to avoid collision with other plugins' `'weekly'` keys.
- Auto-schedules only when `$manager->get_store()->get_license()` is non-empty
- `check()` callback:
  - Gets current status
  - Deletes cached `_license_data` to force a fresh remote check
  - Calls `Manager::check()`
  - (handler likely compares old vs new status — verify in code when editing)

**Current Core.php config does not pass `weekly_check`, so this is not running on real sites today.** When/if it's enabled, factor in: `WEEK_IN_SECONDS` is a long interval; a license that's revoked server-side won't reflect locally for up to a week unless `Manager::check()` is also called from other paths.

### Status states

Status string values seen in code (`LicenseStore::set_status( $status = 'valid' )` default; full enumeration requires running through every response branch):

- `'valid'` — license active and verified
- `'invalid'` — license rejected by store
- `'expired'` — license existed but renewal lapsed
- `'inactive'` — locally deactivated
- `'disabled'` — admin-disabled on store side
- `'site_inactive'` — store thinks the URL is deactivated even if local thinks active
- `''` (empty) — never activated

**`'valid'` is the only state that should unlock paid features.** Any conditional that uses `!== 'invalid'` instead of `=== 'valid'` is wrong — there are too many in-between states.

### Filter / action hooks Pro emits

| Hook                                       | Type   | Fires                                      |
| ------------------------------------------ | ------ | ------------------------------------------ |
| `wpdeveloper_licensing_api_request_args`   | filter | Before each `remote_post` (args mutable)   |
| `wpdeveloper_licensing_error`              | action | On any `remote_post` failure               |
| `wpdeveloper_licensing_activated`          | action | After successful activate / submit_otp     |
| `wpdeveloper_licensing_deactivated`        | action | After successful deactivate                |
| `wpdeveloper_licensing_checked`            | action | After successful check                     |
| `cron_schedules`                           | filter | Registers `wpdeveloper_weekly` (if CronChecker active) |
| `http_request_host_is_external`            | filter | Only when `dev_mode` is true               |
| `plugin_action_links_<basename>`           | filter | Only when `action_links` arg is true       |

The `'wpdeveloper_licensing_*'` namespace is shared with other WPDeveloper plugins using the same License SDK. Be careful — a callback added in another plugin can affect Pro.

## What's missing

1. **CronChecker is not wired.** `Core.php::plugin_licensing()` does not pass `weekly_check`, so no automatic status refresh runs. Status only refreshes when admin opens the license settings page or triggers an explicit check. Engineering decision needed: enable it (and accept weekly-resolution refresh) or document the manual-only model and ship a "Refresh status" button.
2. **No revocation UI.** If a user wants to opt out / deactivate, they must visit the license page. There's no admin notice surfacing a "Deactivate" link after activation succeeds.
3. **License data TTL is opaque.** `LicenseStore::set_license_data` accepts an `$expiration` arg but the default isn't documented in the file header. Trace from the call site (Manager `check()` → `set_license_data( $response )` with no expiration arg → defaults to `false` → no expiration → permanent transient until manually deleted). This contradicts the docblock describing the field as cached; effectively the cache is invalidated only by `delete_license_data()` or `purge_all()`.
4. **OTP rate limiting is delegated to upstream.** No client-side rate limiter on `resend_otp` — a malicious admin could spam OTP requests at the store. Server-side likely handles this, but the Pro side should also throttle to prevent runaway loops.
5. **`http_request_host_is_external` filter is `dev_mode`-only.** In production, allowing the store host relies on `wp_safe_remote_post` not classifying it as internal. If the user's site happens to resolve `api.wpdeveloper.com` to a private IP (corporate VPN, dev DNS), the call will silently fail. The allowance should be unconditional for the store host.
6. **No unit/integration tests** for activation, deactivation, OTP, check, or error paths. License changes today are validated by manual smoke pass only.
7. **`hide_license_key()` is char-count based, not byte-count.** For multibyte license keys (unlikely but possible) the masking math (`mb_strlen( $_license ) - 10`) returns a negative if the key is < 10 chars. Bounds check should be added.
8. **Error messages are translatable but include English-only error codes.** `WP_Error('empty_license', __('Please provide a valid license.', 'essential-addons-elementor'))` — the code `'empty_license'` is not translated, but the user-facing message is. Document which fields are i18n'd and which are stable identifiers.

## Acceptance

This doc accurately reflects:

- Manager singleton entry `get_instance($args)` (not `instance()`) ✓
- `is_admin()` gating in `Core.php::plugin_licensing` ✓
- LicenseStore option keys with `{db_prefix}_` prefix and `_license_data` as a **transient** ✓
- Legacy migration source keys (`essential-addons-elementor-license-key/status`) ✓
- Re-enforcement of security-critical keys after the args filter in `remote_post()` ✓
- 15-second timeout, `wp_safe_remote_post`, `sslverify` toggle ✓
- AJAX action names with `essential-addons-elementor` prefix (no nopriv variants) ✓
- CronChecker is **not active by default** (no `weekly_check` arg) ✓
- `Manager::get_status()` / `set_status()` are deprecated since 2.0.0 ✓
- Updater is EDD-derived v1.9.2 with its own `edd_sl_failed_http_*` cache key ✓

If any of the above change in code, update this doc in the same PR.

## Pairs with

- `.claude/rules/license-and-activation.md` — actionable rules derived from this architecture
- `pro-lite-bridge.md` — license init happens admin-only, after the bridge handshake
- Lite's `docs/architecture/wpinsights.md` — telemetry side; license-status is one of the fields Lite reports

## Related

- `external-api-integrations.md` — the License store call (`api.wpdeveloper.com`) is one of the outbound integrations; subject to those rules + the License-specific ones here

## Out of scope

- The store-side EDD Software Licensing implementation
- License key generation / customer-portal flows on the wpdeveloper.com side
- Multi-site network-level license behaviour beyond per-site activation (current implementation activates per-subsite; no network-license model)
