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
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.zipAn 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.
- Configured. Needs
STAGING_BRIDGE_SECRET(32+ characters) inwp-config.php. Unset means the API answers503. The secret is never stored in the database, so it cannot be changed from wp-admin or leak through an export. - Environment. Only runs when
wp_get_environment_type()islocal,developmentorstaging. On production it answers403unlessSTAGING_BRIDGE_ALLOW_PRODUCTIONis explicitlytrue. - A real WordPress user with
edit_pages. Machine clients authenticate with a core Application Password over HTTPS, so every write is attributable and revocable. - Signed body.
X-Bridge-Signature: sha256=+HMAC-SHA256(secret, timestamp + "\n" + nonce + "\n" + body), withX-Bridge-Timestamp(±300 s) andX-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