diff --git a/docs/superpowers/plans/2026-04-26-wordpress-content-sync-implementation-roadmap.md b/docs/superpowers/plans/2026-04-26-wordpress-content-sync-implementation-roadmap.md index 15fdfbf..fec1174 100644 --- a/docs/superpowers/plans/2026-04-26-wordpress-content-sync-implementation-roadmap.md +++ b/docs/superpowers/plans/2026-04-26-wordpress-content-sync-implementation-roadmap.md @@ -49,7 +49,7 @@ Defines the sync package schema and implements export/import through JSON files ## Phase 4: REST Transport -**Plan to create after Phase 3:** `docs/superpowers/plans/2026-04-26-wordpress-content-sync-rest-transport.md` +**Plan:** `docs/superpowers/plans/2026-04-28-wordpress-content-sync-rest-transport.md` Adds authenticated REST endpoints and REST client support using WordPress application passwords. diff --git a/docs/superpowers/plans/2026-04-28-wordpress-content-sync-rest-transport.md b/docs/superpowers/plans/2026-04-28-wordpress-content-sync-rest-transport.md new file mode 100644 index 0000000..1e34ca4 --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-wordpress-content-sync-rest-transport.md @@ -0,0 +1,915 @@ +# 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.