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 ) );
+ }
+}