From 540083e6e4a7c9d0c5d513931a74088b82a29e52 Mon Sep 17 00:00:00 2001 From: nixpanel Date: Fri, 3 Jul 2026 20:26:00 -0500 Subject: [PATCH] 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 --- PROJECT_STATUS.md | 109 +++++++++--- admin/src/App/AuroraAdmin.php | 2 + admin/src/Licensing/License.php | 155 +++++++++++++++++- admin/src/Pages/SettingsPage.php | 5 + app/src/pages/settings/Settings.vue | 1 + .../settings/components/SettingsField.vue | 22 +++ 6 files changed, 264 insertions(+), 30 deletions(-) diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index ecd7ba4..047af72 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -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 root@auroradragon.studio`). + 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. diff --git a/admin/src/App/AuroraAdmin.php b/admin/src/App/AuroraAdmin.php index 38daf67..f37ddcd 100644 --- a/admin/src/App/AuroraAdmin.php +++ b/admin/src/App/AuroraAdmin.php @@ -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(); diff --git a/admin/src/Licensing/License.php b/admin/src/Licensing/License.php index 07c98ee..8fb89a7 100644 --- a/admin/src/Licensing/License.php +++ b/admin/src/Licensing/License.php @@ -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; + } } diff --git a/admin/src/Pages/SettingsPage.php b/admin/src/Pages/SettingsPage.php index 18b07ce..73add95 100644 --- a/admin/src/Pages/SettingsPage.php +++ b/admin/src/Pages/SettingsPage.php @@ -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(), ]; ?>
{ :key="f.key" :field="f" :options="optionsFor(f)" + :license-status="f.key === 'license_key' ? serverData.licenseStatus : null" v-model="settings[f.key]" />
diff --git a/app/src/pages/settings/components/SettingsField.vue b/app/src/pages/settings/components/SettingsField.vue index 7504aee..e10799b 100644 --- a/app/src/pages/settings/components/SettingsField.vue +++ b/app/src/pages/settings/components/SettingsField.vue @@ -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 = () => {
{{ field.label }}

{{ field.description }}

+

+ {{ licenseStatusText.text }} +

@@ -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 {