# wp-plugins/mega-ai/

The MEGA AI WordPress plugin. Customers install this on their site;
it exposes the `/wp-json/mega/v1/*` REST endpoints the bot writes
through. Distributed via the **WordPress.org plugin directory**
(`https://wordpress.org/plugins/mega-ai/`); updates flow through
WP core's native update path.

## Version contract

The canonical version lives in **two places that must stay in sync**:

- The `Version:` plugin header at the top of `mega.php` (read by
  WordPress core, by `readme.txt`'s `Stable tag`, and by the
  WP.org SVN auto-deploy workflow).
- The `private static $version` constant inside `MegaPlugin` (used
  by the runtime to report version through `/wp-json/mega/v1/version`).

If you bump one, bump the other. Bump `readme.txt`'s `Stable tag`
to match. Semver: feature → minor bump, bugfix → patch bump.

## Intent runtime (`runtime/intent-runner.php`) — hard rules

The plugin gained a site-wide-behavior surface in v2.0.0: customer sites
can store **validated intents** in the `wp_mega_intents` table and the
runtime dispatches them on WordPress hooks. There is **no eval anywhere**
in this surface. The bot picks an `intent_type` from a closed registry
and supplies structured `params`; megaseo-web validates the params and,
for "Class A" intents, also pre-renders the deterministic HTML payload.
The plugin echoes Class A HTML verbatim from a hooked closure, and runs
hard-coded named handlers for Class B intents.

The safety model relies on these invariants. Break any of them and the
customer fleet is exposed to RCE / XSS / data exfiltration.

1. **No dynamic-code primitives on intent data.** The runtime must never
   call `eval()`, `assert(string)`, `create_function()`,
   `call_user_func_array($db_string, …)`, or any other dynamic-dispatch
   primitive on intent data. Class A: echo `rendered_html`. Class B:
   call a hard-coded handler from `get_class_b_handler()`. New intent
   types require editing the PHP registry — they cannot be added via
   the DB.
2. **Intent INSERTs must go through `/wp-json/mega/v1/intents`.** That
   endpoint is RSA-signed via the existing plugin auth path AND enforces
   the intent registry / hook allowlist / payload-size cap. A poisoned
   DB row alone (e.g. via SQL injection elsewhere) cannot introduce new
   behavior because the dispatcher logic is shipped in the plugin, not
   stored in the DB.
3. **Every Class B handler callback MUST be wrapped with
   `MegaIntentRunner::wrap_handler_callback($intent_id, $cb)` before being
   passed to `add_action`/`add_filter`.** That wrapper sets
   `$current_intent_id`, catches Throwables, and auto-deactivates the
   owning intent. Without it, a runtime fatal in a deferred hook is
   not attributed and the intent will keep crashing every request.
4. **Auto-deactivation must be idempotent.** The shutdown handler may
   fire more than once per request lifecycle; `UPDATE … WHERE active=1`
   is a no-op if already 0.
5. **The runtime itself must not throw.** Any uncaught error in
   `runtime/intent-runner.php` takes down every page request on every
   customer site. `load_intents()` wraps its full body in `try/catch`.
   Per-row registration is also wrapped so one bad row never blocks
   the others.
6. **Class A `rendered_html` is echoed verbatim — never re-templated.**
   megaseo-web is the only escaping authority. The runtime must not
   interpolate user-controlled strings into the HTML, run it through
   `printf`, or otherwise process it before echoing. (The payload-size
   cap is enforced at write AND echo time.)
7. **The `?mega-safe-mode=1` query param is the customer's emergency
   hatch.** It MUST require `manage_options` (so a random visitor can't
   disable a site's intents) and MUST be checked at the start of
   `load_intents()` before any handler dispatch. Don't move that check.
8. **Adding a Class B handler is a code change in this repo.** New
   handlers go in `runtime/intent-runner.php` (handler + dispatch table
   entry) AND in `src/server/services/wp_intents/` in megaseo-web (Zod
   schema + intent def). Both registries MUST list the same set of
   `intent_type` strings — a DB row with an intent_type unknown to the
   PHP registry is rejected at write time.
9. **`the_content` (and any other content-string) filter handlers MUST
   coerce `preg_replace*`/`preg_match*` results back to string before
   returning.** `preg_replace_callback` and friends return `null` on a
   PCRE failure (e.g. `pcre.backtrack_limit` on pathological content),
   and a `null` return from a `the_content` filter **wipes the post
   body** on every render. The `wrap_handler_callback` net does not
   catch this — PCRE failures don't throw. Pattern (see
   `handler_outbound_link_attrs` and `handler_image_loading_attr`):
   `$result = preg_replace_callback(...); return is_string($result) ? $result : $content;`
10. **Class A scope is stored in `params._scope`, never in a new DB
    column.** megaseo-web folds normalized scope (`{siteWide:true}` or
    `{siteWide:false,targetUrls:[...]}`) into the intent's `params` JSON
    at publish time, so there is NO `wp_mega_intents` schema change to
    self-heal. The runtime reads it via `scope_from_params()` and gates
    the Class A echo on `scope_matches_current_request()`. **A missing
    `_scope` is a legacy row and MUST be treated as site-wide** (do not
    deactivate, do not error) — that is the only thing keeping
    pre-2.1.0 rows rendering. `scope_matches_current_request()` MUST
    never throw and MUST fail closed (render nowhere) for a malformed
    _targeted_ scope. Match the current request INSIDE the hook callback
    (when the WP query is resolved), never at `load_intents()` time.
    Targeted (`targetUrls`) publishing is version-gated in megaseo-web
    to plugin >= 2.1.0 because older runtimes ignore `_scope` and would
    render targeted intents site-wide.

## Prism reverse-proxy (`prism_handle_request`) — hard rules

Prism intercepts front-end requests on `template_redirect` (priority 20) and,
for a matched route, serves the response from a separate `backend_host`
instead of letting WordPress render the page. Its purpose is proxying a
headless/Next.js front-end (default routes `/next/`, `/_next/`). The backend
is a DIFFERENT host and has no knowledge of the WordPress session.

1. **Never proxy authenticated requests.** `prism_handle_request` MUST return
   early when `is_user_logged_in()` (guarded with `function_exists` so an
   early hook can't fatal on the pluggable function). The backend can't
   validate a WordPress session — proxying a logged-in member returns the
   backend's logged-out view (or a redirect to login/dashboard) and breaks
   member-only pages. This guard MUST stay BEFORE the cache read/write: the
   Prism cache key is path+query only (no auth dimension), so letting an
   authed request reach the cache would bleed anon↔auth content. (Regression:
   WoundCare Today, 2026-06; tests in `tests/test_prism_routing.php`.)
2. **Route matching must reject empty and malformed prefixes.**
   `prism_find_matching_route` MUST skip any route whose `path_prefix` is
   missing/non-string, and any prefix that is empty after `rtrim($p, '/')`
   (i.e. `'/'` or `''`). An empty prefix matches `strpos($path, '/') === 0`
   for EVERY request and would hijack the entire front-end.
3. **Priority 20 is deliberate** — it lets membership/auth plugins (e.g. WP
   Fusion, default priority 10) run their gate first so session state is
   resolved before Prism evaluates any route match. Do not lower it.

## Diagnostic safe-mode on `update_page()`

`POST /mega/v1/pages/<id>` accepts an optional `?diagnostic=1` query
param. When set, the handler SKIPS the `update_post_meta($id,
mega_managed, '1')` write. Reason: the MEGA SEO platform's CMS
diagnostic worker runs a non-destructive text-revert probe (append a
marker → restore the original content) against the customer's most-
recent published page, and without the flag every probed page would be
permanently tagged as MEGA-owned just because we observed it. Production
write paths (real bot publishing) call WITHOUT `diagnostic=1` so the
tag still gets set on legitimate writes.

If you add a new probe path that writes to live customer content, mirror
this pattern: a `diagnostic=1` opt-in on the side-effect (ownership,
indexing, audit-log, etc.) so the probe stays observably non-destructive.
DO NOT add the side effect globally and add `diagnostic=1` as a kill-
switch; the default behavior should be "probe touches nothing else."

## CMS change-log webhook queue

Post/page, schema, editor-widget, and intent writes enqueue a local
`mega_change_log_queue` event after the WordPress mutation succeeds. The queue
is durable in WP options and drains via the `mega_change_log_retry` WP-Cron hook;
events are removed only after a 2xx response from megaseo-web. Delivery signs
`<timestamp>.<raw-json-body>` with HMAC-SHA256 using the generated
`mega_settings.change_log_secret`, which is exposed to megaseo-web only through
the already RSA-authenticated `/mega/v1/plugins` route. Never use the public
site identifier as the signing secret.

The queue is capped at 100 events because it is option-backed; when the cap is
exceeded, drop the oldest events and keep the newest events. If you change the
cap or retention rule, update `tests/test_change_log_queue.php`.

`process_change_log_queue()` must also cap remote attempts per cron run. The
webhook request has a finite timeout, and an outage must still save each
attempt's backoff state before PHP's execution limit can kill the request.

Requests from megaseo-web that already logged the write server-side include
`mega_change_log_bypass` in the signed JSON body. Handlers must unset that
control field before calling WordPress APIs and must skip only the local queue,
not the actual write. New write endpoints should either enqueue a change-log
event after success or document why the write is intentionally unaudited.

## DO NOT re-add a self-update mechanism

Versions 1.5.1 → 1.6.0 shipped a self-update mechanism that polled
`https://app.gomega.ai/api/wp-plugins/mega-ai/update.json` and let
WordPress download + execute new PHP from a `download.zip` endpoint
hosted on `app.gomega.ai`. **It was stripped in 1.6.1 because it
violates WordPress.org Plugin Directory guideline #8 — "No External
Code".**

What the violation looked like in practice:

- A non-WP.org `Update URI:` plugin-header line.
- A `pre_set_site_transient_update_plugins` filter
  (`inject_update_manifest`) that fetched the manifest, compared
  versions, and injected a `response[]` entry pointing to our
  `download.zip`.
- A `plugins_api` filter (`maybe_serve_plugin_info`) that served
  "View details" thickbox metadata.
- An `$allowed_package_hosts` array, transient cache, and
  manifest-fetch helpers that supported the above.

How we found out: WP.org silently paused publishing 1.5.1, 1.5.2,
and 1.6.0 to the public plugin directory. SVN commits and the GH
Actions workflow succeeded; the directory page just froze at
1.4.0 (the last version that predated the self-update code).
`https://api.wordpress.org/plugins/info/1.0/mega-ai.json` is the
authoritative version source — it lagged SVN for the same reason.

**If you find yourself wanting to re-add a self-update flow, the
answer is no.** The plugin's WordPress.org listing is the only
stable distribution channel. To ship a new version: bump the two
version locations + `readme.txt`'s `Stable tag` + add a `Changelog`
entry, then merge to `main`. The
`.github/workflows/mega-ai-wp-deploy.yml` workflow auto-commits to
WP.org SVN. Expect a 24–72h indexing delay before the public
directory page updates.

## Distribution side (informational)

The `app/api/wp-plugins/mega-ai/update.json/route.ts` + `download.zip`
endpoints in this repo are still operational. They exist solely to
let any customer sites currently running 1.5.1, 1.5.2, or 1.6.0
(which were distributed via the now-removed self-update path) transition
forward to 1.6.1. Once the 1.6.1 plugin installs on those sites, the
`Update URI:` header is gone and WP core falls back to WP.org. After
the fleet finishes migrating off 1.5.1+, those msw endpoints become
vestigial and can be deleted.
