Connect License class to the real auroraplugins.com license server

- License.php now calls the aurora-licensing plugin's REST API
  (/wp-json/aurora-licensing/v1/validate|activate|deactivate) instead of
  treating any non-empty license_key as active. Result is cached in the
  aurora_admin_license_status option for 12 hours; is_active() and
  is_feature_available() read the cache instead of hitting the network on
  every admin page load.
- Hooks update_option_aurora_admin_settings to activate/deactivate
  immediately when the license_key setting actually changes, rather than
  waiting out the cache. Instantiated in AuroraAdmin.php so that hook
  registers.
- Settings page now surfaces the real status (active/invalid/unreachable,
  with the server's own message) as a status line under the License key
  field, instead of a field that silently does nothing.
- Verified live against the real server: a fake test key correctly came
  back locked with the server's own "License key not found." message.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
nixpanel
2026-07-03 20:26:00 -05:00
co-authored by Claude Sonnet 5
parent eb03d3dd6c
commit 540083e6e4
6 changed files with 264 additions and 30 deletions
+147 -8
View File
@@ -9,12 +9,12 @@ defined("ABSPATH") || exit();
* Single gate every paid feature funnels through, so the free/paid split
* lives in one place instead of being re-decided in each feature's code.
*
* Today's is_active() is a placeholder: any non-empty license_key counts as
* active, with no remote validation. There's no license server yet — when
* one exists, swap is_active()'s internals for a real check (cached in a
* transient so it isn't a remote call on every admin page load); nothing
* else in the plugin needs to change, since every gated feature already
* calls into this class rather than reading the setting directly.
* Validates against the real license server: the "aurora-licensing" WP
* plugin running on auroraplugins.com, REST namespace
* /wp-json/aurora-licensing/v1/ (validate/activate/deactivate). Every
* result is cached in the aurora_admin_license_status option for
* STATUS_TTL, refreshed immediately (not just on expiry) whenever the
* license_key setting actually changes — see on_settings_updated().
*/
class License
{
@@ -31,10 +31,47 @@ class License
"remote-site-switcher" => "Remote Site Switcher",
];
const API_BASE = "https://auroraplugins.com/wp-json/aurora-licensing/v1/";
const STATUS_OPTION = "aurora_admin_license_status";
const STATUS_TTL = 12 * HOUR_IN_SECONDS;
public function __construct()
{
add_action("update_option_aurora_admin_settings", [self::class, "on_settings_updated"], 10, 2);
}
/**
* Fires on every save of the aurora_admin_settings option (not just the
* license field) — only acts when license_key actually changed, so
* saving an unrelated settings category never triggers a remote call.
* Deactivates the old key's activation on this domain before activating
* the new one, so switching keys doesn't leak a stale activation slot.
*/
public static function on_settings_updated($old_value, $value)
{
$old_key = is_array($old_value) ? trim((string) ($old_value["license_key"] ?? "")) : "";
$new_key = is_array($value) ? trim((string) ($value["license_key"] ?? "")) : "";
if ($old_key === $new_key) {
return;
}
if ($old_key !== "") {
self::call("deactivate", ["license_key" => $old_key, "domain" => self::domain()]);
}
if ($new_key === "") {
delete_option(self::STATUS_OPTION);
return;
}
self::activate($new_key);
}
public static function is_active()
{
$key = trim((string) Settings::get("license_key", ""));
return $key !== "";
$status = self::status();
return !empty($status["valid"]) && ($status["status"] ?? "") === "active";
}
/**
@@ -53,4 +90,106 @@ class License
{
return isset(self::PAID_FEATURES[$feature]) ? self::PAID_FEATURES[$feature] : "";
}
/**
* The license server's /validate response shape (see call()), cached in
* aurora_admin_license_status for STATUS_TTL so every paid-feature check
* across an admin page load — there are several per request — doesn't
* each cost a remote HTTP round trip. Re-validated immediately (not just
* on expiry) if the cached result was for a different key than what's
* currently saved, so a key change is never served stale data even if
* on_settings_updated() somehow didn't fire first.
*/
public static function status()
{
$key = trim((string) Settings::get("license_key", ""));
if ($key === "") {
return ["valid" => false, "status" => "none"];
}
$cached = get_option(self::STATUS_OPTION);
$fresh =
is_array($cached) &&
($cached["checked_key"] ?? null) === $key &&
($cached["checked_at"] ?? 0) > time() - self::STATUS_TTL;
if ($fresh) {
return $cached;
}
return self::validate($key);
}
public static function validate($key)
{
$result = self::call("validate", [
"license_key" => $key,
"domain" => self::domain(),
]);
$result["checked_key"] = $key;
$result["checked_at"] = time();
update_option(self::STATUS_OPTION, $result, false);
return $result;
}
private static function activate($key)
{
self::call("activate", [
"license_key" => $key,
"domain" => self::domain(),
]);
// /activate's response is a narrower shape ({activated, plan, ...} or
// an error) than /validate's ({valid, status, active_sites, ...}) —
// re-validate right after so the cached status has the full shape
// status()/is_active() and the Settings UI expect either way.
self::validate($key);
}
private static function domain()
{
// The server normalizes domain server-side (strips protocol/www/
// trailing slash) — a bare host is enough, no need to duplicate that
// normalization here.
return (string) parse_url(home_url(), PHP_URL_HOST);
}
/**
* POST $endpoint against the license server. Always returns a decoded
* array — network failures and non-JSON responses collapse into the
* same {valid:false, status:"unreachable", ...} shape as a real error
* response, so callers only ever need to branch on the response fields,
* never on WP_Error separately.
*/
private static function call($endpoint, $body)
{
$response = wp_remote_post(self::API_BASE . $endpoint, [
"timeout" => 8,
"headers" => ["Content-Type" => "application/json"],
"body" => wp_json_encode($body),
]);
if (is_wp_error($response)) {
return [
"valid" => false,
"status" => "unreachable",
"error" => "unreachable",
"message" => $response->get_error_message(),
];
}
$decoded = json_decode(wp_remote_retrieve_body($response), true);
if (!is_array($decoded)) {
return [
"valid" => false,
"status" => "unreachable",
"error" => "invalid_response",
"message" => "License server returned an unexpected response.",
];
}
return $decoded;
}
}