hipdevteam/ion-mu

ION MU — WordPress mu-plugin that fire-and-forgets activity events to Site Intelligence

Maintainers

Package info

gitlab.com/hipdevteam/ion-mu

Issues

Type:wordpress-muplugin

pkg:composer/hipdevteam/ion-mu

Transparency log

Statistics

Installs: 33

Dependents: 0

Suggesters: 0

Stars: 0

v1.3.2 2026-07-28 12:40 UTC

README

A WordPress must-use plugin that records what happens on a site — logins, content edits, plugin and theme changes, user and role changes, file changes on disk, third-party integration health — and forwards each event to the Agency Framework Site Intelligence backend.

It is a sensor, not a dashboard. There is no activity-log screen in wp-admin; events are read in the web app. The only wp-admin UI is a settings page for the API connection, plus a banner that appears when the connection is not working.

Built and maintained by ION. Released under the MIT licence — free to use, modify and redistribute, with no warranty.

Contents

What it records

Every item below is logged automatically once the API connection is configured. There are no per-feature toggles.

Fifteen handlers are registered on every request. Each one owns a domain and nothing else — see the list in Plugin.php.

Authentication and security

EventDetail
Login / logoutWith user, roles and client IP. Logout resolves the actor from the user ID, because wp_logout() clears the current user before the hook fires
Failed loginThrottled — one entry per user+IP per 5 min, plus an IP-only cap of 10 per 15 min that a spray cannot sidestep by varying the username
Password reset requested / changedPasswords are compared by hash; only a password_changed flag is sent
Application password created / revokedWith the label the user gave it
Admin code editor usewp_ajax_edit-theme-plugin-file, gated on edit_plugins/edit_themes so a subscriber cannot forge entries
New administrator createdFires only for the administrator role
Direct capability changeCatches $user->add_cap('administrator'), which fires no dedicated hook — the privilege-escalation pattern a role change would not show
Outbound email failureRecipient, subject and error. Silently failing SMTP can mask the alerts this plugin's own backend sends

Content

One entry per post per request, emitted at shutdown after the editor's save has already returned to the browser.

  • Post/page created, published, updated, trashed, restored, permanently deleted
  • Scheduled posts auto-publishing (futurepublish)
  • Media uploaded, updated, deleted — with dimensions, file size and asset URL
  • Package uploads (.zip) filed under Plugin rather than Media
  • Comments approved, unapproved, spammed, trashed, deleted, edited

Content edits are diffed at component level, not as a text blob, by three handlers tried in order:

  1. Elementor — diffs _elementor_data per widget, by widget ID, across both the classic and atomic settings shapes
  2. Beaver Builder — diffs per module, against a session snapshot taken on the first draft write
  3. Default editorparse_blocks(), covering Gutenberg and classic alike (classic HTML parses to a single freeform block). Blocks have no stable ID, so they are matched first by exact markup, then paired by type in document order — a reorder alone never shows as a change

Descriptions come out like 'Pricing' (page) updated — 3 widgets added, 1 widget modified.

Lorem Ipsum detection runs on every content change: 14 placeholder markers matched against all changed components, including repeater items past the first. A hit appends (lorem ipsum detected) and sets a flag on the record, letting the backend run expensive checks on demand instead of polling every site.

Thirteen internal post types (revisions, autosaves, nav-menu items, template parts, …) are never logged — see ION_MU_POST_TYPE_BLOCKLIST.

Plugins, themes, core

Installed, updated, activated, deactivated, deleted, switched — for plugins and themes — plus WordPress core auto-updates and manual updates, with before/after versions taken from a snapshot captured at upgrader_pre_install, while the old files are still on disk.

Uploaded .zip reinstalls are distinguished from fresh installs by comparing against a pre-install inventory, so re-uploading a plugin reads as an update, not a first install.

Users, settings, menus, cache

  • User created, deleted (with who inherited the content), role changed, profile updated, site data exported
  • Profile diffs name changed fields explicitly; password and biography are recorded as flags only, never as values
  • Site settings changed — restricted to a 21-option whitelist (siteurl, home, admin_email, permalinks, roles, comment settings, timezone, …). wp_user_roles and sidebars_widgets get readable diffs instead of [array], and a 10-key sensitive list is scrubbed
  • Nav menus created, updated, deleted, with the theme locations they occupy
  • Full-site cache clears from WP Rocket and Kinsta

File integrity

Catches changes that bypass WordPress entirely — SFTP, rsync, git deploy, CI/CD — which fire no hooks at all.

ScopeMethodCadence
WP core filesmd5 vs. official checksums from api.wordpress.orgDaily
wp-config.phpmd5 of contents (checked in ABSPATH and one level above)Daily
Themesmd5 of .php/.js/.css/.htmlDaily
UploadsExecutable extensions (.php, .phar, .phtml, …)Daily
Plugins + mu-pluginsFilesize fingerprintEvery 15 min

The plugin watch ignores mtime deliberately, so a CI deploy that rewrites timestamps does not produce false positives. After a wp-admin plugin update it defers for 30 minutes without advancing its baseline — deferring reporting, never discarding it, so a backdoor uploaded during that window is still caught on the next clean tick, and the resulting entry says an update also occurred rather than claiming none did.

Core and uploads use a sticky known-issues list (a finding is reported once, not every day), and findings owned by a checker that could not run — WordPress.org unreachable — are carried forward rather than cleared. Config, themes and plugins use a rolling baseline, where only new deltas report. Both scans cap at 20,000 files per scope; a capped run disables removal detection and merges rather than replaces its baseline, so files past the cap do not churn.

Integration health

Monitors three client-facing plugins and reports whether their API connections still work — so a dead Instagram feed is caught before the client notices:

  • Instagram Feed and Instagram Feed Pro (Smash Balloon) — reads the sbi_sources table, falling back to the legacy options row. Tokens stored encrypted are classified passively; plain-text legacy tokens are probed live against the Graph API. Reports valid, invalid, expired, or expiring within 7 days
  • Business Reviews Bundle — probes the Google Places API when a local key and a ChIJ… place ID exist; OAuth-only connections are reported as connected without a probe, since the tokens live at the vendor

Runs hourly by default (configurable 15 min – 24 h). Inactive plugins are skipped entirely; absence of a row is the signal. A checker that throws is reported as unknown rather than dropped, and never stops the next checker.

The plugin's own lifecycle

  • Self-updates. ION MU logs its own version bumps by comparing ION_MU_VERSION against a stored value. As an mu-plugin it never passes through Plugin_Upgrader, so the normal upgrader_* hooks structurally cannot fire for it
  • First connection. The first time the endpoint answers a healthy ping, one "connected for the first time" event is logged, once per site

Requirements and dependencies

Runtime

RequirementDetail
PHP 8.1+Enums, readonly promoted properties, never return type. Declared in both the plugin header and ion-mu/composer.json. Verified on 8.2
WordPress with mu-plugin supportAny standard install. No minimum version is enforced in code; the hooks used are core APIs present since 5.x, and wp_create_application_password since 5.6
Runtime PHP packagesNone. ion-mu/composer.json requires only php >=8.1 and has an empty packages lock

Composer is therefore optional at runtime. ion-mu.php loads classes with a built-in directory walk that always runs; if vendor/autoload.php happens to exist it is loaded first, but it is never relied on for coverage. A missing or stale vendor/ cannot break class loading.

Optional integrations, detected at runtime and never required: Elementor, Beaver Builder, WP Rocket, Kinsta's mu-plugin, Instagram Feed / Instagram Feed Pro, Business Reviews Bundle, WP-CLI.

External services contacted at runtime

HostPurposeCredentialBlocking?
Site Intelligence API (configurable)Event ingestX-Api-Key headerNo — 2 s timeout, response never read
Site Intelligence API (configurable)Health ping for the admin bannerX-Api-Key headerYes — 8 s, no redirects followed
api.wordpress.orgCore file checksumsnoneYes — daily cron only
maps.googleapis.comBusiness Reviews healththe site's own Google keyYes — 5 s
graph.facebook.com / graph.instagram.comInstagram healththe site's own tokenYes — 5 s

Development

Dev tooling lives in the repository root composer.json; the plugin's own ion-mu/composer.json stays a clean shipping manifest, so nothing in vendor/ ever reaches a deployed site.

PackagePurpose
phpunit/phpunit ^10.5Test runner
brain/monkey ^2.6Mocks WordPress functions and hooks — no WordPress, no database
phpstan/phpstan ^2.1Static analysis, level 5
szepeviktor/phpstan-wordpress ^2.0Teaches PHPStan WordPress's types
php-stubs/wordpress-stubs ^6.9Function/class signatures for analysis
squizlabs/php_codesniffer ^3.11Coding standard

Installation

WordPress only auto-loads top-level .php files in mu-plugins/, never subdirectories. That is why this ships as a stub plus a folder — copy both together:

wp-content/mu-plugins/
├── ion-mu-loader.php     ← the stub WordPress actually reads
└── ion-mu/               ← the real plugin
cp -r ion-mu-loader.php ion-mu/ /path/to/wp-content/mu-plugins/

Installing with Composer

Composer installs a package as one directory, and WordPress only auto-loads top-level files in mu-plugins/ — so composer require alone leaves everything a level too deep and the plugin never runs:

wp-content/mu-plugins/
├── load-mu-plugins.php     ← WordPress reads this
└── ion-mu/                 ← the Composer package
    ├── ion-mu-loader.php   ← requires ./ion-mu/ion-mu.php
    └── ion-mu/ion-mu.php

load-mu-plugins.php is the missing piece. The consuming project allows the installer once, and every install and update wires it automatically:

{
  "config": {
    "allow-plugins": {
      "composer/installers": true,
      "hipdevteam/ion-mu-installer": true
    }
  }
}

hipdevteam/ion-mu-installer exists as its own package because a Composer package has exactly one type: this one must be wordpress-muplugin so composer/installers puts it in mu-plugins/ rather than vendor/, which leaves no way for it to also be composer-plugin. A package's own scripts never run when it is installed as a dependency — only the root project's do — so ION MU cannot wire itself up. It arrives as a dependency of this package; nothing extra to require.

The entry is guarded (is_file() before require_once) because load-mu-plugins.php outlives the package it points at. Deleting mu-plugins/ion-mu/ with an unguarded require would fatal on every request, with no Deactivate button and no recovery mode.

Without Composer plugins

For sites that decline allow-plugins, install-loader.php does the same job from the command line, using the same code:

{
  "scripts": {
    "post-install-cmd": "@php wp-content/mu-plugins/ion-mu/install-loader.php",
    "post-update-cmd":  "@php wp-content/mu-plugins/ion-mu/install-loader.php"
  }
}

Both paths call ion_mu_write_mu_loader() in loader-installer.php, so they produce a byte-identical file. Safe to re-run: an entry is never duplicated, entries belonging to other packages are never rewritten, and it exits non-zero on failure so a deploy stops instead of quietly leaving a site unmonitored.

Upgrading from ≤ 1.2.1 — earlier versions generated a standalone mu-plugins/ion-mu-loader.php stub. Both routes require_once the same file, so nothing double-loads, but the old stub still appears in the Must-Use list. It is reported on the next run and is safe to delete.

There is no activation step. Must-use plugins load on every request with no activate/deactivate lifecycle and no wp-admin controls. First-run setup happens automatically by comparing a stored DB version.

Then set the API credentials — see below. A site with no key configured logs nothing and fails quietly, by design; it never falls back to a shared credential.

Configuration

Five settings, each resolved through the same four-tier chain:

1. wp-config.php constant     ← most secure, survives DB clones
2. Server environment var     ← same name; set in the Kinsta dashboard
3. WordPress option (DB)      ← Settings > ION MU, or wp ion-mu configure
4. Baked-in default           ← constants.php

Nothing is ever copied from tier 4 into tier 3. Because the default is read at call time, changing it in code propagates to every site that has not set its own value, while leaving overridden sites untouched.

Every tier-4 default is now empty, deliberately: shipping a production endpoint in source meant any site that lost its override silently started sending activity data — including actor login, email and IP — to whatever host happened to be baked into that release. With the defaults blank, an unprovisioned site logs nothing and fails quietly instead. Every site must therefore be provisioned explicitly, by constant, env var, or wp ion-mu configure.

SettingConstant / env varOptionDefault
API URLWP_ION_MU_API_URLion_mu_api_url(empty)
API keyWP_ION_MU_API_KEYion_mu_api_key(empty)
Web-app base URLWP_ION_MU_APP_BASE_URLion_mu_app_base_url(empty)
Inbound webhook secretWP_ION_MU_WEBHOOK_SECRETion_mu_webhook_secret(empty)
Connection check intervalWP_ION_MU_CONNECTION_CHECK_INTERVAL_SECion_mu_connection_check_interval_sec3600

Recommended for production — credentials never touch the database, so staging clones do not carry them:

// wp-config.php
define('WP_ION_MU_API_KEY', '<key>');
define('WP_ION_MU_APP_BASE_URL', 'https://<web-app-host>');

Or scripted, writing to the DB:

wp ion-mu configure --url=https://<api-host>/api/v2/site-intelligence/wp-activity/add-event --key=<key>
wp ion-mu status     # shows resolved values and which tier each came from

Three notes that will otherwise cost you time:

  • The interval inverts the chain — DB is checked first for it, so an admin's saved choice in the UI keeps winning even if a constant or env var is added later.
  • APP_BASE_URL and WEBHOOK_SECRET have no admin field and no CLI flag. They are constant/env only. While APP_BASE_URL is empty the "View Activity Logs" buttons are hidden rather than pointing somewhere wrong.
  • The API URL is not the web-app URL. The API host serves JSON to the plugin; the web-app host is a browser URL a human opens. Different hosts.

How it works

Load and boot

wp-content/mu-plugins/ion-mu-loader.php
  └── require ion-mu/ion-mu.php                  ← wrapped in try/catch
        ├── require constants.php
        ├── require vendor/autoload.php          ← if present, not required to exist
        ├── walk 14 directories, require_once every class   ← always runs
        └── add_action('plugins_loaded', boot)   ← priority 5

boot() then runs, each step isolated by safeCall():

ION_LegacyMigration::run()      ← MUST be first; everything below reads ion_mu_* options
maybeInstall()                  ← bumps ion_mu_db_version when it changes
maybeLogSelfUpdate()            ← logs this plugin's own version bump
foreach (15 handlers) register()
if (is_admin())  settings page + connection notice
if (WP_CLI)      wp ion-mu commands

Every layer is guarded. A syntax error or fatal anywhere inside disables the plugin and logs one line; WordPress serves the request normally. This matters more than usual here: mu-plugins load on every request, are not covered by WordPress's fatal-error recovery mode, and have no Deactivate button — an uncaught error would mean a site-wide outage fixable only over SFTP.

Event flow

WordPress hook
  └── handler callback           ← variadic + Throwable-contained wrapper
        └── build ActivityRecord ← immutable value object
              └── ApiRepository::save()
                    ├── refuse if URL or key unset
                    ├── SSRF check on the endpoint  (cached)
                    ├── clamp payload (strings, array size, depth)
                    └── wp_remote_post(blocking: false)

Delivery is fire-and-forget with a 2-second timeout. The response is never read. A slow or down backend cannot slow the site or surface an error to a user — and equally, a failed delivery is silently lost. That trade is deliberate.

The JSON body carries site_domain, a UTC logged_at, object_type, action, description, object_name, page_url, before/after versions, a context object, an actor object, and the lorem-ipsum flag. Null fields are dropped entirely, so a system event has no actor key at all rather than a half-populated one.

Request lifecycle

A page save is the busiest path, and shows why the work is deferred:

pre_post_update          snapshot status, title, and the builder's own pre-save state
save_post (×N)           build ONE deferred record per post; repeat firings merge
                         (Created outranks Published outranks Updated)
builder save hooks       Elementor / Beaver Builder record their own snapshots
─────────────────────────  response is sent to the browser here  ─────────────────────
shutdown, priority 1     fastcgi_finish_request() — client is now disconnected
shutdown, priority 5     ask each builder handles($postId); first match diffs
                         scan the diff for lorem ipsum
                         emit one record per post, then queued media uploads
                         clear all per-request state

The editor never waits on diffing. Under a SAPI without fastcgi_finish_request() the work simply runs inline, as it did before.

Scheduled lifecycle

init (≤ once per 5 min)      spawn_cron() so low-traffic sites still run overdue events
every 15 min                 plugin + mu-plugin file watch  (size fingerprints)
hourly (configurable)        integration health checks      (Instagram, Google)
daily                        core checksums, wp-config, themes, uploads

Cron callbacks are bound through safeCall() at the binding itself, not just inside the monitor — a throw escaping into wp-cron.php would kill every event queued behind it, including core's own wp_scheduled_delete.

Hook registration

Handlers never call add_action() directly. They use $this->on() / $this->onFilter(), which wrap every callback in a variadic closure plus a Throwable guard. This buys two structural guarantees:

  • Arity safety. WordPress core genuinely fires the same hook with different argument counts (wp_update_nav_menu — Trac #50208, open since 2020). A variadic closure cannot raise ArgumentCountError at the hook boundary. This class of bug previously produced a guaranteed white screen on Save Menu.
  • Containment. A throw inside a callback costs one log line, not the request.

bin/smoke-test.php asserts that no handler bypasses these wrappers, so the guarantee cannot quietly erode.

Payload limits

All caps are enforced in one place — ApiRepository::save(), the only point an event leaves the site — so every handler inherits them, including ones added later.

CapValue
description255 chars
object_name191 chars
Context string values1000 chars
Context array entries50
Context nesting depth8

Truncation is always marked ( on strings, a _truncated entry on arrays) rather than silent. For an audit trail, a partial record that looks complete is worse than one that is obviously partial.

The depth cap is a runaway-recursion backstop, not a size control: a builder diff legitimately nests five levels (context → changes → elementor_widgets → removed → [widget]), and a tighter cap silently replaced the widget itself with a marker while the description still claimed a count.

Outbound endpoint safety

The API URL is admin-settable, and the key travels with the request, so ION_Utils::isSafeApiEndpoint() is enforced at three independent points — on save, on send, and on the derived ping URL. It requires https, rejects embedded credentials, and requires every resolved A and AAAA record to be public, blocking RFC1918, loopback, link-local/cloud-metadata, IPv6 ULA and CGNAT. Stricter than core's wp_http_validate_url(), which permits plain http and misses 169.254/16 entirely.

Verdicts are cached (1 h positive, 5 min negative) because save() runs on every logged event and per-event DNS resolution would be worse than the problem being solved.

The ping additionally sets redirection => 0, because nothing strips a custom header across a redirect: a validated host answering 302 → http://169.254.169.254/ would otherwise hand the fleet ingest key to the metadata endpoint in cleartext.

Legacy migration

A one-time migration carries a pre-rename "HIP WP Activity Logger" install across to the ION MU names: 12 options copied, 3 orphaned cron events unscheduled, 3 transients and one abandoned post-meta key removed. It runs first in boot(), is guarded by a flag, and never overwrites a value already written under the new name. Without it an existing install would look brand new — no credentials, and every historic file-integrity finding re-alerting at once.

Reference

Cron

HookIntervalRuns
ion_mu_plugin_connection_checkConfigurable, default 1 h (15 min – 24 h)Integration health checks
ion_mu_file_integrity_checkDailyCore, wp-config, themes, uploads
ion_mu_plugin_file_watch_check15 min, fixedPlugin + mu-plugin file watch

The connection scheduler re-checks itself on every boot: WP-Cron freezes an event's interval when scheduled and never re-reads cron_schedules, so a changed setting would otherwise never take effect.

REST

POST /wp-json/ion-mu/v1/run-connection-check
Authorization: Bearer <WP_ION_MU_WEBHOOK_SECRET>

Triggers a connection check on demand. Takes no parameters. Compared with hash_equals(); an unset secret can never be matched, and every failure mode returns one identical 401 so an anonymous caller cannot tell a provisioned site from an unprovisioned one. Throttled to one real run per 60 s — repeat calls return 200 with status: throttled, not 429, since the backend treats 2xx as success and the check genuinely did not need to run.

When WP_ION_MU_WEBHOOK_SECRET is unset this route falls back to accepting the ingest API key. That is a migration affordance, not the intended end state — the ingest key is fleet-wide, so reading it from any one site would confer the right to trigger checks on all of them. Set a real webhook secret, or if the backend never calls this route, consider not exposing it at all.

The wp-admin "Recheck" buttons do not use this route — they are separate AJAX handlers with nonce and capability checks.

WP-CLI

wp ion-mu configure --url=<url> --key=<key>   # either flag alone is fine
wp ion-mu status                              # resolved values + source tier

configure runs the same validation as the settings page and warns when a constant or env var would override what you just saved. Note that --key= with an empty value clears the key.

Settings page

manage_options only. Appears in three places, all the same page: a top-level ION MU menu, its Settings submenu, and Settings → ION MU.

Fields: API Endpoint URL, API Key, and Plugin Monitor Cron Interval. The first two render disabled with a CONSTANT / ENV VAR badge when resolved from a higher tier — the disabled input carries no name, so it is never posted back. Saving goes through AJAX (spinner + toast) with the native options.php POST kept as a no-JS fallback.

Two buttons run checks on demand: Recheck Ping (the API endpoint) and Recheck Plugin Connection (the integration monitor). A How it works? modal documents what the plugin tracks, in language aimed at whoever inherits the site.

Everywhere else in wp-admin, a dismissible banner appears while the connection is unhealthy, so an admin who never opens Settings still finds out that nothing is being logged.

Options written

OptionAutoloadPurpose
ion_mu_api_url, ion_mu_api_key, ion_mu_connection_check_interval_secdefaultSettings (tier 3)
ion_mu_db_version, ion_mu_logged_version, ion_mu_first_connect_logged, ion_mu_legacy_migratedyesLifecycle bookkeeping
ion_mu_integrity_known_issuesnoSticky findings (core + uploads)
ion_mu_config_baseline, ion_mu_theme_baseline, ion_mu_plugin_watch_baselinenoRolling baselines
ion_mu_plugin_watch_deferrednoSet while an update suppresses watch reporting

Transients: ping result (5 min), SSRF verdicts, login-failure counters, cron spawn lock (5 min), remote-check lock (60 s), plugin-change marker (30 min).

One post meta key is used transiently: _ion_mu_bb_draft_snapshot, written at the start of a Beaver Builder editing session and deleted at the end.

Debug logging

ION_Debug::log() writes to the PHP error log only on a local or development environment — WP_ENVIRONMENT_TYPE, a loopback REMOTE_ADDR, a .local/.test/.localhost site URL, WP-CLI, or an explicit define('ION_MU_DEBUG', true). On any live host it is a guaranteed no-op.

The gate is deliberately never influenced by the client: it reads the siteurl option, never the Host header, because a catch-all vhost would otherwise let a remote caller flip debug logging on in production — and one call site logs actor user_login and client IP for every event.

.dev is treated as production: it is a real public gTLD with HSTS preloading.

Development

php and composer must be on your PATH. If you develop against a Local (Flywheel) site and have no system PHP, Local's bundled binary works:

export PATH="$HOME/.config/Local/lightning-services/php-8.2.27+1/bin/linux/bin:$PATH"
export LD_LIBRARY_PATH="$HOME/.config/Local/lightning-services/php-8.2.27+1/bin/linux/shared-libs"

Running the checks

composer install     # dev tooling only; never deployed
composer check       # everything CI runs, in the same order

Or individually:

composer smoke       # load + BIND every class, then boot
composer test        # PHPUnit — no WordPress, no database
composer phpstan     # static analysis, level 5
composer phpcs       # coding standard
composer phpcbf      # auto-fix what is safely fixable

Run the smoke test before every deploy. php -l parses without binding classes, so it reports "No syntax errors detected" for an entire category of fatal:

Fatal error: Class ION_ApiRepository contains 1 abstract method and must
therefore be declared abstract or implement the remaining methods

That is raised during class-declaration binding, before any userland code runs. It is not a Throwable, so none of the plugin's guards can contain it — in an mu-plugin that means a site-wide outage on every site it reaches, from a change that passes a lint-only pipeline. The smoke test catches it in about a second, with no database. It also asserts arity safety and that no handler bypasses the hook wrappers.

The smoke test has its own class loader and does not exercise ion-mu.php. Changes to the real loader are not covered by it.

CI runs all four checks on PHP 8.1, 8.2 and 8.3 — see .github/workflows/ci.yml.

Test suite

396 tests, no WordPress and no database: Brain Monkey stands in for WordPress functions and hooks, and the file-integrity scanners run against a throwaway tree under the system temp directory that is created and removed per process.

tests/
├── bootstrap.php          points the WP path constants at a temp tree, loads every class
├── TestCase.php           Brain Monkey lifecycle, in-memory options/transients, static-state reset
├── FilesystemFixture.php  real files for the scanners
├── Doubles/               SpyRepository, FakeChecker
├── stubs/wp-classes.php   WP_Post, WP_User, WP_Error, … as thin as the code allows
└── Unit/                  24 files, one per unit under test

Two conventions worth keeping:

  • Nothing is stubbed globally. A test that calls a WordPress function it did not stub fails loudly instead of silently exercising a fake.
  • Every test carries its reason. Assertions state the behaviour and the comment states what breaks without it — several of these pin regressions that cost real incidents (a cleared known-issues list after a WordPress.org outage, a baseline advanced during a suppression window, a strict_types mismatch that silently disabled capability-change detection).

Adding a handler

  1. Create wp-activity/Handlers/YourHandler.php — file name without the ION_ prefix, class name with it (ION_YourHandler), final, extending ION_AbstractHandler.
  2. Start with defined('ABSPATH') || exit;.
  3. Register hooks in register() using $this->on(...)never add_action() directly, or the smoke test will fail.
  4. Add it to the handler list in Plugin.php.
  5. Add a test under tests/Unit/.
  6. Run composer check.

Prefer reusing existing ION_ObjectType / ION_Action cases over adding new ones. The backend validates action against a fixed Prisma enum, so a new case needs a matching backend migration; the description string carries the specific meaning instead.

To add a page builder, extend ION_AbstractBuilderHandler and add one line to the $builders array in ION_ContentHandlerGate::register(). ION_DefaultEditorHandler must stay last — its handles() always returns true.

To add an integration check, implement ION_CheckerInterface and pass it to ION_PluginConnectionMonitor; nothing in the monitor changes.

Releasing

  1. Bump both version sites. They must stay in sync:

    • ION_MU_VERSION in ion-mu/wp-activity/constants.php
    • Version: in ion-mu-loader.php

    They are currently in sync at 1.3.2. There used to be a third — a duplicated plugin header inside install-loader.php — which is gone since the loader is required in place instead of regenerated.

  2. Run composer check.
  3. Tag the release: git tag v1.3.2 && git push origin v1.3.2. The publish job in .gitlab-ci.yml runs on tags only and pushes the package to the GitLab Composer registry. Packagist picks the tag up separately.
  4. Copy ion-mu-loader.php and ion-mu/ to wp-content/mu-plugins/ — or, on a site wired to the registry, composer require hipdevteam/ion-mu.

The version constant drives self-update logging — if it is not bumped, the deploy is invisible in the activity log. The header is what WordPress displays in the Must-Use list. install-loader.php embeds that header in the stub it writes, so a missed bump there ships a loader advertising the wrong version.

Bump ION_MU_DB_VERSION only when first-run setup needs to re-run.

Known blind spots

Documented deliberately. In a monitoring product, an operator assuming coverage that does not exist is worse than a gap they know about.

  • A site that never allowed the installer looks installed and reports nothing. If hipdevteam/ion-mu-installer is missing from the site's allow-plugins, Composer prints a warning, skips it, and finishes successfully. load-mu-plugins.php is never written, so WordPress never loads the plugin — and because the plugin never loads, it cannot report its own absence. composer show lists it; the site is dark. Belt and braces: keep the post-update-cmd entry as well, so the loader is written even if the allow-plugins line is forgotten.
  • MyKinsta dashboard cache purges are invisible. They run on Kinsta's infrastructure and never execute WordPress PHP, so no hook fires. Kinsta exposes no webhook or purge-history API. "No cache-clear entry" does not mean no cache was cleared.
  • File scans cap at 20,000 files per scope. Large sites can exceed that; files past the cap are unmonitored, and truncation is reported only to the PHP error log, not to the backend.
  • Failed event deliveries are lost. blocking => false means no retry and no error surface.
  • Baselines live in wp_options — in the same database whose credentials sit in the file being monitored. An attacker with DB write access can pre-seed a fingerprint to hide a backdoor.
  • A size-only fingerprint cannot see a plugin-file edit that preserves the exact byte count. That is the deliberate price of a check cheap enough to run every 15 minutes; the daily theme scan uses full md5, and core files are compared against WordPress.org.
  • Widget content edits are not diffed. sidebars_widgets tracks which widget instances moved between sidebars; each widget's own settings live in a separate option and are out of scope.