Commit 696b60c92ca for woocommerce

commit 696b60c92ca2cba5b6b69b744dca6679851b2dee
Author: Sergey Mitroshin <sergeymitr@gmail.com>
Date:   Fri Oct 16 10:01:19 2026 -0400

    Add UCP discovery profile (#69886)

    Introduce UCP discovery file.

    ---------

    Co-authored-by: Timur Gogolev <timur.gogolev@automattic.com>

diff --git a/plugins/woocommerce/changelog/add-ucp-discovery b/plugins/woocommerce/changelog/add-ucp-discovery
new file mode 100644
index 00000000000..9c22a9e83a4
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-ucp-discovery
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Serve the UCP discovery profile at /.well-known/ucp when the UCP feature is enabled.
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 5d906af3923..7bc1e79c33a 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -440,6 +440,7 @@ final class WooCommerce {
 		$container->get( Automattic\WooCommerce\Internal\StockNotifications\StockNotifications::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\ScheduledSalePriceReconciler::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalController::class )->register();
+		$container->get( Automattic\WooCommerce\Internal\Ucp\UcpDiscovery::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\Admin\OrderTaxLookupMigrator::class )->register();

 		// Classes inheriting from RestApiControllerBase.
diff --git a/plugins/woocommerce/src/Internal/Ucp/UcpDiscovery.php b/plugins/woocommerce/src/Internal/Ucp/UcpDiscovery.php
new file mode 100644
index 00000000000..87752e4ed7b
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Ucp/UcpDiscovery.php
@@ -0,0 +1,132 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Ucp;
+
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+use Automattic\WooCommerce\StoreApi\Utilities\UcpUtils;
+
+/**
+ * Serves the UCP discovery profile at `/.well-known/ucp`.
+ *
+ * @internal Just for internal use.
+ */
+final class UcpDiscovery implements RegisterHooksInterface {
+
+	/**
+	 * Query var set by the `/.well-known/ucp` rewrite rule.
+	 */
+	public const QUERY_VAR = 'wc_ucp_discovery';
+
+	/**
+	 * Rewrite rule regex for `/.well-known/ucp`.
+	 */
+	public const RULE = '^\.well-known/ucp/?$';
+
+	/**
+	 * Register hooks.
+	 */
+	public function register(): void {
+		add_action( FeaturesController::FEATURE_ENABLED_CHANGED_ACTION, array( $this, 'maybe_flush_rewrite_rules' ), 10, 1 );
+		add_action( 'init', array( $this, 'register_feature_hooks' ), 0, 0 );
+	}
+
+	/**
+	 * Register the rewrite rule and renderer when UCP is enabled and pretty permalinks are on.
+	 */
+	public function register_feature_hooks(): void {
+		global $wp_rewrite;
+
+		if ( ! UcpUtils::is_enabled() || ! $wp_rewrite->using_permalinks() ) {
+			return;
+		}
+
+		add_rewrite_rule( self::RULE, 'index.php?' . self::QUERY_VAR . '=1', 'top' );
+		add_filter( 'query_vars', array( $this, 'add_query_var' ), 0 );
+		add_action( 'parse_request', array( $this, 'maybe_render' ), 0 );
+	}
+
+	/**
+	 * Register the discovery query var.
+	 *
+	 * @param array $vars Public query vars.
+	 * @return array
+	 */
+	public function add_query_var( $vars ): array {
+		$vars[] = self::QUERY_VAR;
+		return $vars;
+	}
+
+	/**
+	 * Queue a rewrite rules flush when the UCP feature is toggled.
+	 *
+	 * @param string $feature_id Feature being toggled.
+	 */
+	public function maybe_flush_rewrite_rules( string $feature_id ): void {
+		if ( UcpUtils::FEATURE_ID === $feature_id ) {
+			update_option( 'woocommerce_queue_flush_rewrite_rules', 'yes' );
+		}
+	}
+
+	/**
+	 * Send the discovery profile for `/.well-known/ucp` requests.
+	 *
+	 * @param \WP $wp Current WordPress environment instance.
+	 */
+	public function maybe_render( \WP $wp ): void {
+		// Check the matched rule, not only the query var: WP also fills public query vars from $_GET/$_POST.
+		if ( self::RULE !== $wp->matched_rule || '1' !== ( $wp->query_vars[ self::QUERY_VAR ] ?? '' ) ) {
+			return;
+		}
+
+		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
+		if ( ! in_array( $_SERVER['REQUEST_METHOD'] ?? '', array( 'GET', 'HEAD' ), true ) ) {
+			status_header( 405 );
+			exit;
+		}
+
+		// UCP requires a publicly cacheable profile with a max-age of at least 60 seconds.
+		if ( ! headers_sent() ) {
+			header( 'Cache-Control: public, max-age=300' );
+		}
+
+		wp_send_json( $this->get_profile() );
+	}
+
+	/**
+	 * Build the discovery profile.
+	 *
+	 * @return array
+	 */
+	public function get_profile(): array {
+		return array(
+			'ucp'   => array(
+				'version'      => UcpUtils::VERSION,
+				'services'     => array(
+					'dev.ucp.shopping' => array(
+						array(
+							'version'   => UcpUtils::VERSION,
+							'transport' => 'rest',
+							'endpoint'  => rest_url( 'wc/ucp/v1' ),
+							'spec'      => 'https://ucp.dev/' . UcpUtils::VERSION . '/specification/overview',
+							'schema'    => 'https://ucp.dev/' . UcpUtils::VERSION . '/services/shopping/rest.openapi.json',
+						),
+					),
+				),
+				'capabilities' => array_map(
+					static function ( array $declaration ): array {
+						return array( $declaration );
+					},
+					UcpUtils::capability_declarations()
+				),
+			),
+			'links' => array(
+				array(
+					'rel'  => 'self',
+					'href' => home_url( '/.well-known/ucp' ),
+				),
+			),
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/UcpUtils.php b/plugins/woocommerce/src/StoreApi/Utilities/UcpUtils.php
index 605715d9770..83ccbb12f94 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/UcpUtils.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/UcpUtils.php
@@ -59,13 +59,12 @@ class UcpUtils {
 	}

 	/**
-	 * Spec and schema references for a capability.
+	 * Spec and schema references for every capability these routes serve, keyed by capability name.
 	 *
-	 * @param string $capability One of the CAPABILITY_* constants.
 	 * @return array
 	 */
-	private static function capability_declaration( string $capability ): array {
-		$declarations = array(
+	public static function capability_declarations(): array {
+		return array(
 			self::CAPABILITY_CATALOG_SEARCH => array(
 				'version' => self::VERSION,
 				'spec'    => 'https://ucp.dev/' . self::VERSION . '/specification/catalog/search',
@@ -77,7 +76,15 @@ class UcpUtils {
 				'schema'  => 'https://ucp.dev/' . self::VERSION . '/schemas/shopping/catalog_lookup.json',
 			),
 		);
+	}

-		return $declarations[ $capability ] ?? array( 'version' => self::VERSION );
+	/**
+	 * Spec and schema references for a capability.
+	 *
+	 * @param string $capability One of the CAPABILITY_* constants.
+	 * @return array
+	 */
+	private static function capability_declaration( string $capability ): array {
+		return self::capability_declarations()[ $capability ] ?? array( 'version' => self::VERSION );
 	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/Ucp/UcpDiscoveryTest.php b/plugins/woocommerce/tests/php/src/Internal/Ucp/UcpDiscoveryTest.php
new file mode 100644
index 00000000000..62958bc5acb
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Ucp/UcpDiscoveryTest.php
@@ -0,0 +1,149 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Ucp;
+
+use Automattic\WooCommerce\Internal\Ucp\UcpDiscovery;
+use Automattic\WooCommerce\StoreApi\Utilities\UcpUtils;
+
+/**
+ * Tests for the `/.well-known/ucp` discovery profile.
+ */
+class UcpDiscoveryTest extends \WC_Unit_Test_Case {
+	private const RULE = UcpDiscovery::RULE;
+
+	/**
+	 * System under test.
+	 *
+	 * @var UcpDiscovery
+	 */
+	private UcpDiscovery $sut;
+
+	/**
+	 * Set up the system under test.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new UcpDiscovery();
+	}
+
+	/**
+	 * Undo the global rewrite state register_feature_hooks() may have touched.
+	 */
+	public function tearDown(): void {
+		global $wp_rewrite;
+		unset( $wp_rewrite->extra_rules_top[ self::RULE ] );
+		remove_filter( 'query_vars', array( $this->sut, 'add_query_var' ), 0 );
+		remove_action( 'parse_request', array( $this->sut, 'maybe_render' ), 0 );
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox The profile points at the local UCP REST namespace and lists every capability UcpUtils declares.
+	 */
+	public function test_profile_shape(): void {
+		$profile = $this->sut->get_profile();
+
+		$this->assertSame( array( 'ucp', 'links' ), array_keys( $profile ) );
+		$this->assertSame( UcpUtils::VERSION, $profile['ucp']['version'] );
+		$this->assertSame( rest_url( 'wc/ucp/v1' ), $profile['ucp']['services']['dev.ucp.shopping'][0]['endpoint'] );
+		$declarations = UcpUtils::capability_declarations();
+		$this->assertSame( array_keys( $declarations ), array_keys( $profile['ucp']['capabilities'] ) );
+		foreach ( $declarations as $capability => $declaration ) {
+			$this->assertSame( array( $declaration ), $profile['ucp']['capabilities'][ $capability ] );
+		}
+		$this->assertSame( home_url( '/.well-known/ucp' ), $profile['links'][0]['href'] );
+	}
+
+	/**
+	 * @testdox The rewrite rule is registered only when UCP is enabled and pretty permalinks are on.
+	 * @testWith ["yes", "/%postname%/", true]
+	 *           ["no", "/%postname%/", false]
+	 *           ["yes", "", false]
+	 *
+	 * @param string $enabled   Feature option value.
+	 * @param string $structure Permalink structure.
+	 * @param bool   $expected  Whether the rule should be registered.
+	 */
+	public function test_rewrite_rule_registration( string $enabled, string $structure, bool $expected ): void {
+		global $wp_rewrite;
+		update_option( 'woocommerce_feature_ucp_enabled', $enabled );
+		$this->set_permalink_structure( $structure );
+
+		$this->sut->register_feature_hooks();
+
+		$this->assertSame( $expected ? 'index.php?' . UcpDiscovery::QUERY_VAR . '=1' : null, $wp_rewrite->extra_rules_top[ self::RULE ] ?? null );
+		$this->assertSame( $expected, in_array( UcpDiscovery::QUERY_VAR, apply_filters( 'query_vars', array() ), true ) );
+		$this->assertSame( $expected ? 0 : false, has_action( 'parse_request', array( $this->sut, 'maybe_render' ) ) );
+	}
+
+	/**
+	 * @testdox Toggling the UCP feature queues a rewrite rules flush.
+	 */
+	public function test_feature_toggle_queues_flush(): void {
+		update_option( 'woocommerce_queue_flush_rewrite_rules', 'no' );
+
+		$this->sut->maybe_flush_rewrite_rules( 'some_other_feature' );
+		$this->assertSame( 'no', get_option( 'woocommerce_queue_flush_rewrite_rules' ) );
+
+		$this->sut->maybe_flush_rewrite_rules( UcpUtils::FEATURE_ID );
+		$this->assertSame( 'yes', get_option( 'woocommerce_queue_flush_rewrite_rules' ) );
+	}
+
+	/**
+	 * Run maybe_render() for a request, capturing output and treating wp_send_json()'s exit as an exception.
+	 *
+	 * @param string $matched_rule Value of WP::$matched_rule.
+	 * @param string $query_var    Value of the discovery query var.
+	 * @return string|null Captured output, or null when the renderer returned without sending.
+	 */
+	private function render( string $matched_rule, string $query_var ): ?string {
+		$wp               = new \WP();
+		$wp->matched_rule = $matched_rule;
+		$wp->query_vars   = array( UcpDiscovery::QUERY_VAR => $query_var );
+
+		$_SERVER['REQUEST_METHOD'] = 'GET';
+		$die_handler               = function () {
+			return function () {
+				throw new \WPDieException();
+			};
+		};
+		add_filter( 'wp_doing_ajax', '__return_true' );
+		add_filter( 'wp_die_ajax_handler', $die_handler );
+
+		ob_start();
+		try {
+			$this->sut->maybe_render( $wp );
+			ob_end_clean();
+			return null;
+		} catch ( \WPDieException $e ) {
+			return (string) ob_get_clean();
+		} finally {
+			remove_filter( 'wp_doing_ajax', '__return_true' );
+			remove_filter( 'wp_die_ajax_handler', $die_handler );
+		}
+	}
+
+	/**
+	 * @testdox maybe_render sends the profile as JSON when the rewrite rule matched.
+	 */
+	public function test_maybe_render_sends_profile(): void {
+		$output = $this->render( self::RULE, '1' );
+
+		$this->assertNotNull( $output );
+		$this->assertSame( $this->sut->get_profile(), json_decode( (string) $output, true ) );
+	}
+
+	/**
+	 * @testdox maybe_render ignores requests where the query var is set but the rewrite rule did not match.
+	 * @testWith ["", "1"]
+	 *           ["^shop/?$", "1"]
+	 *           ["^\\.well-known/ucp/?$", ""]
+	 *
+	 * @param string $matched_rule Matched rewrite rule.
+	 * @param string $query_var    Discovery query var value.
+	 */
+	public function test_maybe_render_ignores_other_requests( string $matched_rule, string $query_var ): void {
+		$this->assertNull( $this->render( $matched_rule, $query_var ) );
+	}
+}