# 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;`

## 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."

## 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.
