From e8bc6731b0f1499ea9fc76435edf083db3d1cbff Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 9 Jun 2026 17:55:24 +0100 Subject: [PATCH 1/4] Abilities API: Add a core/settings ability Add a read-only core/settings ability that returns WordPress settings as a flat name => value map. Only settings flagged with the new show_in_abilities registration arg are exposed; callers can filter by settings group or by name (mutually exclusive). Requires the manage_options capability. The logic lives in a new internal WP_Settings_Abilities class, structured so a future core/manage-settings write ability can reuse its helpers. --- src/wp-includes/abilities.php | 5 + .../abilities/class-wp-settings-abilities.php | 278 ++++++++++++++++++ src/wp-includes/option.php | 163 ++++++---- .../wpRegisterCoreSettingsAbility.php | 183 ++++++++++++ 4 files changed, 566 insertions(+), 63 deletions(-) create mode 100644 src/wp-includes/abilities/class-wp-settings-abilities.php create mode 100644 tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsAbility.php diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index 0eb87a4581589..bffa138d895f3 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -9,6 +9,8 @@ declare( strict_types = 1 ); +require_once __DIR__ . '/abilities/class-wp-settings-abilities.php'; + /** * Registers the core ability categories. * @@ -351,4 +353,7 @@ function wp_register_core_abilities(): void { ), ) ); + + // Register the settings abilities (currently the read-only `core/settings`). + WP_Settings_Abilities::register(); } diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php new file mode 100644 index 0000000000000..1eabce7a0fab4 --- /dev/null +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -0,0 +1,278 @@ + $setting ) { + $properties[ $exposed_name ] = $setting['schema']; + } + + wp_register_ability( + 'core/settings', + array( + 'label' => __( 'Get Settings' ), + 'description' => __( 'Returns WordPress settings as a flat map of setting name to value. By default returns all settings exposed to abilities, or optionally a subset filtered by settings group or by setting name.' ), + 'category' => self::CATEGORY, + 'input_schema' => self::get_settings_input_schema( $groups, $slugs ), + 'output_schema' => array( + 'type' => 'object', + 'description' => __( 'A map of setting name to its current value.' ), + 'properties' => $properties, + 'additionalProperties' => false, + ), + 'execute_callback' => array( self::class, 'execute_get_settings' ), + 'permission_callback' => array( self::class, 'has_permission' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + } + + /** + * Executes the `core/settings` ability. + * + * @since 7.1.0 + * + * @param mixed $input Optional. The ability input. Default empty array. + * @return array Map of exposed setting name to current value. + */ + public static function execute_get_settings( $input = array() ): array { + $input = is_array( $input ) ? $input : array(); + + $settings = self::get_exposed_settings(); + $group = isset( $input['group'] ) ? (string) $input['group'] : ''; + $slugs = isset( $input['slugs'] ) && is_array( $input['slugs'] ) ? $input['slugs'] : array(); + + $result = array(); + foreach ( $settings as $exposed_name => $setting ) { + if ( '' !== $group && $setting['group'] !== $group ) { + continue; + } + if ( ! empty( $slugs ) && ! in_array( $exposed_name, $slugs, true ) ) { + continue; + } + + $type = isset( $setting['schema']['type'] ) ? (string) $setting['schema']['type'] : 'string'; + $value = get_option( $setting['option'], $setting['default'] ); + + $result[ $exposed_name ] = self::cast_value( $value, $type ); + } + + return $result; + } + + /** + * Checks whether the current user may use the settings abilities. + * + * @since 7.1.0 + * + * @return bool True if the current user can manage options. + */ + public static function has_permission(): bool { + return current_user_can( 'manage_options' ); + } + + /** + * Builds the input schema for the get ability: filter by group XOR by name. + * + * @since 7.1.0 + * + * @param string[] $groups Available settings groups. + * @param string[] $slugs Available exposed setting names. + * @return array The input JSON Schema. + */ + protected static function get_settings_input_schema( array $groups, array $slugs ): array { + return array( + 'type' => 'object', + 'default' => array(), + // Filter by group OR by name, but not both at once. + 'oneOf' => array( + array( + 'title' => __( 'All settings' ), + 'type' => 'object', + 'additionalProperties' => false, + ), + array( + 'title' => __( 'Filter by group' ), + 'type' => 'object', + 'required' => array( 'group' ), + 'properties' => array( + 'group' => array( + 'type' => 'string', + 'enum' => $groups, + 'description' => __( 'Return only settings that belong to this settings group.' ), + ), + ), + 'additionalProperties' => false, + ), + array( + 'title' => __( 'Filter by name' ), + 'type' => 'object', + 'required' => array( 'slugs' ), + 'properties' => array( + 'slugs' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + 'enum' => $slugs, + ), + 'description' => __( 'Return only the settings with these names.' ), + ), + ), + 'additionalProperties' => false, + ), + ), + ); + } + + /** + * Returns the settings exposed through the Abilities API. + * + * Reads {@see get_registered_settings()} and keeps only settings flagged with a truthy + * `show_in_abilities` argument. Each entry is keyed by its exposed name and carries the + * underlying option name, the settings group, the registration default, and a JSON Schema + * describing the value. + * + * @since 7.1.0 + * + * @return array}> Settings keyed by exposed name. + */ + protected static function get_exposed_settings(): array { + $settings = array(); + + foreach ( get_registered_settings() as $option_name => $args ) { + $show = $args['show_in_abilities'] ?? false; + if ( empty( $show ) ) { + continue; + } + + $option_name = (string) $option_name; + $exposed_name = is_array( $show ) && ! empty( $show['name'] ) ? (string) $show['name'] : $option_name; + + $settings[ $exposed_name ] = array( + 'option' => $option_name, + 'group' => isset( $args['group'] ) ? (string) $args['group'] : '', + 'default' => array_key_exists( 'default', $args ) ? $args['default'] : false, + 'schema' => self::value_schema( $args, $show ), + ); + } + + return $settings; + } + + /** + * Builds the JSON Schema describing a single setting's value. + * + * @since 7.1.0 + * + * @param array $args The setting registration arguments. + * @param bool|array $show The setting's `show_in_abilities` value. + * @return array The value JSON Schema. + */ + protected static function value_schema( array $args, $show ): array { + $schema = array( + 'type' => isset( $args['type'] ) ? (string) $args['type'] : 'string', + ); + if ( ! empty( $args['label'] ) ) { + $schema['title'] = $args['label']; + } + if ( ! empty( $args['description'] ) ) { + $schema['description'] = $args['description']; + } + if ( is_array( $show ) && isset( $show['schema'] ) && is_array( $show['schema'] ) ) { + $schema = array_merge( $schema, $show['schema'] ); + } + + return $schema; + } + + /** + * Casts a stored option value to the type declared in its settings registration. + * + * @since 7.1.0 + * + * @param mixed $value The raw option value. + * @param string $type The registered setting type. + * @return mixed The value cast to the declared type. + */ + protected static function cast_value( $value, string $type ) { + switch ( $type ) { + case 'boolean': + return (bool) $value; + case 'integer': + return (int) $value; + case 'number': + return (float) $value; + case 'array': + case 'object': + return is_array( $value ) ? $value : array(); + default: + return is_scalar( $value ) ? (string) $value : $value; + } + } +} diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index 7979c119a986f..2901470628e1c 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -2743,12 +2743,13 @@ function register_initial_settings() { 'general', 'blogname', array( - 'show_in_rest' => array( + 'show_in_rest' => array( 'name' => 'title', ), - 'type' => 'string', - 'label' => __( 'Title' ), - 'description' => __( 'Site title.' ), + 'show_in_abilities' => true, + 'type' => 'string', + 'label' => __( 'Title' ), + 'description' => __( 'Site title.' ), ) ); @@ -2756,12 +2757,13 @@ function register_initial_settings() { 'general', 'blogdescription', array( - 'show_in_rest' => array( + 'show_in_rest' => array( 'name' => 'description', ), - 'type' => 'string', - 'label' => __( 'Tagline' ), - 'description' => __( 'Site tagline.' ), + 'show_in_abilities' => true, + 'type' => 'string', + 'label' => __( 'Tagline' ), + 'description' => __( 'Site tagline.' ), ) ); @@ -2770,14 +2772,15 @@ function register_initial_settings() { 'general', 'siteurl', array( - 'show_in_rest' => array( + 'show_in_rest' => array( 'name' => 'url', 'schema' => array( 'format' => 'uri', ), ), - 'type' => 'string', - 'description' => __( 'Site URL.' ), + 'show_in_abilities' => true, + 'type' => 'string', + 'description' => __( 'Site URL.' ), ) ); } @@ -2787,14 +2790,19 @@ function register_initial_settings() { 'general', 'admin_email', array( - 'show_in_rest' => array( + 'show_in_rest' => array( 'name' => 'email', 'schema' => array( 'format' => 'email', ), ), - 'type' => 'string', - 'description' => __( 'This address is used for admin purposes, like new user notification.' ), + 'show_in_abilities' => array( + 'schema' => array( + 'format' => 'email', + ), + ), + 'type' => 'string', + 'description' => __( 'This address is used for admin purposes, like new user notification.' ), ) ); } @@ -2803,11 +2811,12 @@ function register_initial_settings() { 'general', 'timezone_string', array( - 'show_in_rest' => array( + 'show_in_rest' => array( 'name' => 'timezone', ), - 'type' => 'string', - 'description' => __( 'A city in the same timezone as you.' ), + 'show_in_abilities' => true, + 'type' => 'string', + 'description' => __( 'A city in the same timezone as you.' ), ) ); @@ -2815,9 +2824,10 @@ function register_initial_settings() { 'general', 'date_format', array( - 'show_in_rest' => true, - 'type' => 'string', - 'description' => __( 'A date format for all date strings.' ), + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'string', + 'description' => __( 'A date format for all date strings.' ), ) ); @@ -2825,9 +2835,10 @@ function register_initial_settings() { 'general', 'time_format', array( - 'show_in_rest' => true, - 'type' => 'string', - 'description' => __( 'A time format for all time strings.' ), + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'string', + 'description' => __( 'A time format for all time strings.' ), ) ); @@ -2835,9 +2846,10 @@ function register_initial_settings() { 'general', 'start_of_week', array( - 'show_in_rest' => true, - 'type' => 'integer', - 'description' => __( 'A day number of the week that the week should start on.' ), + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'integer', + 'description' => __( 'A day number of the week that the week should start on.' ), ) ); @@ -2845,12 +2857,13 @@ function register_initial_settings() { 'general', 'WPLANG', array( - 'show_in_rest' => array( + 'show_in_rest' => array( 'name' => 'language', ), - 'type' => 'string', - 'description' => __( 'WordPress locale code.' ), - 'default' => 'en_US', + 'show_in_abilities' => true, + 'type' => 'string', + 'description' => __( 'WordPress locale code.' ), + 'default' => 'en_US', ) ); @@ -2858,10 +2871,11 @@ function register_initial_settings() { 'writing', 'use_smilies', array( - 'show_in_rest' => true, - 'type' => 'boolean', - 'description' => __( 'Convert emoticons like :-) and :-P to graphics on display.' ), - 'default' => true, + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'boolean', + 'description' => __( 'Convert emoticons like :-) and :-P to graphics on display.' ), + 'default' => true, ) ); @@ -2869,9 +2883,10 @@ function register_initial_settings() { 'writing', 'default_category', array( - 'show_in_rest' => true, - 'type' => 'integer', - 'description' => __( 'Default post category.' ), + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'integer', + 'description' => __( 'Default post category.' ), ) ); @@ -2879,9 +2894,10 @@ function register_initial_settings() { 'writing', 'default_post_format', array( - 'show_in_rest' => true, - 'type' => 'string', - 'description' => __( 'Default post format.' ), + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'string', + 'description' => __( 'Default post format.' ), ) ); @@ -2889,11 +2905,12 @@ function register_initial_settings() { 'reading', 'posts_per_page', array( - 'show_in_rest' => true, - 'type' => 'integer', - 'label' => __( 'Maximum posts per page' ), - 'description' => __( 'Blog pages show at most.' ), - 'default' => 10, + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'integer', + 'label' => __( 'Maximum posts per page' ), + 'description' => __( 'Blog pages show at most.' ), + 'default' => 10, ) ); @@ -2901,10 +2918,11 @@ function register_initial_settings() { 'reading', 'show_on_front', array( - 'show_in_rest' => true, - 'type' => 'string', - 'label' => __( 'Show on front' ), - 'description' => __( 'What to show on the front page' ), + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'string', + 'label' => __( 'Show on front' ), + 'description' => __( 'What to show on the front page' ), ) ); @@ -2912,10 +2930,11 @@ function register_initial_settings() { 'reading', 'page_on_front', array( - 'show_in_rest' => true, - 'type' => 'integer', - 'label' => __( 'Page on front' ), - 'description' => __( 'The ID of the page that should be displayed on the front page' ), + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'integer', + 'label' => __( 'Page on front' ), + 'description' => __( 'The ID of the page that should be displayed on the front page' ), ) ); @@ -2923,9 +2942,10 @@ function register_initial_settings() { 'reading', 'page_for_posts', array( - 'show_in_rest' => true, - 'type' => 'integer', - 'description' => __( 'The ID of the page that should display the latest posts' ), + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'integer', + 'description' => __( 'The ID of the page that should display the latest posts' ), ) ); @@ -2933,13 +2953,18 @@ function register_initial_settings() { 'discussion', 'default_ping_status', array( - 'show_in_rest' => array( + 'show_in_rest' => array( + 'schema' => array( + 'enum' => array( 'open', 'closed' ), + ), + ), + 'show_in_abilities' => array( 'schema' => array( 'enum' => array( 'open', 'closed' ), ), ), - 'type' => 'string', - 'description' => __( 'Allow link notifications from other blogs (pingbacks and trackbacks) on new articles.' ), + 'type' => 'string', + 'description' => __( 'Allow link notifications from other blogs (pingbacks and trackbacks) on new articles.' ), ) ); @@ -2947,14 +2972,19 @@ function register_initial_settings() { 'discussion', 'default_comment_status', array( - 'show_in_rest' => array( + 'show_in_rest' => array( + 'schema' => array( + 'enum' => array( 'open', 'closed' ), + ), + ), + 'show_in_abilities' => array( 'schema' => array( 'enum' => array( 'open', 'closed' ), ), ), - 'type' => 'string', - 'label' => __( 'Allow comments on new posts' ), - 'description' => __( 'Allow people to submit comments on new posts.' ), + 'type' => 'string', + 'label' => __( 'Allow comments on new posts' ), + 'description' => __( 'Allow people to submit comments on new posts.' ), ) ); } @@ -2988,6 +3018,10 @@ function register_initial_settings() { * @type bool|array $show_in_rest Whether data associated with this setting should be included in the REST API. * When registering complex settings, this argument may optionally be an * array with a 'schema' key. + * @type bool|array $show_in_abilities Whether this setting should be exposed through the Abilities API + * (e.g. the `core/settings` ability). When registering complex settings, + * this argument may optionally be an array with optional 'name' and + * 'schema' keys, mirroring the `show_in_rest` shape. * @type mixed $default Default value when calling `get_option()`. * } */ @@ -3207,6 +3241,9 @@ function unregister_setting( $option_group, $option_name, $deprecated = '' ) { * @type bool|array $show_in_rest Whether data associated with this setting should be included in the REST API. * When registering complex settings, this argument may optionally be an * array with a 'schema' key. + * @type bool|array $show_in_abilities Whether this setting should be exposed through the Abilities API + * (e.g. the `core/settings` ability). May optionally be an array with + * optional 'name' and 'schema' keys, mirroring the `show_in_rest` shape. * @type mixed $default Default value when calling `get_option()`. * } * } diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsAbility.php new file mode 100644 index 0000000000000..5da616744eb5c --- /dev/null +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsAbility.php @@ -0,0 +1,183 @@ +get_name() ); + } + foreach ( wp_get_ability_categories() as $ability_category ) { + wp_unregister_ability_category( $ability_category->get_slug() ); + } + + parent::tear_down_after_class(); + } + + /** + * Logs in as an administrator so abilities gated behind `manage_options` can run. + */ + private function become_admin(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + } + + /** + * The ability is registered in the `site` category and flagged read-only. + * + * @ticket 64146 + */ + public function test_core_settings_ability_is_registered(): void { + $ability = wp_get_ability( 'core/settings' ); + + $this->assertInstanceOf( WP_Ability::class, $ability ); + $this->assertSame( 'site', $ability->get_category() ); + $this->assertTrue( $ability->get_meta_item( 'show_in_rest', false ) ); + + $annotations = $ability->get_meta_item( 'annotations', array() ); + $this->assertTrue( $annotations['readonly'] ); + $this->assertFalse( $annotations['destructive'] ); + } + + /** + * The input schema exposes mutually exclusive `group` and `slugs` filters. + * + * @ticket 64146 + */ + public function test_core_settings_input_schema_is_one_of_group_or_slugs(): void { + $schema = wp_get_ability( 'core/settings' )->get_input_schema(); + + $this->assertSame( 'object', $schema['type'] ); + $this->assertArrayHasKey( 'default', $schema ); + $this->assertCount( 3, $schema['oneOf'] ); + + $group_branch = $schema['oneOf'][1]; + $this->assertSame( array( 'group' ), $group_branch['required'] ); + $this->assertContains( 'general', $group_branch['properties']['group']['enum'] ); + $this->assertContains( 'reading', $group_branch['properties']['group']['enum'] ); + + $slugs_branch = $schema['oneOf'][2]; + $this->assertSame( array( 'slugs' ), $slugs_branch['required'] ); + $this->assertContains( 'blogname', $slugs_branch['properties']['slugs']['items']['enum'] ); + $this->assertContains( 'posts_per_page', $slugs_branch['properties']['slugs']['items']['enum'] ); + } + + /** + * Without input the ability returns a flat map of correctly typed setting values. + * + * @ticket 64146 + */ + public function test_core_settings_returns_flat_typed_values(): void { + $this->become_admin(); + + update_option( 'blogname', 'My Test Site' ); + update_option( 'posts_per_page', 7 ); + update_option( 'use_smilies', '1' ); + + $result = wp_get_ability( 'core/settings' )->execute( array() ); + + $this->assertIsArray( $result ); + $this->assertSame( 'My Test Site', $result['blogname'] ); + $this->assertSame( 7, $result['posts_per_page'] ); + $this->assertTrue( $result['use_smilies'] ); + } + + /** + * The `group` filter narrows the response to a single settings group. + * + * @ticket 64146 + */ + public function test_core_settings_filters_by_group(): void { + $this->become_admin(); + + $result = wp_get_ability( 'core/settings' )->execute( array( 'group' => 'reading' ) ); + + $this->assertArrayHasKey( 'posts_per_page', $result ); + $this->assertArrayNotHasKey( 'blogname', $result ); + } + + /** + * The `slugs` filter narrows the response to the requested setting names. + * + * @ticket 64146 + */ + public function test_core_settings_filters_by_slugs(): void { + $this->become_admin(); + + $result = wp_get_ability( 'core/settings' )->execute( array( 'slugs' => array( 'blogname', 'posts_per_page' ) ) ); + + $this->assertSame( array( 'blogname', 'posts_per_page' ), array_keys( $result ) ); + } + + /** + * Supplying both `group` and `slugs` violates the `oneOf` and is rejected. + * + * @ticket 64146 + */ + public function test_core_settings_rejects_group_and_slugs_together(): void { + $this->become_admin(); + + $result = wp_get_ability( 'core/settings' )->execute( + array( + 'group' => 'reading', + 'slugs' => array( 'blogname' ), + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); + } + + /** + * Users without `manage_options` cannot run the ability. + * + * @ticket 64146 + */ + public function test_core_settings_requires_manage_options(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + + $result = wp_get_ability( 'core/settings' )->execute( array() ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + } +} From a28c976b9b5a077debe9fec3debeafd97660c11d Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 15 Jun 2026 20:31:12 +0100 Subject: [PATCH 2/4] Abilities API: rename the core/settings 'slugs' input to 'settings' 'settings' reads more naturally than 'slugs' for filtering an abilities-exposed settings map by name. --- .../abilities/class-wp-settings-abilities.php | 26 +++++++++---------- .../wpRegisterCoreSettingsAbility.php | 26 +++++++++---------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index 1eabce7a0fab4..f4fac8901c41c 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -58,10 +58,10 @@ public static function register(): void { * @since 7.1.0 */ public static function register_get_settings(): void { - $settings = self::get_exposed_settings(); - $groups = array_values( array_unique( array_filter( wp_list_pluck( $settings, 'group' ) ) ) ); - $slugs = array_keys( $settings ); - $properties = array(); + $settings = self::get_exposed_settings(); + $groups = array_values( array_unique( array_filter( wp_list_pluck( $settings, 'group' ) ) ) ); + $setting_names = array_keys( $settings ); + $properties = array(); foreach ( $settings as $exposed_name => $setting ) { $properties[ $exposed_name ] = $setting['schema']; } @@ -72,7 +72,7 @@ public static function register_get_settings(): void { 'label' => __( 'Get Settings' ), 'description' => __( 'Returns WordPress settings as a flat map of setting name to value. By default returns all settings exposed to abilities, or optionally a subset filtered by settings group or by setting name.' ), 'category' => self::CATEGORY, - 'input_schema' => self::get_settings_input_schema( $groups, $slugs ), + 'input_schema' => self::get_settings_input_schema( $groups, $setting_names ), 'output_schema' => array( 'type' => 'object', 'description' => __( 'A map of setting name to its current value.' ), @@ -106,14 +106,14 @@ public static function execute_get_settings( $input = array() ): array { $settings = self::get_exposed_settings(); $group = isset( $input['group'] ) ? (string) $input['group'] : ''; - $slugs = isset( $input['slugs'] ) && is_array( $input['slugs'] ) ? $input['slugs'] : array(); + $names = isset( $input['settings'] ) && is_array( $input['settings'] ) ? $input['settings'] : array(); $result = array(); foreach ( $settings as $exposed_name => $setting ) { if ( '' !== $group && $setting['group'] !== $group ) { continue; } - if ( ! empty( $slugs ) && ! in_array( $exposed_name, $slugs, true ) ) { + if ( ! empty( $names ) && ! in_array( $exposed_name, $names, true ) ) { continue; } @@ -142,11 +142,11 @@ public static function has_permission(): bool { * * @since 7.1.0 * - * @param string[] $groups Available settings groups. - * @param string[] $slugs Available exposed setting names. + * @param string[] $groups Available settings groups. + * @param string[] $setting_names Available exposed setting names. * @return array The input JSON Schema. */ - protected static function get_settings_input_schema( array $groups, array $slugs ): array { + protected static function get_settings_input_schema( array $groups, array $setting_names ): array { return array( 'type' => 'object', 'default' => array(), @@ -173,13 +173,13 @@ protected static function get_settings_input_schema( array $groups, array $slugs array( 'title' => __( 'Filter by name' ), 'type' => 'object', - 'required' => array( 'slugs' ), + 'required' => array( 'settings' ), 'properties' => array( - 'slugs' => array( + 'settings' => array( 'type' => 'array', 'items' => array( 'type' => 'string', - 'enum' => $slugs, + 'enum' => $setting_names, ), 'description' => __( 'Return only the settings with these names.' ), ), diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsAbility.php index 5da616744eb5c..e7a0ad11f08f8 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsAbility.php @@ -79,11 +79,11 @@ public function test_core_settings_ability_is_registered(): void { } /** - * The input schema exposes mutually exclusive `group` and `slugs` filters. + * The input schema exposes mutually exclusive `group` and `settings` filters. * * @ticket 64146 */ - public function test_core_settings_input_schema_is_one_of_group_or_slugs(): void { + public function test_core_settings_input_schema_is_one_of_group_or_settings(): void { $schema = wp_get_ability( 'core/settings' )->get_input_schema(); $this->assertSame( 'object', $schema['type'] ); @@ -95,10 +95,10 @@ public function test_core_settings_input_schema_is_one_of_group_or_slugs(): void $this->assertContains( 'general', $group_branch['properties']['group']['enum'] ); $this->assertContains( 'reading', $group_branch['properties']['group']['enum'] ); - $slugs_branch = $schema['oneOf'][2]; - $this->assertSame( array( 'slugs' ), $slugs_branch['required'] ); - $this->assertContains( 'blogname', $slugs_branch['properties']['slugs']['items']['enum'] ); - $this->assertContains( 'posts_per_page', $slugs_branch['properties']['slugs']['items']['enum'] ); + $settings_branch = $schema['oneOf'][2]; + $this->assertSame( array( 'settings' ), $settings_branch['required'] ); + $this->assertContains( 'blogname', $settings_branch['properties']['settings']['items']['enum'] ); + $this->assertContains( 'posts_per_page', $settings_branch['properties']['settings']['items']['enum'] ); } /** @@ -136,30 +136,30 @@ public function test_core_settings_filters_by_group(): void { } /** - * The `slugs` filter narrows the response to the requested setting names. + * The `settings` filter narrows the response to the requested setting names. * * @ticket 64146 */ - public function test_core_settings_filters_by_slugs(): void { + public function test_core_settings_filters_by_settings(): void { $this->become_admin(); - $result = wp_get_ability( 'core/settings' )->execute( array( 'slugs' => array( 'blogname', 'posts_per_page' ) ) ); + $result = wp_get_ability( 'core/settings' )->execute( array( 'settings' => array( 'blogname', 'posts_per_page' ) ) ); $this->assertSame( array( 'blogname', 'posts_per_page' ), array_keys( $result ) ); } /** - * Supplying both `group` and `slugs` violates the `oneOf` and is rejected. + * Supplying both `group` and `settings` violates the `oneOf` and is rejected. * * @ticket 64146 */ - public function test_core_settings_rejects_group_and_slugs_together(): void { + public function test_core_settings_rejects_group_and_settings_together(): void { $this->become_admin(); $result = wp_get_ability( 'core/settings' )->execute( array( - 'group' => 'reading', - 'slugs' => array( 'blogname' ), + 'group' => 'reading', + 'settings' => array( 'blogname' ), ) ); From 46e9d18b5ec477b09d6372abdbe13694bdda83b0 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 15 Jun 2026 20:37:03 +0100 Subject: [PATCH 3/4] Abilities API: cover a custom registered setting in the core/settings tests Register a setting with show_in_abilities and assert it is exposed in the ability's input enum, output schema, and execute output. --- .../wpRegisterCoreSettingsAbility.php | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsAbility.php index e7a0ad11f08f8..85619c9a1c331 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsAbility.php @@ -25,6 +25,20 @@ public static function set_up_before_class(): void { register_initial_settings(); + // A non-core setting flagged for the Abilities API, to verify that any registered + // setting (not just the core ones) is exposed by the ability. + register_setting( + 'general', + 'core_settings_ability_test_option', + array( + 'type' => 'integer', + 'label' => 'Custom Ability Setting', + 'description' => 'A custom setting exposed through the Abilities API.', + 'show_in_abilities' => true, + 'default' => 42, + ) + ); + // Temporarily remove the unhook functions so we can register core abilities. remove_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); remove_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); @@ -51,6 +65,8 @@ public static function tear_down_after_class(): void { wp_unregister_ability_category( $ability_category->get_slug() ); } + unregister_setting( 'general', 'core_settings_ability_test_option' ); + parent::tear_down_after_class(); } @@ -180,4 +196,26 @@ public function test_core_settings_requires_manage_options(): void { $this->assertWPError( $result ); $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); } + + /** + * A setting registered with `show_in_abilities` (for example by a plugin) is exposed by the ability. + * + * @ticket 64146 + */ + public function test_core_settings_exposes_a_custom_registered_setting(): void { + $ability = wp_get_ability( 'core/settings' ); + + // Present in both the input `settings` enum and the output schema built at registration. + $settings_branch = $ability->get_input_schema()['oneOf'][2]; + $this->assertContains( 'core_settings_ability_test_option', $settings_branch['properties']['settings']['items']['enum'] ); + $this->assertArrayHasKey( 'core_settings_ability_test_option', $ability->get_output_schema()['properties'] ); + + // And returned, correctly typed, by execute. + $this->become_admin(); + update_option( 'core_settings_ability_test_option', 7 ); + + $result = $ability->execute( array( 'settings' => array( 'core_settings_ability_test_option' ) ) ); + + $this->assertSame( array( 'core_settings_ability_test_option' => 7 ), $result ); + } } From 5b8d223e8cf43b59e4e54a105e182ee567e01cce Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 17 Jun 2026 14:04:44 +0100 Subject: [PATCH 4/4] Abilities API: refine the core/settings ability per review. - Simplify the input schema: replace the group-XOR-name `oneOf` with optional, combinable `group` and `fields` filters (rename `slugs` -> `fields`, matching core/get-site-info). Default to an empty object so the type:object schema default serializes as {}. - Memoize the exposed settings so the input/output schema and execute() derive from a single walk of get_registered_settings(). - Cast object-typed values to objects so they serialize as {} (not []) and satisfy the output schema validated by execute(). - Harden value handling against loosely-typed registration data. - Tests: assert keys order-insensitively and cover combined group+fields filtering. --- .../abilities/class-wp-settings-abilities.php | 120 +++++++++--------- .../wpRegisterCoreSettingsAbility.php | 46 +++---- 2 files changed, 84 insertions(+), 82 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index f4fac8901c41c..26042ad49a53b 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -34,6 +34,17 @@ class WP_Settings_Abilities { */ const CATEGORY = 'site'; + /** + * Settings exposed through the Abilities API, computed once at registration. + * + * Cached so the input/output schema and the executed result derive from the exact same + * structure, and {@see get_registered_settings()} is only walked once per request. + * + * @since 7.1.0 + * @var array}>|null + */ + private static $exposed_settings = null; + /** * Registers all settings abilities. * @@ -58,21 +69,28 @@ public static function register(): void { * @since 7.1.0 */ public static function register_get_settings(): void { - $settings = self::get_exposed_settings(); - $groups = array_values( array_unique( array_filter( wp_list_pluck( $settings, 'group' ) ) ) ); - $setting_names = array_keys( $settings ); - $properties = array(); + // Compute once; execute_get_settings() reuses this exact structure. + self::$exposed_settings = self::get_exposed_settings(); + + $settings = self::$exposed_settings; + $field_names = array_keys( $settings ); + $groups = array(); + $properties = array(); foreach ( $settings as $exposed_name => $setting ) { $properties[ $exposed_name ] = $setting['schema']; + if ( '' === $setting['group'] || in_array( $setting['group'], $groups, true ) ) { + continue; + } + $groups[] = $setting['group']; } wp_register_ability( 'core/settings', array( 'label' => __( 'Get Settings' ), - 'description' => __( 'Returns WordPress settings as a flat map of setting name to value. By default returns all settings exposed to abilities, or optionally a subset filtered by settings group or by setting name.' ), + 'description' => __( 'Returns WordPress settings as a flat map of setting name to value. By default returns all settings exposed to abilities, or optionally a subset filtered by settings group, by setting name, or both.' ), 'category' => self::CATEGORY, - 'input_schema' => self::get_settings_input_schema( $groups, $setting_names ), + 'input_schema' => self::get_settings_input_schema( $groups, $field_names ), 'output_schema' => array( 'type' => 'object', 'description' => __( 'A map of setting name to its current value.' ), @@ -104,20 +122,20 @@ public static function register_get_settings(): void { public static function execute_get_settings( $input = array() ): array { $input = is_array( $input ) ? $input : array(); - $settings = self::get_exposed_settings(); - $group = isset( $input['group'] ) ? (string) $input['group'] : ''; - $names = isset( $input['settings'] ) && is_array( $input['settings'] ) ? $input['settings'] : array(); + $settings = self::$exposed_settings ?? self::get_exposed_settings(); + $group = isset( $input['group'] ) && is_string( $input['group'] ) ? $input['group'] : ''; + $fields = isset( $input['fields'] ) && is_array( $input['fields'] ) ? $input['fields'] : array(); $result = array(); foreach ( $settings as $exposed_name => $setting ) { if ( '' !== $group && $setting['group'] !== $group ) { continue; } - if ( ! empty( $names ) && ! in_array( $exposed_name, $names, true ) ) { + if ( ! empty( $fields ) && ! in_array( $exposed_name, $fields, true ) ) { continue; } - $type = isset( $setting['schema']['type'] ) ? (string) $setting['schema']['type'] : 'string'; + $type = isset( $setting['schema']['type'] ) && is_string( $setting['schema']['type'] ) ? $setting['schema']['type'] : 'string'; $value = get_option( $setting['option'], $setting['default'] ); $result[ $exposed_name ] = self::cast_value( $value, $type ); @@ -138,55 +156,38 @@ public static function has_permission(): bool { } /** - * Builds the input schema for the get ability: filter by group XOR by name. + * Builds the input schema for the get ability: optional filters by group and/or name. + * + * Both `group` and `fields` are optional; supplying both narrows the response to their + * intersection, and supplying neither returns every exposed setting. * * @since 7.1.0 * - * @param string[] $groups Available settings groups. - * @param string[] $setting_names Available exposed setting names. + * @param string[] $groups Available settings groups. + * @param string[] $field_names Available exposed setting names. * @return array The input JSON Schema. */ - protected static function get_settings_input_schema( array $groups, array $setting_names ): array { + protected static function get_settings_input_schema( array $groups, array $field_names ): array { return array( - 'type' => 'object', - 'default' => array(), - // Filter by group OR by name, but not both at once. - 'oneOf' => array( - array( - 'title' => __( 'All settings' ), - 'type' => 'object', - 'additionalProperties' => false, - ), - array( - 'title' => __( 'Filter by group' ), - 'type' => 'object', - 'required' => array( 'group' ), - 'properties' => array( - 'group' => array( - 'type' => 'string', - 'enum' => $groups, - 'description' => __( 'Return only settings that belong to this settings group.' ), - ), - ), - 'additionalProperties' => false, + 'type' => 'object', + // Object (not array()) so the serialized schema default is {}, consistent with type:object. + 'default' => (object) array(), + 'properties' => array( + 'group' => array( + 'type' => 'string', + 'enum' => $groups, + 'description' => __( 'Return only settings that belong to this settings group.' ), ), - array( - 'title' => __( 'Filter by name' ), - 'type' => 'object', - 'required' => array( 'settings' ), - 'properties' => array( - 'settings' => array( - 'type' => 'array', - 'items' => array( - 'type' => 'string', - 'enum' => $setting_names, - ), - 'description' => __( 'Return only the settings with these names.' ), - ), + 'fields' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + 'enum' => $field_names, ), - 'additionalProperties' => false, + 'description' => __( 'Return only the settings with these names.' ), ), ), + 'additionalProperties' => false, ); } @@ -212,11 +213,11 @@ protected static function get_exposed_settings(): array { } $option_name = (string) $option_name; - $exposed_name = is_array( $show ) && ! empty( $show['name'] ) ? (string) $show['name'] : $option_name; + $exposed_name = is_array( $show ) && isset( $show['name'] ) && is_string( $show['name'] ) && '' !== $show['name'] ? $show['name'] : $option_name; $settings[ $exposed_name ] = array( 'option' => $option_name, - 'group' => isset( $args['group'] ) ? (string) $args['group'] : '', + 'group' => isset( $args['group'] ) && is_string( $args['group'] ) ? $args['group'] : '', 'default' => array_key_exists( 'default', $args ) ? $args['default'] : false, 'schema' => self::value_schema( $args, $show ), ); @@ -236,7 +237,7 @@ protected static function get_exposed_settings(): array { */ protected static function value_schema( array $args, $show ): array { $schema = array( - 'type' => isset( $args['type'] ) ? (string) $args['type'] : 'string', + 'type' => isset( $args['type'] ) && is_string( $args['type'] ) ? $args['type'] : 'string', ); if ( ! empty( $args['label'] ) ) { $schema['title'] = $args['label']; @@ -245,7 +246,9 @@ protected static function value_schema( array $args, $show ): array { $schema['description'] = $args['description']; } if ( is_array( $show ) && isset( $show['schema'] ) && is_array( $show['schema'] ) ) { - $schema = array_merge( $schema, $show['schema'] ); + /** @var array $show_schema */ + $show_schema = $show['schema']; + $schema = array_merge( $schema, $show_schema ); } return $schema; @@ -265,12 +268,15 @@ protected static function cast_value( $value, string $type ) { case 'boolean': return (bool) $value; case 'integer': - return (int) $value; + return is_scalar( $value ) ? (int) $value : 0; case 'number': - return (float) $value; + return is_scalar( $value ) ? (float) $value : 0.0; case 'array': - case 'object': return is_array( $value ) ? $value : array(); + case 'object': + // Cast to object so an empty/non-array value serializes as {} (not []) and + // satisfies the `object` output schema validated by execute(). + return (object) ( is_array( $value ) ? $value : array() ); default: return is_scalar( $value ) ? (string) $value : $value; } diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsAbility.php index 85619c9a1c331..79dd7e909a07f 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsAbility.php @@ -95,26 +95,22 @@ public function test_core_settings_ability_is_registered(): void { } /** - * The input schema exposes mutually exclusive `group` and `settings` filters. + * The input schema exposes optional `group` and `fields` filters. * * @ticket 64146 */ - public function test_core_settings_input_schema_is_one_of_group_or_settings(): void { + public function test_core_settings_input_schema_exposes_group_and_fields_filters(): void { $schema = wp_get_ability( 'core/settings' )->get_input_schema(); $this->assertSame( 'object', $schema['type'] ); $this->assertArrayHasKey( 'default', $schema ); - $this->assertCount( 3, $schema['oneOf'] ); + $this->assertArrayNotHasKey( 'oneOf', $schema ); - $group_branch = $schema['oneOf'][1]; - $this->assertSame( array( 'group' ), $group_branch['required'] ); - $this->assertContains( 'general', $group_branch['properties']['group']['enum'] ); - $this->assertContains( 'reading', $group_branch['properties']['group']['enum'] ); + $this->assertContains( 'general', $schema['properties']['group']['enum'] ); + $this->assertContains( 'reading', $schema['properties']['group']['enum'] ); - $settings_branch = $schema['oneOf'][2]; - $this->assertSame( array( 'settings' ), $settings_branch['required'] ); - $this->assertContains( 'blogname', $settings_branch['properties']['settings']['items']['enum'] ); - $this->assertContains( 'posts_per_page', $settings_branch['properties']['settings']['items']['enum'] ); + $this->assertContains( 'blogname', $schema['properties']['fields']['items']['enum'] ); + $this->assertContains( 'posts_per_page', $schema['properties']['fields']['items']['enum'] ); } /** @@ -152,35 +148,36 @@ public function test_core_settings_filters_by_group(): void { } /** - * The `settings` filter narrows the response to the requested setting names. + * The `fields` filter narrows the response to the requested setting names. * * @ticket 64146 */ - public function test_core_settings_filters_by_settings(): void { + public function test_core_settings_filters_by_fields(): void { $this->become_admin(); - $result = wp_get_ability( 'core/settings' )->execute( array( 'settings' => array( 'blogname', 'posts_per_page' ) ) ); + $result = wp_get_ability( 'core/settings' )->execute( array( 'fields' => array( 'blogname', 'posts_per_page' ) ) ); - $this->assertSame( array( 'blogname', 'posts_per_page' ), array_keys( $result ) ); + $this->assertEqualSets( array( 'blogname', 'posts_per_page' ), array_keys( $result ) ); } /** - * Supplying both `group` and `settings` violates the `oneOf` and is rejected. + * Supplying both `group` and `fields` narrows the response to their intersection. * * @ticket 64146 */ - public function test_core_settings_rejects_group_and_settings_together(): void { + public function test_core_settings_combines_group_and_fields_filters(): void { $this->become_admin(); + // `blogname` is in the `general` group and `posts_per_page` in `reading`; only the + // latter satisfies both filters. $result = wp_get_ability( 'core/settings' )->execute( array( - 'group' => 'reading', - 'settings' => array( 'blogname' ), + 'group' => 'reading', + 'fields' => array( 'blogname', 'posts_per_page' ), ) ); - $this->assertWPError( $result ); - $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); + $this->assertEqualSets( array( 'posts_per_page' ), array_keys( $result ) ); } /** @@ -205,16 +202,15 @@ public function test_core_settings_requires_manage_options(): void { public function test_core_settings_exposes_a_custom_registered_setting(): void { $ability = wp_get_ability( 'core/settings' ); - // Present in both the input `settings` enum and the output schema built at registration. - $settings_branch = $ability->get_input_schema()['oneOf'][2]; - $this->assertContains( 'core_settings_ability_test_option', $settings_branch['properties']['settings']['items']['enum'] ); + // Present in both the input `fields` enum and the output schema built at registration. + $this->assertContains( 'core_settings_ability_test_option', $ability->get_input_schema()['properties']['fields']['items']['enum'] ); $this->assertArrayHasKey( 'core_settings_ability_test_option', $ability->get_output_schema()['properties'] ); // And returned, correctly typed, by execute. $this->become_admin(); update_option( 'core_settings_ability_test_option', 7 ); - $result = $ability->execute( array( 'settings' => array( 'core_settings_ability_test_option' ) ) ); + $result = $ability->execute( array( 'fields' => array( 'core_settings_ability_test_option' ) ) ); $this->assertSame( array( 'core_settings_ability_test_option' => 7 ), $result ); }