Commit b2c3a50e6a8 for woocommerce
commit b2c3a50e6a8337cc2a20e160d3e453b97d7426af
Author: Sergey Mitroshin <sergeymitr@gmail.com>
Date: Thu Nov 12 17:02:19 2026 -0500
Fix UCP discovery profile not served with plain permalinks (#70680)
- Generate static `/.well-known/ucp` file if permalinks are disabled.
- Mark the file with a link to WooCommerce to distinguish the Woo-owned files.
- If the `/.well-known/ucp` file is not writable, we **don't** display any warnings, and rely on Agentic Readiness tools (#70816) to surface the issue.
diff --git a/plugins/woocommerce/changelog/fix-ucp-discovery-plain-permalinks b/plugins/woocommerce/changelog/fix-ucp-discovery-plain-permalinks
new file mode 100644
index 00000000000..c8bd4d4236c
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-ucp-discovery-plain-permalinks
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Serve the UCP discovery profile at /.well-known/ucp when the store uses plain permalinks.
diff --git a/plugins/woocommerce/src/Internal/Ucp/UcpDiscovery.php b/plugins/woocommerce/src/Internal/Ucp/UcpDiscovery.php
index 4fd1498b2d6..91b9529a1c7 100644
--- a/plugins/woocommerce/src/Internal/Ucp/UcpDiscovery.php
+++ b/plugins/woocommerce/src/Internal/Ucp/UcpDiscovery.php
@@ -24,33 +24,42 @@ final class UcpDiscovery implements RegisterHooksInterface {
*/
public const RULE = '^\.well-known/ucp/?$';
+ /**
+ * Rel of the profile link identifying WooCommerce; also marks the static file as one WooCommerce wrote.
+ */
+ public const LINK_REL = 'woocommerce';
+
+ /**
+ * Href of the WooCommerce profile link.
+ */
+ private const LINK_HREF = 'https://developer.woocommerce.com/docs/';
+
/**
* Register hooks.
*/
public function register(): void {
- add_action( FeaturesController::FEATURE_ENABLED_CHANGED_ACTION, array( $this, 'maybe_flush_rewrite_rules' ), 10, 1 );
+ add_action( FeaturesController::FEATURE_ENABLED_CHANGED_ACTION, array( $this, 'maybe_flush_rewrite_rules' ), 10, 2 );
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.
+ * Register the renderer, plus the rewrite rule when pretty permalinks are on, when UCP is enabled.
*/
public function register_feature_hooks(): void {
global $wp_rewrite;
- $active = Utils::is_enabled() && $wp_rewrite->using_permalinks();
+ $enabled = Utils::is_enabled();
+ $active = $enabled && $wp_rewrite->using_permalinks();
- if ( $active ) {
- add_rewrite_rule( self::RULE, 'index.php?' . self::QUERY_VAR . '=1', 'top' );
- add_filter( 'query_vars', array( $this, 'add_query_var' ), 0 );
+ if ( $enabled ) {
add_action( 'parse_request', array( $this, 'maybe_render' ), 0 );
+ add_action( 'admin_init', array( $this, 'maybe_sync_file' ) );
+ register_deactivation_hook( WC_PLUGIN_FILE, array( $this, 'delete_file' ) );
}
- // The flag can change without FEATURE_ENABLED_CHANGED_ACTION firing (direct DB write, option import),
- // so flush when the stored rules disagree; empty stored rules are regenerated by WP on their own.
- $rules = get_option( 'rewrite_rules' );
- if ( is_array( $rules ) && ! empty( $rules ) && isset( $rules[ self::RULE ] ) !== $active ) {
- update_option( 'woocommerce_queue_flush_rewrite_rules', 'yes' );
+ if ( $active ) {
+ add_rewrite_rule( self::RULE, 'index.php?' . self::QUERY_VAR . '=1', 'top' );
+ add_filter( 'query_vars', array( $this, 'add_query_var' ), 0 );
}
}
@@ -66,16 +75,122 @@ final class UcpDiscovery implements RegisterHooksInterface {
}
/**
- * Queue a rewrite rules flush when the UCP feature is toggled.
+ * Queue a rewrite rules flush when the UCP feature is toggled, and remove the static file when it is turned off.
*
* @param string $feature_id Feature being toggled.
+ * @param bool $is_enabled Whether the feature is now enabled.
*/
- public function maybe_flush_rewrite_rules( string $feature_id ): void {
- if ( Utils::FEATURE_ID === $feature_id ) {
- update_option( 'woocommerce_queue_flush_rewrite_rules', 'yes' );
+ public function maybe_flush_rewrite_rules( $feature_id, $is_enabled = true ): void {
+ $feature_id = (string) $feature_id;
+ $is_enabled = (bool) $is_enabled;
+
+ if ( Utils::FEATURE_ID !== $feature_id ) {
+ return;
+ }
+
+ update_option( 'woocommerce_queue_flush_rewrite_rules', 'yes' );
+
+ if ( ! $is_enabled ) {
+ $this->delete_file();
+ }
+ }
+
+ /**
+ * Remove the static discovery file, which the web server would otherwise keep serving without WooCommerce.
+ * Files WooCommerce did not write are left alone.
+ */
+ public function delete_file(): void {
+ $path = $this->get_file_path();
+ if ( $this->is_deletable( $path ) && $this->is_owned( $path ) ) {
+ wp_delete_file( $path );
}
}
+ /**
+ * Keep the static discovery file in sync on admin screens: write it under plain permalinks, delete it under pretty ones.
+ * The file only refreshes on admin page loads.
+ *
+ * Failures are silent here: the Agentic Readiness checks report an unreachable or invalid discovery endpoint.
+ */
+ public function maybe_sync_file(): void {
+ global $wp_rewrite;
+
+ // admin_init also fires for anonymous admin-ajax.php/admin-post.php requests and on every heartbeat.
+ if ( wp_doing_ajax() || ! current_user_can( 'manage_woocommerce' ) ) {
+ return;
+ }
+
+ if ( $wp_rewrite->using_permalinks() ) {
+ // A file that cannot be deleted keeps overriding the profile WooCommerce serves, as the web server answers first.
+ $this->delete_file();
+ return;
+ }
+
+ // One file under ABSPATH cannot hold a profile per site, so multisite requires pretty permalinks.
+ if ( is_multisite() ) {
+ return;
+ }
+
+ // A file WooCommerce did not write is left in place, so it is served instead of the WooCommerce profile.
+ $path = $this->get_file_path();
+ if ( is_file( $path ) && ! $this->is_owned( $path ) ) {
+ return;
+ }
+
+ // phpcs:disable WordPress.WP.AlternativeFunctions
+ $content = (string) wp_json_encode( $this->get_profile(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
+ $dir = dirname( $path );
+
+ // Without write access no file is written, and /.well-known/ucp is unreachable unless the server routes it to index.php.
+ $writable = is_file( $path ) ? is_writable( $path ) : wp_mkdir_p( $dir ) && is_writable( $dir );
+ if ( $writable && ! ( is_file( $path ) && file_get_contents( $path ) === $content ) ) {
+ // A full disk can leave a truncated file behind, which is then served as an invalid profile.
+ // It also loses the ownership marker, so it is no longer rewritten or removed.
+ file_put_contents( $path, $content );
+ }
+ // phpcs:enable WordPress.WP.AlternativeFunctions
+ }
+
+ /**
+ * Whether the static file exists and the web server can remove it: unlink needs write access to the directory, not the file.
+ *
+ * @param string $path Absolute file path.
+ * @return bool
+ */
+ private function is_deletable( string $path ): bool {
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable
+ return is_file( $path ) && is_writable( dirname( $path ) );
+ }
+
+ /**
+ * Whether the static file was written by WooCommerce. Unreadable or non-JSON files count as not owned.
+ *
+ * @param string $path Absolute file path.
+ * @return bool
+ */
+ private function is_owned( string $path ): bool {
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ $profile = is_file( $path ) ? json_decode( (string) file_get_contents( $path ), true ) : null;
+ $links = $profile['links'] ?? null;
+ return is_array( $links ) && in_array( self::LINK_REL, array_column( $links, 'rel' ), true );
+ }
+
+ /**
+ * Path of the static discovery file served under plain permalinks.
+ *
+ * @return string
+ */
+ private function get_file_path(): string {
+ /**
+ * Filters the path of the static UCP discovery file written when plain permalinks are active.
+ *
+ * @since 11.4.0
+ *
+ * @param string $path Absolute file path.
+ */
+ return (string) apply_filters( 'woocommerce_ucp_discovery_file_path', ABSPATH . '.well-known/ucp' );
+ }
+
/**
* Send the discovery profile for `/.well-known/ucp` requests.
*
@@ -83,7 +198,11 @@ final class UcpDiscovery implements RegisterHooksInterface {
*/
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 ] ?? '' ) ) {
+ $rule_matched = self::RULE === $wp->matched_rule && '1' === ( $wp->query_vars[ self::QUERY_VAR ] ?? '' );
+ // Servers that route every path to index.php (nginx try_files) reach WP without a matching rule under plain permalinks.
+ // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
+ $path = untrailingslashit( (string) wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ), PHP_URL_PATH ) );
+ if ( ! $rule_matched && wp_parse_url( home_url( '/.well-known/ucp' ), PHP_URL_PATH ) !== $path ) {
return;
}
@@ -137,6 +256,10 @@ final class UcpDiscovery implements RegisterHooksInterface {
'rel' => 'self',
'href' => home_url( '/.well-known/ucp' ),
),
+ array(
+ 'rel' => self::LINK_REL,
+ 'href' => self::LINK_HREF,
+ ),
),
);
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Ucp/UcpDiscoveryTest.php b/plugins/woocommerce/tests/php/src/Internal/Ucp/UcpDiscoveryTest.php
index 6ca0f2e2dc9..dbda8d744e4 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Ucp/UcpDiscoveryTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Ucp/UcpDiscoveryTest.php
@@ -19,12 +19,22 @@ class UcpDiscoveryTest extends \WC_Unit_Test_Case {
*/
private UcpDiscovery $sut;
+ /**
+ * Static discovery file path, inside a per-test temp dir.
+ *
+ * @var string
+ */
+ private string $file;
+
/**
* Set up the system under test.
*/
public function setUp(): void {
parent::setUp();
- $this->sut = new UcpDiscovery();
+ $this->sut = new UcpDiscovery();
+ $this->file = get_temp_dir() . uniqid( 'wc-ucp-' ) . '/.well-known/ucp';
+ add_filter( 'woocommerce_ucp_discovery_file_path', fn() => $this->file );
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
}
/**
@@ -35,7 +45,12 @@ class UcpDiscoveryTest extends \WC_Unit_Test_Case {
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 );
+ remove_action( 'admin_init', array( $this->sut, 'maybe_sync_file' ) );
+ remove_action( 'deactivate_' . plugin_basename( WC_PLUGIN_FILE ), array( $this->sut, 'delete_file' ) );
delete_option( 'woocommerce_queue_flush_rewrite_rules' );
+ if ( is_file( $this->file ) ) {
+ wp_delete_file( $this->file );
+ }
parent::tearDown();
}
@@ -57,7 +72,7 @@ class UcpDiscoveryTest extends \WC_Unit_Test_Case {
}
/**
- * @testdox The rewrite rule is registered only when UCP is enabled and pretty permalinks are on.
+ * @testdox The rewrite rule is registered only when UCP is enabled and pretty permalinks are on; the renderer whenever UCP is enabled.
* @testWith ["yes", "/%postname%/", true]
* ["no", "/%postname%/", false]
* ["yes", "", false]
@@ -75,46 +90,89 @@ class UcpDiscoveryTest extends \WC_Unit_Test_Case {
$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' ) ) );
+ $this->assertSame( 'yes' === $enabled ? 0 : false, has_action( 'parse_request', array( $this->sut, 'maybe_render' ) ) );
}
/**
- * @testdox register_feature_hooks queues a rewrite flush when the stored rules disagree with the feature flag.
- * @testWith ["yes", "present", false]
- * ["yes", "missing", true]
- * ["yes", "empty", false]
- * ["no", "present", true]
- *
- * @param string $enabled Feature option value.
- * @param string $stored Whether the stored rules contain the rule, lack it, or are empty.
- * @param bool $expected Whether a flush should be queued.
+ * @testdox Toggling the UCP feature queues a rewrite rules flush.
*/
- public function test_register_feature_hooks_heals_stale_rewrite_rules( string $enabled, string $stored, bool $expected ): void {
- update_option( 'woocommerce_feature_ucp_enabled', $enabled );
- $this->set_permalink_structure( '/%postname%/' );
- $rules = array( '^shop/?$' => 'index.php?post_type=product' );
- if ( 'present' === $stored ) {
- $rules[ self::RULE ] = 'index.php?' . UcpDiscovery::QUERY_VAR . '=1';
- }
- update_option( 'rewrite_rules', 'empty' === $stored ? array() : $rules );
+ 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( Utils::FEATURE_ID );
+ $this->assertSame( 'yes', get_option( 'woocommerce_queue_flush_rewrite_rules' ) );
+ }
+
+ /**
+ * @testdox The static file is written under plain permalinks, removed under pretty ones, and removed when UCP is turned off or WooCommerce is deactivated.
+ */
+ public function test_sync_file(): void {
+ $this->set_permalink_structure( '' );
+ $this->sut->maybe_sync_file();
+ $this->assertSame( wp_json_encode( $this->sut->get_profile(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ), file_get_contents( $this->file ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+
+ $this->set_permalink_structure( '/%postname%/' );
+ $this->sut->maybe_sync_file();
+ $this->assertFileDoesNotExist( $this->file );
+
+ $this->set_permalink_structure( '' );
+ $this->sut->maybe_sync_file();
+ $this->sut->maybe_flush_rewrite_rules( Utils::FEATURE_ID, false );
+ $this->assertFileDoesNotExist( $this->file );
+
+ update_option( 'woocommerce_feature_ucp_enabled', 'yes' );
$this->sut->register_feature_hooks();
+ $this->sut->maybe_sync_file();
+ do_action( 'deactivate_' . plugin_basename( WC_PLUGIN_FILE ) ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+ $this->assertFileDoesNotExist( $this->file );
+ }
+
+ /**
+ * @testdox A stale file WooCommerce wrote is rewritten and removed; a file it did not write is never touched.
+ * @testWith [true]
+ * [false]
+ *
+ * @param bool $owned Whether the existing file carries the WooCommerce marker.
+ */
+ public function test_sync_file_only_touches_owned_file( bool $owned ): void {
+ $stale = (string) wp_json_encode( array( 'links' => array( array( 'rel' => $owned ? UcpDiscovery::LINK_REL : 'other' ) ) ) );
+ wp_mkdir_p( dirname( $this->file ) );
+ // phpcs:disable WordPress.WP.AlternativeFunctions
+ file_put_contents( $this->file, $stale );
- $this->assertSame( $expected ? 'yes' : 'no', get_option( 'woocommerce_queue_flush_rewrite_rules' ) );
+ $this->set_permalink_structure( '' );
+ $this->sut->maybe_sync_file();
+ $this->assertSame( ! $owned, file_get_contents( $this->file ) === $stale );
+
+ $this->sut->maybe_flush_rewrite_rules( Utils::FEATURE_ID, false );
+ $this->assertSame( ! $owned, is_file( $this->file ) );
+
+ file_put_contents( $this->file, $stale );
+ $this->set_permalink_structure( '/%postname%/' );
+ $this->sut->maybe_sync_file();
+ $this->assertSame( ! $owned, is_file( $this->file ) );
+ // phpcs:enable WordPress.WP.AlternativeFunctions
}
/**
- * @testdox Toggling the UCP feature queues a rewrite rules flush.
+ * @testdox The static file is not written for ajax requests or users who cannot manage the store.
+ * @testWith [true, "administrator"]
+ * [false, "subscriber"]
+ *
+ * @param bool $ajax Whether the request is an ajax request.
+ * @param string $role Current user role.
*/
- public function test_feature_toggle_queues_flush(): void {
- update_option( 'woocommerce_queue_flush_rewrite_rules', 'no' );
+ public function test_sync_file_is_gated( bool $ajax, string $role ): void {
+ $this->set_permalink_structure( '' );
+ wp_set_current_user( self::factory()->user->create( array( 'role' => $role ) ) );
+ add_filter( 'wp_doing_ajax', $ajax ? '__return_true' : '__return_false' );
- $this->sut->maybe_flush_rewrite_rules( 'some_other_feature' );
- $this->assertSame( 'no', get_option( 'woocommerce_queue_flush_rewrite_rules' ) );
+ $this->sut->maybe_sync_file();
- $this->sut->maybe_flush_rewrite_rules( Utils::FEATURE_ID );
- $this->assertSame( 'yes', get_option( 'woocommerce_queue_flush_rewrite_rules' ) );
+ $this->assertFileDoesNotExist( $this->file );
}
/**
@@ -122,14 +180,16 @@ class UcpDiscoveryTest extends \WC_Unit_Test_Case {
*
* @param string $matched_rule Value of WP::$matched_rule.
* @param string $query_var Value of the discovery query var.
+ * @param string $uri Request URI.
* @return string|null Captured output, or null when the renderer returned without sending.
*/
- private function render( string $matched_rule, string $query_var ): ?string {
+ private function render( string $matched_rule, string $query_var, string $uri = '/?wc_ucp_discovery=1' ): ?string {
$wp = new \WP();
$wp->matched_rule = $matched_rule;
$wp->query_vars = array( UcpDiscovery::QUERY_VAR => $query_var );
$_SERVER['REQUEST_METHOD'] = 'GET';
+ $_SERVER['REQUEST_URI'] = $uri;
$die_handler = function () {
return function () {
throw new \WPDieException();
@@ -161,6 +221,15 @@ class UcpDiscoveryTest extends \WC_Unit_Test_Case {
$this->assertSame( $this->sut->get_profile(), json_decode( (string) $output, true ) );
}
+ /**
+ * @testdox maybe_render sends the profile for a /.well-known/ucp request URI when no rewrite rule matched.
+ */
+ public function test_maybe_render_request_uri_fallback(): void {
+ $output = $this->render( '', '', '/.well-known/ucp/' );
+
+ $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"]