feat: Sprint 2 — WordPress plugin (auth/api/site-info/admin) + web SiteConnector
Mirror to GitHub / mirror (push) Canceled after 0s

- plugin: Wursor_Auth (token hashing, HMAC, scoped tokens), Wursor_API (REST + auth), Wursor_Site_Info (builder/capabilities/preflight), Wursor_Admin
- plugin: test-auth.php (10 auth tests, run in a WP+PHP env)
- web: SiteConnector pairing UI (code + poll + states)
- api: PluginClient signs full REST route (matches WP get_route)
- 92 unit tests green
This commit is contained in:
SinachPat
2026-08-15 23:17:16 +01:00
parent 9801b9475b
commit 69b2481299
12 changed files with 515 additions and 4 deletions
View File
+81
View File
@@ -0,0 +1,81 @@
<?php
class Wursor_Auth_Test extends WP_UnitTestCase {
private function sign( $secret, $timestamp, $method, $route, $body ) {
$canonical = $timestamp . "\n" . strtoupper( $method ) . "\n" . $route . "\n" . hash( 'sha256', $body );
return hash_hmac( 'sha256', $canonical, $secret );
}
public function tear_down() {
Wursor_Auth::clear_tokens();
parent::tear_down();
}
public function test_store_tokens_hashes_tokens_not_plaintext() {
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
$this->assertNotEquals( 'read-token', get_option( Wursor_Auth::OPTION_READ_HASH ) );
$this->assertEquals( hash( 'sha256', 'read-token' ), get_option( Wursor_Auth::OPTION_READ_HASH ) );
$this->assertNotEquals( 'hmac-secret', get_option( Wursor_Auth::OPTION_HMAC_SECRET ) );
}
public function test_verify_token_accepts_matching_token() {
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
$this->assertTrue( Wursor_Auth::verify_token( 'read-token', 'read' ) );
$this->assertTrue( Wursor_Auth::verify_token( 'deploy-token', 'deploy' ) );
}
public function test_verify_token_rejects_wrong_token() {
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
$this->assertFalse( Wursor_Auth::verify_token( 'wrong', 'read' ) );
}
public function test_read_token_does_not_verify_as_deploy() {
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
$this->assertFalse( Wursor_Auth::verify_token( 'read-token', 'deploy' ) );
}
public function test_hmac_accepts_valid_signature() {
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
$ts = (string) time();
$route = '/wursor/v1/site-info';
$body = '';
$this->assertTrue( Wursor_Auth::verify_hmac( $ts, 'GET', $route, $body, $this->sign( 'hmac-secret', $ts, 'GET', $route, $body ) ) );
}
public function test_hmac_rejects_stale_timestamp() {
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
$ts = (string) ( time() - 120 );
$route = '/wursor/v1/site-info';
$body = '';
$this->assertFalse( Wursor_Auth::verify_hmac( $ts, 'GET', $route, $body, $this->sign( 'hmac-secret', $ts, 'GET', $route, $body ) ) );
}
public function test_hmac_rejects_tampered_body() {
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
$ts = (string) time();
$route = '/wursor/v1/files';
$sig = $this->sign( 'hmac-secret', $ts, 'POST', $route, '{"a":1}' );
$this->assertFalse( Wursor_Auth::verify_hmac( $ts, 'POST', $route, '{"a":2}', $sig ) );
}
public function test_hmac_rejects_missing_signature() {
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
$this->assertFalse( Wursor_Auth::verify_hmac( (string) time(), 'GET', '/wursor/v1/site-info', '', null ) );
}
public function test_rotated_tokens_invalidate_old_hashes() {
Wursor_Auth::store_tokens( 'old-read', 'old-deploy', 'old-secret' );
Wursor_Auth::store_tokens( 'new-read', 'new-deploy', 'new-secret' );
$this->assertFalse( Wursor_Auth::verify_token( 'old-read', 'read' ) );
$this->assertTrue( Wursor_Auth::verify_token( 'new-read', 'read' ) );
}
public function test_disconnect_clears_tokens() {
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
Wursor_Auth::clear_tokens();
$this->assertFalse( Wursor_Auth::is_connected() );
}
}
View File
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* Admin settings page: paste the Wursor pairing code, connect, disconnect.
*/
class Wursor_Admin {
public static function register_menu() {
add_menu_page( 'Wursor', 'Wursor', 'manage_options', 'wursor', array( __CLASS__, 'render' ), 'dashicons-update' );
}
public static function render() {
self::handle_post();
$connected = Wursor_Auth::is_connected();
?>
<div class="wrap">
<h1>Wursor</h1>
<?php if ( $connected ) : ?>
<p>This site is connected to Wursor.</p>
<form method="post">
<input type="hidden" name="wursor_disconnect" value="1" />
<?php submit_button( 'Disconnect' ); ?>
</form>
<?php else : ?>
<p>Paste the pairing code from Wursor to connect this site.</p>
<form method="post">
<input type="text" name="wursor_pairing_code" maxlength="8" autocomplete="off" placeholder="ABCD1234" />
<?php submit_button( 'Connect' ); ?>
</form>
<?php endif; ?>
</div>
<?php
}
private static function handle_post() {
if ( isset( $_POST['wursor_disconnect'] ) ) {
Wursor_Auth::clear_tokens();
return;
}
if ( empty( $_POST['wursor_pairing_code'] ) ) {
return;
}
$code = sanitize_text_field( wp_unslash( $_POST['wursor_pairing_code'] ) );
$api_url = get_option( 'wursor_api_url', 'https://api.wursor.dev' );
$response = wp_remote_post(
rtrim( $api_url, '/' ) . '/sites/redeem',
array(
'body' => wp_json_encode( array( 'code' => $code, 'siteUrl' => home_url( '/' ) ) ),
'headers' => array( 'Content-Type' => 'application/json' ),
'timeout' => 15,
)
);
if ( is_wp_error( $response ) ) {
return;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( isset( $body['readToken'], $body['deployToken'], $body['hmacSecret'] ) ) {
Wursor_Auth::store_tokens( $body['readToken'], $body['deployToken'], $body['hmacSecret'] );
}
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
/**
* REST API routes under /wp-json/wursor/v1/.
* Every request requires a Bearer token with the right scope and a valid HMAC signature.
*/
class Wursor_API {
public static function register_routes() {
register_rest_route( 'wursor/v1', '/site-info', array(
'methods' => 'GET',
'callback' => array( __CLASS__, 'get_site_info' ),
'permission_callback' => array( __CLASS__, 'authorize_read' ),
) );
register_rest_route( 'wursor/v1', '/files', array(
'methods' => 'POST',
'callback' => array( __CLASS__, 'stub' ),
'permission_callback' => array( __CLASS__, 'authorize_deploy' ),
) );
register_rest_route( 'wursor/v1', '/db', array(
'methods' => 'POST',
'callback' => array( __CLASS__, 'stub' ),
'permission_callback' => array( __CLASS__, 'authorize_deploy' ),
) );
register_rest_route( 'wursor/v1', '/wp-cli', array(
'methods' => 'POST',
'callback' => array( __CLASS__, 'stub' ),
'permission_callback' => array( __CLASS__, 'authorize_deploy' ),
) );
}
public static function authorize_read( WP_REST_Request $request ) {
return self::authorize( $request, 'read' );
}
public static function authorize_deploy( WP_REST_Request $request ) {
return self::authorize( $request, 'deploy' );
}
private static function authorize( WP_REST_Request $request, $scope ) {
$auth = $request->get_header( 'authorization' );
if ( ! is_string( $auth ) || 0 !== strpos( $auth, 'Bearer ' ) ) {
return new WP_Error( 'wursor_unauthorized', 'Missing bearer token', array( 'status' => 401 ) );
}
$token = substr( $auth, 7 );
if ( 'deploy' === $scope ) {
if ( ! Wursor_Auth::verify_token( $token, 'deploy' ) ) {
return new WP_Error( 'wursor_forbidden', 'Deploy token required', array( 'status' => 403 ) );
}
} elseif ( ! Wursor_Auth::verify_token( $token, 'read' ) && ! Wursor_Auth::verify_token( $token, 'deploy' ) ) {
return new WP_Error( 'wursor_unauthorized', 'Invalid token', array( 'status' => 401 ) );
}
$timestamp = $request->get_header( 'x-wursor-timestamp' );
$signature = $request->get_header( 'x-wursor-signature' );
$route = $request->get_route();
$body = $request->get_body();
if ( ! Wursor_Auth::verify_hmac( $timestamp, $request->get_method(), $route, $body, $signature ) ) {
return new WP_Error( 'wursor_bad_signature', 'Bad HMAC signature', array( 'status' => 401 ) );
}
return true;
}
public static function get_site_info( WP_REST_Request $request ) {
return Wursor_Site_Info::get_site_info();
}
public static function stub( WP_REST_Request $request ) {
return new WP_Error( 'wursor_not_implemented', 'Not implemented until Sprint 6', array( 'status' => 501 ) );
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
/**
* Token storage, verification, and request signing (HMAC).
*
* Tokens are stored only as SHA-256 hashes; the HMAC secret is stored encrypted
* with a key derived from the site's AUTH_KEY + AUTH_SALT. See spikes/pairing-threat-model.md.
*/
class Wursor_Auth {
const OPTION_READ_HASH = 'wursor_read_token_hash';
const OPTION_DEPLOY_HASH = 'wursor_deploy_token_hash';
const OPTION_HMAC_SECRET = 'wursor_hmac_secret';
const MAX_SKEW_SECONDS = 60;
public static function store_tokens( $read_token, $deploy_token, $hmac_secret ) {
update_option( self::OPTION_READ_HASH, hash( 'sha256', $read_token ), true );
update_option( self::OPTION_DEPLOY_HASH, hash( 'sha256', $deploy_token ), true );
update_option( self::OPTION_HMAC_SECRET, self::encrypt( $hmac_secret ), true );
}
public static function clear_tokens() {
delete_option( self::OPTION_READ_HASH );
delete_option( self::OPTION_DEPLOY_HASH );
delete_option( self::OPTION_HMAC_SECRET );
}
public static function is_connected() {
return false !== get_option( self::OPTION_READ_HASH );
}
public static function verify_token( $token, $scope ) {
$option = 'deploy' === $scope ? self::OPTION_DEPLOY_HASH : self::OPTION_READ_HASH;
$stored = get_option( $option );
return is_string( $stored ) && hash_equals( $stored, hash( 'sha256', $token ) );
}
public static function verify_hmac( $timestamp, $method, $route, $body, $signature ) {
if ( ! is_string( $signature ) ) {
return false;
}
if ( abs( time() - intval( $timestamp ) ) > self::MAX_SKEW_SECONDS ) {
return false;
}
$canonical = $timestamp . "\n" . strtoupper( $method ) . "\n" . $route . "\n" . hash( 'sha256', $body );
$expected = hash_hmac( 'sha256', $canonical, self::hmac_secret() );
return hash_equals( $expected, $signature );
}
private static function hmac_secret() {
$encrypted = get_option( self::OPTION_HMAC_SECRET );
return false === $encrypted ? '' : self::decrypt( $encrypted );
}
private static function encryption_key() {
return hash( 'sha256', wp_salt( 'auth' ) . wp_salt( 'auth_salt' ) );
}
private static function encrypt( $value ) {
$iv = random_bytes( 16 );
$tag = '';
$ciphertext = openssl_encrypt( $value, 'aes-256-gcm', self::encryption_key(), OPENSSL_RAW_DATA, $iv, $tag );
if ( false === $ciphertext ) {
return false;
}
return base64_encode( $iv . $tag . $ciphertext );
}
private static function decrypt( $value ) {
$data = base64_decode( $value, true );
if ( false === $data || strlen( $data ) < 32 ) {
return '';
}
$iv = substr( $data, 0, 16 );
$tag = substr( $data, 16, 16 );
$ciphertext = substr( $data, 32 );
$plaintext = openssl_decrypt( $ciphertext, 'aes-256-gcm', self::encryption_key(), OPENSSL_RAW_DATA, $iv, $tag );
return false === $plaintext ? '' : $plaintext;
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
/**
* Site information provider: theme, plugins, versions, builder, capability tiers, preflight.
* Builder detection mirrors e2e/golden/src/builder-detect.ts (see ADR 0006).
*/
class Wursor_Site_Info {
public static function get_site_info() {
$theme = wp_get_theme();
return array(
'theme' => $theme->get_stylesheet(),
'plugins' => self::plugins(),
'wordpress_version' => get_bloginfo( 'version' ),
'php_version' => PHP_VERSION,
'builder' => self::detect_builder(),
'capabilities' => self::capabilities(),
'preflight' => self::preflight(),
);
}
private static function plugins() {
if ( ! function_exists( 'get_plugins' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$all = get_plugins();
$active = (array) get_option( 'active_plugins', array() );
$result = array();
foreach ( $all as $plugin_file => $data ) {
$result[] = array(
'slug' => self::slug_from_file( $plugin_file ),
'active' => in_array( $plugin_file, $active, true ),
);
}
return $result;
}
private static function active_slugs() {
$active = (array) get_option( 'active_plugins', array() );
return array_map( array( __CLASS__, 'slug_from_file' ), $active );
}
private static function slug_from_file( $file ) {
$dir = dirname( $file );
return '.' === $dir ? basename( $file, '.php' ) : $dir;
}
private static function front_page_id() {
$front = (int) get_option( 'page_on_front' );
if ( $front > 0 ) {
return $front;
}
$pages = get_pages( array( 'number' => 1 ) );
return empty( $pages ) ? 0 : $pages[0]->ID;
}
private static function front_page_content() {
$id = self::front_page_id();
return $id > 0 ? (string) get_post_field( 'post_content', $id ) : '';
}
private static function detect_builder() {
$theme = wp_get_theme()->get_stylesheet();
$active = self::active_slugs();
$id = self::front_page_id();
$content = self::front_page_content();
$elementor_mode = $id > 0 ? get_post_meta( $id, '_elementor_edit_mode', true ) : '';
$elementor_data = $id > 0 ? get_post_meta( $id, '_elementor_data', true ) : '';
$fl_builder = $id > 0 ? get_post_meta( $id, '_fl_builder_data', true ) : '';
$et_pb = $id > 0 ? get_post_meta( $id, '_et_pb_use_builder', true ) : '';
if ( in_array( 'elementor', $active, true ) && ( '' !== $elementor_mode || '' !== $elementor_data ) ) {
return 'elementor';
}
if ( in_array( 'beaver-builder-lite-version', $active, true ) && '' !== $fl_builder ) {
return 'beaver';
}
if ( false !== stripos( $theme, 'divi' ) && 'on' === $et_pb ) {
return 'divi';
}
if ( false !== strpos( $content, '<!-- wp:' ) ) {
return 'gutenberg';
}
return 'classic';
}
private static function capabilities() {
$full = version_compare( get_bloginfo( 'version' ), '6.1', '>=' ) && version_compare( PHP_VERSION, '8.0', '>=' );
$install_safe = ! defined( 'DISALLOW_FILE_MODS' ) || ! DISALLOW_FILE_MODS;
return array(
'content' => true,
'design' => $full,
'install' => $full && $install_safe,
);
}
private static function preflight() {
return array(
'https' => is_ssl(),
'disallow_file_mods' => defined( 'DISALLOW_FILE_MODS' ) && DISALLOW_FILE_MODS,
'disk_free' => function_exists( 'disk_free_space' ) ? disk_free_space( ABSPATH ) : null,
);
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
/**
* Plugin Name: Wursor
* Description: Connects this WordPress site to Wursor for safe, previewed changes.
* Version: 0.1.0
* Requires PHP: 7.4
* Author: Wursor
* License: GPL-2.0-or-later
*/
defined('ABSPATH') || exit;
require_once __DIR__ . '/src/class-auth.php';
require_once __DIR__ . '/src/class-site-info.php';
require_once __DIR__ . '/src/class-api.php';
require_once __DIR__ . '/src/class-admin.php';
add_action('rest_api_init', array('Wursor_API', 'register_routes'));
add_action('admin_menu', array('Wursor_Admin', 'register_menu'));