# WordPress Content Sync REST Transport Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add authenticated REST package receive/status endpoints and a REST client that can test connections and send validated content packages using WordPress application passwords. **Architecture:** This phase adds a REST controller under `src/Rest/` and a REST client under `src/Transport/`. The REST controller validates request shape, permissions, and package schema, then returns a typed response without mutating WordPress content. The REST client serializes the existing `ContentPackage`, sends it with Basic application-password authentication, and converts network/API failures into typed transport errors so later orchestration can fall back to file transport. **Tech Stack:** PHP 7.4, WordPress REST API, WordPress HTTP API, application passwords, PHPUnit, PHPStan, PHPCS/WPCS. --- ## File Structure - Create: `src/Transport/RestTransportException.php` for typed REST failure details. - Create: `src/Transport/RestTransportClient.php` for connection tests and package sends. - Create: `src/Rest/RestPackageController.php` for `/wp-content-sync/v1/status` and `/wp-content-sync/v1/package` endpoints. - Modify: `src/Plugin.php` to register REST services. - Modify: `tests/bootstrap.php` to add REST and HTTP API stubs. - Test: `tests/Unit/Transport/RestTransportExceptionTest.php` - Test: `tests/Unit/Transport/RestTransportClientTest.php` - Test: `tests/Unit/Rest/RestPackageControllerTest.php` - Test: `tests/Unit/PluginTest.php` --- ## REST Contract ### Status Endpoint Route: `GET /wp-json/wp-content-sync/v1/status` Successful response: ```json { "ok": true, "plugin": "wp-content-sync", "version": "0.1.0" } ``` ### Package Receive Endpoint Route: `POST /wp-json/wp-content-sync/v1/package` Request JSON: ```json { "package": { "schema_version": "1.0", "generated_at": "2026-04-28T12:00:00+00:00", "source": { "site_url": "https://example.test", "name": "Example Production" }, "destination": { "site_url": "https://staging.example.test", "name": "Example Staging" }, "manifest": { "posts": 0, "terms": 0, "media": 0, "custom_post_types": 0 }, "records": { "posts": [], "terms": [], "media": [], "custom_post_types": [] }, "checksums": { "records": "sha256:..." } } } ``` Successful response: ```json { "accepted": true, "schema_version": "1.0", "manifest": { "posts": 0, "terms": 0, "media": 0, "custom_post_types": 0 } } ``` Error response for invalid package: ```json { "accepted": false, "errors": [ "records is required." ] } ``` --- ## Task 1: REST Transport Exception **Files:** - Create: `tests/Unit/Transport/RestTransportExceptionTest.php` - Create: `src/Transport/RestTransportException.php` - [ ] **Step 1: Write the failing exception test** Create `tests/Unit/Transport/RestTransportExceptionTest.php`: ```php 'https://example.test/wp-json/wp-content-sync/v1/status' ) ); self::assertSame( 'connection_failed', $exception->failureCode() ); self::assertSame( 'Connection timed out.', $exception->getMessage() ); self::assertSame( array( 'url' => 'https://example.test/wp-json/wp-content-sync/v1/status' ), $exception->context() ); } public function test_it_exposes_authentication_failures(): void { $exception = RestTransportException::authenticationFailed( 'REST authentication failed.' ); self::assertSame( 'authentication_failed', $exception->failureCode() ); self::assertSame( 'REST authentication failed.', $exception->getMessage() ); self::assertSame( array(), $exception->context() ); } } ``` - [ ] **Step 2: Run the test to verify it fails** Run: `composer test -- --filter RestTransportExceptionTest` Expected: FAIL with class `WPContentSync\Transport\RestTransportException` not found. - [ ] **Step 3: Implement the exception** Create `src/Transport/RestTransportException.php`: ```php */ private array $context; private string $failure_code; /** * @param array $context Failure context. */ private function __construct( string $failure_code, string $message, array $context = array() ) { parent::__construct( $message ); $this->failure_code = $failure_code; $this->context = $context; } /** * @param array $context Failure context. */ public static function connectionFailed( string $message, array $context = array() ): self { return new self( 'connection_failed', $message, $context ); } /** * @param array $context Failure context. */ public static function authenticationFailed( string $message, array $context = array() ): self { return new self( 'authentication_failed', $message, $context ); } /** * @param array $context Failure context. */ public static function remoteRejected( string $message, array $context = array() ): self { return new self( 'remote_rejected', $message, $context ); } public function failureCode(): string { return $this->failure_code; } /** * @return array */ public function context(): array { return $this->context; } } ``` - [ ] **Step 4: Run the test to verify it passes** Run: `composer test -- --filter RestTransportExceptionTest` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/Transport/RestTransportException.php tests/Unit/Transport/RestTransportExceptionTest.php git commit -m "feat: add rest transport exception" ``` --- ## Task 2: REST Transport Client **Files:** - Create: `tests/Unit/Transport/RestTransportClientTest.php` - Create: `src/Transport/RestTransportClient.php` - Modify: `tests/bootstrap.php` - [ ] **Step 1: Add HTTP API test stubs** Add these stubs to `tests/bootstrap.php` if they do not already exist: ```php if ( ! class_exists( 'WP_Error' ) ) { class WP_Error { private string $message; public function __construct( string $code, string $message ) { $this->message = $message; } public function get_error_message(): string { return $this->message; } } } if ( ! function_exists( 'wp_remote_get' ) ) { function wp_remote_get( string $url, array $args = array() ) { $GLOBALS['wpcs_last_http_request'] = array( 'method' => 'GET', 'url' => $url, 'args' => $args ); return $GLOBALS['wpcs_http_response'] ?? array( 'response' => array( 'code' => 200 ), 'body' => '{"ok":true}' ); } } if ( ! function_exists( 'wp_remote_post' ) ) { function wp_remote_post( string $url, array $args = array() ) { $GLOBALS['wpcs_last_http_request'] = array( 'method' => 'POST', 'url' => $url, 'args' => $args ); return $GLOBALS['wpcs_http_response'] ?? array( 'response' => array( 'code' => 200 ), 'body' => '{"accepted":true}' ); } } if ( ! function_exists( 'wp_remote_retrieve_response_code' ) ) { function wp_remote_retrieve_response_code( array $response ): int { return (int) ( $response['response']['code'] ?? 0 ); } } if ( ! function_exists( 'wp_remote_retrieve_body' ) ) { function wp_remote_retrieve_body( array $response ): string { return (string) ( $response['body'] ?? '' ); } } if ( ! function_exists( 'is_wp_error' ) ) { function is_wp_error( $value ): bool { return $value instanceof WP_Error; } } ``` - [ ] **Step 2: Write failing client tests** Create `tests/Unit/Transport/RestTransportClientTest.php`: ```php testConnection( 'https://destination.test', 'codex', 'app-pass' ) ); self::assertSame( 'GET', $GLOBALS['wpcs_last_http_request']['method'] ); self::assertSame( 'https://destination.test/wp-json/wp-content-sync/v1/status', $GLOBALS['wpcs_last_http_request']['url'] ); self::assertSame( 'Basic ' . base64_encode( 'codex:app-pass' ), $GLOBALS['wpcs_last_http_request']['args']['headers']['Authorization'] ); } public function test_it_sends_packages_to_receive_endpoint(): void { $client = new RestTransportClient(); self::assertTrue( $client->sendPackage( 'https://destination.test/', 'codex', 'app-pass', $this->package() ) ); self::assertSame( 'POST', $GLOBALS['wpcs_last_http_request']['method'] ); self::assertSame( 'https://destination.test/wp-json/wp-content-sync/v1/package', $GLOBALS['wpcs_last_http_request']['url'] ); self::assertStringContainsString( '"package"', $GLOBALS['wpcs_last_http_request']['args']['body'] ); self::assertSame( 'application/json', $GLOBALS['wpcs_last_http_request']['args']['headers']['Content-Type'] ); } public function test_it_throws_authentication_failures_for_unauthorized_status(): void { $GLOBALS['wpcs_http_response'] = array( 'response' => array( 'code' => 401 ), 'body' => '{"message":"Unauthorized"}' ); $client = new RestTransportClient(); $this->expectException( RestTransportException::class ); $this->expectExceptionMessage( 'REST authentication failed.' ); $client->testConnection( 'https://destination.test', 'codex', 'bad-pass' ); } public function test_it_throws_remote_rejected_for_invalid_package_response(): void { $GLOBALS['wpcs_http_response'] = array( 'response' => array( 'code' => 400 ), 'body' => '{"message":"Invalid package"}' ); $client = new RestTransportClient(); $this->expectException( RestTransportException::class ); $this->expectExceptionMessage( 'Invalid package' ); $client->sendPackage( 'https://destination.test', 'codex', 'app-pass', $this->package() ); } private function package(): ContentPackage { $records = array( 'posts' => array(), 'terms' => array(), 'media' => array(), 'custom_post_types' => array(), ); return ContentPackage::fromArray( array( 'schema_version' => '1.0', 'generated_at' => '2026-04-28T12:00:00+00:00', 'source' => array( 'site_url' => 'https://example.test', 'name' => 'Example' ), 'destination' => array( 'site_url' => 'https://destination.test', 'name' => 'Destination' ), 'manifest' => array( 'posts' => 0, 'terms' => 0, 'media' => 0, 'custom_post_types' => 0 ), 'records' => $records, 'checksums' => array( 'records' => PackageChecksum::records( $records ) ), ) ); } } ``` - [ ] **Step 3: Run tests to verify they fail** Run: `composer test -- --filter RestTransportClientTest` Expected: FAIL with class `WPContentSync\Transport\RestTransportClient` not found. - [ ] **Step 4: Implement REST client** Create `src/Transport/RestTransportClient.php`: ```php endpoint( $base_url, 'status' ), $this->requestArgs( $username, $application_password ) ); $this->assertSuccessfulResponse( $response, 200 ); return true; } public function sendPackage( string $base_url, string $username, string $application_password, ContentPackage $package ): bool { $body = wp_json_encode( array( 'package' => $package->toArray() ) ); if ( false === $body ) { throw RestTransportException::remoteRejected( 'Unable to encode REST package payload.' ); } $args = $this->requestArgs( $username, $application_password ); $args['body'] = $body; $args['headers']['Content-Type'] = 'application/json'; $response = wp_remote_post( $this->endpoint( $base_url, 'package' ), $args ); $this->assertSuccessfulResponse( $response, 200 ); return true; } private function endpoint( string $base_url, string $route ): string { return rtrim( $base_url, '/' ) . '/wp-json/wp-content-sync/v1/' . ltrim( $route, '/' ); } /** * @return array */ private function requestArgs( string $username, string $application_password ): array { return array( 'timeout' => 15, 'headers' => array( 'Authorization' => 'Basic ' . base64_encode( $username . ':' . $application_password ), ), ); } /** * @param mixed $response HTTP response. */ private function assertSuccessfulResponse( $response, int $expected_code ): void { if ( is_wp_error( $response ) ) { throw RestTransportException::connectionFailed( $response->get_error_message() ); } $status_code = wp_remote_retrieve_response_code( $response ); if ( 401 === $status_code || 403 === $status_code ) { throw RestTransportException::authenticationFailed( 'REST authentication failed.' ); } if ( $expected_code !== $status_code ) { throw RestTransportException::remoteRejected( $this->responseMessage( $response ) ); } } /** * @param array $response HTTP response. */ private function responseMessage( array $response ): string { $body = wp_remote_retrieve_body( $response ); $decoded = json_decode( $body, true ); if ( is_array( $decoded ) && isset( $decoded['message'] ) && is_string( $decoded['message'] ) ) { return $decoded['message']; } return 'REST transport request failed.'; } } ``` - [ ] **Step 5: Run tests to verify they pass** Run: `composer test -- --filter RestTransportClientTest` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add src/Transport/RestTransportClient.php src/Transport/RestTransportException.php tests/Unit/Transport/RestTransportClientTest.php tests/bootstrap.php git commit -m "feat: add rest transport client" ``` --- ## Task 3: REST Package Controller **Files:** - Create: `tests/Unit/Rest/RestPackageControllerTest.php` - Create: `src/Rest/RestPackageController.php` - Modify: `tests/bootstrap.php` - [ ] **Step 1: Add REST API test stubs** Add these stubs to `tests/bootstrap.php` if they do not already exist: ```php if ( ! function_exists( 'register_rest_route' ) ) { function register_rest_route( string $namespace, string $route, array $args ): bool { $GLOBALS['wpcs_rest_routes'][ $namespace . $route ] = $args; return true; } } if ( ! function_exists( 'rest_ensure_response' ) ) { function rest_ensure_response( $response ) { return $response; } } ``` - [ ] **Step 2: Write failing controller tests** Create `tests/Unit/Rest/RestPackageControllerTest.php`: ```php register(); self::assertArrayHasKey( 'wp-content-sync/v1/status', $GLOBALS['wpcs_rest_routes'] ); self::assertArrayHasKey( 'wp-content-sync/v1/package', $GLOBALS['wpcs_rest_routes'] ); } public function test_it_requires_manage_options_permission(): void { $GLOBALS['wpcs_current_user_can']['manage_options'] = false; $controller = new RestPackageController( new PackageValidator() ); self::assertFalse( $controller->canReceivePackage() ); } public function test_it_returns_status_payload(): void { $controller = new RestPackageController( new PackageValidator() ); self::assertSame( array( 'ok' => true, 'plugin' => 'wp-content-sync', 'version' => WPCS_VERSION, ), $controller->status() ); } public function test_it_accepts_valid_packages(): void { $controller = new RestPackageController( new PackageValidator() ); self::assertSame( array( 'accepted' => true, 'schema_version' => '1.0', 'manifest' => array( 'posts' => 0, 'terms' => 0, 'media' => 0, 'custom_post_types' => 0 ), ), $controller->receivePackage( array( 'package' => $this->validPackage() ) ) ); } public function test_it_rejects_invalid_package_shapes(): void { $controller = new RestPackageController( new PackageValidator() ); self::assertSame( array( 'accepted' => false, 'errors' => array( 'package is required and must be an object.' ), ), $controller->receivePackage( array() ) ); } /** * @return array */ private function validPackage(): array { $records = array( 'posts' => array(), 'terms' => array(), 'media' => array(), 'custom_post_types' => array(), ); return array( 'schema_version' => '1.0', 'generated_at' => '2026-04-28T12:00:00+00:00', 'source' => array( 'site_url' => 'https://example.test', 'name' => 'Example' ), 'destination' => array( 'site_url' => 'https://destination.test', 'name' => 'Destination' ), 'manifest' => array( 'posts' => 0, 'terms' => 0, 'media' => 0, 'custom_post_types' => 0 ), 'records' => $records, 'checksums' => array( 'records' => PackageChecksum::records( $records ) ), ); } } ``` - [ ] **Step 3: Run tests to verify they fail** Run: `composer test -- --filter RestPackageControllerTest` Expected: FAIL with class `WPContentSync\Rest\RestPackageController` not found. - [ ] **Step 4: Implement REST controller** Create `src/Rest/RestPackageController.php`: ```php validator = $validator; } public function register(): void { register_rest_route( 'wp-content-sync/v1', '/status', array( 'methods' => 'GET', 'callback' => array( $this, 'status' ), 'permission_callback' => array( $this, 'canReceivePackage' ), ) ); register_rest_route( 'wp-content-sync/v1', '/package', array( 'methods' => 'POST', 'callback' => array( $this, 'receivePackage' ), 'permission_callback' => array( $this, 'canReceivePackage' ), ) ); } public function canReceivePackage(): bool { return current_user_can( 'manage_options' ); } /** * @return array */ public function status(): array { return array( 'ok' => true, 'plugin' => 'wp-content-sync', 'version' => WPCS_VERSION, ); } /** * @param array $request Request data. * * @return array */ public function receivePackage( $request ): array { $data = $this->requestData( $request ); if ( ! isset( $data['package'] ) || ! is_array( $data['package'] ) ) { return array( 'accepted' => false, 'errors' => array( 'package is required and must be an object.' ), ); } $result = $this->validator->validate( $data['package'] ); if ( ! $result->isValid() ) { return array( 'accepted' => false, 'errors' => $result->errors(), ); } $package = ContentPackage::fromArray( $data['package'] ); return array( 'accepted' => true, 'schema_version' => $package->schemaVersion(), 'manifest' => $package->manifest(), ); } /** * @param mixed $request REST request or test payload. * * @return array */ private function requestData( $request ): array { if ( is_array( $request ) ) { return $request; } if ( is_object( $request ) && method_exists( $request, 'get_json_params' ) ) { $params = $request->get_json_params(); return is_array( $params ) ? $params : array(); } return array(); } } ``` - [ ] **Step 5: Run tests to verify they pass** Run: `composer test -- --filter RestPackageControllerTest` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add src/Rest/RestPackageController.php tests/Unit/Rest/RestPackageControllerTest.php tests/bootstrap.php git commit -m "feat: add rest package endpoints" ``` --- ## Task 4: Service Wiring **Files:** - Modify: `src/Plugin.php` - Modify: `tests/Unit/PluginTest.php` - [ ] **Step 1: Extend plugin wiring tests** Add this test to `tests/Unit/PluginTest.php`: ```php public function test_it_registers_rest_transport_services(): void { $container = $this->getPluginContainer( Plugin::create() ); self::assertInstanceOf( \WPContentSync\Transport\RestTransportClient::class, $container->get( \WPContentSync\Transport\RestTransportClient::class ) ); self::assertInstanceOf( \WPContentSync\Rest\RestPackageController::class, $container->get( \WPContentSync\Rest\RestPackageController::class ) ); } ``` - [ ] **Step 2: Run the test to verify it fails** Run: `composer test -- --filter PluginTest` Expected: FAIL with service `WPContentSync\Transport\RestTransportClient` not registered. - [ ] **Step 3: Wire services in `src/Plugin.php`** Add imports: ```php use WPContentSync\Rest\RestPackageController; use WPContentSync\Transport\RestTransportClient; ``` Register factories before `AdminPage::class`: ```php $container->factory( RestTransportClient::class, static function (): RestTransportClient { return new RestTransportClient(); } ); $container->factory( RestPackageController::class, static function () use ( $container ): RestPackageController { return new RestPackageController( $container->get( PackageValidator::class ) ); } ); ``` Register the REST controller in `register()`: ```php /** @var RestPackageController $rest_package_controller */ $rest_package_controller = $this->container->get( RestPackageController::class ); $rest_package_controller->register(); ``` - [ ] **Step 4: Run tests to verify they pass** Run: `composer test -- --filter PluginTest` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/Plugin.php tests/Unit/PluginTest.php git commit -m "feat: wire rest transport services" ``` --- ## Task 5: Full REST Transport Verification **Files:** - Verify all files created or modified in Tasks 1-4. - [ ] **Step 1: Run Composer validation** Run: `composer validate --strict` Expected: PASS with `./composer.json is valid`. - [ ] **Step 2: Run PHPCS** Run: `composer lint` Expected: PASS with no PHPCS errors. - [ ] **Step 3: Run PHPStan** Run: `composer stan` Expected: PASS with `[OK] No errors`. - [ ] **Step 4: Run PHPUnit** Run: `composer test` Expected: PASS with existing foundation, URL, file transport, and REST transport tests. - [ ] **Step 5: Run REST client smoke test** Run: ```powershell php -r "require 'tests/bootstrap.php'; `$client=new WPContentSync\Transport\RestTransportClient(); var_export(`$client->testConnection('https://destination.test','codex','app-pass')); echo PHP_EOL;" ``` Expected output: ```text true ``` - [ ] **Step 6: Manual WordPress smoke test** In `http://basic-wp.test/wp-admin`, verify: - The plugin still activates and the WP Content Sync admin page still loads. - `GET http://basic-wp.test/wp-json/wp-content-sync/v1/status` requires authentication. - Authenticated status requests return `ok: true`, plugin name, and version. - Invalid package POST requests return `accepted: false` and validation errors. - Valid package POST requests return `accepted: true` without creating posts, terms, media, or custom post type records. --- ## Spec Coverage - REST API transport is covered by `RestTransportClient`. - Application password authentication is covered by Basic auth header tests. - Destination receive/status endpoints are covered by `RestPackageController`. - Permission validation is covered by `canReceivePackage()` tests and route registration. - Request shape and package schema validation are covered by receive endpoint tests. - Typed REST failures for connection, authentication, and remote rejection are covered by `RestTransportException` and client tests. - File fallback decision remains available to later sync orchestration through `RestTransportException::failureCode()`. ## Deferred Work - Automatic retry/backoff remains in Phase 5 sync orchestration. - Choosing REST versus file transport remains in Phase 5 sync orchestration. - Storing and rotating application passwords remains in Phase 6 admin hardening. - Applying accepted package records to WordPress content remains in Phase 5 content handlers. ## Placeholder Scan - No unspecified implementation markers are intentionally included. - Every code-creating step names exact files and includes concrete code. - Every verification step includes exact commands and expected outcomes.