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: 125

Dependents: 0

Suggesters: 0

Stars: 0

v1.4.0 2026-08-03 05:20 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.

Sixteen 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. Both resolve the actor from the hook's own argument, never from the current user: at wp_login the auth cookies are set for the next request so there is no current user yet, and at wp_logout there is no longer one. Login also stamps ion_mu_last_login usermeta, which core does not track
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
  • Cache clears from WP Rocket and Kinsta — whole-site and per-layer (site / object / CDN) and per-page/URL, each labelled with the layer and with whether it was deliberate or automatic. Kinsta's own nonce is verified before a row is written, so a request Kinsta is about to reject is not logged as a purge that happened
  • User mirror sync started, completed, failed — wp_user_sync_*, carrying the run key, users sent, total, and on a failure the HTTP status and error

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
Site Intelligence API (configurable)User mirror — bulk chunks and per-user deltasX-Api-Key headerYes — 20 s, no redirects followed
Site Intelligence API (configurable)Download the backend's Ed25519 public keyX-Api-Key headerYes — 8 s, no redirects followed, floored to one fetch per 5 min
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

Six 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)
API base URLWP_ION_MU_API_BASE_URLion_mu_api_base_url(derived from the API URL)
Web-app base URLWP_ION_MU_APP_BASE_URLion_mu_app_base_url(empty)
IP allowlistWP_ION_MU_IP_ALLOW_LISTion_mu_ip_allow_list(empty — meaning no restriction)
Connection check intervalWP_ION_MU_CONNECTION_CHECK_INTERVAL_SECion_mu_connection_check_interval_sec3600

There is no inbound credential to configure. Inbound authentication is an Ed25519 signature the backend produces and the site verifies with a public key it downloads by itself — no field, no constant, no paste. See Internal API.

The API base URL is where the user-mirror endpoints and the public-key fetch live. Left unset it is derived from the API URL by stripping the exact /wp-activity/add-event suffix, and derivation returning nothing means "do not sync" rather than a guessed host.

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 API_BASE_URL have no admin field and no CLI flag. They are constant/env/option only. While APP_BASE_URL is empty the "View Activity Logs" buttons are hidden rather than pointing somewhere wrong.
  • An empty IP allowlist means no restriction, not deny-all. A new setting that defaulted to deny would lock the backend out of every site on upgrade. The signature is what authorises a call; the allowlist only narrows where a valid one may be presented from. A constant or env var makes the list read-only in wp-admin, and the AJAX save refuses to write in that case rather than storing a list the site would then ignore.
  • 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 16 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
InternalApiGate::registerResponseHeaders()   ← one Retry-After filter for every gated route
foreach (16 handlers) register()
userSyncJob->register()         ← the cron listener for the user mirror's bulk push
WpUserRestEndpoints->register() ← the three backend-facing /users routes
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.

Internal API (backend → site)

Everything above is outbound: the site pushes to the backend and proves "I am this site" with X-Api-Key. A handful of routes run the other way, and they need the opposite proof — "I am the backend" — which a fleet-wide shared key cannot give. Full detail in docs/WP_USERS_HOW_IT_WORKS.md; the shape is:

OUTBOUND   site → backend    X-Api-Key      proves "I am this site"
INBOUND    backend → site    Ed25519 sig    proves "I am the backend"

The backend holds the private key; every site holds only the public half and cannot produce a signature. That is what makes one fleet-wide keypair safe: reading every file off a compromised site yields nothing usable against any other site.

The key is never configured by hand. On its first successful ping the site fetches the current public key from the API host over https (redirection => 0, since the request carries the ingest key) and caches it with its version. When the backend rotates, the next request carries a higher X-Key-Version; the site notices, re-fetches once and retries within the same request, keeping the previous key so in-flight requests still verify. Re-fetching is floored to one per 5 minutes — that header arrives on an as-yet-unverified request, so without the floor it would be an attacker-driven amplification primitive.

What gets signed is a fixed ten-line canonical string (ION_CanonicalRequest): format version, method, route path, host, millisecond timestamp, nonce, scope, key version, operator IP, and the sha256 of the body. Four of those close specific attacks — host stops a signature for site A being replayed at site B, scope stops a read signature triggering a full export, key version is signed rather than merely sent so a forged header cannot select a different key, and body hash stops a valid signature being reused with altered content.

Verification order (ION_SignedRequestVerifier) is cheapest-rejection-first: headers present and well-formed → timestamp is milliseconds and within ±60 s → scope matches the route → signature against each held key (claimed version first, host and its www/non-www sibling) → nonce unseen. The nonce is claimed last, only after verification succeeds, so an attacker who observes a nonce in flight cannot pre-claim it with a garbage signature and have the real request rejected as a replay. Nonce TTL is 180 s — 3× the skew window, because a nonce first seen at the early edge of a request's validity must not expire while that request is still acceptable.

The gate (ION_InternalApiGate) is the single permission_callback for every one of these routes, in this order:

  1. IP allowlist — free, no-ops entirely when unconfigured, 403
  2. Authentication — the signature, unconditionally, 401
  3. Record — who called, kept only for calls that got past step 2
  4. Rate limit — token bucket, subject + global, both must pass, 429

Rate limiting is deliberately last. Limiting before authentication looks more defensive but lets anyone on the internet drain the bucket for a route and deny it to the backend; after authentication, the limit applies to authorised traffic only. A Throwable anywhere inside the gate returns 401 — the one case where we genuinely do not know whether the caller is authorised, and the only safe answer to that is no.

The audit record is written after authentication and before the rate limit: a 429 is still an authentic backend call and belongs in the record, while letting an unauthenticated caller write their own address there would poison the very field the allowlist modal offers to trust.

Core's rest_send_allow_header() re-invokes every matched permission_callback after dispatch and discards the answer, assuming they are pure. This one is not — it consumes a nonce, spends a token and writes a record — so the verdict is memoised per request. One HTTP request gets exactly one verification, one nonce, one token and one record.

The user mirror

The backend keeps a read model of every site's users, fed three ways: a resumable bulk push over WP-Cron (500 users per chunk, cursor keyed on ID > rather than OFFSET so a registration mid-walk cannot skip a row), per-user deltas at shutdown on create/update/role-change/delete, and the live users/lookup read above. A run is only reported complete after a chunk comes back short — completion is what authorises the backend to prune, so it is never inferred from a timeout.

No password hashes, ever. ION_WpUserProjection names the columns it reads and user_pass / user_activation_key are not among them. They are dropped at the point the array is built rather than filtered downstream: a denylist elsewhere is one forgotten update away from leaking, a projection that never reads the column cannot.

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
ion_mu_wp_user_syncSingle events, 5 s apart while a sync runsOne chunk of the user mirror's bulk push

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

Four backend-facing routes, all under ion-mu/v1, all gated identically by ION_InternalApiGate — optional IP allowlist, then an Ed25519 signature, then a rate limit. There is no shared secret, no bearer path, and no setting that can create one. See Internal API.

RouteMethodScopePurpose
/run-connection-checkPOSTconnection.checkTrigger an integration health check on demand
/usersGETusers.readA page of users, keyed on after (last id), not offset
/users/lookupGETusers.readOne user by id/email/login, optionally with their authored content
/users/syncPOSTusers.syncStart a full resync; force abandons an in-flight run

Signed routes deliberately have no dynamic path segmentsget_route() returns the registered pattern, so /users/(?P<id>\d+) would sign as a regex and one captured signature would work for every user. Identifiers travel as query args, which are not signed (proxies reorder them); the authority came from the signature, the args only select the record.

/run-connection-check takes no parameters and is additionally 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. That lock is shared with the wp-admin recheck buttons so the two paths cannot be alternated to sidestep it.

Every rejection returns the byte-identical 401 Invalid credentials. (or 403 Forbidden. from the allowlist, which leaks nothing a caller does not already know), so an anonymous caller cannot tell a provisioned site from an unprovisioned one. Rate-limit refusals return 429 with a real Retry-After header.

The wp-admin "Recheck" buttons do not use these routes — 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.

Two sections. Connection Settings: API Endpoint URL, API Key, Plugin Monitor Cron Interval. Internal API Security: the IP allowlist. Fields resolved from a higher tier render disabled with a CONSTANT / ENV VAR badge — 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.

The API key field is write-only: it renders empty, shows a masked fingerprint (•••••••• plus the last four characters) as its placeholder, and a blank submission means "keep what is stored". Clearing it is a separate deliberate act via the checkbox beside it. The stored key never reaches the browser.

The allowlist is edited in a modal with add / edit / remove per entry, saved on click through its own AJAX action (ion_mu_save_ip_allow_list, nonce + manage_options) rather than waiting for the page save. That action refuses with 409 when a constant or env var owns the list, and re-answers backend_refused server-side after each save — whether the backend's address is covered is a CIDR containment question, and re-implementing that in JavaScript would be a second parser to keep in step forever.

There is deliberately no public-key field and no key-version field. Asking an operator to paste the same base64 blob into a thousand settings pages is the provisioning failure this design removes: whatever fraction is mistyped becomes sites rejecting every backend request with the same 401 a forged request produces.

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
ion_mu_ip_allow_listdefaultIP allowlist for the backend-facing routes (tier 3)
ion_mu_backend_public_key, ion_mu_backend_public_key_versionyesThe backend's current signing key — read on every signed request
ion_mu_backend_public_key_prev, ion_mu_backend_public_key_prev_versionnoThe key rotated away from, so in-flight requests still verify
ion_mu_last_verified_inboundnoLast verified inbound call: backend IP, operator IP, scope, time
ion_mu_wp_user_sync_statenoResumable cursor for the bulk user push
ion_mu_wp_user_sync_completed_atnoTimestamp of the last backend-confirmed completion
ion_mu_wp_user_sync_backfillnoBackfill retry attempts and last attempt time

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), rate-limit buckets (ion_mu_rl_*, subject hashed), signature nonces (ion_mu_sig_nonce_*, 180 s, nonce hashed), public-key refetch floor (300 s), user-sync chunk lock (120 s).

Subjects and nonces are hashed into their key names because without a persistent object cache a transient name is an option_name — readable by every other plugin on the site and present in every backup. An unhashed key would publish which email address was looked up.

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

964 tests, no WordPress and no database: Brain Monkey stands in for WordPress functions and hooks, the file-integrity scanners run against a throwaway tree under the system temp directory that is created and removed per process, and $wpdb is a fake that records the SQL it was handed.

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, FakeWpdb, SpyTransport
├── fixtures/              sib-signed-vectors.json — signatures produced by the BACKEND's
│                          signer, so the canonical-string contract is pinned across repos
├── stubs/wp-classes.php   WP_Post, WP_User, WP_Error, … as thin as the code allows
└── Unit/                  42 files, one per unit under test

Three conventions worth keeping:

  • Cross-repo contracts are tested against recorded vectors, not against our own encoder. sib-signed-vectors.json holds signatures the backend actually produced; a canonicalisation change on either side fails here rather than as a fleet-wide 401.

  • 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.4.0. 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.4.0 && git push origin v1.4.0. 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.
  • The user mirror runs one full sync, then relies on deltas. Once a sync completes it never re-runs unprompted — a full export on a timer would have every site in the fleet re-sending its whole user table to discover nothing changed. So a user removed by a path that fires no WordPress hook (a direct SQL delete, a database restore) stays in the mirror until someone triggers a resync from the dashboard.
  • A site that has never connected rejects every inbound call. It has no public key yet, so it cannot verify anything, and "I cannot verify this" must fail closed. Those sites were unreachable regardless; the key arrives on the site's own first successful outbound ping.
  • The rate limiter is exact on sustained rate and approximate on burst. Transients have no compare-and-swap, so simultaneous requests can read the same token count and both spend it — under C parallel callers the effective allowance is closer to C× the configured number. The real ceiling on simultaneous work is the PHP-FPM worker pool; this bounds one caller monopolising it over time, which it does exactly.