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:
co-authored by
Claude Sonnet 5
parent
eb03d3dd6c
commit
540083e6e4
+87
-22
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
`--global`). Commit after each user-confirmed-working change, not before.
|
||||
- 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
|
||||
|
||||
@@ -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
|
||||
checks — `PAID_FEATURES` maps a feature id to a human label,
|
||||
`is_active()` and `is_feature_available($feature)` are the two calls
|
||||
everything else uses. **`is_active()` is a placeholder today**: any
|
||||
non-empty `license_key` setting counts as licensed, no remote validation
|
||||
at all. There's no license server yet — when one exists, only
|
||||
`is_active()`'s internals need to change (ideally caching the remote
|
||||
check in a transient so it isn't a network call on every admin page
|
||||
load); every gated feature already funnels through this class rather than
|
||||
reading the setting directly, so nothing else needs to change.
|
||||
everything else uses.
|
||||
- **Real license-server integration** (this session, supersedes the earlier
|
||||
"any non-empty key counts as active" placeholder): validates against the
|
||||
actual license server — the `aurora-licensing` WordPress plugin running
|
||||
on `auroraplugins.com` (a cPanel box at `auroradragon.studio`, SSH-
|
||||
accessible as root via a dedicated key,
|
||||
`~/.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
|
||||
(`null` for the 4 free ones). Locked pages still fully register
|
||||
(`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
|
||||
stay expanded at once (pushed as commit `73591e0`).
|
||||
|
||||
Most recently: added the free/paid feature-gating system end-to-end (see
|
||||
"Licensing" above) — Role Editor, Activity Log, Database Explorer,
|
||||
Analytics, White Label, and the remote site switcher are now paid; Menu
|
||||
Creator, Admin Notices, Comments, Plugins, and the whole shell/theming
|
||||
product stay free. Verified both directions live (entering/clearing a
|
||||
license key immediately locks/unlocks the sidebar badges, the sub-app
|
||||
pages, the Dashboard's Analytics tab, and the underlying REST endpoints),
|
||||
and fixed an unrelated pre-existing 500 in Role Editor's REST endpoint
|
||||
along the way (pushed as commit `6d50260`). The distributable zip
|
||||
An earlier pass this session added the free/paid feature-gating system
|
||||
end-to-end (see "Licensing" above) — Role Editor, Activity Log, Database
|
||||
Explorer, Analytics, White Label, and the remote site switcher are now
|
||||
paid; Menu Creator, Admin Notices, Comments, Plugins, and the whole
|
||||
shell/theming product stay free. That pass used a placeholder
|
||||
`is_active()` (any non-empty key counted as licensed) and fixed an
|
||||
unrelated pre-existing 500 in Role Editor's REST endpoint along the way
|
||||
(pushed as commit `6d50260`).
|
||||
|
||||
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
|
||||
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
|
||||
compiled build artifact, not a separate edit surface).
|
||||
|
||||
**Not yet built**: an actual license-purchase/checkout flow or remote
|
||||
validation server — today entering *any* non-empty text in the License key
|
||||
field unlocks every paid feature (see "Licensing" above). This is fine for
|
||||
internal testing but must be replaced with real validation before this
|
||||
ships to real users, or the paid tier isn't actually gated.
|
||||
**Not yet done**: the activate/site-limit-reached path hasn't been tested
|
||||
with a real, valid license key (only the invalid-key path was verified
|
||||
against the live server) — worth a follow-up pass once a real test key is
|
||||
available. No purchase/checkout flow lives in this repo (that's entirely
|
||||
on the `auroraplugins.com`/WooCommerce side, which auto-generates keys on
|
||||
order completion) — this repo only ever consumes the license API.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
namespace AuroraAdmin\App;
|
||||
|
||||
use AuroraAdmin\Options\Settings;
|
||||
use AuroraAdmin\Licensing\License;
|
||||
use AuroraAdmin\Shell\Shell;
|
||||
use AuroraAdmin\Pages\DashboardTakeover;
|
||||
use AuroraAdmin\Pages\SettingsPage;
|
||||
@@ -35,6 +36,7 @@ class AuroraAdmin
|
||||
public function __construct()
|
||||
{
|
||||
new Settings();
|
||||
new License();
|
||||
new Shell();
|
||||
new DashboardTakeover();
|
||||
new SettingsPage();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ namespace AuroraAdmin\Pages;
|
||||
|
||||
use AuroraAdmin\Utility\Assets;
|
||||
use AuroraAdmin\Options\Settings;
|
||||
use AuroraAdmin\Licensing\License;
|
||||
|
||||
defined("ABSPATH") || exit();
|
||||
|
||||
@@ -67,6 +68,10 @@ class SettingsPage
|
||||
"settings" => Settings::get(),
|
||||
"roles" => $roles,
|
||||
"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
|
||||
|
||||
@@ -181,6 +181,7 @@ const importSettings = (e) => {
|
||||
:key="f.key"
|
||||
:field="f"
|
||||
:options="optionsFor(f)"
|
||||
:license-status="f.key === 'license_key' ? serverData.licenseStatus : null"
|
||||
v-model="settings[f.key]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -11,9 +11,24 @@ const props = defineProps({
|
||||
modelValue: { default: undefined },
|
||||
// Options for multiselect fields (resolved by the page from optionsSource).
|
||||
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 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({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
@@ -95,6 +110,9 @@ const openFontList = () => {
|
||||
<div class="field__info">
|
||||
<div class="field__label">{{ field.label }}</div>
|
||||
<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 class="field__control">
|
||||
@@ -280,6 +298,10 @@ const openFontList = () => {
|
||||
}
|
||||
.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__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__input {
|
||||
|
||||
Reference in New Issue
Block a user