# Divi Essential Licensing System — Design Spec

**Release version:** 5.5.1
**Date:** 2026-04-19
**Status:** Design approved, ready for implementation plan

---

## Problem

Divi Essential currently has a licensing scaffold (EDD Software Licensing integration) but no enforcement. `module_restriction()` exists in [`includes/Admin/Traits/Data.php:836`](../../../includes/Admin/Traits/Data.php) but is never wired up. Both `valid` and `expired` license states unlock all features in the activation flow. The plugin ships fully functional whether or not a user activates a key.

Goal: enforce licensing while protecting existing customers from disruption. Grandfather users who previously had a valid license at the version they held when it expired; fully lock modules and extensions for users who have never held a valid license.

---

## User states

The plugin resolves every request to one of six states via a central helper (`License\Gate::get_effective_state()`):

| State | Trigger | Functional behavior | Updates |
|---|---|---|---|
| `LICENSED` | `license_status == 'valid'` | Full access | Allowed |
| `GRANDFATHERED` | status ∈ (`expired`, `disabled`, `revoked`) AND `grandfather_version >= DIVI_ESSENTIAL_VERSION` | Full access at pinned version | Blocked |
| `LOCKED_BYPASSED` | status ∈ (`expired`, `disabled`, `revoked`) AND `grandfather_version < DIVI_ESSENTIAL_VERSION` (user uploaded a newer ZIP) | Locked | Blocked |
| `LOCKED_MIGRATION` | status ∈ (`missing`, `invalid`, empty) AND `lock_deadline > now()` | Full access (30-day grace for pre-5.5.1 unlicensed installs) | Blocked |
| `LOCKED` | status ∈ (`missing`, `invalid`, empty) AND no active grace | Locked | Blocked |
| `LOCKED_STALE` | `now() - last_verified > 14 days` (server unreachable) | Locked, distinct banner | Blocked |

"Locked" means: DE modules render empty on the frontend for public visitors, render a yellow admin-visible placeholder for logged-in users with `edit_posts`, are hidden from the Divi 5 module library palette, and non-license DE admin pages redirect to the license activation page.

---

## License identification strategy (B1)

EDD's response strings are the source of truth. No changes required on divinext.com.

- `license: "valid"` → user is actively licensed.
- `license: "expired" | "disabled" | "revoked"` → user was genuinely issued a license at some point (EDD never emits these for non-existent keys). Treat as "previously licensed" for grandfathering.
- `license: "missing" | "invalid"` → new/unlicensed user. Full lock after grace.

This gives server-authoritative identification without new API endpoints.

---

## Data model

New options added to `wp_options`:

| Option | Values | Purpose |
|---|---|---|
| `dnext_essential_license_status` | string | Current EDD status. Existing; typo `dnwoo_essential_license_status` on [`Licensing.php:391`](../../../includes/Admin/Licensing.php) is fixed during this change. |
| `dnext_essential_grandfather_version` | version string (e.g. `"5.5.1"`) | Last `DIVI_ESSENTIAL_VERSION` observed while status was `valid`. Written every daily check while licensed. |
| `dnext_essential_license_last_verified` | Unix timestamp | When we last received a real HTTP response (success or error payload) from divinext.com. Drives the 14-day stale-server grace. |
| `dnext_essential_lock_deadline` | Unix timestamp, or `0`/absent | Migration timer for existing pre-5.5.1 unlicensed installs. When set and `> now()`, grants `LOCKED_MIGRATION` grace. Cleared on successful license activation. |

Existing options retained as-is: `dnext_essential_license_key`, `divi_essential_installed`, `divi_essential_version`, `dnxte_inactive_modules`, `dnxte_inactive_extensions`.

---

## Daily license check

WP-Cron event `dnext_essential_daily_license_check`:
- Registered on plugin activation, unregistered on deactivation.
- Scheduled hourly as a safety net, but internally no-ops unless 24 hours have elapsed since `last_verified`.
- Calls `check_license` against `DNEXT_ESSENTIAL_STORE_URL` with the stored key.
- On HTTP success: overwrite `license_status`, update `last_verified`. If status is `valid`, also overwrite `grandfather_version` with current `DIVI_ESSENTIAL_VERSION`.
- On HTTP error (timeout, 5xx, network): do NOT touch `license_status` or `last_verified`. Only update a `last_check_error` timestamp (diagnostic, not user-visible).
- User can trigger a manual recheck from the license admin page.

The 14-day grace (`LOCKED_STALE`) is enforced in `Gate::get_effective_state()` by comparing `now() - last_verified`. Up to 14 days without a successful verification → last known state continues. After 14 days → force `LOCKED_STALE`.

---

## Enforcement points

**Design principle:** modules always *register* (so existing pages don't crash on unknown module types). The lock intercepts at *render time* and *library visibility*.

### Frontend module render

All Divi 5 DE modules produce output through the `divi_module_wrapper_render` filter. A single hook in [`divi-5/divi-5.php`](../../../divi-5/divi-5.php) wraps any module whose name starts with `dnxte/`:

```
if ( ! Gate::should_render_module() ) {
    return current_user_can( 'edit_posts' )
        ? Gate::placeholder_html( $module_name )
        : '';
}
```

This one filter covers all 95+ Divi 5 modules without editing each trait.

### Frontend — Divi 4 modules

Legacy Divi 4 modules use shortcodes. A `the_content` filter at priority `9999` matches `[et_pb_dnxte_*]` shortcodes and replaces them with the same placeholder / empty output. Only runs when `Gate::is_locked()`.

### Extensions

The 20+ extensions are instantiated from [`includes/Admin.php:18-70`](../../../includes/Admin.php). Each `new Extension()` call is wrapped:

```
if ( Gate::extensions_enabled() ) {
    new Popup_Pro_Extension();
    new Maintenance_Mode();
    // ...
}
```

Extensions that don't instantiate simply don't register their hooks — their features silently don't apply. No placeholder needed (extensions are behavioral, not renderable).

### Divi 5 builder — module library

A new TypeScript file [`divi-5/visual-builder/src/license-gate.ts`](../../../divi-5/visual-builder/src/license-gate.ts) reads a localized PHP flag (`window.DiviEssentialLicenseState`) and filters DE modules out of the Divi 5 module library palette when state is anything other than `LICENSED` or `GRANDFATHERED`.

The flag is passed via [`includes/Frontend/Assets.php`](../../../includes/Frontend/Assets.php), extending the existing `wp_localize_script('divi_essential_main', ...)` call:

```php
'licenseState' => License\Gate::get_effective_state(),
```

Existing DE modules on pages remain editable (the filter only affects the "Add Module" palette) so users can delete/rearrange without the builder crashing.

**This step requires `npm run build:divi5` after the TS file is added.**

### Divi 4 builder

Divi 4 modules are PHP-registered in [`divi-essential.php:102`](../../../divi-essential.php) via `dnxte_register_module()`. Gate the `require_once`:

```php
public function dnxte_register_module() {
    if ( $this->is_divi5_active() ) return;
    if ( ! License\Gate::should_register_modules() ) return;
    if ( file_exists( ... ) ) require_once ...;
}
```

Divi 4 handles unknown-module shortcodes gracefully, so skipping registration is safe.

### Admin dashboard redirect

In [`includes/Admin/Menu.php`](../../../includes/Admin/Menu.php), register all DE menus as before. Add one `admin_init` hook:

```php
add_action( 'admin_init', function () {
    if ( ! License\Gate::is_locked() ) return;
    if ( ! isset( $_GET['page'] ) ) return;
    $page = sanitize_text_field( $_GET['page'] );
    if ( ! str_starts_with( $page, 'dnxte-' ) ) return;
    if ( $page === DNEXT_ESSENTIAL_PLUGIN_LICENSE_PAGE ) return;
    wp_safe_redirect( admin_url( 'admin.php?page=' . DNEXT_ESSENTIAL_PLUGIN_LICENSE_PAGE ) );
    exit;
});
```

License page remains the only accessible DE page when locked. Non-DE pages (WordPress core, Divi, other plugins) are unaffected.

---

## Update blocking

### Server-side

EDD Software Licensing returns no update for expired/disabled licenses by default. No changes required on divinext.com.

### Client-side hardening

In [`divi-essential-updater.php`](../../../divi-essential-updater.php), add a filter on `pre_set_site_transient_update_plugins`:

```php
add_filter( 'pre_set_site_transient_update_plugins', function ( $transient ) {
    if ( ! is_object( $transient ) ) return $transient;
    $state = License\Gate::get_effective_state();
    $blocked = [ 'GRANDFATHERED', 'LOCKED', 'LOCKED_BYPASSED', 'LOCKED_MIGRATION', 'LOCKED_STALE' ];
    if ( in_array( $state, $blocked, true ) ) {
        $basename = plugin_basename( DIVI_ESSENTIAL_FILE );
        unset( $transient->response[ $basename ], $transient->no_update[ $basename ] );
    }
    return $transient;
}, 999 );
```

`GRANDFATHERED` is included by design — pinning depends on blocking updates.

**Renewal path:** on successful license activation in [`Licensing.php:422`](../../../includes/Admin/Licensing.php), the handler writes `grandfather_version = DIVI_ESSENTIAL_VERSION`, clears `lock_deadline`, and deletes `site_transient('update_plugins')`. The update notice reappears within 24 hours of renewal (via normal WP update check) — acceptable latency.

---

## Migration

Migration runs on both `admin_init` (for admin traffic — preserves existing `do_after_update()` behavior) AND `plugins_loaded` priority 9 (to ensure frontend-only visitors also trigger migration before the Gate resolves state). The migration body checks `divi_essential_version !== DIVI_ESSENTIAL_VERSION` as its guard, so it's idempotent — runs once per version upgrade regardless of how many hooks fire it.

Triggered from [`divi-essential.php:263`](../../../divi-essential.php) `do_after_update()` and also from a new `plugins_loaded` hook in `init_plugin()`:

```php
if ( version_compare( $current_version, '5.5.1', '<' ) ) {
    $status = get_option( 'dnext_essential_license_status' );

    $previously_valid_states = [ 'expired', 'disabled', 'revoked' ];
    $new_user_states         = [ '', 'missing', 'invalid', false ];

    if ( $status === 'valid' ) {
        // Already licensed — no grace, just pin the grandfather version
        // in case they later let it expire.
        update_option( 'dnext_essential_grandfather_version', DIVI_ESSENTIAL_VERSION );
        update_option( 'dnext_essential_license_last_verified', time() );
    }
    elseif ( in_array( $status, $previously_valid_states, true ) ) {
        // User had a valid license at some point (EDD confirms this via the
        // status string) — grandfather at current version immediately.
        // No lock deadline; state resolver will return GRANDFATHERED.
        update_option( 'dnext_essential_grandfather_version', DIVI_ESSENTIAL_VERSION );
        update_option( 'dnext_essential_license_last_verified', time() );
    }
    else {
        // New/unlicensed user with a pre-existing install — grant 30-day grace.
        update_option(
            'dnext_essential_lock_deadline',
            time() + ( 30 * DAY_IN_SECONDS )
        );
    }

    update_option( 'divi_essential_version', DIVI_ESSENTIAL_VERSION );
}
```

**Why existing expired licenses get grandfathered at the current version:** before 5.5.1, there was no `grandfather_version` option, so we have no historical record of what version their license actually covered. The most defensible default is "grandfather them at whatever version 5.5.1 locks them into" — they had working access up to this point, they continue to have it. Once they renew, the next valid-license write updates the pin normally.

On fresh activation ([`divi-essential.php:221`](../../../divi-essential.php) `activate()`): `activate()` writes `divi_essential_version = DIVI_ESSENTIAL_VERSION` immediately, so `do_after_update()` will later see `$current_version === '5.5.1'` and skip the migration block. No `lock_deadline` is set, so `Gate::get_effective_state()` returns `LOCKED` — the plugin shows the license page and nothing else.

On successful license activation (valid response from EDD): `lock_deadline` is deleted; `grandfather_version` and `last_verified` are set; update transient is flushed.

---

## Admin banners

`includes/License/AdminBanners.php` hooks `admin_notices` and emits state-specific banners:

| State | Color | Copy |
|---|---|---|
| `LICENSED` | — | (none) |
| `GRANDFATHERED` | yellow | "Your Divi Essential license expired. The plugin remains functional at v{pinned}. [Renew to receive updates.]" |
| `LOCKED_MIGRATION` | orange (30–14d) / red (<14d) | "Divi Essential will lock in {days} day(s) unless a license is activated. [Activate now]" |
| `LOCKED_BYPASSED` | red | "Version mismatch. Your grandfathered version is v{pinned}; you installed v{current}. Plugin locked. Renew license or reinstall v{pinned}." |
| `LOCKED` | red | "Divi Essential is locked. [Activate your license]" |
| `LOCKED_STALE` | yellow | "License server unreachable for 14+ days. Plugin locked until we can verify. [Retry now]" |

Banners are dismissible only in `GRANDFATHERED` and `LOCKED_MIGRATION` states (per-session, not permanent).

---

## Placeholder HTML

Single source in `License\Gate::placeholder_html()`. Inline style so no extra CSS enqueue:

```html
<div class="dnxte-license-placeholder" style="padding:16px;border:1px dashed #ccc;
     background:#fff8e5;color:#6b5900;font-family:sans-serif;border-radius:4px;">
  <strong>Divi Essential — License required</strong>
  <p>This module requires an active Divi Essential license to render.
     <a href="{license_page_url}">Activate your license</a>.</p>
</div>
```

Visible only to logged-in users with `edit_posts`. Public visitors see nothing.

---

## Files to create

- [`includes/License/Gate.php`](../../../includes/License/Gate.php) — state machine, lock predicates, placeholder renderer.
- [`includes/License/Cron.php`](../../../includes/License/Cron.php) — daily cron registration, `check_license` HTTP client.
- [`includes/License/AdminBanners.php`](../../../includes/License/AdminBanners.php) — `admin_notices` hooks for each state.
- [`divi-5/visual-builder/src/license-gate.ts`](../../../divi-5/visual-builder/src/license-gate.ts) — module-library hider. Imported from `index.ts`. Requires `npm run build:divi5`.

## Files to modify

- [`includes/Admin/Licensing.php`](../../../includes/Admin/Licensing.php) — fix `dnwoo_` typo; activation flow writes `grandfather_version`, `last_verified`; clears `lock_deadline`; flushes update transient.
- [`includes/Admin.php`](../../../includes/Admin.php) — wrap extension instantiations in `Gate::extensions_enabled()`.
- [`includes/Admin/Menu.php`](../../../includes/Admin/Menu.php) — admin_init redirect hook.
- [`divi-essential.php`](../../../divi-essential.php) — migration logic in `do_after_update()`; schedule cron in `activate()`; register `License\Cron` and `License\AdminBanners` in `init_plugin()`.
- [`divi-essential-updater.php`](../../../divi-essential-updater.php) — `pre_set_site_transient_update_plugins` filter.
- [`includes/Frontend/Assets.php`](../../../includes/Frontend/Assets.php) — add `licenseState` to the existing `wp_localize_script` call.
- [`divi-5/divi-5.php`](../../../divi-5/divi-5.php) — `divi_module_wrapper_render` gate filter; Divi 4 shortcode content filter.

## Files explicitly NOT touched

- The 200+ lines of commented-out legacy license code in [`Licensing.php:83-304`](../../../includes/Admin/Licensing.php) are left in place (scope discipline; separate cleanup pass).
- Individual module trait files — enforcement happens via the single wrapper filter.
- Unconditional three.js enqueue gating and other performance items from prior audit are out of scope here.

---

## Testing checklist

Manual scenarios to verify post-implementation:

1. **Fresh install, no key entered** → immediate lock. License page accessible; all other DE pages redirect. Frontend modules render blank for public; yellow placeholder for admin.
2. **Fresh install, valid key activated** → full access. `grandfather_version` set to current. `last_verified` set.
3. **Pre-5.5.1 unlicensed install (no key, or `missing`/`invalid`), upgrade to 5.5.1** → `lock_deadline` set to +30 days. Banner shows countdown. Plugin fully functional during grace.
3a. **Pre-5.5.1 install with already-expired license, upgrade to 5.5.1** → migration writes `grandfather_version = 5.5.1`. State resolves to `GRANDFATHERED`. Plugin keeps working; updates blocked.
3b. **Pre-5.5.1 install with currently-valid license, upgrade to 5.5.1** → migration writes `grandfather_version = 5.5.1` and `last_verified = now()`. State remains `LICENSED`. Nothing visibly changes.
4. **Grace deadline passes** → state transitions to `LOCKED`. Frontend and admin locks engage.
5. **Valid license → expires on server** → daily cron detects `expired`. State becomes `GRANDFATHERED`. Plugin still works. Updates blocked.
6. **Grandfathered user uploads newer ZIP** (`DIVI_ESSENTIAL_VERSION > grandfather_version`) → state becomes `LOCKED_BYPASSED`. Full lock.
7. **Grandfathered user renews** → activation flow flips `license_status` to `valid`, writes new `grandfather_version`, clears `lock_deadline`, flushes update transient. Lock lifts.
8. **License server unreachable for <14 days** → last known state continues. No user-visible change unless status was already `LOCKED_*`.
9. **License server unreachable for >14 days** → state forces `LOCKED_STALE`. Distinct banner; lock engages.
10. **Existing page with DE modules, site becomes locked** → public visitors see pages with missing modules (blank space). Admin preview shows yellow placeholders.

---

## Open concerns

- **Per-site vs per-installation license tracking:** EDD SL activations are per-site URL. If a user clones a site (e.g., dev → staging → production), each counts as an activation. This spec treats each WordPress install independently; no cross-site grandfathering.
- **Multisite:** the spec assumes single-site WordPress. Network-activated Divi Essential on WP multisite would need per-blog vs network-wide license storage decisions. Out of scope for 5.5.1.
- **CLI / cron-disabled environments:** if WP-Cron is disabled (`DISABLE_WP_CRON`), the daily check won't run. Fallback: `admin_init` lazy check if `last_verified` is > 25 hours old. Tolerable performance cost.
