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
+87 -22
View File
@@ -1,6 +1,6 @@
# Aurora Admin v2 — Project Status # Aurora Admin v2 — Project Status
_Last updated: 2026-07-03 (latest session — free/paid feature gating (licensing))_ _Last updated: 2026-07-04 (latest session — real license-server integration)_
## What this project is ## What this project is
@@ -65,6 +65,20 @@ get back up to speed instead of relying on prior chat history.
- **Git**: local repo config only (`user.name`/`user.email` set per-repo, not - **Git**: local repo config only (`user.name`/`user.email` set per-repo, not
`--global`). Commit after each user-confirmed-working change, not before. `--global`). Commit after each user-confirmed-working change, not before.
- Site under test: `http://admin.local/wp-admin/`. - Site under test: `http://admin.local/wp-admin/`.
- **License server access**: `auroraplugins.com` runs on the same cPanel
box as the git remote, `auroradragon.studio` (hostname
`wyvern.auroradragon.studio`), but the git remote's SSH key
(`~/.ssh/id_ed25519`) is scoped to git-only — no shell. Root shell access
is a separate dedicated key, `~/.ssh/id_ed25519_auroradragon_root`
(`ssh -i ~/.ssh/id_ed25519_auroradragon_root [email protected]`).
The license server itself is the `aurora-licensing` WordPress plugin at
`/home/aurora/public_html/wp-content/plugins/aurora-licensing/aurora-licensing.php`
(also present under `/home/plugins/public_html/...` — a second copy on
the same box). **If SSH to port 22 suddenly times out**, something on the
box (fail2ban or similar) appears to re-trigger a short block on rapid
repeated connection attempts, including plain `nc`/probe connections —
it clears on its own after roughly 1-2 minutes; wait it out with one
careful attempt rather than retrying in a tight loop.
## Architecture ## Architecture
@@ -449,13 +463,56 @@ Creator, Admin Notices, Comments, and the Plugins page stay free.
- `admin/src/Licensing/License.php` is the single gate every paid feature - `admin/src/Licensing/License.php` is the single gate every paid feature
checks — `PAID_FEATURES` maps a feature id to a human label, checks — `PAID_FEATURES` maps a feature id to a human label,
`is_active()` and `is_feature_available($feature)` are the two calls `is_active()` and `is_feature_available($feature)` are the two calls
everything else uses. **`is_active()` is a placeholder today**: any everything else uses.
non-empty `license_key` setting counts as licensed, no remote validation - **Real license-server integration** (this session, supersedes the earlier
at all. There's no license server yet — when one exists, only "any non-empty key counts as active" placeholder): validates against the
`is_active()`'s internals need to change (ideally caching the remote actual license server — the `aurora-licensing` WordPress plugin running
check in a transient so it isn't a network call on every admin page on `auroraplugins.com` (a cPanel box at `auroradragon.studio`, SSH-
load); every gated feature already funnels through this class rather than accessible as root via a dedicated key,
reading the setting directly, so nothing else needs to change. `~/.ssh/id_ed25519_auroradragon_root` — the git-remote key is scoped
git-only and can't get a shell). REST namespace
`/wp-json/aurora-licensing/v1/`:
- `POST /validate` — `{license_key, domain, product_id?}` →
`{valid, status, plan, max_sites, active_sites, active_on_domain,
expires_at, product_id}` on success, `{valid:false, error, message}`
on failure (e.g. `invalid_key`).
- `POST /activate` — same request shape → `{activated, plan, max_sites,
expires_at}`, or `{activated:true, message:"Already activated..."}` if
already active on this domain, or a 403 `{valid:false,
error:"limit_reached", message}` if `max_sites` is exceeded.
- `POST /deactivate` — `{license_key, domain}` → `{deactivated, message}`.
- `domain` is normalized server-side (strips protocol/`www.`/trailing
slash) — `License::domain()` just sends the bare host from `home_url()`.
- `GET /my-licenses` (cookie-auth, lists all of a customer's licenses) is
the account-portal-facing endpoint, not used by the plugin itself.
- `License::status()` caches the `/validate` response in the
`aurora_admin_license_status` option for 12 hours
(`STATUS_TTL`) — `is_active()`/`is_feature_available()` read this
cache rather than hitting the network on every admin page load.
- `License::on_settings_updated()` hooks `update_option_aurora_admin_settings`
(same hook `ActivityLog.php` uses) and re-activates/deactivates
immediately whenever `license_key` actually changes, rather than
waiting out the cache TTL — deactivates the old key's slot on this
domain before activating the new one, so switching keys doesn't leak
an activation. Instantiated in `AuroraAdmin.php` (`new License();`)
so this hook actually registers — it wasn't needed when the class was
static-only.
- Network failures/non-JSON responses collapse into a synthetic
`{valid:false, status:"unreachable", ...}` shape (`License::call()`)
so callers only ever branch on response fields, never on `WP_Error`.
- **Settings UI feedback**: `SettingsPage.php` passes `License::status()`
to the Vue settings page as `licenseStatus`; `Settings.vue` forwards it
only to the `license_key` field; `SettingsField.vue` shows a colored
status line under the field (green "License active (plan) — expires
X", amber "unreachable, will retry", red the server's own error
message) — verified live against the real server with a fake test key
and got back the exact `{"valid":false,"error":"invalid_key","message":
"License key not found."}` shape.
- **Not yet done**: no real license key was available to test the
activate/site-limit-reached path end-to-end (only the invalid-key path
was verified against the live server) — worth a follow-up pass once a
real test key exists. No UI yet for the `limit_reached` case beyond
surfacing its message string.
- **Sub-apps** (`SubApps.php`): `PAGES` now has a `"feature"` key per page - **Sub-apps** (`SubApps.php`): `PAGES` now has a `"feature"` key per page
(`null` for the 4 free ones). Locked pages still fully register (`null` for the 4 free ones). Locked pages still fully register
(`add_submenu_page`, real capability, real URL — same as before) so nav/ (`add_submenu_page`, real capability, real URL — same as before) so nav/
@@ -614,15 +671,22 @@ scrollbar with a large empty gap below the frame. Also made the sidebar's
native-menu sections a single-open accordion instead of letting multiple native-menu sections a single-open accordion instead of letting multiple
stay expanded at once (pushed as commit `73591e0`). stay expanded at once (pushed as commit `73591e0`).
Most recently: added the free/paid feature-gating system end-to-end (see An earlier pass this session added the free/paid feature-gating system
"Licensing" above) — Role Editor, Activity Log, Database Explorer, end-to-end (see "Licensing" above) — Role Editor, Activity Log, Database
Analytics, White Label, and the remote site switcher are now paid; Menu Explorer, Analytics, White Label, and the remote site switcher are now
Creator, Admin Notices, Comments, Plugins, and the whole shell/theming paid; Menu Creator, Admin Notices, Comments, Plugins, and the whole
product stay free. Verified both directions live (entering/clearing a shell/theming product stay free. That pass used a placeholder
license key immediately locks/unlocks the sidebar badges, the sub-app `is_active()` (any non-empty key counted as licensed) and fixed an
pages, the Dashboard's Analytics tab, and the underlying REST endpoints), unrelated pre-existing 500 in Role Editor's REST endpoint along the way
and fixed an unrelated pre-existing 500 in Role Editor's REST endpoint (pushed as commit `6d50260`).
along the way (pushed as commit `6d50260`). The distributable zip
Most recently: replaced that placeholder with real validation against the
license server at `auroraplugins.com` (see "Real license-server
integration" above) — `/validate`/`/activate`/`/deactivate` calls, 12-hour
cached status, immediate re-check on a license-key change, and a status
line in the Settings UI. Verified live end-to-end against the real server
with a fake test key, which correctly came back locked with the server's
own `"License key not found."` message. The distributable zip
(`dist/aurora-admin-v2.zip`) was rebuilt fresh from source and should be (`dist/aurora-admin-v2.zip`) was rebuilt fresh from source and should be
re-verified/re-zipped again after this doc update if anything changed since. re-verified/re-zipped again after this doc update if anything changed since.
@@ -632,8 +696,9 @@ hot-patched into
the repo's source files are the actual edits; that copy is just the the repo's source files are the actual edits; that copy is just the
compiled build artifact, not a separate edit surface). compiled build artifact, not a separate edit surface).
**Not yet built**: an actual license-purchase/checkout flow or remote **Not yet done**: the activate/site-limit-reached path hasn't been tested
validation server — today entering *any* non-empty text in the License key with a real, valid license key (only the invalid-key path was verified
field unlocks every paid feature (see "Licensing" above). This is fine for against the live server) — worth a follow-up pass once a real test key is
internal testing but must be replaced with real validation before this available. No purchase/checkout flow lives in this repo (that's entirely
ships to real users, or the paid tier isn't actually gated. on the `auroraplugins.com`/WooCommerce side, which auto-generates keys on
order completion) — this repo only ever consumes the license API.
+2
View File
@@ -2,6 +2,7 @@
namespace AuroraAdmin\App; namespace AuroraAdmin\App;
use AuroraAdmin\Options\Settings; use AuroraAdmin\Options\Settings;
use AuroraAdmin\Licensing\License;
use AuroraAdmin\Shell\Shell; use AuroraAdmin\Shell\Shell;
use AuroraAdmin\Pages\DashboardTakeover; use AuroraAdmin\Pages\DashboardTakeover;
use AuroraAdmin\Pages\SettingsPage; use AuroraAdmin\Pages\SettingsPage;
@@ -35,6 +36,7 @@ class AuroraAdmin
public function __construct() public function __construct()
{ {
new Settings(); new Settings();
new License();
new Shell(); new Shell();
new DashboardTakeover(); new DashboardTakeover();
new SettingsPage(); new SettingsPage();
+147 -8
View File
@@ -9,12 +9,12 @@ defined("ABSPATH") || exit();
* Single gate every paid feature funnels through, so the free/paid split * 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. * 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 * Validates against the real license server: the "aurora-licensing" WP
* active, with no remote validation. There's no license server yet — when * plugin running on auroraplugins.com, REST namespace
* one exists, swap is_active()'s internals for a real check (cached in a * /wp-json/aurora-licensing/v1/ (validate/activate/deactivate). Every
* transient so it isn't a remote call on every admin page load); nothing * result is cached in the aurora_admin_license_status option for
* else in the plugin needs to change, since every gated feature already * STATUS_TTL, refreshed immediately (not just on expiry) whenever the
* calls into this class rather than reading the setting directly. * license_key setting actually changes — see on_settings_updated().
*/ */
class License class License
{ {
@@ -31,10 +31,47 @@ class License
"remote-site-switcher" => "Remote Site Switcher", "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() public static function is_active()
{ {
$key = trim((string) Settings::get("license_key", "")); $status = self::status();
return $key !== ""; return !empty($status["valid"]) && ($status["status"] ?? "") === "active";
} }
/** /**
@@ -53,4 +90,106 @@ class License
{ {
return isset(self::PAID_FEATURES[$feature]) ? self::PAID_FEATURES[$feature] : ""; 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;
}
} }
+5
View File
@@ -3,6 +3,7 @@ namespace AuroraAdmin\Pages;
use AuroraAdmin\Utility\Assets; use AuroraAdmin\Utility\Assets;
use AuroraAdmin\Options\Settings; use AuroraAdmin\Options\Settings;
use AuroraAdmin\Licensing\License;
defined("ABSPATH") || exit(); defined("ABSPATH") || exit();
@@ -67,6 +68,10 @@ class SettingsPage
"settings" => Settings::get(), "settings" => Settings::get(),
"roles" => $roles, "roles" => $roles,
"postTypes" => $post_types, "postTypes" => $post_types,
// Real-time result from the license server (see License::status()),
// not just whether a key is present — drives the status line under
// the License key field (active/invalid/site-limit-reached/etc.).
"licenseStatus" => License::status(),
]; ];
?> ?>
<div <div
+1
View File
@@ -181,6 +181,7 @@ const importSettings = (e) => {
:key="f.key" :key="f.key"
:field="f" :field="f"
:options="optionsFor(f)" :options="optionsFor(f)"
:license-status="f.key === 'license_key' ? serverData.licenseStatus : null"
v-model="settings[f.key]" v-model="settings[f.key]"
/> />
</div> </div>
@@ -11,9 +11,24 @@ const props = defineProps({
modelValue: { default: undefined }, modelValue: { default: undefined },
// Options for multiselect fields (resolved by the page from optionsSource). // Options for multiselect fields (resolved by the page from optionsSource).
options: { type: Array, default: () => [] }, options: { type: Array, default: () => [] },
// License::status()'s cached result, only passed for the license_key field.
licenseStatus: { type: Object, default: null },
}); });
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue']);
const licenseStatusText = computed(() => {
const s = props.licenseStatus;
if (!s || s.status === 'none') return null;
if (s.status === 'active') {
const plan = s.plan ? ` (${s.plan} plan)` : '';
return { tone: 'ok', text: `License active${plan}${s.expires_at ? ` — renews/expires ${s.expires_at}` : ''}` };
}
if (s.status === 'unreachable') {
return { tone: 'warn', text: s.message || 'Could not reach the license server — will retry automatically.' };
}
return { tone: 'error', text: s.message || 'License invalid.' };
});
const val = computed({ const val = computed({
get: () => props.modelValue, get: () => props.modelValue,
set: (v) => emit('update:modelValue', v), set: (v) => emit('update:modelValue', v),
@@ -95,6 +110,9 @@ const openFontList = () => {
<div class="field__info"> <div class="field__info">
<div class="field__label">{{ field.label }}</div> <div class="field__label">{{ field.label }}</div>
<p v-if="field.description" class="field__desc">{{ field.description }}</p> <p v-if="field.description" class="field__desc">{{ field.description }}</p>
<p v-if="licenseStatusText" class="field__license-status" :class="`field__license-status--${licenseStatusText.tone}`">
{{ licenseStatusText.text }}
</p>
</div> </div>
<div class="field__control"> <div class="field__control">
@@ -280,6 +298,10 @@ const openFontList = () => {
} }
.field__label { font-size: 0.95rem; font-weight: 600; color: var(--aurora-text); } .field__label { font-size: 0.95rem; font-weight: 600; color: var(--aurora-text); }
.field__desc { margin: 6px 0 0; font-size: 0.8125rem; color: var(--aurora-text-muted); line-height: 1.5; } .field__desc { margin: 6px 0 0; font-size: 0.8125rem; color: var(--aurora-text-muted); line-height: 1.5; }
.field__license-status { margin: 8px 0 0; font-size: 0.8125rem; font-weight: 600; }
.field__license-status--ok { color: #34d399; }
.field__license-status--warn { color: #fbbf24; }
.field__license-status--error { color: #f87171; }
.field__control { display: flex; justify-content: flex-start; } .field__control { display: flex; justify-content: flex-start; }
.field__input { .field__input {