Skip to content

Add GET /environment: read-only site & runtime facts (PHP/DB versions, resolved defines) for the kntnt-wp-skills cutover #15

Description

@TBarregren

Summary

Add a read-only, capability-gated GET /kntnt-extractor/v1/environment endpoint returning the site's runtime and configuration facts — PHP version, database server flavour/version/collation, WordPress URLs/paths/prefix/core version, active plugins, present drop-ins, and the site's resolved non-secret wp-config defines.

This exists for the kntnt-wp-skills#24 hard cutover: kntnt-wp-skills's clone/pull skills are moving off the Novamira MCP (arbitrary execute-php) onto this plugin's REST surface. Their discovery phase used to gather a batch of site facts by running arbitrary PHP. Most of those facts are reconstructable from the existing tables/files/extractions surface — but a handful are not obtainable at all through it, and one class (defines) is only obtainable in a way that regresses the caller's security posture. This endpoint closes that gap while keeping the plugin generic (it exposes "facts about this install", never any kntnt-wp-skills-specific categorisation — ADR-0003 stays intact).

Why these facts have no other source

Fact the caller needs Available today?
DB total size, per-table sizes, table prefix (derivable from names) GET /tables
Uploads breakdown, drop-ins present, installed themes/plugins (dirs) GET /files manifest
Live PHP version ❌ runtime only — in no file and no table dump
DB server flavour + version (MySQL 8.x vs MariaDB 11.x, and the exact major.minor) Table_Dumper emits per-table DROP/CREATE/INSERT only — no -- Server version header, so a dump cannot reveal it
DB default collation ⚠️ inferable from CREATE TABLE DDL, but only after extracting a table; cheap to serve here
home/site URL, core version, active-plugins option ⚠️ only by extracting & parsing wp_options / version.php
wp-config defines ⚠️ only by extracting wp-config.php whole — which drags DB_PASSWORD, salts and nonces onto the caller's disk in cleartext

The last row is the important one. kntnt-wp-skills has a hard rule that the database password never leaves production (its safety rail 8 / user story 40). If the skills had to recover defines by extracting wp-config.php, that rule breaks. Resolving defines here, server-side, with the secret family redacted to null, preserves that rule — the plugin is the only place that can read the config without shipping the secrets. So this is a security-preserving move, not scope creep.

The PHP and DB-server versions pin the caller's local DDEV environment. Without them the caller cannot avoid the "MySQL-8 dump crashes a MariaDB import on modern collations" failure or match production's PHP — both documented, load-bearing correctness properties on the caller's side. They are unrecoverable by any amount of extraction.

Decision E1 — how broad should the endpoint be?

Minimal (just the two forced facts) vs fuller (all the cheap generic facts, so the caller needs no wp_options bootstrap parse and never pulls wp-config.php).

Recommended: the fuller contract below. Rationale per field group:

  • php_version, database.{server,version,collation}forced; no other source (see table).
  • defines (resolved, secret family redacted) — security-preserving; keeps the DB password/salts on production (see above). Redaction denylist below.
  • wordpress.{home_url,site_url,core_version,table_prefix,content_dir,uploads_dir} — cheap get_bloginfo/get_option/wp_upload_dir one-liners; also lets the caller verify it is talking to production and not its local copy (home_url) before any heavy work, and map non-default content/uploads layouts. Paths are relative to the install root (no absolute server paths — least disclosure).
  • active_plugins, dropins — generic site state every migration/staging tool wants (which plugins are active; which drop-ins are present). dropins is also derivable from the manifest, so it MAY be dropped if you prefer the leaner surface.
  • server_software — best-effort from $_SERVER['SERVER_SOFTWARE']; informational only.

If you prefer a leaner endpoint (e.g. versions + defines only, everything else from extraction), say so and I'll trim the contract before this goes ready-for-agent.

Secret-define redaction denylist (fixed rule, not caller-specific)

Resolve each define's live value via defined()/constant(), except this family, whose value is emitted as null and never read:

  • exact: DB_PASSWORD, AUTH_KEY, SECURE_AUTH_KEY, LOGGED_IN_KEY
  • suffix *_SALT (covers AUTH_SALT, SECURE_AUTH_SALT, LOGGED_IN_SALT, NONCE_SALT)
  • prefix NONCE_* (covers NONCE_KEY, NONCE_SALT)

(This mirrors kntnt-wp-skills's is_secret_define(), giving defence in depth at both ends of the boundary.)

Proposed contract

GET /kntnt-extractor/v1/environment      (permission: shared Authorizer — both caps, 403 otherwise)
200 →
{
  "php_version": "8.2.18",
  "server_software": "Apache/2.4.57",
  "wordpress": {
    "core_version": "6.5.2",
    "home_url": "https://example.com",
    "site_url": "https://example.com/wp",
    "table_prefix": "wp_",
    "content_dir": "wp-content",            // relative to the installation root
    "uploads_dir": "wp-content/uploads"     // relative to the installation root
  },
  "database": {
    "server": "mariadb",                    // "mysql" | "mariadb", derived from @@version_comment/VERSION()
    "version": "11.4.2",
    "collation": "utf8mb4_unicode_520_ci"   // @@collation_database
  },
  "active_plugins": ["akismet/akismet.php", "..."],
  "dropins": ["object-cache.php", "advanced-cache.php"],
  "defines": [
    { "name": "WP_MEMORY_LIMIT", "value": "256M" },
    { "name": "DB_PASSWORD",     "value": null },     // redacted family → null
    { "name": "WP_DEBUG",        "value": false }
  ]
}
  • Gated by the existing shared Authorizer (kntnt_extractor_operate + manage_options), like /tables — read-only, no parameters, no side effects.
  • server is derived from @@version_comment/VERSION() (e.g. contains "MariaDB" → mariadb, else mysql).
  • defines names come from a light regex over the located wp-config.php source (the same "find define('NAME'" approach the caller uses today), values resolved live via constant() with the denylist redacted — never the raw unevaluated source expression.

Acceptance criteria

  • AC1 — an authorized GET /environment returns php_version, database.{server,version,collation}, wordpress.{core_version,home_url,site_url,table_prefix,content_dir,uploads_dir}, active_plugins, dropins, and defines.
  • AC2 — an anonymous or single-capability caller is refused 403 (same behaviour as /tables).
  • AC3 — every define in the redaction family (DB_PASSWORD, the four exact keys, *_SALT, NONCE_*) is present in defines by name with value: null; no secret value is ever emitted.
  • AC4wordpress.content_dir / uploads_dir are relative to the installation root and correct for a non-default WP_CONTENT_DIR/uploads layout; no absolute server path is disclosed.
  • AC5database.server is mariadb on a MariaDB backend and mysql on a MySQL backend.
  • AC6 — increments the REST API version to 2 (a new caller-visible endpoint); GET /status reports 2. (If the sibling cutover tickets land in the same release, this is a single bump to 2 — coordinate, don't bump per ticket.)

Testing (test-first, per the project's red → green rhythm)

  • Playground integration (tests/Integration/*-test.php): shape + both-caps gate (403) + secret redaction (seed a DB_PASSWORD/salt define, assert value: null) + active_plugins/dropins reflect seeded state + relative content/uploads paths. Follows the existing files-endpoint-test.php/tables-test.php pattern.
  • DDEV/MySQL harness (tests/Integration/DDEV/): the real php_version and database.{server,version,collation} values can only be asserted against a real MySQL-family engine — Playground/SQLite cannot report them (exactly as tables-size-test.php already notes for SHOW TABLE STATUS). Add an assertion there that database.server/version are non-empty and plausible and php_version matches the container's PHP.

References

  • Origin: kntnt-wp-skills#24 cutover (this plugin is that project's companion control channel). The caller's old discovery payload is kntnt-wp-skills/templates/discovery.php — this endpoint replaces the runtime/config half of it.
  • ADR-0002 (authn/authz — reuse the Authorizer), ADR-0003 (generic surface, no server-side categorisation — this endpoint exposes only generic facts, honouring it), ADR-0005 (API version).
  • Code touch points: new classes/Rest/Environment_Controller.php (mirror Tables_Controller), wired in Plugin.php on rest_api_init; Status_Controller::API_VERSION bump.

DECIDED (maintainer, 2026-07-22) — now ready-for-agent. E1 → implement the fuller contract above (versions + urls/prefix/core + active_plugins + dropins + resolved, secret-redacted defines). Gated by the both-capabilities Authorizer, so only a holder of an application password for a user with both caps can read it — and the redaction denylist applies even to that authorized caller, so no secret value is ever returned. Sibling cutover tickets: #16 (structure-only extraction), #17 (list caller's own jobs).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestready-for-agentFully specified, ready for an AFK agent

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions