From e9e46a4ee579d72ff7cb12094a2d76633a7c6ea0 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 19 Jun 2026 13:11:42 +0200 Subject: [PATCH 1/3] Abilities API: Refine filtering and expose meta over REST. Narrow the `category` argument of `wp_get_abilities()` to a single string so it matches `namespace`. Both arguments now take one slug. Callers that need to match several values can use `item_include_callback`. Starting simple keeps the public surface predictable and leaves room to accept arrays later without a breaking change. Add a `meta` query parameter to the REST abilities list endpoint, alongside `category` and `namespace`. The schema declares the well-defined behavioral annotations (`readonly`, `destructive`, `idempotent`), so a query-string value such as "true" is coerced to a boolean before matching. The forced `show_in_rest` condition always wins, so a caller cannot use meta to reveal abilities that are hidden from REST. Expand unit test coverage for the single-string category, the REST meta parameter, AND logic across several conditions, and the `show_in_rest` guard. Regenerate the REST API client fixture for the new query parameter. Fixes #64990. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-includes/abilities-api.php | 16 +-- ...s-wp-rest-abilities-v1-list-controller.php | 32 +++++ .../tests/abilities-api/wpGetAbilities.php | 17 ++- .../wpRestAbilitiesV1ListController.php | 123 ++++++++++++++++++ tests/qunit/fixtures/wp-api-generated.js | 36 +++++ 5 files changed, 212 insertions(+), 12 deletions(-) diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index 4b95cddf5b111..5f3001a680ff0 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -404,7 +404,7 @@ function wp_get_ability( string $name ): ?WP_Ability { * Filtering pipeline (executed in order): * * 1. Declarative filters (`category`, `namespace`, `meta`) — per-item, AND logic between - * arg types, OR logic within multi-value `category` arrays. + * arg types. * 2. `item_include_callback` — per-item, caller-scoped. Return true to include, false to exclude. * 3. `wp_get_abilities_item_include` filter — per-item, ecosystem-scoped. Plugins can enforce * universal inclusion rules regardless of what the caller passed. @@ -421,9 +421,6 @@ function wp_get_ability( string $name ): ?WP_Ability { * // Filter by category. * $abilities = wp_get_abilities( array( 'category' => 'content' ) ); * - * // Filter by multiple categories (OR logic). - * $abilities = wp_get_abilities( array( 'category' => array( 'content', 'settings' ) ) ); - * * // Filter by namespace. * $abilities = wp_get_abilities( array( 'namespace' => 'woocommerce' ) ); * @@ -466,9 +463,8 @@ function wp_get_ability( string $name ): ?WP_Ability { * @param array $args { * Optional. Arguments to filter the returned abilities. Default empty array (returns all). * - * @type string|string[] $category Filter by category slug. A single string or an array of - * slugs — abilities matching any of the given slugs are - * included (OR logic within this arg type). + * @type string $category Filter by category slug. Only abilities whose category + * exactly matches the given slug are included. * @type string $namespace Filter by ability namespace prefix. Pass the namespace * without a trailing slash, e.g. `'woocommerce'` matches * `'woocommerce/create-order'`. @@ -495,7 +491,7 @@ function wp_get_abilities( array $args = array() ): array { $abilities = $registry->get_all_registered(); - $category = isset( $args['category'] ) ? (array) $args['category'] : array(); + $category = isset( $args['category'] ) && is_string( $args['category'] ) ? $args['category'] : ''; $namespace = isset( $args['namespace'] ) && is_string( $args['namespace'] ) ? rtrim( $args['namespace'], '/' ) . '/' : ''; $meta = isset( $args['meta'] ) && is_array( $args['meta'] ) ? $args['meta'] : array(); $item_include_callback = isset( $args['item_include_callback'] ) && is_callable( $args['item_include_callback'] ) ? $args['item_include_callback'] : null; @@ -504,8 +500,8 @@ function wp_get_abilities( array $args = array() ): array { $matched = array(); foreach ( $abilities as $name => $ability ) { - // Step 1a: Filter by category (OR logic within the arg). - if ( ! empty( $category ) && ! in_array( $ability->get_category(), $category, true ) ) { + // Step 1a: Filter by category. + if ( '' !== $category && $ability->get_category() !== $category ) { continue; } diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php index 9fd251815b383..18a3728e02fcf 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php @@ -98,6 +98,11 @@ public function get_items( $request ) { $query_args['namespace'] = $request['namespace']; } + if ( ! empty( $request['meta'] ) ) { + // Merge caller meta first so the forced show_in_rest filter always wins. + $query_args['meta'] = array_merge( $request['meta'], $query_args['meta'] ); + } + $abilities = wp_get_abilities( $query_args ); $page = $request['page']; @@ -496,6 +501,33 @@ public function get_collection_params(): array { 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', ), + 'meta' => array( + 'description' => __( 'Limit results to abilities matching all of the given meta fields.' ), + 'type' => 'object', + 'properties' => array( + // show_in_rest is omitted on purpose. It is forced on and cannot be filtered by a caller. + 'annotations' => array( + 'description' => __( 'Limit results to abilities matching the given behavioral annotations.' ), + 'type' => 'object', + 'properties' => array( + 'readonly' => array( + 'description' => __( 'Whether the ability does not modify its environment.' ), + 'type' => array( 'boolean', 'null' ), + ), + 'destructive' => array( + 'description' => __( 'Whether the ability may perform destructive updates to its environment.' ), + 'type' => array( 'boolean', 'null' ), + ), + 'idempotent' => array( + 'description' => __( 'Whether repeated calls with the same arguments have no additional effect.' ), + 'type' => array( 'boolean', 'null' ), + ), + ), + 'additionalProperties' => true, + ), + ), + 'additionalProperties' => true, + ), ); } } diff --git a/tests/phpunit/tests/abilities-api/wpGetAbilities.php b/tests/phpunit/tests/abilities-api/wpGetAbilities.php index db3fc6856beaf..475ef341d869f 100644 --- a/tests/phpunit/tests/abilities-api/wpGetAbilities.php +++ b/tests/phpunit/tests/abilities-api/wpGetAbilities.php @@ -39,6 +39,13 @@ public function set_up(): void { 'description' => 'Text operations.', ) ); + wp_register_ability_category( + 'media', + array( + 'label' => 'Media', + 'description' => 'Media operations.', + ) + ); } /** @@ -53,6 +60,7 @@ public function tear_down(): void { wp_unregister_ability_category( 'math' ); wp_unregister_ability_category( 'text' ); + wp_unregister_ability_category( 'media' ); parent::tear_down(); } @@ -135,15 +143,19 @@ static function ( WP_Ability $a ) { } /** - * Tests that passing an array of categories uses OR logic. + * Tests that a non-string category is ignored rather than treated as a multi-value filter. + * + * The declarative filters accept a single slug. Anything other than a string is ignored, + * matching how the `namespace` and `meta` args guard their own types. * * @ticket 64990 */ - public function test_filter_by_category_array_uses_or_logic(): void { + public function test_filter_by_non_string_category_is_ignored(): void { $this->simulate_wp_abilities_init(); $this->register_test_ability( 'test/math-add', array( 'category' => 'math' ) ); $this->register_test_ability( 'test/text-upper', array( 'category' => 'text' ) ); + $this->register_test_ability( 'test/media-crop', array( 'category' => 'media' ) ); $result = wp_get_abilities( array( 'category' => array( 'math', 'text' ) ) ); $names = array_map( @@ -155,6 +167,7 @@ static function ( WP_Ability $a ) { $this->assertContains( 'test/math-add', $names ); $this->assertContains( 'test/text-upper', $names ); + $this->assertContains( 'test/media-crop', $names ); } /** diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php index 20a773bea1628..e23ad6cce5c92 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php @@ -828,6 +828,129 @@ public function test_filter_by_namespace_still_respects_show_in_rest(): void { $this->assertNotContains( 'test/not-show-in-rest', $names ); } + /** + * Test filtering abilities by a well-defined behavioral annotation. + * + * The 'test/system-info' fixture is the only ability marked read only. The + * value is passed as a string, the way it arrives over the query string, so + * this also confirms the meta schema coerces it to a boolean before matching. + * + * @ticket 64990 + */ + public function test_filter_by_annotation(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $request->set_param( 'meta', array( 'annotations' => array( 'readonly' => 'true' ) ) ); + $request->set_param( 'per_page', 100 ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $names = wp_list_pluck( $response->get_data(), 'name' ); + + $this->assertContains( 'test/system-info', $names ); + $this->assertNotContains( 'test/calculator', $names, 'Abilities not marked read only should be excluded.' ); + } + + /** + * Test that a non-matching annotation returns empty results. + * + * No fixture marks itself destructive, so the result set is empty. + * + * @ticket 64990 + */ + public function test_filter_by_non_matching_annotation(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $request->set_param( 'meta', array( 'annotations' => array( 'destructive' => true ) ) ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertEmpty( $response->get_data() ); + } + + /** + * Test filtering abilities by several meta conditions at once. + * + * All conditions must match (AND logic). + * + * @ticket 64990 + */ + public function test_filter_by_multiple_meta_conditions(): void { + $this->register_test_ability( + 'test/read-only-idempotent', + array( + 'label' => 'Read Only and Idempotent', + 'description' => 'Marked both read only and idempotent.', + 'category' => 'general', + 'execute_callback' => '__return_true', + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => array( + 'readonly' => true, + 'idempotent' => true, + ), + ), + ) + ); + + $this->register_test_ability( + 'test/read-only-only', + array( + 'label' => 'Read Only', + 'description' => 'Marked read only but not idempotent.', + 'category' => 'general', + 'execute_callback' => '__return_true', + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => array( + 'readonly' => true, + ), + ), + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $request->set_param( + 'meta', + array( + 'annotations' => array( + 'readonly' => 'true', + 'idempotent' => 'true', + ), + ) + ); + $request->set_param( 'per_page', 100 ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $names = wp_list_pluck( $response->get_data(), 'name' ); + + $this->assertContains( 'test/read-only-idempotent', $names, 'An ability matching every condition should be included.' ); + $this->assertNotContains( 'test/read-only-only', $names, 'An ability matching only one condition should be excluded.' ); + } + + /** + * Test that a caller cannot use the meta filter to reveal abilities hidden from REST. + * + * The forced `show_in_rest => true` condition must always win, even when the + * caller passes `show_in_rest => false` through the meta parameter. + * + * @ticket 64990 + */ + public function test_filter_by_meta_cannot_override_show_in_rest(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $request->set_param( 'meta', array( 'show_in_rest' => false ) ); + $request->set_param( 'per_page', 100 ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $names = wp_list_pluck( $response->get_data(), 'name' ); + $this->assertNotContains( 'test/not-show-in-rest', $names, 'A caller must not reveal hidden abilities through meta.' ); + } + /** * Test that schema keywords outside the allow-list are stripped from ability schemas in REST response. * diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index fa03d9751fe99..eb44f84c18dfc 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -12655,6 +12655,42 @@ mockedApiResponse.Schema = { "description": "Limit results to abilities in a specific namespace.", "type": "string", "required": false + }, + "meta": { + "description": "Limit results to abilities matching all of the given meta fields.", + "type": "object", + "properties": { + "annotations": { + "description": "Limit results to abilities matching the given behavioral annotations.", + "type": "object", + "properties": { + "readonly": { + "description": "Whether the ability does not modify its environment.", + "type": [ + "boolean", + "null" + ] + }, + "destructive": { + "description": "Whether the ability may perform destructive updates to its environment.", + "type": [ + "boolean", + "null" + ] + }, + "idempotent": { + "description": "Whether repeated calls with the same arguments have no additional effect.", + "type": [ + "boolean", + "null" + ] + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true, + "required": false } } } From 9a3b8b1bdd6d9141b0084cecd0751b466221c75e Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Mon, 22 Jun 2026 13:51:06 +0200 Subject: [PATCH 2/3] Abilities API: Align meta annotations schema and clarify visibility guard. Model the item schema `meta.annotations` as an object with the well-defined behavioral keys (readonly, destructive, idempotent), matching the shape used by the new `meta` collection parameter. The previous `boolean|null` scalar type did not describe the value returned in the response. Expand the inline note on the show_in_rest merge to explain its intent. The forced filter keeps a caller from using meta to reveal abilities hidden from REST, so the comment now states the reason instead of only the mechanics. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...s-wp-rest-abilities-v1-list-controller.php | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php index 18a3728e02fcf..e5131205ad2b2 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php @@ -99,7 +99,7 @@ public function get_items( $request ) { } if ( ! empty( $request['meta'] ) ) { - // Merge caller meta first so the forced show_in_rest filter always wins. + // Merge caller meta first so the forced show_in_rest filter wins. This keeps a caller from using meta to reveal abilities hidden from REST. $query_args['meta'] = array_merge( $request['meta'], $query_args['meta'] ); } @@ -452,9 +452,23 @@ public function get_item_schema(): array { 'type' => 'object', 'properties' => array( 'annotations' => array( - 'description' => __( 'Annotations for the ability.' ), - 'type' => array( 'boolean', 'null' ), - 'default' => null, + 'description' => __( 'Behavioral annotations for the ability.' ), + 'type' => 'object', + 'properties' => array( + 'readonly' => array( + 'description' => __( 'Whether the ability does not modify its environment.' ), + 'type' => array( 'boolean', 'null' ), + ), + 'destructive' => array( + 'description' => __( 'Whether the ability may perform destructive updates to its environment.' ), + 'type' => array( 'boolean', 'null' ), + ), + 'idempotent' => array( + 'description' => __( 'Whether repeated calls with the same arguments have no additional effect.' ), + 'type' => array( 'boolean', 'null' ), + ), + ), + 'additionalProperties' => true, ), ), 'context' => array( 'view', 'edit' ), From c3c678d91d99c21aaf592e7d8895fcb32b24a4cc Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Mon, 22 Jun 2026 14:18:52 +0200 Subject: [PATCH 3/3] Abilities API: Add a collection params filter for custom meta types. Open-ended meta keys arrive over the query string as strings. The meta schema declares only the well-defined annotations, so a custom key has no declared type. REST leaves a value such as "true" as a string, and the strict meta match never equals the stored boolean, so the ability is excluded. Add a `rest_abilities_collection_params` filter to the list controller, matching the convention used by other REST controllers. A plugin can hook it to declare the schema type of its own meta key. REST then coerces the value before matching, which makes the custom key filterable. Add two unit tests. The first shows the default behavior, where a custom meta key with no declared type does not coerce. The second shows the filter declaring the type so the value coerces and the ability matches. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...s-wp-rest-abilities-v1-list-controller.php | 15 ++- .../wpRestAbilitiesV1ListController.php | 94 +++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php index e5131205ad2b2..b3d94be28c4f3 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php @@ -488,7 +488,7 @@ public function get_item_schema(): array { * @return array Collection parameters. */ public function get_collection_params(): array { - return array( + $query_params = array( 'context' => $this->get_context_param( array( 'default' => 'view' ) ), 'page' => array( 'description' => __( 'Current page of the collection.' ), @@ -543,5 +543,18 @@ public function get_collection_params(): array { 'additionalProperties' => true, ), ); + + /** + * Filters REST API collection parameters for the abilities controller. + * + * Use this to declare the schema type of a custom meta key. A declared + * type lets REST coerce a query-string value, for example "true" to a + * boolean, before the meta filter matches it. + * + * @since 7.1.0 + * + * @param array $query_params JSON Schema-formatted collection parameters. + */ + return apply_filters( 'rest_abilities_collection_params', $query_params ); } } diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php index e23ad6cce5c92..56987fa57e36b 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php @@ -142,6 +142,30 @@ private function register_test_ability( string $name, array $args ): void { array_pop( $wp_current_filter ); } + /** + * Helper to register an ability with a custom boolean meta key. + * + * The `featured` key stands in for any plugin-defined meta. It is not part + * of the well-defined annotations, so the meta schema does not declare its + * type by default. + */ + private function register_featured_ability(): void { + $this->register_test_ability( + 'test/featured', + array( + 'label' => 'Featured', + 'description' => 'Declares a custom boolean meta value.', + 'category' => 'general', + 'execute_callback' => '__return_true', + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + 'featured' => true, + ), + ) + ); + } + /** * Register test abilities for testing. */ @@ -951,6 +975,76 @@ public function test_filter_by_meta_cannot_override_show_in_rest(): void { $this->assertNotContains( 'test/not-show-in-rest', $names, 'A caller must not reveal hidden abilities through meta.' ); } + /** + * Test the default behavior for a custom meta key with no declared type. + * + * Open-ended meta keys arrive over the query string as strings. The meta + * schema declares only the well-defined annotations, so a custom key such as + * `featured` has no declared type. REST leaves the value "true" as a string, + * and the strict meta match never equals the stored boolean. The ability is + * excluded. + * + * @ticket 64990 + */ + public function test_filter_by_custom_meta_without_declared_type_is_not_coerced(): void { + $this->register_featured_ability(); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + // The value is passed as a string, the way it arrives over the query string. + $request->set_param( 'meta', array( 'featured' => 'true' ) ); + $request->set_param( 'per_page', 100 ); + + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $names = wp_list_pluck( $response->get_data(), 'name' ); + $this->assertNotContains( 'test/featured', $names, 'A custom meta key without a declared type should not coerce the query-string value.' ); + } + + /** + * Test that a filter can declare a custom meta key's type so its value coerces. + * + * A plugin can declare the type for its own meta key through the + * `rest_abilities_collection_params` filter. REST then coerces the value + * "true" to a boolean before matching, so the ability is included. This is + * the supported way to make a custom meta key filterable. + * + * @ticket 64990 + */ + public function test_filter_can_declare_custom_meta_type_for_coercion(): void { + $this->register_featured_ability(); + + // Declare the type for the custom meta key so REST coerces the value first. + add_filter( + 'rest_abilities_collection_params', + static function ( array $query_params ): array { + $query_params['meta']['properties']['featured'] = array( + 'type' => array( 'boolean', 'null' ), + ); + return $query_params; + } + ); + + // Re-register the routes on a fresh server so the collection parameters pick up the filter. + global $wp_rest_server; + $wp_rest_server = new WP_REST_Server(); + $this->server = $wp_rest_server; + do_action( 'rest_api_init' ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + // The value is passed as a string, the way it arrives over the query string. + $request->set_param( 'meta', array( 'featured' => 'true' ) ); + $request->set_param( 'per_page', 100 ); + + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $names = wp_list_pluck( $response->get_data(), 'name' ); + $this->assertContains( 'test/featured', $names, 'A declared schema type should coerce the query-string value before matching.' ); + } + /** * Test that schema keywords outside the allow-list are stripped from ability schemas in REST response. *