WP Manifestindependent plugin directory
manifest / developer / wp-staging-bridge

WP Staging Bridge

Authenticated WordPress REST plugin that creates and updates draft Elementor pages from a validated JSON payload: HMAC + Application Passwords, idempotent, read-back verified, rollback.

by Micole Kurt T. Gonda · github.com/micolekurt/wp-staging-bridge · website

★ 0stars
0forks

Install

No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:

wp plugin install https://github.com/micolekurt/wp-staging-bridge/archive/refs/heads/main.zip

An authenticated REST plugin that creates and updates draft Elementor pages on a staging WordPress site from a validated JSON payload, and either finishes the job or leaves nothing behind.

Author: Micole Kurt T. Gonda (@MicoleKurt) · MIT licensed Part of a three-repo toolkit for programmatic WordPress/Elementor production: elementor-layout-compiler (the JSON generator that produces the payload) · wp-staging-bridge (this repo, the plugin that writes it) · tenant-memory-service (the tenant-isolated vector memory that supplies client content).

What it is for

A generator (human, script or language model) produces an _elementor_data tree. Writing that tree straight into wp_postmeta from outside WordPress is where things go wrong: double-encoded JSON, lost backslashes, half-written pages, duplicates on retry, content that opens as a blank editor. This plugin is the single, narrow, defensive place where that write happens.

Concern What the plugin does
Encoding Encodes _elementor_data once and passes it through wp_slash(), because update_post_meta() unslashes. Rejects a pre-encoded string.
Validation Re-validates every payload server-side with the same rules as the Python compiler (ids, nesting, isInner, widget allowlist, no scripts, safe URLs, repeater ids). Never trusts the sender.
Sanitising Defence in depth after validation: wp_kses_post on rich-text fields, esc_url_raw on URLs, wp_strip_all_tags on everything else.
Draft only status must be draft. There is no way to publish through this API.
Idempotency A retry with the same idempotency_key changes nothing. New content for the same (tenant_id, slug) updates the page in place; the same slug for another tenant is a separate page.
Atomicity Every meta write is read back and compared byte-for-byte. On any mismatch a new page is deleted and an updated page has its previous meta restored.
Elementor cache Clears the per-post CSS so a new layout does not render with stale styles.
Hooks staging_bridge_payload (filter), staging_bridge_before_write, staging_bridge_after_write for integrators.

Security model

Four independent gates, evaluated cheapest first, all failing closed. A request must pass every one.

  1. Configured. Needs STAGING_BRIDGE_SECRET (32+ characters) in wp-config.php. Unset means the API answers 503. The secret is never stored in the database, so it cannot be changed from wp-admin or leak through an export.
  2. Environment. Only runs when wp_get_environment_type() is local, development or staging. On production it answers 403 unless STAGING_BRIDGE_ALLOW_PRODUCTION is explicitly true.
  3. A real WordPress user with edit_pages. Machine clients authenticate with a core Application Password over HTTPS, so every write is attributable and revocable.
  4. Signed body. X-Bridge-Signature: sha256= + HMAC-SHA256(secret, timestamp + "\n" + nonce + "\n" + body), with X-Bridge-Timestamp (±300 s) and X-Bridge-Nonce (single use for 10 minutes). A stolen Application Password alone is not enough to write, and a captured request cannot be replayed. Nonces are only stored after the signature verifies, so unauthenticated callers cannot fill the replay store.

The body is size-capped (default 1.5 MB) before any hashing. Reading a page back only works for pages the bridge itself wrote, and anything else answers 404. See SECURITY.md.

Install

# in wp-content/plugins/
git clone https://github.com/MicoleKurt/wp-staging-bridge.git
// wp-config.php
define('STAGING_BRIDGE_SECRET', '<at least 32 random characters, e.g. openssl rand -hex 32>');
define('WP_ENVIRONMENT_TYPE', 'staging');
// optional
define('STAGING_BRIDGE_MAX_BODY_BYTES', 1500000);

The plugin has no runtime dependencies (composer is only used for the test suite).

API

Base: /wp-json/staging-bridge/v1

Method Route Purpose
GET /health Authenticated liveness: version, environment, whether Elementor is active
POST /pages Create or update a draft page. 201 created, 200 updated or idempotent repeat
GET /pages/{id} Read back a bridge-written page: tenant, status, token hash, element count
POST /wp-json/staging-bridge/v1/pages
Authorization: Basic <application password>
X-Bridge-Timestamp: 1790000000
X-Bridge-Nonce: 8c1f0c9e5b7a4e2f
X-Bridge-Signature: sha256=<hex>
Content-Type: application/json

{ "tenant_id": "brightside-dental", "slug": "home", "title": "Home", "status": "draft",
  "design_tokens_sha256": "…", "idempotency_key": "…", "elementor_data": [ … ] }
{ "id": 214, "status": "draft", "created": true, "idempotent": false,
  "edit_url": "https://staging.example.com/wp-admin/post.php?post=214&action=elementor" }

A rejected payload answers 422 with every problem at once:

{ "code": "staging_bridge_invalid_payload", "data": { "status": 422,
  "errors": ["status: only 'draft' is accepted", "elementor_data[2].settings.link.url: scheme not allowed"] } }

The payload is exactly what elc compile from elementor-layout-compiler emits. The test suite uses a payload generated by that CLI as its fixture, so a change to either repo that breaks the contract fails a test.

Tests

composer install
vendor/bin/phpunit        # 44 tests

Covers: the signature scheme (tampered body, wrong secret, stale and future timestamps, non-digit timestamps, odd nonces, replay, replay store not poisoned by bad signatures), the permission order (503, 403, 401, 413), payload validation, create → idempotent repeat → update in place, tenant separation, the slash/unslash round trip with quotes, backslashes, apostrophes and emoji, rollback on a failed write (new page and updated page), sanitising, and hook order.

Status and limits (read this)

  • The suite runs against a small in-memory stand-in for WordPress (tests/bootstrap.php) so the plugin's own logic runs in plain PHPUnit with no database. The stand-in's sanitisers are deliberately simple; it is not WordPress.
  • This repo has not been run against a live WordPress plus Elementor install yet. The first thing to do on a real staging site is GET /health, create the sample page, and open it in the editor; the plugin's read-back check proves what was stored, not how Elementor renders it.
  • Style keys inside the sample payload follow Elementor's export conventions and are documented as unverified in the compiler repo.
  • This plugin does not publish, delete, upload media or touch anything but pages carrying its own meta. That is the design, not a gap.

License

MIT, © 2026 Micole Kurt T. Gonda