- New Integrity class checksums the files that implement/enforce the free/paid split (SubApps, Analytics, AnalyticsData, WhiteLabel, Shell, License itself, and the two bootstrap files) against a manifest generated at zip-build time. No manifest present (a dev checkout) is never treated as tampered. - License::is_feature_available() checks Integrity::is_tampered() before checking the actual license, so a distributed copy with the check stripped/commented out re-locks every paid feature even with an otherwise-valid license, rather than the plugin trusting whatever code happens to still be there. - bin/build-integrity-manifest.php generates the manifest against a staged zip-build copy (gitignored build artifact, same pattern as app/dist) — now a required step in the zip-build workflow. - Verified live: untampered+unlicensed stays locked, untampered+licensed unlocks, one tampered file re-locks regardless of license status (tested via a temporary mu-plugin faking an active license, kept outside any protected file so it didn't itself trip the check). This raises the bar against the realistic low-effort case (finding and deleting/inverting the one license-check line) but can't stop someone who rewrites a file's logic to never call the gate, or who edits this integrity check itself — an inherent limit of protection living in plain, editable PHP. Co-Authored-By: Claude Sonnet 5 <[email protected]>
50 KiB
Aurora Admin v2 — Project Status
Last updated: 2026-07-04 (latest session — file-integrity tamper protection for the license gate)
What this project is
A ground-up rebuild of the "uiXpress" WordPress admin-theme plugin, relaunched
as Aurora Admin. The original plugin's look/feel and feature set are the
target, but no original code is reused — all Vue components, PHP classes,
and build tooling are freshly written. This lives in aurora-admin-v2/ (the
earlier in-place rebrand attempt, aurora-admin-working/, is superseded and
no longer the active codebase).
Repo
Now on GitLab (self-hosted): ssh://[email protected]:2289/NixReaper/aurora-wordpress-admin.git
(HTTPS also available). Repo root = the contents of aurora-admin-v2/
(admin/, app/, aurora-admin.php), not the whole uixpress/ folder — that
folder has a bunch of dead-end attempts (aurora-admin-working,
aurora-admin-distribution, aurora-admin-rewrite, aurora-admin-GLM,
uixpress) that are debugging history, not part of the project.
.gitignore excludes app/node_modules/ and app/dist/ (build output —
regenerate, don't version). To clone on a new machine: add an SSH key to the
GitLab account, then git clone the URL above, then cd app && npm install.
Claude Code conversation history does not sync across machines — it's local per project-folder-per-machine. Point a fresh session at this file to get back up to speed instead of relying on prior chat history.
Workflow rules established
- Always verify live in Chrome before declaring a fix done. Use the
Chrome MCP tools (
tabs_context_mcp,navigate,computer/screenshot,javascript_exec) to inspect real DOM/computed styles rather than guessing from source. - Zip packaging: on macOS, build via
npm run buildinapp/,rsyncthe repo (excludingnode_modules,.git,/dist) into a scratch staging dir, runphp bin/build-integrity-manifest.php <staging-dir>/aurora-adminto generateadmin/src/Licensing/integrity-manifest.jsoninto the staged copy (see "Tamper protection" below — this step is new, don't forget it or the shipped zip will have no manifest and Integrity::is_tampered() will treat it as a dev checkout, i.e. never lock anything), thenzip -r -Xit intodist/aurora-admin-v2.zip— macOSzipproduces forward-slash paths natively, no path-separator corruption issue like WindowsCompress-Archive/PowerShell had. Always delete the existing zip first (rm -f dist/aurora-admin-v2.zip) —zip -rmerges into an existing archive rather than replacing it, so stale entries (old content hashes) silently survive otherwise. If building on Windows again, the old PowerShellSystem.IO.Compression.ZipFile+.Replace('\', '/')approach from earlier sessions still applies (the manifest-generation step would need a Windows-available PHP CLI too — LocalWP bundles one at~/Library/Application Support/Local/lightning-services/php-*/bin/*/bin/phpon macOS; there's presumably an equivalent bundled binary on Windows). - Fast local iteration: the test site is LocalWP at
/Users/kris/Local Sites/aurora/app/public/wp-content/plugins/aurora-admin. Instead of rezipping + manually reinstalling for every small CSS/JS tweak, hot-patch the built files straight into that install (rm -rf app/dist && cp -R <repo>/app/dist app/dist, or copy individual files likeadmin/assets/native-dark.css) and just reload the page in Chrome — near-instant feedback loop. Rezip properly (see above) once the change is confirmed, so the zip artifact matches what's live-verified. - User manually reinstalls the zip from a downloaded/attached copy when
actually deploying — the Chrome MCP
file_uploadtool cannot push arbitrary local files into the browser, confirmed in an earlier session, so that step can't be automated. (Hot-patching, above, sidesteps this for local dev/verification.) - Old build caching gotcha: WordPress serves whatever plugin files were
last installed — if the user says "didn't fix it," suspect a stale zip
still installed before suspecting the code. Verify the live script hash via
document.querySelector('script[src*="shell"]')+ computed styles when in doubt (done successfully this session to prove a spacing change was live). - Git: local repo config only (
user.name/user.emailset 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.comruns on the same cPanel box as the git remote,auroradragon.studio(hostnamewyvern.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 theaurora-licensingWordPress 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 plainnc/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
- PHP (
admin/src/..., namespacedAuroraAdmin\...), bootstrapped fromaurora-admin.php(manualrequirelist, dependency order matters), instantiated inadmin/src/App/AuroraAdmin.php's constructor. - Vue 3 app (
app/src/...), Vite multi-entry build (app/vite.config.jsrollupOptions.input) →manifest.json, read byadmin/src/Utility/Assets.php::enqueue()which also walks the manifest'simportschain to pull in shared-chunk CSS. Data reaches each Vue entry via adata-aurora-adminJSON attribute on a mount div (neverwp_localize_script). - Single global stylesheet:
app/src/shell/shell-frame.css— all--aurora-*CSS variables (incl..aurora-darkoverrides) and the only rules touching WP structural IDs (#wpcontent,#wpbody-content). No element resets/utility classes leak onto native WP pages. - Immersive shell (
admin/src/Shell/Shell.php+app/src/shell/SiteShell.vueSidebar.vue+Toolbar.vue+UserMenu.vue): hides native WP chrome, mounts a Vue sidebar/toolbar on every admin page.
MenuSerializer.phpconverts WP's$menu/$submenuglobals into JSON for the sidebar's native-menu section — explicitly skips theaurora-adminslug so Aurora's own pages aren't duplicated (they're represented in the sidebar's own branded nav section instead).- Sidebar has two sections: the WP-native menu list (icon-chip style,
28×28px rounded icon backgrounds, tight ~2px vertical padding per item)
and a bottom "Aurora" section (collapsible, expanded by default,
persisted via
localStorage['aurora-nav-expanded']) styled like a native WP submenu (plain indented text via.sidebar__sublist/.sidebar__sublink, no icons) — deliberately matches how e.g. Posts' own submenu (All Posts/Add Post/Categories/Tags) looks, per explicit user request. - Sidebar is
display:flex; flex-direction:column, split into a.sidebar__scroll(flex:1, scrollable — holds brand/native-list/Aurora section) and a pinnedUserMenufooter below it (not part of the scrollable area, so the account card is always visible). UserMenu.vue: floating card that expands upward from a bottom-left trigger (avatar+name+chevron). Card contains: user header (avatar/name/ email), links (Edit Profile →profile.php, Visit site, Site Settings →options-general.php, Sign Out in red). No theme control here anymore — see "Theme system" below; the old light/dark/system 3-way swatch picker and thedark_modesetting it drove are both gone entirely.- Old top-right toolbar user-dropdown and sun/moon toggle button are
removed — fully replaced by the bottom-left
UserMenu. - Sidebar is inset, not flush against the browser edge: fixed at
top/left/bottom: var(--aurora-sidebar-gap)(16px), fully rounded corners,background: var(--aurora-card-bg)— floats as its own card over the page gradient.--aurora-sidebar-gapis a shared CSS var (shell-frame.css) that the toolbar'sleftoffset and#wpcontent'smargin-leftboth add too, so nothing overlaps when the gap changes. - Native-menu accordion: only one WP-native-menu section's submenu is
open at a time (
Sidebar.vue'sopenIdref) — expanding one collapses whichever else was open, instead of the list growing indefinitely as more sections get expanded. Was previously aSetof independently toggleable ids (each section remembered its own open/closed state); replaced with a single id so opening any section closes the rest. Starts on whichever section matches the current page (each nav is a full page load in this multi-page admin, so this is computed once at mount, not reactively). The separate "Aurora" branded nav section below it keeps its own independent expand/collapse state (localStorage['aurora-nav-expanded']) — not part of this accordion.
Theme system (rewrite — supersedes the old dark_mode 3-state model)
Went through several rounds of user-directed rework this session, landing on: 10 fixed, named color presets + 2 flat neutrals ("Black & Grey", "White") — no custom color picker, no separate light/dark toggle. Each preset is fully self-contained (its own coordinated backgrounds/cards/borders/text), not a hue applied on top of a shared light/dark base.
app/src/pages/settings/themePresets.js:themePresetsarray (10 hue-based presets like{ id: 'indigo', label: 'Indigo', hue: 235 }, plusmonoandwhitewith an explicitpaletteobject instead of a hue).buildPalette(preset)returns all--aurora-*tokens — for hue presets, computed via HSL ramps; for the two flat presets, returned as-is.resolvePreset(id)looks up by id.- The main app background (
--aurora-app-bg) is a 3-stop gradient, not flat:linear-gradient(135deg, vivid1 0%, vivid2 55%, near-black 100%)— two bright, saturated stops of the same hue family carry most of the bar, dipping to a near-black anchor only in the last stretch. This exact ratio went through several corrections against a competing plugin's ("FleekDash") reference screenshots — a 50/50 dark-to-vivid split reads "washed out", two competing saturated hues ~70° apart reads "muddy"; this formula was the one the user confirmed as clean. If revisiting the gradient formula, re-check against those reference screenshots in the conversation history rather than re-deriving from scratch. --aurora-card-bg-deep: a newer token, one step darker than--aurora-card-bg, used only by the Dashboard'sAuroraCard.vue. Needed because the Dashboard got its own frame background using--aurora-frame-bg(see "Window chrome" below), which is close in lightness to--aurora-card-bgfor most presets — cards would nearly disappear into the new frame without it.applyThemePalette(themePresetId)/applyFontFamily(family)(app/src/pages/settings/applyLiveTheme.js) — shared between the settings page's live preview (Settings.vue'swatch()) and the real per-page-load application (SiteShell.vue::onMounted). Sets each token viadocument.documentElement.style.setProperty.- Google Fonts picker:
app/src/pages/settings/googleFonts.js(curated ~70 family list, not the full catalog), searchable dropdown inSettingsField.vue'sfont-pickerfield type, applied by injecting a Google Fonts CSS2<link>tag + setting--aurora-font-family. - Recurring bug class this session — "independently-painted gradient":
several elements (sidebar, toolbar,
#wpbody-contenton the settings page,#wpbody-contentin general) each used to paint their own copy ofvar(--aurora-app-bg). Since it's a gradient, each element computes it independently over its own bounding box — a narrow tall sidebar and a wide short toolbar get visibly different-looking slices of "the same" gradient, creating seams at their shared edges. Fixed by makinghtml/bodythe only thing that paints the gradient (background-attachment: fixedso it doesn't scroll away from the fixed sidebar/toolbar), and leaving every other element transparent so that one gradient plane shows through. If a new seam/mismatched-rectangle bug shows up, suspect this pattern first — grep forvar(--aurora-app-bg)outsideshell-frame.css'shtml.aurora-ready, html.aurora-ready bodyrule; it shouldn't be there. - Dashboard takeover (
admin/src/Pages/DashboardTakeover.php+app/src/pages/dashboard/Dashboard.vue): hijacks nativeindex.phpviain_admin_header, hides the native.wrap, renders the Aurora dashboard in its place. 12-col grid; cards are single<section class="aurora-card">elements with inlinegrid-column: span N(NOT wrapped — component attribute fallthrough for styles was unreliable). Never use unprefixed class names on Aurora components (.cardcollided with WP core once). - Settings page (
admin/src/Pages/SettingsPage.php+app/src/pages/ settings/Settings.vue): owns the top-level "Aurora Admin" WP admin menu directly viaadd_menu_page(slugaurora-admin). Two-box layout:.settings__nav(category list) +.settings__content(fields), both boxed/bordered on a darker backdrop. Field schema is declarative inapp/src/pages/settings/settingsConfig.js, rendered byapp/src/pages/settings/components/SettingsField.vue(types: text, toggle, color, image, repeater, multiselect, segmented, remote-sites). - 6 sub-apps (
admin/src/Pages/SubApps.php+ one PHP feature-class pair each + one Vue page + one Vite entry each) — reachable only via the sidebar's Aurora nav section, not the native WP menu:- Registered via
add_submenu_page('aurora-admin', ...)(or'plugins.php'for Plugins) and left registered in WP's native$submenuglobal — no longer hidden viaremove_submenu_page(). An earlier version did call it (the standard register-but-hide trick) but that corrupts WP core's ownuser_can_access_admin_page()capability lookup, which partly depends on the page still being present in$submenuto resolve its required capability — removing it caused "Sorry, you are not allowed to access this page" for every user, including admins, on Menu Creator/ Activity Log/Role Editor/Comments. Root-caused and fixed this session by deletinghide_from_native_submenu()entirely; duplication in the sidebar's native-menu section is instead prevented inMenuSerializer::serialize()(see below), which has no such side effect. - All slugs prefixed
aurora-admin-*soShell::is_own_page()'s existingstrpos($page, "aurora-admin") === 0check covers them for free (full custom dark theme,native-dark.cssskipped).MenuSerializer::serialize()skips any submenu slug startingaurora-admin-(in addition to already skipping theaurora-admintop-level slug) so none of them are duplicated in the sidebar's native-menu section — needed because most are parented underaurora-admin(already skipped wholesale) butaurora-admin-pluginsis parented under the realplugins.phpmenu. - Each page uses the shared two-pane layout
app/src/pages/_shared/AppListShell.vue(generalized from Settings.vue's box-pair CSS) +AppListItem.vuefor list rows. - Menu Creator (
admin/src/MenuCreator/): DB tableaurora_admin_menus(name/status/roles JSON/items JSON). Runtimeadmin_menuhook (priorityPHP_INT_MAX) applies published configs matching the current user's roles — hides/relabels native menu items by slug. REST:GET/POST /menus,GET/PUT/DELETE /menus/:id,GET /menus/native-items(real WP menu list to build the editor from, viaMenuSerializer::serialize()). Reordering is stored (orderfield) but not yet applied at runtime — CRUD + hide/relabel only so far. - Admin Notices (
admin/src/Notices/): DB tableaurora_admin_notices(title/message/type/roles JSON/pages JSON/dismissible/active). Runtimeadmin_noticeshook renders active, role/page-matching rows via real WP notice markup. REST:GET/POST /notices,PUT/DELETE /notices/:id. - Role Editor (
admin/src/Roles/): no custom table — wraps coreget_editable_roles()/WP_Rolesdirectly. Hard-codes the 5 default roles (administrator, editor, author, contributor, subscriber) as delete-protected (WP has no built-in check for this). REST:GET/POST /roles,PUT/DELETE /roles/:slug. - Activity Log (
admin/src/ActivityLog/): DB tableaurora_admin_activity_log. Hooked tosave_post(guardswp_is_post_autosave()/wp_is_post_revision()),before_delete_post,user_register,profile_update,wp_login,activated_plugin,deactivated_plugin,switch_theme,update_option_aurora_admin_settings. REST:GET /activity-log?action_type=&object_type=&search=&page=. - Plugins (
admin/src/Rest/PluginsData.php+app/src/pages/plugins/Plugins.vue): custom plugin management list (activate/deactivate/delete), also usingAppListShell.vue. - Comments (
admin/src/Rest/CommentsData.php+app/src/pages/comments/): newest sub-app,edit_posts-gated (notmanage_optionslike the others).SubApps::redirect_native_pages()(admin_init) redirects the nativeedit-comments.php(when hit with no?page=param, i.e. a direct nav or an old bookmark/menu link) toadmin.php?page=aurora-admin-comments.MenuSerializer::resolve_url()also maps theedit-comments.phpslug straight to the Aurora URL so the native-menu-derived sidebar entry (for users who canedit_postsbut notmanage_options, so don't see the Aurora nav section) still lands on the Aurora page. The Dashboard's comments card (DashboardData.php'slistUrl) was updated to point at the same Aurora URL instead of the native one. - Database Explorer (
admin/src/DatabaseExplorer/): no custom table — reads real$wpdbschema.GET /db-tables(SHOW TABLE STATUS),GET /db-tables/:table(columns + paginated rows — table name validated against the real$wpdbtable list before use, never interpolated unchecked),POST /db-query(raw query runner, hard-restricted server-side to queries startingSELECT/SHOW/DESCRIBE/EXPLAIN, single-statement only, auto-LIMIT 200if none given). All 3 endpointsmanage_options-gated. Identifier allow-listing + backtick-escaping is the actual safety boundary ($wpdb->prepare()only binds values, not identifiers). - All 5 backend classes +
SubApps.phparerequire'd inaurora-admin.phpand instantiated inAuroraAdmin.php.
- Registered via
- Settings persistence:
admin/src/Options/Settings.php— partial-payload save viamerge_on_save()sanitize callback (merges into existing option, doesn't overwrite).Settings::get_safe()stripslicense_keyand remote site passwords for use on non-admin-gated pages;Settings::get()(full) only used on the manage_options-gated settings page itself. - Global search:
app/src/shell/components/SearchModal.vue, Ctrl+K, debouncedwp/v2/searchREST calls, gated ondisable_global_searchsetting. - Remote site switcher: toolbar dropdown, gated on
canSwitchSites(fromremote_site_switcher_capabilitysetting) + non-emptyremote_sites. - Scheduled content:
app/src/pages/dashboard/cards/ScheduledContent.vuecalendar cells (squares, date top-left, count centered) are clickable — navigate toedit.php?post_status=future&aurora_day=YYYY-MM-DDif that day has scheduled posts, else show a toast ("no scheduled content"). Server side:admin/src/Posts/ScheduledDayFilter.phpfilters theedit.phpquery viapre_get_postsand prints an info notice. - Analytics:
admin/src/Analytics/Analytics.php(first-party pageview tracker, own DB tablewp_aurora_admin_pageviews, bot detection, gated onenable_analytics/track_adminssettings) +admin/src/Rest/AnalyticsData.php(aggregation REST endpoint: totals, series, device/referrer/country breakdowns, bounce rate, active users). - White label:
admin/src/WhiteLabel/WhiteLabel.php— custom favicon, rename plugin in Plugins list, rename top-level menu item, gettext find/replace viatext_replacementssetting. - Native page dark styling:
admin/assets/native-dark.css(large static sheet, all rules gatedhtml.aurora-dark.aurora-ready, scoped under#wpbody-content) +admin/assets/native-enhance.js(progressive JS enhancement — currently onlyenhanceUpdates(), which groups the flat Updates page sections into.aurora-native-cardwrapper divs onbody.update-core-php). This session also extended coverage to media/ thickbox modals, plugin/theme card grids, and the plugin-search filter bar (all previously untouched, so still native white regardless of theme). Known gap: the plugin "More Details" popup renders inside an<iframe>pointing at a separate WP page — CSS can't reach into it; would need JS injection into the iframe's own document if that's ever worth fixing. This session also added dark-theme rules for the block editor's own chrome (.editor-header/.edit-post-header,.interface-interface-skeleton__sidebarand its nested.components-panel/.editor-post-status/.interface-complementary-area-header) — found by inspecting the real screen viacomputer-useand drilling into nested elements' computed styles, since.interface-complementary-area(nested inside the sidebar) had its own explicit white background the outer rule didn't reach, leaving a white patch below the Tags panel until it was targeted directly. The actual post content canvas is deliberately left untouched (WYSIWYG, matches the front-end).
Window chrome (macOS-style title bar + taskbar)
Added earlier this session, user-requested after seeing a competing plugin's demo; extended later the same session to cover the block editor and the Site Editor, and the taskbar was rewritten to support multiple minimized windows. Every page gets a traffic-light title bar; minimizing persists across a full page navigation via a taskbar.
app/src/shell/windowChrome.js:initWindowChrome(adminUrl), called unconditionally fromSiteShell.vue::onMounted(no longer skipped on the block editor — that special case was removed once the editor got its own handling). Three cases, checked in priority order:- Dashboard (
#aurora-admin-dashboard-rootpresent) — wraps the bar + the dashboard root in a new.aurora-dashboard-framediv, giving it the same card-frame treatment (--aurora-frame-bg, border, radius) every other page gets from#wpbody-contentitself. - Block editor / Site Editor (
isBlockEditorPage(): body hasblock-editor-page, or the path ends/post.phpor/post-new.php—site-editor.phpalso getsblock-editor-pageon its body, so this already covers it with no JS change needed) — appends a.aurora-winbar--editorbar (dots only, no bar surface) directly todocument.body, since neither editor's real content container is#wpbody-content. - Everything else — native WP pages and all of Aurora's own Vue pages,
inserted as the first child of
#wpbody-content.
- Dashboard (
- Red (close):
window.location.href = adminUrl. - Green (maximize): toggles
html.aurora-maximized— hides.sidebar/.toolbar, zeroes#wpcontent'smargin-leftand the editor frame's inset, so content fills the whole browser window. Toggle again (or minimize) to restore. Nothing persisted — resets on next page load. - Yellow (minimize): on the Dashboard itself, toggles
html.aurora-minimizedwhich just collapses#aurora-admin-dashboard-rootdown to the bar in place (nowhere further "home" to send it). On every other page, callstaskbar.js'saddMinimizedWindow(title, url)then navigates to the dashboard. - Taskbar rewritten from a single sessionStorage slot to a list
(
app/src/shell/taskbar.js): minimizing a second page used to silently overwrite the first one's entry, so only the most recent minimize ever showed up. NowgetMinimizedWindows()/addMinimizedWindow()(dedupes by URL)/removeMinimizedWindow(id)operate on an array undersessionStorage['aurora-minimized-windows'];SiteShell.vuev-fors over it, rendering one.aurora-taskbar__itempill per entry inside a transparent, floating.aurora-taskbarbar (inset by--aurora-sidebar-gapon all exposed edges, rounded, its own box-shadow — went through a design iteration from a small centered pill back to full-length per explicit user correction, keeping only the pill shape on the individual items). Each item is independently restorable/closable.currentPageTitle()prefers the page's own on-screen<h1>(#wpbody-content h1, #aurora-admin-dashboard-root h1) overdocument.title, since WP's title-tag generation came up empty for Aurora's sub-app pages back when they were stillremove_submenu_page()'d (see the sub-apps permissions bug above) —document.titleis still the fallback for pages where that isn't an issue. - Content-behind-taskbar fix:
html.aurora-has-taskbarno longer just addsmargin-bottom(that only guarantees clearance once scrolled to the very bottom — content taller than the viewport still rendered underneath the fixed taskbar on first paint). Instead it caps#wpbody-content's/.aurora-dashboard-frame's own height viamax-height+overflow-y: auto, same principle as the toolbar's reserved space at the top. This appeared to do nothing at first —#wpbody-content's base rule setsmin-height: calc(100vh - toolbar-h - 40px), and per specmin-heightalways wins over a smallermax-height; fixed by addingmin-height: 0to the taskbar-specific override. - Universal content-area height cap (this session): the taskbar-only
max-height/overflow-y: autotreatment above is now applied to#wpbody-contentunconditionally (not just whenhtml.aurora-has-taskbaris set), capped tocalc(100vh - toolbar-h - 40px). Several native and Aurora pages with a lot of content (Add Plugins, Add User, Profile, Appearance > Fonts, the file editors, every Settings page, Activity Log, Database Explorer, Comments) used to grow taller than the viewport and force the whole document to scroll — janky since the sidebar/toolbar are fixed, so scrolling just moved the content away from them with a lot of dead space. Now the content card's own box is capped and scrolls internally, same as the toolbar/taskbar reservations already did.- Scroll-anchoring bug this surfaced: making
#wpbody-contenta real scroll container exposed a second, unrelated bug on Add Plugins — its plugin-card grid renders via JS well after first paint, inserting a lot of content above whatever was already visible in the now-scrollable box. Chrome's scroll anchoring "corrects" for that by silently jumpingscrollTopdown to compensate, so the page loaded already scrolled ~1700px into the content instead of at the top. Fixed withoverflow-anchor: noneon#wpbody-content(and the taskbar/dashboard- frame variant) — the standard fix for this class of bug, opting the container out of anchoring entirely. - Phantom document-level scrollbar: independently, Add Plugins also
left the outer
<html>/<body>scrollable with ~1200px of empty dead space below the frame, even though every element inside#wpbody-contentmeasured as properly capped to the viewport — narrowed down to some interaction between the plugin grid's async rendering and the browser's own scrollable-overflow bookkeeping (root cause not fully pinned down; forcibly shrinking or hiding#wpbody-contentreduced but didn't eliminate it, and no single oversized/mispositioned descendant could be found). Since the intended design already has the sidebar/toolbar fixed and every page's real content scrolling internally within its own frame, the document itself was never supposed to scroll at all — fixed directly by addingoverflow: hiddentohtml.aurora-ready/body, which removes the dead scroll space outright regardless of what was causing it.
- Scroll-anchoring bug this surfaced: making
- Block editor / Site Editor framing: neither editor renders inside
#wpcontent's normal box model — each has its own fixed, full-viewport root (.interface-interface-skeletonitself onpost.php/post-new.php;#site-editoronsite-editor.php, where.interface-interface-skeletonis merelyposition: relative, nested inside it — a reset rule puts the nested skeleton back to a plain unpositioned child so the two frames don't stack).html.aurora-ready .interface-interface-skeleton, html.aurora-ready #site-editorinshell-frame.cssframes whichever is the real fixed root with the same insets/background/radius/shadow treatment#wpbody-contentgets elsewhere, pluspadding-top: var(--aurora-editor-winbar-h)to reserve a strip for.aurora-winbar--editor's dots andoverflow: hiddenso the rounded corners actually clip the editor's own white canvas background (border-radius alone doesn't clip children). Known CSS-over-constraint gotcha: WP core's.edit-site { height: 100vh; }class (present on#site-editor) meanstop,height, andbottomare all specified on that box at once — per spec this is over-constrained andbottomsilently gets recomputed fromtop + heightinstead of honoring an explicit value, which is why the frame initially ran past the intended bottom inset despitebottom: 24px !importantbeing set. Fixed by addingheight: auto !importantto the same rule sotop/bottomactually determine the box size again. If a future feature needs to reach either editor's own chrome, the real containers are.interface-interface-skeleton(post editor) or#site-editor(Site Editor) — don't assume the normal#wpbody-contentpage structure there. Shell.php::is_enabled()no longer excludes the Site Editor screen (site-editorused to be in the skip list alongsidecustomize) — it turns out to be built on the same@wordpress/interfacepackage as the block editor (body.block-editor-pageis present on both), so the existing block-editor handling covers it once#site-editoris framed.
Licensing (free vs. paid feature gating)
Added this session. The split, as decided with the user: the whole shell/ theming product is free (it's the adoption hook); Role Editor, Activity Log, Database Explorer, Analytics, White Label, and the remote site switcher are paid (the "operational" tools an agency/serious site owner pays for). Menu Creator, Admin Notices, Comments, and the Plugins page stay free.
admin/src/Licensing/License.phpis the single gate every paid feature checks —PAID_FEATURESmaps a feature id to a human label,is_active()andis_feature_available($feature)are the two calls 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-licensingWordPress plugin running onauroraplugins.com(a cPanel box atauroradragon.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}ifmax_sitesis exceeded.POST /deactivate—{license_key, domain}→{deactivated, message}.domainis normalized server-side (strips protocol/www./trailing slash) —License::domain()just sends the bare host fromhome_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/validateresponse in theaurora_admin_license_statusoption 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()hooksupdate_option_aurora_admin_settings(same hookActivityLog.phpuses) and re-activates/deactivates immediately wheneverlicense_keyactually 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 inAuroraAdmin.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 onWP_Error. - Settings UI feedback:
SettingsPage.phppassesLicense::status()to the Vue settings page aslicenseStatus;Settings.vueforwards it only to thelicense_keyfield;SettingsField.vueshows 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_reachedcase beyond surfacing its message string.
- Sub-apps (
SubApps.php):PAGESnow has a"feature"key per page (nullfor the 4 free ones). Locked pages still fully register (add_submenu_page, real capability, real URL — same as before) so nav/ bookmarks/capability checks all keep working;render()andmaybe_enqueue()just checkLicense::is_feature_available()and swap in a shared upgrade prompt (below) instead of the real feature's mount div/JS bundle/REST data when unlicensed. This also means a locked page's sensitive data (e.g. Database Explorer's schema access) never gets fetched or serialized to the page in the first place. - Shared upgrade prompt:
app/src/pages/_shared/UpgradeNotice.vue(lock icon, feature name, description, a CTA linking toadmin.php?page=aurora-admin#license) + a dedicatedapp/src/entries/locked-feature.jsVite entry that mounts it onto[data-aurora-locked-feature].SettingsField.vuegives the license key fieldid="license"so that CTA link scrolls straight to it (General is the default-active settings category, so the anchor resolves on a fresh page load too). Reused directly by the Dashboard's Analytics tab (below) rather than building a second locked-state component. - Analytics (paid):
Analytics.php's constructor returns immediately (no hooks registered, no pageview tracking) when unlicensed.AnalyticsData.php's RESTpermission_callbackalso checksLicense::is_feature_available("analytics"), so the endpoint 403s even if someone hits it directly. The Dashboard's Analytics tab (Dashboard.vue) rendersUpgradeNoticein place of the real charts whenuseAnalyticsData's fetch comes back non-OK (the 403, in practice). - White Label (paid):
WhiteLabel.php's constructor returns immediately when unlicensed — favicon/plugin-rename/menu-rename/text-replacement filters simply never register. Its settings fields stay visible either way (so an admin can pre-configure before upgrading) — enforcement is entirely server-side, nothing in the UI needs to disable inputs. - Remote site switcher (paid):
Shell.php'scanSwitchSitesflag now additionally requiresLicense::is_feature_available("remote-site-switcher"), alongside its existingremote_site_switcher_capabilitycheck. - Sidebar lock badges:
Shell::aurora_nav()adds a"locked"bool per nav item (true for Role Editor/Activity Log/Database Explorer when unlicensed);Sidebar.vuerenders a small lock dashicon next to the label when set. The link still navigates normally —SubApps::render()is what actually shows the upgrade prompt there. - Settings page: White Label/Analytics category subtitles and the
Remote Site Switcher Capability field's description now say "Pro feature
— requires a license" (
settingsConfig.js) — a plain-text note only, no functional gating in the settings UI itself (the features' own code is the actual boundary). - Bug fix surfaced while testing this:
RolesData.php's REST callback calledget_editable_roles()unqualified, which 500'd every time — that function lives inwp-admin/includes/user.php, which WP only auto-loads for real wp-admin page requests, not REST API requests. Fixed with afunction_existsguard +require_oncebefore the call. Unrelated to licensing, just happened to surface while verifying Role Editor's unlock path.
Tamper protection (file-integrity check on the license gate)
Added this session — the user's concern was specifically someone stripping out the license check from a distributed copy and giving away a "cracked" version that shows every paid feature unlocked with no license at all.
Honest limit, stated up front (and to the user) before building this:
nothing living in plain, editable PHP can make server-side code truly
tamper-proof — whoever has file access to their own server can always edit
whatever they want, including this integrity check itself. What this
does stop is the realistic, low-effort case: someone finds the one
if (!License::is_feature_available(...)) line and comments it out or
inverts it. It does not stop someone who rewrites a whole file's logic to
never call the gate at all, or who edits Integrity.php's own comparison
logic — that's an unavoidable ceiling for this approach, only closable
with a compiled/encoded extension (its own real costs: hosting
compatibility, trust), which wasn't in scope here.
admin/src/Licensing/Integrity.php:Integrity::is_tampered()compares the sha256 of each "gate" file (the ones that implement or directly enforce the free/paid split) againstadmin/src/Licensing/integrity-manifest.json. No manifest present (e.g. a git checkout) → never treated as tampered — this only ever applies to a distributed zip, never blocks local dev. Result is cached in a static property per request (file hashing isn't free).- Protected files (kept in sync between
Integrity.php's doc comment andbin/build-integrity-manifest.php's$protectedarray):aurora-admin.php,AuroraAdmin.php,License.php,Integrity.phpitself,SubApps.php,Analytics.php,AnalyticsData.php,WhiteLabel.php,Shell.php. License::is_feature_available()checksIntegrity::is_tampered()before checking the actual license — a tampered install re-locks every paid feature regardless of an otherwise-valid, active license. This is the "re-lock paid features only" behavior the user explicitly chose over a harsher "block the whole plugin" option, specifically to avoid a false positive (a host mangling line endings, some other edge case) breaking the entire admin shell rather than just the paid tier.bin/build-integrity-manifest.php <staged-plugin-root>generates the manifest — run against the staged zip-build copy, never against the git checkout (the manifest itself is a build artifact, gitignored, same "regenerate, don't version" pattern asapp/dist). This is now a required step in the zip-build workflow (see "Zip packaging" above) — forgetting it means the shipped zip has no manifest and therefore no tamper protection at all.- Verified live, without a real license key, by faking an "active"
license through a temporary mu-plugin that directly set the
aurora_admin_license_status/aurora_admin_settingsoptions (kept entirely outside any protected file, so it didn't itself trip the integrity check) — confirmed: (1) untampered + unlicensed → locked (baseline unaffected), (2) untampered + simulated-active-license → unlocked, (3) one protected file edited (a trailing comment appended toSubApps.php) while still "licensed" → re-locked immediately. All test artifacts (the mu-plugin, the tampered file, the test manifest) were removed from the LocalWP install afterward.
Key bugs fixed (root causes, in case they resurface)
.cardglobal class collision: original Dashboard cards usedclass="card", colliding with WP core's.card { max-width: 520px }. Fixed by renaming to.aurora-card*everywhere. Never use unprefixed class names on Aurora components.- Grid-column not applying on wrapped cards: settled on unwrapped
<section class="aurora-card" :style="{gridColumn: ...}">— component boundaries ate the style binding in earlier attempts. - Two-box settings layout: took two wrong iterations before matching the user's reference image. Re-confirm layout intent against a screenshot before assuming.
- Redundant outer frame border on settings page: fixed with a
body.toplevel_page_aurora-adminoverride inshell-frame.css. - Dashboard submenu defaulting collapsed:
Sidebar.vue'snormalize()URL helper didn't map the bare/wp-admin/root toindex.php, so the Dashboard section's active-state check (and thus its default-expanded state) never matched on the dashboard itself. Fixed by normalizing empty/ query-only paths to'index.php' + path. - Aurora top-level menu appearing twice:
SettingsPage.php'sadd_menu_page('aurora-admin', ...)naturally appears in WP's native$menuglobal. Since it's now also shown in the sidebar's dedicated Aurora nav section,MenuSerializer::serialize()explicitly skips slugaurora-adminto avoid the duplicate. - Browser automation can't install plugins: the Chrome MCP
file_uploadtool only accepts files already "shared with the session" (attachments/ connected folders) — local build output on disk doesn't qualify. Confirmed this session; the user must manually reinstall the zip every time (though see "Fast local iteration" above for the hot-patch workaround used for verification during dev). - Independently-painted gradient seams (sidebar/toolbar/settings-page
#wpbody-contenteach showing a visibly different slice of "the same" background gradient): see the theme-system section above — root cause was multiple elements each painting their own copy of a gradient-valuedvar(--aurora-app-bg)instead of one element painting it and the rest staying transparent. - Block editor content rendering underneath the sidebar: Gutenberg's
.interface-interface-skeletonisposition: fixed; inset: 0, completely bypassing themargin-leftthat pushes every other page's#wpcontentover — see the "Window chrome" block editor exception above for the full mechanism and the fix. zip -rmerging into a stale archive: rebuilding the deployable zip without first deleting the old one left old-hash asset filenames mixed into the new zip (macOSzip -rappends/merges rather than replacing). Alwaysrm -fthe zip before rebuilding.remove_submenu_page()corrupting WP's own capability check: root cause of "Sorry, you are not allowed to access this page" on Menu Creator/ Activity Log/Role Editor/Comments — see the sub-apps bullet above. Also brokedocument.titleon those pages, which is whytaskbar.js'scurrentPageTitle()prefers the on-page<h1>instead. If a future custom admin page needs to be kept out of the native menu while staying reachable and capability-checked, filter it out at the menu-serialization layer (likeMenuSerializer.phpdoes), never withremove_submenu_page().min-height/max-heightconflict silently no-oping a fix: per spec, whenmin-heightis larger thanmax-height,min-heightalways wins andmax-heightis ignored outright — this is why the taskbar-overlap fix first appeared to do nothing (see Window chrome section above).position: fixedover-constraint silently ignoring an explicitbottom: whentop,height, andbottomare all specified on a fixed-position box, the spec hasbottomget silently recomputed fromtop + heightrather than honored — root cause of the Site Editor frame running past the viewport despite an explicitbottom: 24px !important(see Window chrome section above).
Not-yet-done (deferred, no active request)
- Full field sets for settings categories beyond General/Analytics/White Label (original plugin had more).
- Extending
native-enhance.js/native-dark.csscoverage to more native surfaces — the block editor's own chrome, Widgets screen, Customizer. Media/thickbox modals and plugin/theme card grids were added this session; the plugin "More Details" popup specifically can't be reached (it's an<iframe>— see native page dark styling notes above). - Porting the real license-key activation flow (built previously in the old
aurora-admin-workingproject, not yet ported to v2 — currently just a text field). - Menu Creator: stored item
orderfield isn't applied at runtime yet (hide/relabel work; reordering doesn't). - No automated tests anywhere in the project — all verification has been manual, live-in-Chrome, per the established workflow rule above.
Current state
No task in progress. An earlier session rebuilt the entire theming system
(fixed color presets, gradient backgrounds, "independently-painted gradient"
seam bugs) and added the macOS-style window chrome + taskbar feature
end-to-end (pushed as commits 4a547d2/b0b0a99). Later that same session:
root-caused and fixed the Menu Creator/Activity Log/Role Editor "Sorry, you
are not allowed to access this page" bug (was remove_submenu_page()
corrupting WP's own capability lookup); added a 6th sub-app, Comments; fixed
a white-patch bug in the block editor's dark-themed sidebar; rewrote the
taskbar from a single sessionStorage slot to a list supporting multiple
minimized windows; and extended the window-chrome/taskbar framing to the
Site Editor (site-editor.php), including tracking down a CSS
over-constraint bug (top+height+bottom all set on a fixed-position box
silently ignoring bottom) that was making its frame run past the viewport
(pushed as commit 8153d43).
An earlier pass this session capped every native/Aurora page's content area
to the viewport with internal scrolling instead of letting tall pages grow
past it and scroll the whole document (see "Universal content-area height
cap" above), which surfaced and fixed two more bugs along the way — a
Chrome scroll-anchoring jump on Add Plugins, and a phantom document-level
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).
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).
An earlier pass this session 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 (pushed as commit
540083e).
Most recently: added file-integrity tamper protection (see "Tamper
protection" above) so that stripping/commenting-out the license check in a
distributed copy re-locks every paid feature, even with an otherwise-valid
license — Integrity::is_tampered() checksums the gate files against a
manifest generated at zip-build time. Verified live via a temporary
mu-plugin that faked an active license without touching any protected
file, confirming untampered+unlicensed stays locked, untampered+licensed
unlocks, and one tampered file re-locks regardless of license status. The
distributable zip (dist/aurora-admin-v2.zip) was rebuilt with the new
manifest-generation step and should be re-verified/re-zipped again after
this doc update if anything changed since.
All of it was live-verified in Chrome against the LocalWP site (build
hot-patched into
/Users/kris/Local Sites/aurora/.../aurora-admin/app/dist for iteration —
the repo's source files are the actual edits; that copy is just the
compiled build artifact, not a separate edit surface).
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.