# EZ Page Sync

Push page edits from a staging WordPress instance to production — with full data fidelity, HMAC security, and automatic rollback.

---

## Requirements

| Requirement | Minimum |
|---|---|
| WordPress | 6.9+ |
| PHP | 8.1+ |
| MySQL | 5.7+ / MariaDB 10.3+ |

> **Both staging and production** must have the plugin installed and activated.

---

## Features

**What it syncs**
- Post content, title, slug, status, and dates
- All custom post meta (custom fields)
- ACF fields (if Advanced Custom Fields is active on both ends)
- Elementor page layouts (`_elementor_data`) — including images, Global Widgets, and Template-widget references, remapped to production IDs
- Astra theme meta (if Astra is active on the receiver)
- Taxonomy assignments across all registered taxonomies
- Featured images and inline media attachments (base64 transferred)
- Yoast SEO meta fields
- URL rewriting — staging URLs in content and meta are rewritten to production URLs on arrival

**Sync options**
- **Full Sync** — syncs all of the above
- **Content Only** — skips meta, ACF, media, and taxonomies; just the post body/title/slug
- **Dry Run mode** — runs the full pipeline including validation and media processing without writing to the database; returns a diff summary
- **Preview** — analyses the production match before committing, showing match confidence, proposed action, and any warnings (e.g. title mismatch, ambiguous slug)
- **Bulk sync** — select and push multiple pages at once from the admin dashboard
- **Force sync** — override ambiguous-match warnings after review

**Security**
- HMAC-SHA256 signed requests (every payload cryptographically verified)
- Replay attack protection via one-time nonces with a 10-minute TTL
- Timestamp validation (±5 minute window)
- IP whitelist on the receiver (supports CIDR notation)
- Configurable trusted proxy IP list for reverse-proxied environments
- Rate limiting on the receiver (60 sync requests/hour/IP; ping/health-check requests are exempt)
- HTTPS enforced — HTTP connections are rejected unless `WP_DEBUG` is enabled and the request is local

**Rollback system**
- Automatic snapshot taken before every sync (configurable)
- One-click restore from the Rollback Manager tab (receiver only)
- Auto rollback on sync failure (configurable)
- "Require snapshot before apply" safety policy — aborts sync if snapshot fails

**Admin dashboard**
- **Sync Pages tab** — searchable page list with per-page sync/preview buttons and bulk sync
- **Sync Log tab** — filterable, paginated log with status, event type, post type, duration, user, and CSV export
- **Rollback Manager tab** (receiver only) — snapshot history per post with restore controls
- **Settings tab** — all configuration in one place
- Sync status metabox on every post edit screen (sender only)

**Operational**
- Cache purge after successful sync (WP Super Cache, W3 Total Cache, LiteSpeed Cache, WP Rocket)
- Email notifications on sync success or failure
- Dedicated `wp_ez_ps_logs` database table — no pollution of core WP tables
- Auto-prune of log table to `ez_ps_max_log_entries` limit (default 500)
- Debug telemetry routed to PHP `error_log()` — never pollutes the audit log

**Free vs Premium**

EZ Page Sync Free covers unlimited basic content sync — single destination, `page`/`post`, draft-or-publish. The following require Premium:

| Feature | Free | Premium |
|---|---|---|
| Sync destinations | 1 | Unlimited (multi-destination / relay chains) |
| Plugin role | Sender *or* Receiver | Both simultaneously (multi-role) |
| Post types | `page`, `post` | Any registered custom post type |
| ACF / custom meta / Yoast / taxonomy sync | — | Included |
| Elementor page-builder sync | — | Layout, images, Global Widget / Template references |
| Astra theme meta sync | — | Included |
| Media sideloading | — | Featured image + inline media |
| Bulk sync | — | Included |
| IP whitelist | — | Included |
| Settings Access restriction | Editors & Administrators (fixed) | Restrict to any role or a specific user |
| Rollback Manager tab | — | Included |
| Sync log retention / CSV export | Capped | Extended retention, unlimited, CSV export |

Force sync (the data-safety override used when a sync match is ambiguous) is free on
every tier — it's a user acknowledgment, not a monetizable feature, so it isn't part of
the comparison above.

Premium licensing runs through [Freemius](https://freemius.com). All gating routes through a single seam (`Capabilities::allows()`) — a Free install never fatals or loses configuration if a feature lapses; it simply stops being enforced until re-upgraded.

---

## Installation

1. Upload the `ez-page-sync` folder to `/wp-content/plugins/`
2. Activate the plugin through the **Plugins** menu in WordPress
3. Go to **EZ Page Sync** in the admin menu
4. Configure the plugin role (Sender or Receiver)

## Testing

This repository includes a lightweight PHPUnit setup for hardening-focused unit tests.

### Local test run

1. Install PHP 8.1+ and Composer
2. Install dev dependencies:

```bash
composer install
```

3. Run the test suite:

```bash
composer test
```

Optional verbose output:

```bash
composer run test:verbose
```

Run only unit tests:

```bash
composer run test:unit
```

Run only integration tests:

```bash
composer run test:integration
```

### Make targets

If you prefer `make`:

```bash
make test
make test-unit
make test-integration
make test-verbose
make build
make deploy
```

### Local Deployment

The `make deploy` command automatically builds the production zip and syncs it to any detected local Docker WordPress plugin directories defined in `scripts/deploy-local.sh`.

### CI

A GitHub Actions workflow runs PHPUnit on every push/PR:

- `.github/workflows/phpunit.yml`

### Sender (Staging)

1. Set role to **Sender (Staging)**
2. Enter the Production Site URL
3. Copy the generated API Secret Key
4. Test the connection from the Settings tab

### Receiver (Production)

1. Set role to **Receiver (Production)**
2. Paste the same API Secret Key as staging
3. Optionally restrict by sender IP address (comma-separated IPs or CIDR blocks)
4. Optionally configure Trusted Proxy IPs if behind a load balancer

---

## Configuration Reference

| Setting | Role | Default | Description |
|---|---|---|---|
| Plugin Role | Both | `sender` | `sender` (staging) or `receiver` (production); both simultaneously requires Premium |
| Settings Access | Both | Editors & Admins | Who can view/edit plugin settings; restricting to a specific role/user requires Premium |
| Production Site URL | Sender | — | Full URL of the production WordPress install |
| API Secret Key | Both | — | Shared HMAC key; stored encrypted in `wp_options` |
| Allowed Sender IPs | Receiver | _(any)_ | Comma-separated IPs/CIDR blocks; empty = allow all |
| Trusted Proxy IPs | Receiver | — | Only trust `X-Forwarded-For` header from these IPs |
| Sync Post Types | Both | `page, post` | Post types eligible for sync |
| Sync ACF Fields | Sender | `true` | Include ACF field data in payload |
| Sync Custom Meta | Sender | `true` | Include all `post_meta` in payload |
| Sync Media | Sender | `true` | Include featured image and inline media |
| Overwrite Existing Media | Receiver | `false` | Replace media on production if filename matches |
| Auto Rollback on Failure | Receiver | `true` | Revert production post if sync fails |
| Require Snapshot Before Apply | Receiver | `true` | Abort sync if snapshot creation fails |
| Fail On Partial Sync | Receiver | `true` | Treat ACF/media errors as fatal |
| Allow HTTP In Dev Only | Receiver | `false` | Accept HTTP only when `WP_DEBUG` is enabled and request is local |
| Max Log Entries | Both | `500` | Row cap for `wp_ez_ps_logs`; oldest pruned automatically |
| Dry Run Mode | Sender | `false` | Simulate sync without writing to production |
| Notify Email | Receiver | — | Email address for sync success/failure notifications |
| Connection Timeout | Sender | `30` | Seconds before the outbound request times out |
| Excluded Meta Keys | Sender | — | One key per line — never synced |

---

## Frequently Asked Questions

**Does this support WordPress Multisite?**
Not in v1. Single-site only.

**Does it work with ACF?**
Yes. ACF fields are detected automatically. Both staging and production must have ACF active. Fields are matched by key first, with a name-based fallback.

**What happens if a sync fails?**
If Auto Rollback is enabled (default), the production post is automatically restored from a pre-sync snapshot. A `sync_failed` log entry is created with error details.

**Can I preview before syncing?**
Yes — use the **Preview** button per page before committing. It returns a match confidence score, proposed action, and any warnings without writing anything.

**What is Dry Run mode?**
Dry Run runs the full sync pipeline (validation → snapshot → media → ACF → meta → taxonomies) but makes no database writes. It returns a summary of what _would_ change.

**How do I regenerate the API key?**
Go to **EZ Page Sync → Settings**, click **Regenerate Key**. Update the key on the other installation immediately — existing connections are invalidated instantly.

**Does this work with caching plugins?**
Yes. After a successful sync the plugin purges the page cache for WP Super Cache, W3 Total Cache, LiteSpeed Cache, and WP Rocket.

**Why are debug messages not in the Sync Log?**
By design. Debug/telemetry entries go to PHP's `error_log()` (visible in your server error log when `WP_DEBUG` is on). Only audit-worthy events (sync_success, sync_failed, warnings, rollbacks) are stored in the database log.

**I'm getting "Rate limit exceeded" errors.**
The receiver allows 60 authenticated sync requests per hour per sender IP. This counter does **not** include health-check pings. If you hit the limit, wait for the window to reset (the error message shows the retry-after seconds) or reduce the frequency of bulk syncs.

---

## Architecture

```
staging (Sender)                     production (Receiver)
─────────────────                    ────────────────────
EzPageSync\Sender                           EzPageSync\Receiver
  └─ build_payload()                   └─ handle_sync()
  └─ sign (HMAC-SHA256)                  ├─ verify_request()
  └─ wp_remote_post ──────────────►     ├─ validate_payload_schema()
                                         ├─ create_snapshot()
                                         ├─ find_or_create_production_post()
                                         ├─ apply_post_update()
                                         ├─ sync_post_meta()
                                         ├─ sync_media()         ← EzPageSync\Media_Sync
                                         ├─ sync_acf_fields()
                                         ├─ sync_taxonomies()
                                         ├─ sync_yoast_meta()
                                         └─ log_sync_success()
```

**Key classes**

| Class / File | Purpose |
|---|---|
| `class-ez-ps-sender.php` | Payload builder, signing, request dispatch, sync status |
| `class-ez-ps-receiver.php` | REST endpoint handler, orchestrates all sync steps |
| `class-ez-ps-auth.php` | HMAC signing/verification, nonce, IP whitelist, rate limiting |
| `class-ez-ps-logger.php` | DB log table, `log_sync_success`, `log_sync_failed`, `get_logs` |
| `class-ez-ps-media-sync.php` | Featured image and inline media upload, URL rewriting |
| `class-ez-ps-rollback.php` | Snapshot creation and restore |
| `class-ez-ps-validator.php` | Payload schema validation and sanitization |
| `class-ez-ps-admin.php` | REST API endpoints for admin dashboard, settings |
| `class-ez-ps-capabilities.php` | Feature gating, role management, multi-role support |
| `class-ez-ps-migration.php` | Schema and option upgrades (legacy single-dest to multi-dest) |
| `trait-ez-ps-shared-utilities.php` | Shared helpers (`calculate_duration_ms`, `get_current_user_display_name`) |

---

## Changelog

### 1.1.37 — 2026-07-03

**Fix: Free-tier Plugin Role lock now covers test-connection-only receivers**

- **Fix**: `Admin::has_incoming_sync_history()` now returns true when a receiver has been contacted by an authenticated sender via any route — not only when at least one post has been synced. Previous version only checked the `_ez_ps_last_synced_site` post meta, which is written by `Receiver::handle_sync()` but not by test-connection pings. That gap meant a Free-tier receiver could pass Test Connection with a sender and still flip its role via the Settings form, orphaning the sender's outgoing flow — the exact regression this release closes. Fast-path signal is a new `ez_ps_receiver_last_contacted_at` option set inside `Receiver::verify_request()` once, on the first successful auth, so there's no `update_option()` write on every incoming request.
- **Fix**: `rest_reset_sync_origin()` now deletes the `ez_ps_receiver_last_contacted_at` option alongside the three ownership meta keys and the API key regeneration. Without this, a receiver that had ever been contacted would stay locked into Receiver role forever, and the admin couldn't repurpose the site as a Sender post-Reset.
- **Tests**: 2 new methods in `tests/AdminGatingTest.php` verify the option-based fast path in `has_incoming_sync_history()` (returns true when the option is set even without any post meta) and that Reset clears the option. 606 PHPUnit + 17 JS tests pass (up from 604).

### 1.1.36 — 2026-07-03

**Fix: Reset Sync Origin now truly disconnects the current staging (regenerates API key)**

- **Fix**: `Admin::rest_reset_sync_origin()` now regenerates the API secret key in addition to clearing the three ownership meta keys (`_ez_ps_last_synced_site` / `_ez_ps_staging_dirty` / `_ez_ps_last_synced_gmt`). Previously the meta-only reset was a no-op against a determined sender — the current staging held the same API key, so its next sync passed HMAC verification and re-established ownership immediately, effectively bypassing the reset. Rotating the key at the same time makes the button do what its label promises. The new key is returned in the response and surfaced in the API key input + copy button via the same JS flow the Regenerate Key button uses.
- **UI**: Reset Sync Origin description and confirmation dialog updated to reflect the combined behavior. Admin flow is: click Reset → confirm → new key appears in the field → copy to the reconnecting staging site. The Regenerate Key button remains for the narrower use case of rotating just the key without wiping ownership metadata.
- **Endpoint gate**: `rest_can_manage_security` (genuine `manage_options`, not the delegatable `ez_ps_manage_settings` virtual cap) already gated the endpoint — confirmed the now-more-destructive combined action is still restricted to real admins.
- **Tests**: 3 new methods in `tests/AdminGatingTest.php` verify the response includes a new plain-text key different from the old, storage holds the encrypted new key, the three ownership meta keys are deleted from `wp_postmeta`, and the success message references both actions. `EZ_PS_Test_WPDB` gained a `delete()` stub + `delete_calls[]` trace to unlock this and future postmeta-cleanup coverage. 604 PHPUnit + 17 JS tests pass (up from 601).

### 1.1.35 — 2026-07-03

**Free-tier lock on Plugin Role when Receiver has established incoming sync**

- **Fix**: Plugin Role selector on the Settings tab is now locked on Free tier when the site is currently a Receiver and has already received at least one sync from an upstream Sender (evidence: any `_ez_ps_last_synced_site` post meta exists). Prior behavior let a Free-tier receiver flip to Sender via the checkboxes, silently orphaning the sender's outgoing flow. Both checkboxes render disabled with a lock explanation; server-side `Admin::sanitize_setting()` also early-returns existing storage when the lock triggers, so a tampered POST can't bypass the UI.
- **New helpers**: `Admin::has_incoming_sync_history()` (indexed `LIMIT 1` postmeta probe) and `Admin::is_role_change_locked_on_free()` (composite: Free tier + active role is `['receiver']` + has history). Premium bypasses via the `multi_role` capability — running both roles simultaneously is safe there, so no lock is needed.
- **Preserved**: Grandfathered dual-role storage (`['sender', 'receiver']` with only Sender capped-active on Free) is unaffected — the existing regression test `testSettingsRoleCheckboxResubmitsGrandfatheredCappedRoleWhenNotEntitled` still passes; the lock only fires for a pure-Receiver active state.
- **Tests**: 10 new methods in `tests/AdminGatingTest.php` cover the sync-history probe (true / false), the composite lock (triggered / not-triggered by role, history, entitlement), the sanitize early-return (rejects flip / passes through when no history), and the template rendering (disabled checkboxes + lock message / clickable checkboxes when no history). 601 PHPUnit + 17 JS tests pass.

### 1.1.34 — 2026-07-03

**Full internal identifier rename: `WPS_*`/`wps_*` → `EZ_PS_*`/`ez_ps_*` (breaking protocol)**

- **Breaking (protocol)**: Completes the two-stage rebrand started in v1.1.20/v1.1.21. Sender and Receiver must upgrade lockstep — REST namespace changed from `wps/v1` to `ez-page-sync/v1`, and HMAC payload fields `x_wps_origin_chain`/`x_wps_domain_map`/`wps_version` renamed to `x_ez_ps_*`/`ez_ps_version`.
- **Rename map**: 8 PHP constants (`WPS_VERSION` → `EZ_PS_VERSION`, `WPS_PLUGIN_DIR`, `WPS_PLUGIN_URL`, `WPS_PLUGIN_BASENAME`, `WPS_FS_PLUGIN_ID`, `WPS_FS_PUBLIC_KEY`, `WPS_FS_IS_PREMIUM`, `WPS_UPGRADE_URL`); PHP namespace `WPS\` → `EzPageSync\`; 13 class files `class-wps-*.php` → `class-ez-ps-*.php`; DB option prefix `wps_*` → `ez_ps_*`; post-meta prefix `_wps_*` → `_ez_ps_*`; log table `wp_wps_logs` → `wp_ez_ps_logs`; capability `wps_manage_settings` → `ez_ps_manage_settings`; settings group `wps_settings_group` → `ez_ps_settings_group`; filter hooks `wps_syncable_post_types`/`wps_syncable_pages_limit`/`wps_feature_enabled`/`wps_excluded_meta_keys`/`wps_max_payload_bytes`/`wps_max_encoded_file_bytes`/`wps_post_type_access`/`wps_sender_ssl_verify`/`wps_trust_forwarded_ip_headers` → `ez_ps_*`; cron hooks `wps_snapshot_expiry`/`wps_rl_purge` → `ez_ps_*`; JS globals `wpsAdmin`/`wpsMetabox`/`wpsAdminColumns`/`wpsActiveTab`/`wpsElementorSyncAllowed`/`wpsMultiRoleAllowed` → `ezPageSync…`; enqueue handles `wps-admin`/`wps-metabox` → `ez-ps-*`; CSS classes `.wps-*` → `.ez-ps-*`; DOM data attribute `data-wps-tooltip` → `data-ez-ps-tooltip`; meta-box + column IDs `wps_sync_metabox`/`wps_sync_status`/`wps_sync` → `ez_ps_*`; object-cache group `wp_cache_flush_group('wps')` → `'ez_ps'`; debug log file `wps-debug.log` → `ez-ps-debug.log`; CSV export filename `wps-logs-*.csv` → `ez-ps-logs-*.csv`.
- **New**: `Uninstaller::cleanup_legacy_wps_data()` runs on every plugin activation and idempotently wipes any leftover pre-rebrand data — legacy `wps_*` options and their transient wrappers, `_wps_*` post meta, the `wp_wps_logs` table, and the legacy cron hooks. SQL LIKE patterns pipe-escape underscore and pair with an explicit `ESCAPE '|'` clause to prevent wildcard collision with unrelated plugin prefixes like `wpsl_*` (WP Store Locator) or `wpsc_*` (WP Shopping Cart) — destructive delete on activation must be strictly prefix-scoped. Multisite cron cleanup runs INSIDE the per-site loop since WP cron is stored in each site's own `cron` option.
- **Deliberately unchanged**: Yoast SEO's `_yoast_wpseo_*` post meta (external plugin's identifier, not ours to rename), the vendored Freemius SDK under `includes/freemius/`, historical audit reports under `docs/`, plugin folder slug (`ez-page-sync/`), text domain (`ez-page-sync`), and Freemius product slug (all already correct since v1.1.20).
- **Upgrade action**: WordPress does NOT auto-deactivate this time (main file path unchanged since v1.1.21). But because the activation cleanup wipes legacy `wps_*` data, existing settings and sync history are cleared — you'll re-run through Settings once, and every synced post loses its sync-status metadata (returns to "Never Synced"). Rollback snapshots taken pre-v1.1.34 are also cleared. This is intentional — safe now because there are no external installs to migrate.
- **Tests**: 591 PHPUnit (up from 586) + 17 JS. Five new `UninstallerTest` methods cover the destructive legacy-cleanup path.
- **Review**: A dual-agent code review (architecture + security) landed the SQL wildcard escape, the multisite cron placement fix, and the added test coverage before merge.

### 1.1.31 — 2026-07-01

**Distribution model: single Premium build via Freemius, wp.org path dropped**

- **Build**: Removed `build-free` and `build-premium` Makefile targets; consolidated to a single `make build` that produces the Premium-mode zip. PHP lint (`php -l`) is now part of the standard build, scoped to skip the vendored Freemius SDK.
- **Config**: `EZ_PS_FS_IS_PREMIUM` is now `true` by default in source — the sed-flip step is no longer needed. `Freemius_Gate::can_use_premium_code()` still gates every Premium feature at runtime via the SDK license check, so an unlicensed install remains functionally identical to the old Free tier.
- **Freemius**: Flipped `'is_org_compliant'` to `false` in `fs_dynamic_init()` — unlocks the SDK's full upsell/opt-in UX now that wp.org guideline compliance no longer applies. Removed the `'pricing' => false` menu arg and the `admin_head` CSS hack that suppressed the Freemius "Pricing" submenu link (both existed only to satisfy wp.org Guideline 11).
- **Housekeeping**: Swept stale "Free/wp.org build" references from code and test comments (`ez-page-sync.php`, `class-ez-ps-freemius.php`, `class-ez-ps-receiver.php`, `AstraMetaSyncTest`, `MediaSyncTest`) and updated `includes/freemius/README.md` upgrade instructions to reference `make build` instead of the deleted variant targets.

### 1.1.25 — 2026-06-25
- Fix: Logger `get_logs()` ternary rewritten to if/else — scanner was tokenising `!`, `empty`, `$params`, `?`, `:` inside the ternary as raw SQL fragments, generating multiple ERRORs.
- Fix: Resolved `InterpolatedNotPrepared` warnings in auth nonce purge, rollback postmeta cleanup, and sender post-ID IN-clause queries (all use `array_fill(%s/%d)` placeholder strings — not user data).
- Fix: Expanded phpcs:ignore on uninstaller `DROP TABLE` to cover all codes flagged for DDL that cannot accept `%i` placeholders.
- Fix: Removed deprecated `load_plugin_textdomain()` call (auto-loaded by WP since 4.6 for WP.org-hosted plugins).
- Fix: Created missing `languages/` directory referenced in plugin header `Domain Path`.
- Fix: Suppressed `NonceVerification.Recommended` false-positives on read-only `$_GET` params used only for admin tab navigation.
- Fix: Added `phpcs:ignore` / class-level `phpcs:disable` for `DirectQuery`/`NoCaching` across all files with legitimate custom-table and atomic-options queries.
- Fix: Suppressed `error_log()`/`var_export()` advisory warnings in receiver and sender (all calls inside `WP_DEBUG` guards).

### 1.1.24 — 2026-06-25

**WordPress.org Plugin Scan Compliance**

- **Fix**: Missing `translators:` comment moved to the line immediately preceding the `sprintf(__(...))` call so PHPCS can associate it correctly.
- **Fix**: `$count` (integer from `count()`) now passed through `absint()` in `printf()` to satisfy the output-escaping rule.
- **Fix**: Logger `get_logs()` no longer builds SQL into intermediate variables — literals go directly into `$wpdb->prepare()` so static analysers can confirm no unparameterised user data reaches the DB functions.
- **Fix**: `fopen('php://temp', ...)` and `fclose()` in `export_logs_csv()` annotated with `phpcs:ignore`; `php://temp` is an in-memory buffer, not a filesystem path, and WP_Filesystem has no streaming/CSV API.
- **Fix**: Remaining `@unlink()` call in `trait-ez-ps-shared-utilities.php` replaced with `wp_delete_file()`.
- **Fix**: `log_debug()` now routes through `error_log()` instead of `file_put_contents(WP_CONTENT_DIR/...)`, eliminating the PluginCheck false-positive for "plugin-folder write" and the `file_put_contents` alternative-functions flag.
- **Fix**: LIKE wildcard in `Auth::purge_expired_rate_limits()` now passed as a parameterised `%s` placeholder via `$wpdb->esc_like()` rather than embedded directly in the query string.

### 1.1.23 — 2026-06-25

**PHPCS Compliance, Media Encoding Reliability, and Build Hardening**

- **Fix**: Media file encoding now falls back to native PHP `file_get_contents()` on FTP/SSH WordPress installs where `WP_Filesystem()` cannot initialise without interactive credentials. Previously those installs silently omitted all media from sync payloads with no error.
- **Fix**: Added a post-read `strlen($content) > $max_bytes` guard after `WP_Filesystem::get_contents()` to close a TOCTOU race where a file could grow between the `filesize()` pre-check and the actual read, potentially loading more data than the configured limit into memory.
- **Fix**: `RuntimeException` message in the snapshot safety-policy abort path now uses `__()` instead of `esc_html__()`. The message flows to the DB audit log and email notifications (plain-text contexts), so HTML-escaping caused literal `&amp;` entities to appear there.
- **Fix**: Removed a stale machine-generated PHPCS report artifact that was inadvertently committed to `docs/`.
- **Fix**: WordPress i18n compliance — added `translators:` comments to all `sprintf()` calls with placeholders, switched unordered `%s`/`%s` placeholder pairs to ordered `%1$s`/`%2$s`, and replaced `parse_url()` with `wp_parse_url()` throughout for consistent cross-PHP behaviour.
- **Fix**: `premium_badge()` output is now wrapped in `wp_kses_post()` at every echo site in templates and admin output; sync-button label passes through `esc_html()`.
- **Fix**: Replaced `@unlink()` with `wp_delete_file()` in the receiver's media sideload cleanup path.
- **Build**: Excluded `includes/freemius` from the `build-free` distribution zip; added a hidden-file cleanup pass to remove any dotfiles left in the dist tree.

### 1.1.22 — 2026-06-24

**Freemius Uninstall Compatibility, Branding Fix, and Help & FAQ Tab**
- **Fix**: Freemius rejects plugin uploads that ship an `uninstall.php` file, since WordPress only fires Freemius's own uninstall-reporting hook (which includes user feedback) when that file is absent. Removed `uninstall.php` and moved its cleanup logic into `EzPageSync\Uninstaller::cleanup()` (`includes/class-ez-ps-uninstaller.php`), registered via Freemius's `after_uninstall` action inside `ez_ps_fs()` (`includes/class-ez-ps-freemius.php`).
- **Fix**: The Freemius upgrade/account pages showed a generic Freemius icon instead of the EZ Page Sync logo — the SDK's own icon auto-discovery guessed the wrong path for our vendored SDK's nesting depth. Hooked the SDK's documented `fs_plugin_icon_{slug}` filter to point directly at `assets/img/ez-page-sync.png` (which was on disk but had never actually been committed to git, so it never shipped to a fresh checkout either).
- **Feature**: Added a Help & FAQ tab to the admin dashboard with a setup guide and troubleshooting documentation.
- **Tests**: Added `tests/UninstallerTest.php` (single-site, multisite, and negative-case coverage for the uninstall cleanup path) and expanded `tests/AdminGatingTest.php` for the new Help & FAQ tab.

### 1.1.21 — 2026-06-24

**Rebrand Completion: Main File Rename & Freemius Identity Verification**
- **Breaking (upgrade action required)**: The main plugin file is renamed `wp-page-sync.php` → `ez-page-sync.php`, completing the rebrand started in 1.1.20. WordPress identifies active plugins by file path, so this update triggers an **automatic deactivation** — re-activate EZ Page Sync immediately after upgrading from any pre-1.1.21 version. All internal references (`Makefile` build/zip targets, `tests/bootstrap.php`'s mocked basename, a comment in `class-ez-ps-receiver.php`, `.claude/CLAUDE.md`) were updated to match.
- **Verified**: Confirmed the Freemius SDK's top-level `slug` (license/account storage identity, keyed to the dashboard-registered product slug) and `menu.slug` (WordPress admin-menu attachment point, keyed to `Admin::SLUG`) are two independent SDK concepts — they don't need to equal each other, only their own respective counterparts. Traced the vendored SDK's menu-manager (`includes/freemius/includes/managers/class-fs-admin-menu-manager.php`) and confirmed live in a browser that the Account/Upgrade submenus still attach correctly under the EZ Page Sync top-level menu.
- **Housekeeping**: `composer.json` package name updated to `jon/ez-page-sync`; ~16 historical `docs/` files had their remaining `wp-page-sync` / `WP Page Sync` text references updated. Real external filesystem paths that were *not* actually renamed (this project's own git workspace directory, a `.hermes` skill directory) were deliberately left as-is rather than rewritten to match the new name.

### 1.1.20 — 2026-06-23

**Elementor Free-Tier Restriction**
- **Feature**: Syncing or previewing an Elementor-built page on the Free tier is now rejected outright (`Sender::check_elementor_sync_gate()`, called from both `push_to_destination()` and `preview_sync()`) instead of silently succeeding while the Elementor layout is dropped — Elementor pages render almost entirely from `_elementor_data` rather than `post_content`, so a partial sync could previously leave production showing a near-blank page while still reporting success.
- **UI**: The Sync Pages tab (`assets/js/admin.js`), the post-list Sync column (`Admin::render_sync_column()`), and the post-edit metabox (`Admin::render_metabox()` / `templates/metabox-sync.php`) all show a Premium lock badge in place of their sync controls for Elementor pages on Free — including the same click-to-upgrade link every other Premium badge in the dashboard gets (the JS-rendered badge previously rendered a dead-end, non-clickable span; it now reads `ezPageSyncAdmin.upgradeUrl`, localized from `Freemius_Gate::get_upgrade_url()`, the same way `Admin::premium_badge()` does server-side).
- **Fix**: The Sync Pages tab's "Select All" checkbox set the `checked` property on Elementor-locked (disabled) rows too — `disabled` blocks user interaction, not a scripted `.prop()` write, so those rows could end up included in a bulk sync despite showing a locked badge instead of a sync control. `EzPageSync.updateBulkButton()`, `EzPageSync.bulkSync()`, and the checked-state preserved across table re-renders now all exclude disabled rows.
- **Hardening**: Consolidated two near-identical copies of the Elementor Free-tier gate (one each in `push_to_destination()` and `preview_sync()`) into a single shared `Sender::check_elementor_sync_gate()` method, removing a duplicated condition/error that could have drifted out of sync between the two call sites.

### 1.1.19 — 2026-06-23

**Elementor Sync Hardening**
- **Performance**: Scoped the sender's Elementor image-attachment collection to the exact shapes the receiver actually resolves (`Sender::collect_elementor_attachment_bundle()`) instead of reusing the ACF/Astra helper's blanket numeric scan, which treated any coincidental numeric value in an Elementor page's layout tree (column counts, z-index, spacing) as a candidate attachment and bundled its full file data — on every sync, every tier, even on Free where the result was discarded by the gate anyway.
- **Correctness**: Elementor's "Template" widget (`template_id`) and Global Widget (`templateID`) references — both of which embed another post by its staging post ID — are now remapped to their production counterpart (`Receiver::remap_elementor_template_refs()`), reusing the existing ACF relationship-field post-ID mapping (`_ez_ps_dest_{id}_post_id` via `Sender::collect_post_id_map()`) rather than a new mechanism. An unresolved reference (the target template was never synced to this destination) is left unchanged and logged (`elementor_template_ref_unresolved`), never guessed.
- **Packaging**: Fixed `make build-free` / `make build-premium` bundling a stray local Playwright debugging artifact (`.playwright-mcp/`) into both shipped zips — the Makefile's rsync exclude list didn't match `.gitignore`.
- **Tests**: Added 6 tests covering the scoped attachment extraction, the Template/Global-Widget post-ID remapping (including original-type preservation and the unresolved-reference logging path), and `sync_elementor_meta()`'s remap-trigger condition.

### 1.1.18 — 2026-06-23

**Freemius Feature Gating Audit Fixes**
- **Documentation**: Resolved three-way contradiction about `force_sync` Premium status across the README, the Freemius spec doc, and the `Capabilities::PREMIUM_FEATURES` constant. Force sync is universally free (data-safety acknowledgment, not a monetization boundary); README feature table now lists it as a separate free item with an explanatory footnote, and the spec doc includes a note documenting the deliberate non-enforcement.
- **Documentation**: Added the missing `astra_sync` row to the Premium feature table in `docs/premium-plugin-spec-freemius.md` §2. Constant, test mirror, and receiver-side gate (`Receiver::sync_astra_meta()`) were already in sync — only the spec was incomplete.
- **Settings UI**: The "Max Log Entries" number input now renders disabled with a Premium badge and a helper paragraph on Free builds, matching the affordance pattern used for the ACF / custom-meta / media checkboxes. The displayed value is the effective cap (50 on Free) so users don't set 10000 and wonder why the log is still capped. `Logger::FREE_MAX_LOG_ENTRIES` is now `public` so the template can reference it.
- **Hardening**: `Freemius_Gate::can_use_premium_code()` now emits a one-time `error_log()` warning when `EZ_PS_FORCE_PREMIUM_FOR_TESTS` is defined, so an accidentally-shipped production `wp-config.php` define is at least visible in the error log instead of silently unlocking every Premium feature.
- **Hardening**: Expanded the `Capabilities::allows()` docstring to warn future contributors that security-sensitive features must not rely on the `ez_ps_feature_enabled` filter seam (a third-party plugin could unlock them for every site).
- **Docs**: Added `docs/freemius-gating-audit-2026-06-23.md` — the detailed audit report (with evidence, fix recommendations, and verification checklist) that drove the above changes.

### 1.1.17 — 2026-06-22

**Fixes & Improvements**
- **Elementor Support**: Fixed Elementor image rendering issue by resolving the newer nested `"image-attachment-id"` JSON layout format during attachment ID remapping.
- **Sync Ordering**: Reordered receiver sync pipeline execution to process Elementor and Astra custom meta after step 8 (media uploads). This ensures media IDs are resolved and mapped correctly on their first sync.
- **Tests**: Added a unit test to verify nested image attachment ID resolution.

### 1.1.16 — 2026-06-22

**Fixes & Hardening**
- **Data Integrity**: Fixed a critical bug where syncing an existing post silently reset `post_parent` and `menu_order` to 0. Rollback snapshots were also updated to capture and restore `post_parent` and `menu_order`.
- **Security & Feature Gating**:
  - Hardened the receiver by adding server-side Premium entitlement checks (`Capabilities::allows('custom_meta_sync')`) on the generic custom-meta sync endpoint.
  - Added a Premium entitlement check (`Capabilities::allows('acf_sync')`) and source-site ownership validation (`assert_site_can_update_post()`) to the bundled ACF field-group sync.
  - Fixed a bypass in `handle_preview()` where unauthorized post types could be previewed due to a `WP_Error` truthiness evaluation bug.
- **Multi-Destination Sync**:
  - Fixed multi-destination sync to avoid overwriting legacy fallback meta keys (`_ez_ps_production_post_id`, `_ez_ps_last_synced`) when syncing to non-default destinations.
  - Corrected the dashboard "Last Synced" column to calculate and show the maximum (most recent) sync timestamp across destinations rather than the minimum.
  - Optimized sequential multi-destination sync performance by memoizing expensive base64 encoding and content image extraction operations.
- **REST & Admin UI**:
  - Fixed REST endpoints to correctly resolve and sanitize a destination ID literally named `"0"`.
  - Fixed Settings checkboxes (`ez_ps_sync_acf`, `ez_ps_sync_meta`, `ez_ps_sync_media`, `ez_ps_auto_rollback`) to render as checked by default on new plugin installations.
- **Timezones & Robustness**:
  - Fixed `human_time_diff()` timestamp comparisons to use true UTC on both sides, resolving timezone-offset labeling errors where recent syncs could display as "8 hours ago".
  - Guarded the orphan-cleanup `delete_field()` call with a `function_exists()` check.
  - Extracted the IP whitelist parse-and-loop helper to `SharedUtilities::ip_in_comma_list()` to prevent code duplication.
  - Reset the `Receiver::$staging_url_to_production_id` static map in both the init and finally blocks to prevent data leaks across requests in persistent PHP environments.

### 1.1.15 — 2026-06-21

**Sync behavior**
- Force Sync is no longer gated as a Premium capability. Sender and Receiver no longer drop the `force` payload flag based on `Capabilities::allows('force_sync')` — it is treated as a user data-safety acknowledgment (the user has reviewed an ambiguous match and explicitly accepted the risk), not a monetizable feature boundary.
- Added a structured `EZ_PS_FORCE_REQUIRED` error path. When the receiver detects an ambiguous match and the sender omits the force flag, the admin REST layer returns a 200 JSON response with `force_required: true` instead of a 400 `WP_Error`. This lets `wp.apiFetch` resolve (not reject) so the metabox JS can open the force-acknowledgment dialog directly without forcing a prior Dry Run.

**Admin UI**
- Removed server-side `disabled` attribute on grandfathered role checkboxes. Previously, a Free-tier site with both Sender + Receiver configured (grandfathered over-cap) had the off-role checkbox rendered disabled — which silently blocked legitimate single-role switching. Enforcement is now client-side via JS as radio-like mutual exclusion, gated by a new `window.ezPageSyncMultiRoleAllowed` flag emitted from the template based on `Capabilities::allows('multi_role')`. Server-side capping in `Admin::sanitize_setting()` is unchanged.
- Metabox handles the new `response.force_required` case by opening the force-acknowledgment dialog directly.

**Tests**
- Updated `AdminGatingTest` assertions to match the new checkbox rendering (no longer `disabled`) and added `testMultiRoleAllowedJsFlagReflectsEntitlementWhenNotEntitled` to cover the new `window.ezPageSyncMultiRoleAllowed` emission.

**Repo**
- `.gitignore`: added `.playwright-mcp/` to keep local Playwright MCP session dumps out of the repo.
- `.claude/CLAUDE.md`: corrected test suite layout (the actual suite is flat `tests/*.php`; the doc previously listed a non-existent `tests/Unit/` tree).

### 1.1.14 — 2026-06-21

**Fixes & Test Coverage**
- Added missing test coverage for capabilities, media sync, admin gating, update notifications, rollback snapshots, validator payloads, and multi-destination regression.
- Fixed Premium feature gating gaps and reinforced feature entitlement checks.
- Strengthened capability validation in `Admin` and `Validator` payloads.
- Improved multi-destination payload handling in `Receiver` and `Sender`.
- Hardened rollback snapshots and media synchronization processes.

### 1.1.13 — 2026-06-20

**Features**
- Introduced a Freemius-based Premium tier. All feature gating routes through a single seam (`Capabilities::allows()`, backed by `Freemius_Gate`) — the Free build never fatals and never loses stored configuration when a feature isn't entitled; enforcement simply re-arms the moment Premium is active again. Premium unlocks: multi-destination/relay sync, multi-role (Sender + Receiver on one site), custom post types, ACF/custom-meta/Yoast/taxonomy sync, media sideloading, bulk sync, force sync, IP whitelist, restricting Settings Access to a specific role/user, the Rollback Manager tab, and extended/unlimited log retention with CSV export.
- Added a "Premium" lock badge with an explanatory tooltip next to every gated setting, so Free-tier users can see at a glance what's behind the upgrade rather than discovering it only after a save is silently capped.
- Settings Access now defaults to Editors and Administrators on Free (previously Administrator-only); restricting it further to one role or a specific user requires Premium.

**Security & Hardening**
- Closed several capped-destination-bypass paths where `render_destination_options()`, the "sync to all" REST branch, and the sync-status destination loop read raw (uncapped) `ez_ps_destinations` storage instead of the Premium-capped accessor — a Free-tier site could have surfaced or synced to a grandfathered destination beyond its plan.
- Added server-side gating to the Rollback Manager REST endpoints (`/admin/snapshots`, `/admin/rollback`) so they self-defend instead of relying solely on the UI tab being hidden.
- Added write-time entitlement checks to the Settings save handler for Settings Access, ACF/meta/media sync toggles, custom post types, and multi-role — previously these were enforced only by disabling the control in the UI, which a direct or tampered form submission could bypass.
- Guarded a fatal in `Media_Sync`'s Astra meta path when sender-controlled media-map data reached it on a Free build where that class is physically absent, and capped custom-post-type reads on the sender side (syncable-pages list, connection-test diagnostic) to the Premium-capped accessor.
- Fixed a screen-reader regression where the new lock-badge tooltip (CSS-rendered) wasn't exposed to assistive tech; added `aria-label` so the explanation is announced on focus, not just shown on hover.

**Fixes**
- Existing over-cap configuration (destinations, roles, custom post types) is never truncated in storage on Free — only read-time behavior is capped — and a `ez_ps_grandfathered_over_cap` admin notice explains what's inactive until upgrade.

### 1.1.12 — 2026-06-18

**Fixes**
- Fixed an issue where syncing a post that uses an ACF field group would unconditionally re-import the field group even if unchanged, causing ACF's Local JSON to falsely flag the group as "out of sync" on the receiving site.
- Fixed field group bundling to properly exclude internal plugin sync metadata (`_ez_ps_*`) and ACF's internal `_group_key` index, preventing false-positive metadata diffs between environments.

### 1.1.11 — 2026-06-18

**Fixes**
- Fixed the root cause behind the 1.1.10 WebP/AVIF content-mismatch regression: the check required the uploaded bytes to decode as the *exact declared* image type. Image-optimization plugins on the source site routinely rewrite `.jpg`/`.png` files to WebP in place while keeping the original filename and `post_mime_type` — that's harmless image data, not an attack payload, and was being rejected outright. The check now accepts any genuinely-decodable raster image (JPEG/PNG/GIF/WebP/AVIF) regardless of which type was declared; only content that fails every recognized-format check (e.g. a script or markup disguised as an image) is rejected.

### 1.1.10 — 2026-06-18

**Fixes**
- Fixed a regression introduced in 1.1.9's image content-sniffing check: WebP and AVIF uploads could be wrongly rejected with "File content does not match its declared image type" because `getimagesize()` has known, PHP-version-dependent decode gaps on otherwise-valid WebP/AVIF files (animated/alpha-channel WebP, certain AVIF profiles). WebP and AVIF are now verified via their raw container signature bytes instead, which doesn't depend on the host's image parser supporting every format variant. JPEG/PNG/GIF are unaffected and still verified via `getimagesize()`.
- Added debug logging (`ez-ps-debug.log`) of the declared vs. detected type on any content-mismatch rejection, to make future cases self-diagnosing.

### 1.1.9 — 2026-06-18

**Security**
- Closed a TOCTOU race in `Auth::check_nonce` that could allow a nonce to be reused between its existence check and consumption.
- Added content-sniffing (`getimagesize()`) on top of extension-based MIME validation in media sync, so a non-image file renamed with an image extension is rejected instead of overwriting or being inserted as an attachment.
- Closed a TOCTOU race in media attachment deduplication using MySQL named locks, preventing duplicate attachments from concurrent sync requests for the same file.
- Split destructive admin actions (rollback, API key rotation, sync-origin reset, log deletion) onto a stricter permission check requiring genuine `manage_options`, rather than the delegatable settings-access capability used for general settings management.
- Tightened `/admin/logs` (view and export) from a bare `edit_posts` check to the settings-access capability, so any Contributor/Author can no longer read sitewide sync audit data.
- Added defense-in-depth capability checks directly inside `Sender::push_to_destination()` and `Sender::preview_sync()`, instead of relying solely on caller-side checks.
- Bounded the YYYY/MM upload-date regex extraction in media sync to plausible year/month ranges, preventing a malformed staging URL from producing a nonsense upload path or post date.

**Fixes & Reliability**
- Replaced silent `@`-suppressed file I/O errors in media sync with captured, logged failures (`ez-ps-debug.log`).
- Replaced brittle string-prefix matching for sync failure detection with an explicit `failed` flag.
- Added exception isolation to `Rollback::revert_restore()` so one failing revert step (meta, ACF, or taxonomy) can't abort the rest or crash an already-failing restore.
- Removed a dead `is_array()` check in `Rollback::get_snapshots()` and corrected a stale docblock on `Rollback::decode_snapshot_data()` that claimed legacy PHP-serialize support, which was never implemented (snapshots are JSON-only).

### 1.1.8 — 2026-06-11

**Fixes**
- Fixed PHP 8.5 deprecation warnings from `fputcsv()` calls in the CSV export path of `Logger` by explicitly passing the `$escape` parameter.
- Suppressed plugin error-log output during PHPUnit runs by routing `error_log()` to `/dev/null` via `phpunit.xml.dist`, eliminating noisy `[EZ Page Sync Error]` lines from the test output.

### 1.1.7 — 2026-06-10

**Fixes & Improvements**
- Fixed broken image synchronization issue.
- Improved handling of local modifications on production to prevent silent overwrites.
- Added a "Settings" action link directly below the plugin name on the WordPress Plugins page for quicker access.
- Fixed a bug where the Connection Timeout setting would clamp empty values to 5 seconds instead of properly falling back to 30 seconds.
- Added support for WordPress Multisite uninstallation, ensuring all plugin data is cleared across all sites in the network when deleted.
- Added a spinning animation to the sync Dashicons in the metabox while a sync is in progress to improve visual feedback.
- Added handling for `429 EZ_PS_CONCURRENT_SYNC` errors to display a helpful warning if a sync is already in progress on the receiver, rather than treating it as a hard failure.
- Refactored error handling logic across REST API endpoints and JavaScript components to strictly follow DRY principles.

### 1.1.6 — 2026-06-06

**Security**
- Hardened `production_post_id` ownership verification using `_ez_ps_last_synced_site` metadata to prevent referential ID hijacking.
- Migrated rollback snapshots from PHP serialization to JSON encoding (`wp_json_encode`) to mitigate Object Injection vulnerabilities.
- Fixed a rate limiter race condition by implementing atomic SQL increments.
- Removed the `ez_ps_trust_forwarded_ip_headers` filter to prevent IP whitelist spoofing.
- Removed reliance on staging payload `site_url` for production HTTP security decisions.

**Fixes & Reliability**
- Wrapped the multi-step sync sequence in database transactions (`START TRANSACTION`) to ensure atomic writes.
- Fixed missing `wp_slash` escaping in the ACF fallback logic and correctly applied it for nested meta array updates.
- Added `_ez_ps_rollback_failed` metadata flagging to track silent rollback failures for manual recovery.
- Centralized role capability checks using DRY `is_sender_active()` and `is_receiver_active()` helpers.
- Added left/right directional arrow indicators to the Sync Status column in the admin post list.

### 1.1.5 — 2026-06-04

**Features**
- Implemented advanced sorting and filtering for syncable pages in the admin dashboard.

### 1.1.4 — 2026-05-12

**Fixes & Improvements**
- Fixed Astra Pro "Custom Layouts" display and user conditions synchronization by resolving a meta key skipping bug in the `Sender`.
- Added support for automatic creation of missing taxonomy terms (categories, tags, etc.) on the `Receiver` during sync.
- Enhanced taxonomy sync to include term descriptions and hierarchical parent-child relationships.
- Fixed a bug where Yoast SEO meta fields were being skipped in the `Sender` payload.

### 1.1.3 — 2026-05-11

**Fixes & Hardening**
- Hardened ACF field deletion synchronization across all architectural layers (Sender, Validator, Receiver).
- Fixed a multi-layer payload key mismatch (`acf_field_groups`) that caused field group definitions to be stripped during sanitization.
- Fixed direct ACF Field Group sync (Step 8.1) failing to prune deleted fields due to a missing `delete_missing` flag in `sync_child_posts`.
- Improved Sender to recursively collect sub-fields (repeaters, groups) for direct field group syncs, ensuring a complete "source of truth" for cleanup.
- Refactored `log_debug` to unconditionally write to `wp-content/ez-ps-debug.log` for better visibility in restricted production environments.

### 1.1.2 — 2026-05-10

**Improvements**
- Added support for ACF field value deletion: deleting a field value on staging and syncing the page now correctly removes the value from the production post.
- Hardened ACF field group sync to handle empty child arrays during cleanup.

### 1.1.1 — 2026-05-10

**Fixes**
- Fixed ACF field deletion sync: fields deleted from a group on staging are now correctly identified and removed (recursively) on the receiver side to ensure full data parity.

### 1.1.0 — 2026-05-10

**Security**
- Upgraded secret key storage to use `AES-256-GCM` for authenticated encryption, ensuring keys cannot be tampered with at rest.
- Fixed signature verification by canonicalizing payloads before signing (preventing ordering-based sync failures).
- Hardened HMAC logic with stable JSON sorting to ensure cross-environment consistency.

**Fixes & Stability**
- Resolved all PHP 8.5 deprecation warnings related to `ReflectionMethod::setAccessible()` and static property access.
- Hardened `EZ_PS_Test_WPDB` mock to prevent 'undefined property' warnings during unit tests.
- Fixed potential memory issues in media sync by streamlining the rsync exclude list in the build process.

**Performance**
- Optimized deployment artifacts: build size reduced by 90% by excluding development-only directories (`tests`, `vendor`, `scripts`, `docs`).
- Automated local deployment pipeline with `make deploy` supporting multiple target environments.

### 1.0.2 — 2026-05-09

**Fixes**
- Fixed multi-stage sync regressions: migration now uses non-destructive `INSERT` instead of `UPDATE`, preserving legacy `_ez_ps_production_post_id` meta keys.
- Fixed `Capabilities::get_enabled_roles()` incorrectly defaulting to 'sender' on pre-migration receiver sites.
- Fixed `Fatal Error: Call to undefined function wp_generate_attachment_metadata()` by ensuring `wp-admin/includes/image.php` is loaded in all media sync paths.
- Fixed `Sender` success handler failing to write the legacy `_ez_ps_production_post_id` key for the default destination.
- Fixed UI clutter: Destinations settings table is now hidden on sites configured only as a Receiver.

**Features**
- Multi-Destination Support: Introduced `ez_ps_destinations` array, allowing a single Staging site to push to multiple targets (e.g. Pre-Prod, Staging-2, Production).
- Multi-Role Support: Sites can now be configured as both a Sender and a Receiver simultaneously, enabling chained sync workflows.
- Migration System: Automated upgrade path for legacy single-destination settings.
- Local Deployment: Added `make build` and `make deploy` targets for automated local Docker environment synchronization.

### 1.0.0 — 2026-04-14

**Fixes**
- Fixed sender `page_title` mismatch vs receiver: sender now uses raw `post_title` from payload instead of `get_the_title()` which applied WordPress filters (e.g. "Private: " prefix)
- Fixed early failure `log_sync_failed` call missing `post_type` and `sync_mode` arguments
- Fixed `get_post_type()` returning `false` under `strict_types=1` — guarded with `?: ''`
- Fixed rate limit exhaustion from admin dashboard pings — connection status now cached for 60 seconds; ping/health-check requests bypass the rate limiter entirely
- Fixed `sync_success` log entries being displaced by debug entries — all `WP_DEBUG`-gated telemetry now routes to PHP `error_log()` instead of the database log table
- Fixed bulk sync in admin dashboard not passing `sync_mode` parameter
- Fixed media sync URL rewriting when staging and production share the same base URL
- Fixed ACF field sync failing silently when `update_field()` is unavailable on production

**Features**
- Sender/receiver Sync Log now share identical `page_title`, `post_type`, and `sync_mode` values for matching records
- Preview endpoint returns match confidence (high/medium/low), proposed action, and structured warnings
- Force-sync guard — receiver rejects ambiguous slug/title matches unless `force` flag is set in payload
- Rollback Manager tab for one-click restore from production snapshots
- Snapshot taken automatically before every sync (configurable)
- Trusted Proxy IP support for correct sender IP resolution behind load balancers
- Connection status cached in transient to reduce unnecessary ping traffic
- Rate limiter uses `wp_options` for atomic counter increment (transients can't persist incremented values reliably)
- Yoast SEO meta fields synced separately from generic meta
- ACF media fields detected and base64-transferred alongside ACF field values
- URL rewriting applied recursively through serialized `post_meta` values
- CSV export for sync log with active filter support
- Admin dashboard tabs hidden / shown based on plugin role (sender hides Rollback Manager; receiver hides Sync Pages)

**Security**
- HTTPS enforced on receiver; `ez_ps_allow_insecure_http_dev` setting allows HTTP only when `WP_DEBUG` is on and the request originates from a local/private IP
- API secret key stored encrypted (AES-256-CBC) using `SECURE_AUTH_KEY` as the encryption salt
- Nonce TTL: 10 minutes; timestamp window: ±5 minutes

---

## License

GPL-2.0-or-later — see [https://www.gnu.org/licenses/gpl-2.0.html](https://www.gnu.org/licenses/gpl-2.0.html)
