From d20eb30997f116055ff30f875895134c49241d24 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 9 Jun 2026 17:55:24 +0100 Subject: [PATCH 01/39] 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 | 164 +++++++---- .../wpRegisterCoreSettingsAbility.php | 183 ++++++++++++ 4 files changed, 567 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 1386c0deb7741..9e9e7afcbfea1 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. * @@ -360,4 +362,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 d5c179c645af3..da5e4df91d3d3 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -2746,12 +2746,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.' ), ) ); @@ -2759,12 +2760,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.' ), ) ); @@ -2773,14 +2775,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.' ), ) ); } @@ -2790,14 +2793,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.' ), ) ); } @@ -2806,11 +2814,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.' ), ) ); @@ -2818,9 +2827,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.' ), ) ); @@ -2828,9 +2838,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.' ), ) ); @@ -2838,9 +2849,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.' ), ) ); @@ -2848,12 +2860,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', ) ); @@ -2861,10 +2874,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, ) ); @@ -2872,9 +2886,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.' ), ) ); @@ -2882,9 +2897,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.' ), ) ); @@ -2892,11 +2908,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, ) ); @@ -2904,10 +2921,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' ), ) ); @@ -2915,10 +2933,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' ), ) ); @@ -2926,9 +2945,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' ), ) ); @@ -2948,13 +2968,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.' ), ) ); @@ -2962,14 +2987,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.' ), ) ); } @@ -3005,6 +3035,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()`. * } */ @@ -3226,6 +3260,10 @@ 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()`. Only present when the * setting was registered with a default. * } 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 2175d52b62db028ccf0ba512c1a6724e49766d57 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 15 Jun 2026 20:31:12 +0100 Subject: [PATCH 02/39] 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 b184ea3981744ca2ea92ac8fcd8eb4b8a21a7486 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 15 Jun 2026 20:37:03 +0100 Subject: [PATCH 03/39] 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 c22aecae5a82d80fc4480ac88e5deab25960f6be Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 17 Jun 2026 14:04:44 +0100 Subject: [PATCH 04/39] 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 ); } From 827ccbe92064185780cea60add6b1f8165363182 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Fri, 19 Jun 2026 17:26:06 +0100 Subject: [PATCH 05/39] Abilities API: use list for the core/settings input schema docblock The $groups and $field_names params are sequential string lists (array_keys() and an appended array), so list is the precise type rather than string[]. --- src/wp-includes/abilities/class-wp-settings-abilities.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index 26042ad49a53b..7c818e86f483e 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -163,8 +163,8 @@ public static function has_permission(): bool { * * @since 7.1.0 * - * @param string[] $groups Available settings groups. - * @param string[] $field_names Available exposed setting names. + * @param list $groups Available settings groups. + * @param list $field_names Available exposed setting names. * @return array The input JSON Schema. */ protected static function get_settings_input_schema( array $groups, array $field_names ): array { From 18333658c0d6c79b815e2d091e907eb31060e81f Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Fri, 19 Jun 2026 17:26:30 +0100 Subject: [PATCH 06/39] Abilities API: drop the unreachable recompute branch in execute_get_settings() The exposed-settings cache is always populated in register_get_settings() before the ability is registered, so the recompute fallback was dead code. Read the cache directly and keep a defensive null guard so the unexpected case is explicit rather than silently recomputing. --- .../abilities/class-wp-settings-abilities.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index 7c818e86f483e..acb77a34c4ee4 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -122,9 +122,15 @@ public static function register_get_settings(): void { public static function execute_get_settings( $input = array() ): array { $input = is_array( $input ) ? $input : 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(); + $settings = self::$exposed_settings; + if ( null === $settings ) { + // The cache is populated in register_get_settings() before the ability is + // registered, so this is unreachable in practice; bail defensively otherwise. + return array(); + } + + $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 ) { From d94697b57f094cfb18f0eb8f31e01689fb3da6da Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Fri, 19 Jun 2026 17:27:27 +0100 Subject: [PATCH 07/39] Abilities API: document the show_in_abilities registration-timing contract The core/settings ability snapshots the exposed settings when it registers on wp_abilities_api_init, so a setting must be registered before that hook fires to be exposed. Note this on the show_in_abilities register_setting() argument so integrators know their register_setting() has to run earlier. --- src/wp-includes/option.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index da5e4df91d3d3..9512038be21fa 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -3038,7 +3038,10 @@ function register_initial_settings() { * @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. + * 'schema' keys, mirroring the `show_in_rest` shape. The set of exposed + * settings is captured when the `core/settings` ability registers on the + * `wp_abilities_api_init` hook, so a setting must be registered before + * that hook fires to be exposed. * @type mixed $default Default value when calling `get_option()`. * } */ From 2cc70e7715f50ce3779baef7b0cea0fb9c3cf70e Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 22 Jun 2026 10:35:28 +0100 Subject: [PATCH 08/39] Abilities API: scope the WP_Settings_Abilities class. Make WP_Settings_Abilities final and tighten member visibility so the intended surface is enforced rather than only documented via @access private. Only the externally-invoked entry points remain public (register(), execute_get_settings(), has_permission()); register_get_settings() and the shared helpers become private, and CATEGORY becomes a private const. Method bodies and the static cache are unchanged. --- .../abilities/class-wp-settings-abilities.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index acb77a34c4ee4..012c22064d426 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -24,7 +24,7 @@ * * @access private */ -class WP_Settings_Abilities { +final class WP_Settings_Abilities { /** * The ability category used for settings abilities. @@ -32,7 +32,7 @@ class WP_Settings_Abilities { * @since 7.1.0 * @var string */ - const CATEGORY = 'site'; + private const CATEGORY = 'site'; /** * Settings exposed through the Abilities API, computed once at registration. @@ -68,7 +68,7 @@ public static function register(): void { * * @since 7.1.0 */ - public static function register_get_settings(): void { + private static function register_get_settings(): void { // Compute once; execute_get_settings() reuses this exact structure. self::$exposed_settings = self::get_exposed_settings(); @@ -173,7 +173,7 @@ public static function has_permission(): bool { * @param list $field_names Available exposed setting names. * @return array The input JSON Schema. */ - protected static function get_settings_input_schema( array $groups, array $field_names ): array { + private static function get_settings_input_schema( array $groups, array $field_names ): array { return array( 'type' => 'object', // Object (not array()) so the serialized schema default is {}, consistent with type:object. @@ -209,7 +209,7 @@ protected static function get_settings_input_schema( array $groups, array $field * * @return array}> Settings keyed by exposed name. */ - protected static function get_exposed_settings(): array { + private static function get_exposed_settings(): array { $settings = array(); foreach ( get_registered_settings() as $option_name => $args ) { @@ -241,7 +241,7 @@ protected static function get_exposed_settings(): array { * @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 { + private static function value_schema( array $args, $show ): array { $schema = array( 'type' => isset( $args['type'] ) && is_string( $args['type'] ) ? $args['type'] : 'string', ); @@ -269,7 +269,7 @@ protected static function value_schema( array $args, $show ): array { * @param string $type The registered setting type. * @return mixed The value cast to the declared type. */ - protected static function cast_value( $value, string $type ) { + private static function cast_value( $value, string $type ) { switch ( $type ) { case 'boolean': return (bool) $value; From 43b5897a2df6fdde06d0a58bae53678baa72f43b Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 22 Jun 2026 15:12:37 +0100 Subject: [PATCH 09/39] Abilities API: make WP_Settings_Abilities instance-based. Move register() and the ability callbacks (execute_get_settings(), has_permission()) from public static to public methods, and the internal helpers to private methods; the exposed-settings cache becomes an instance property. wp_register_core_abilities() now registers the ability via ( new WP_Settings_Abilities() )->register(), reducing the static surface of this @access private class. --- src/wp-includes/abilities.php | 2 +- .../abilities/class-wp-settings-abilities.php | 38 +++++++++---------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index 9e9e7afcbfea1..edb608190d089 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -364,5 +364,5 @@ function wp_register_core_abilities(): void { ); // Register the settings abilities (currently the read-only `core/settings`). - WP_Settings_Abilities::register(); + ( new 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 index 012c22064d426..f2826e54baf4e 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -43,7 +43,7 @@ final class WP_Settings_Abilities { * @since 7.1.0 * @var array}>|null */ - private static $exposed_settings = null; + private $exposed_settings = null; /** * Registers all settings abilities. @@ -52,14 +52,14 @@ final class WP_Settings_Abilities { * * @since 7.1.0 */ - public static function register(): void { - self::register_get_settings(); + public function register(): void { + $this->register_get_settings(); /* * A future write-oriented ability can be registered here, reusing the shared * helpers below (get_exposed_settings(), value_schema(), cast_value()): * - * self::register_manage_settings(); + * $this->register_manage_settings(); */ } @@ -68,11 +68,11 @@ public static function register(): void { * * @since 7.1.0 */ - private static function register_get_settings(): void { + private function register_get_settings(): void { // Compute once; execute_get_settings() reuses this exact structure. - self::$exposed_settings = self::get_exposed_settings(); + $this->exposed_settings = $this->get_exposed_settings(); - $settings = self::$exposed_settings; + $settings = $this->exposed_settings; $field_names = array_keys( $settings ); $groups = array(); $properties = array(); @@ -90,15 +90,15 @@ private 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, by setting name, or both.' ), 'category' => self::CATEGORY, - 'input_schema' => self::get_settings_input_schema( $groups, $field_names ), + 'input_schema' => $this->get_settings_input_schema( $groups, $field_names ), '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' ), + 'execute_callback' => array( $this, 'execute_get_settings' ), + 'permission_callback' => array( $this, 'has_permission' ), 'meta' => array( 'annotations' => array( 'readonly' => true, @@ -119,10 +119,10 @@ private static function register_get_settings(): void { * @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 { + public function execute_get_settings( $input = array() ): array { $input = is_array( $input ) ? $input : array(); - $settings = self::$exposed_settings; + $settings = $this->exposed_settings; if ( null === $settings ) { // The cache is populated in register_get_settings() before the ability is // registered, so this is unreachable in practice; bail defensively otherwise. @@ -144,7 +144,7 @@ public static function execute_get_settings( $input = array() ): array { $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 ); + $result[ $exposed_name ] = $this->cast_value( $value, $type ); } return $result; @@ -157,7 +157,7 @@ public static function execute_get_settings( $input = array() ): array { * * @return bool True if the current user can manage options. */ - public static function has_permission(): bool { + public function has_permission(): bool { return current_user_can( 'manage_options' ); } @@ -173,7 +173,7 @@ public static function has_permission(): bool { * @param list $field_names Available exposed setting names. * @return array The input JSON Schema. */ - private static function get_settings_input_schema( array $groups, array $field_names ): array { + private function get_settings_input_schema( array $groups, array $field_names ): array { return array( 'type' => 'object', // Object (not array()) so the serialized schema default is {}, consistent with type:object. @@ -209,7 +209,7 @@ private static function get_settings_input_schema( array $groups, array $field_n * * @return array}> Settings keyed by exposed name. */ - private static function get_exposed_settings(): array { + private function get_exposed_settings(): array { $settings = array(); foreach ( get_registered_settings() as $option_name => $args ) { @@ -225,7 +225,7 @@ private static function get_exposed_settings(): array { 'option' => $option_name, '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 ), + 'schema' => $this->value_schema( $args, $show ), ); } @@ -241,7 +241,7 @@ private static function get_exposed_settings(): array { * @param bool|array $show The setting's `show_in_abilities` value. * @return array The value JSON Schema. */ - private static function value_schema( array $args, $show ): array { + private function value_schema( array $args, $show ): array { $schema = array( 'type' => isset( $args['type'] ) && is_string( $args['type'] ) ? $args['type'] : 'string', ); @@ -269,7 +269,7 @@ private static function value_schema( array $args, $show ): array { * @param string $type The registered setting type. * @return mixed The value cast to the declared type. */ - private static function cast_value( $value, string $type ) { + private function cast_value( $value, string $type ) { switch ( $type ) { case 'boolean': return (bool) $value; From 9ba57eab58d9bf51b7880e85857b1cc251c8b9bf Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 2 Jul 2026 18:08:50 +0100 Subject: [PATCH 10/39] Update tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsAbility.php MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Greg Ziółkowski --- .../tests/abilities-api/wpRegisterCoreSettingsAbility.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsAbility.php index 79dd7e909a07f..742715844eb78 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsAbility.php @@ -80,7 +80,7 @@ private function become_admin(): void { /** * The ability is registered in the `site` category and flagged read-only. * - * @ticket 64146 + * @ticket 64605 */ public function test_core_settings_ability_is_registered(): void { $ability = wp_get_ability( 'core/settings' ); From 513f5c3bdf01fcaac3b99604774a5b96d597f4a3 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 2 Jul 2026 18:51:11 +0100 Subject: [PATCH 11/39] Abilities API: rename the core/settings ability to core/read-settings. Syncs the core PR with the WordPress/ai plugin, where the ability was renamed in https://github.com/WordPress/ai/pull/806. Renames the ability, its label (Get Settings -> Read Settings), the docblock references in option.php and abilities.php, and the test file, and adds the plugin's new assertion on the registered ability name. --- src/wp-includes/abilities.php | 2 +- .../abilities/class-wp-settings-abilities.php | 10 ++-- src/wp-includes/option.php | 16 +++--- ... => wpRegisterCoreReadSettingsAbility.php} | 51 ++++++++++--------- 4 files changed, 40 insertions(+), 39 deletions(-) rename tests/phpunit/tests/abilities-api/{wpRegisterCoreSettingsAbility.php => wpRegisterCoreReadSettingsAbility.php} (72%) diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index edb608190d089..fe5c208a773cd 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -363,6 +363,6 @@ function wp_register_core_abilities(): void { ) ); - // Register the settings abilities (currently the read-only `core/settings`). + // Register the settings abilities (currently the read-only `core/read-settings`). ( new 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 index f2826e54baf4e..580cdd34edd33 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -12,7 +12,7 @@ /** * Core class used to register settings-related abilities. * - * Provides the read-only `core/settings` ability and the shared building blocks + * Provides the read-only `core/read-settings` ability and the shared building blocks * (exposed-settings discovery, schema generation, value casting) that are intended to * also back a future write-oriented `core/manage-settings` ability. * @@ -64,7 +64,7 @@ public function register(): void { } /** - * Registers the read-only `core/settings` ability. + * Registers the read-only `core/read-settings` ability. * * @since 7.1.0 */ @@ -85,9 +85,9 @@ private function register_get_settings(): void { } wp_register_ability( - 'core/settings', + 'core/read-settings', array( - 'label' => __( 'Get Settings' ), + 'label' => __( 'Read 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, by setting name, or both.' ), 'category' => self::CATEGORY, 'input_schema' => $this->get_settings_input_schema( $groups, $field_names ), @@ -112,7 +112,7 @@ private function register_get_settings(): void { } /** - * Executes the `core/settings` ability. + * Executes the `core/read-settings` ability. * * @since 7.1.0 * diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index 9512038be21fa..1a94baad29233 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -3036,12 +3036,12 @@ function register_initial_settings() { * 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. The set of exposed - * settings is captured when the `core/settings` ability registers on the - * `wp_abilities_api_init` hook, so a setting must be registered before - * that hook fires to be exposed. + * (e.g. the `core/read-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. The set of + * exposed settings is captured when the `core/read-settings` ability + * registers on the `wp_abilities_api_init` hook, so a setting must be + * registered before that hook fires to be exposed. * @type mixed $default Default value when calling `get_option()`. * } */ @@ -3264,8 +3264,8 @@ function unregister_setting( $option_group, $option_name, $deprecated = '' ) { * 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` + * (e.g. the `core/read-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()`. Only present when the * setting was registered with a default. diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreReadSettingsAbility.php similarity index 72% rename from tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsAbility.php rename to tests/phpunit/tests/abilities-api/wpRegisterCoreReadSettingsAbility.php index 742715844eb78..f440d863f42a5 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreReadSettingsAbility.php @@ -3,14 +3,14 @@ declare( strict_types=1 ); /** - * Tests for the core/settings ability shipped with the Abilities API. + * Tests for the core/read-settings ability shipped with the Abilities API. * * @covers wp_register_core_abilities * @covers WP_Settings_Abilities * * @group abilities-api */ -class Tests_Abilities_API_WpRegisterCoreSettingsAbility extends WP_UnitTestCase { +class Tests_Abilities_API_WpRegisterCoreReadSettingsAbility extends WP_UnitTestCase { /** * Set up before the class. @@ -29,7 +29,7 @@ public static function set_up_before_class(): void { // setting (not just the core ones) is exposed by the ability. register_setting( 'general', - 'core_settings_ability_test_option', + 'core_read_settings_ability_test_option', array( 'type' => 'integer', 'label' => 'Custom Ability Setting', @@ -65,7 +65,7 @@ public static function tear_down_after_class(): void { wp_unregister_ability_category( $ability_category->get_slug() ); } - unregister_setting( 'general', 'core_settings_ability_test_option' ); + unregister_setting( 'general', 'core_read_settings_ability_test_option' ); parent::tear_down_after_class(); } @@ -82,10 +82,11 @@ private function become_admin(): void { * * @ticket 64605 */ - public function test_core_settings_ability_is_registered(): void { - $ability = wp_get_ability( 'core/settings' ); + public function test_core_read_settings_ability_is_registered(): void { + $ability = wp_get_ability( 'core/read-settings' ); $this->assertInstanceOf( WP_Ability::class, $ability ); + $this->assertSame( 'core/read-settings', $ability->get_name() ); $this->assertSame( 'site', $ability->get_category() ); $this->assertTrue( $ability->get_meta_item( 'show_in_rest', false ) ); @@ -99,8 +100,8 @@ public function test_core_settings_ability_is_registered(): void { * * @ticket 64146 */ - public function test_core_settings_input_schema_exposes_group_and_fields_filters(): void { - $schema = wp_get_ability( 'core/settings' )->get_input_schema(); + public function test_core_read_settings_input_schema_exposes_group_and_fields_filters(): void { + $schema = wp_get_ability( 'core/read-settings' )->get_input_schema(); $this->assertSame( 'object', $schema['type'] ); $this->assertArrayHasKey( 'default', $schema ); @@ -118,14 +119,14 @@ public function test_core_settings_input_schema_exposes_group_and_fields_filters * * @ticket 64146 */ - public function test_core_settings_returns_flat_typed_values(): void { + public function test_core_read_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() ); + $result = wp_get_ability( 'core/read-settings' )->execute( array() ); $this->assertIsArray( $result ); $this->assertSame( 'My Test Site', $result['blogname'] ); @@ -138,10 +139,10 @@ public function test_core_settings_returns_flat_typed_values(): void { * * @ticket 64146 */ - public function test_core_settings_filters_by_group(): void { + public function test_core_read_settings_filters_by_group(): void { $this->become_admin(); - $result = wp_get_ability( 'core/settings' )->execute( array( 'group' => 'reading' ) ); + $result = wp_get_ability( 'core/read-settings' )->execute( array( 'group' => 'reading' ) ); $this->assertArrayHasKey( 'posts_per_page', $result ); $this->assertArrayNotHasKey( 'blogname', $result ); @@ -152,10 +153,10 @@ public function test_core_settings_filters_by_group(): void { * * @ticket 64146 */ - public function test_core_settings_filters_by_fields(): void { + public function test_core_read_settings_filters_by_fields(): void { $this->become_admin(); - $result = wp_get_ability( 'core/settings' )->execute( array( 'fields' => array( 'blogname', 'posts_per_page' ) ) ); + $result = wp_get_ability( 'core/read-settings' )->execute( array( 'fields' => array( 'blogname', 'posts_per_page' ) ) ); $this->assertEqualSets( array( 'blogname', 'posts_per_page' ), array_keys( $result ) ); } @@ -165,12 +166,12 @@ public function test_core_settings_filters_by_fields(): void { * * @ticket 64146 */ - public function test_core_settings_combines_group_and_fields_filters(): void { + public function test_core_read_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( + $result = wp_get_ability( 'core/read-settings' )->execute( array( 'group' => 'reading', 'fields' => array( 'blogname', 'posts_per_page' ), @@ -185,10 +186,10 @@ public function test_core_settings_combines_group_and_fields_filters(): void { * * @ticket 64146 */ - public function test_core_settings_requires_manage_options(): void { + public function test_core_read_settings_requires_manage_options(): void { wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); - $result = wp_get_ability( 'core/settings' )->execute( array() ); + $result = wp_get_ability( 'core/read-settings' )->execute( array() ); $this->assertWPError( $result ); $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); @@ -199,19 +200,19 @@ public function test_core_settings_requires_manage_options(): void { * * @ticket 64146 */ - public function test_core_settings_exposes_a_custom_registered_setting(): void { - $ability = wp_get_ability( 'core/settings' ); + public function test_core_read_settings_exposes_a_custom_registered_setting(): void { + $ability = wp_get_ability( 'core/read-settings' ); // 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'] ); + $this->assertContains( 'core_read_settings_ability_test_option', $ability->get_input_schema()['properties']['fields']['items']['enum'] ); + $this->assertArrayHasKey( 'core_read_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 ); + update_option( 'core_read_settings_ability_test_option', 7 ); - $result = $ability->execute( array( 'fields' => array( 'core_settings_ability_test_option' ) ) ); + $result = $ability->execute( array( 'fields' => array( 'core_read_settings_ability_test_option' ) ) ); - $this->assertSame( array( 'core_settings_ability_test_option' => 7 ), $result ); + $this->assertSame( array( 'core_read_settings_ability_test_option' => 7 ), $result ); } } From ae0f458d0c6cd2db0051b15e3c1e8947de23517d Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 2 Jul 2026 19:24:49 +0100 Subject: [PATCH 12/39] Abilities API: document why the settings abilities use a dedicated class. Per review, spell out in the class docblock why WP_Settings_Abilities departs from the self-contained closures in wp_register_core_abilities(): the exposed-settings snapshot is shared between the schemas and the execute callback, and the helpers are meant to back a future core/manage-settings write ability. Also documents the snapshot timing contract with register_initial_settings() on rest_api_init. --- .../abilities/class-wp-settings-abilities.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index 580cdd34edd33..c7fe8139646dd 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -16,6 +16,17 @@ * (exposed-settings discovery, schema generation, value casting) that are intended to * also back a future write-oriented `core/manage-settings` ability. * + * Unlike the other core abilities, which are self-contained closures registered directly + * in wp_register_core_abilities(), the settings abilities live in a dedicated class + * because they share state: the set of exposed settings is computed once at registration + * and reused by the input schema, the output schema, and the execute callback, and the + * same helpers are meant to be shared with the future write ability. + * + * The exposed settings are captured when the ability registers on `wp_abilities_api_init`, + * so a setting must be registered with `show_in_abilities` before that hook fires to be + * exposed. Core registers its own settings on `rest_api_init`, which always precedes the + * lazy initialization of the abilities registry during REST API requests. + * * This class is part of WordPress' internal implementation of the core abilities and is * not part of the public API. It may be changed or removed at any time without notice. * Do not use it directly or rely on its existence. From fb5cddfc8c24411b4b39baea4df09f5da2035021 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 2 Jul 2026 19:24:49 +0100 Subject: [PATCH 13/39] Abilities API: serialize empty input schema defaults as objects. Aligns the core/get-site-info, core/get-user-info, and core/get-environment-info input schema defaults with core/read-settings: (object) array() instead of array(), so the serialized default is {}, consistent with type:object. --- src/wp-includes/abilities.php | 9 ++++++--- .../tests/abilities-api/wpRegisterCoreAbilities.php | 6 +++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index fe5c208a773cd..96ad1bff38266 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -117,7 +117,8 @@ function wp_register_core_abilities(): void { ), ), 'additionalProperties' => false, - 'default' => array(), + // Object (not array()) so the serialized schema default is {}, consistent with type:object. + 'default' => (object) array(), ), 'output_schema' => array( 'type' => 'object', @@ -234,7 +235,8 @@ function wp_register_core_abilities(): void { ), ), 'additionalProperties' => false, - 'default' => array(), + // Object (not array()) so the serialized schema default is {}, consistent with type:object. + 'default' => (object) array(), ), 'output_schema' => array( 'type' => 'object', @@ -321,7 +323,8 @@ function wp_register_core_abilities(): void { ), ), 'additionalProperties' => false, - 'default' => array(), + // Object (not array()) so the serialized schema default is {}, consistent with type:object. + 'default' => (object) array(), ), 'output_schema' => array( 'type' => 'object', diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php index 85a4e5c1e82e4..de48eb247b985 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php @@ -82,7 +82,7 @@ public function test_core_get_site_info_ability_is_registered(): void { $this->assertSame( 'object', $input_schema['type'] ); $this->assertArrayHasKey( 'default', $input_schema ); - $this->assertSame( array(), $input_schema['default'] ); + $this->assertEquals( (object) array(), $input_schema['default'] ); $this->assertArrayHasKey( 'fields', $input_schema['properties'] ); $this->assertSame( 'array', $input_schema['properties']['fields']['type'] ); @@ -225,7 +225,7 @@ public function test_core_get_user_info_ability_is_registered(): void { $this->assertSame( 'object', $input_schema['type'] ); $this->assertArrayHasKey( 'default', $input_schema ); - $this->assertSame( array(), $input_schema['default'] ); + $this->assertEquals( (object) array(), $input_schema['default'] ); $this->assertArrayHasKey( 'fields', $input_schema['properties'] ); $this->assertSame( 'array', $input_schema['properties']['fields']['type'] ); @@ -331,7 +331,7 @@ public function test_core_get_environment_info_ability_is_registered(): void { $this->assertSame( 'object', $input_schema['type'] ); $this->assertArrayHasKey( 'default', $input_schema ); - $this->assertSame( array(), $input_schema['default'] ); + $this->assertEquals( (object) array(), $input_schema['default'] ); $this->assertArrayHasKey( 'fields', $input_schema['properties'] ); $this->assertSame( 'array', $input_schema['properties']['fields']['type'] ); From ff55b9df3ac61d0354f36eed1c2ba851e8cc3442 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 2 Jul 2026 19:25:33 +0100 Subject: [PATCH 14/39] Abilities API: use ticket 64605 in the core/read-settings tests. Per review, all the tests added by this PR should reference the ticket that introduces the ability. --- .../wpRegisterCoreReadSettingsAbility.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreReadSettingsAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreReadSettingsAbility.php index f440d863f42a5..3f46d5a006104 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreReadSettingsAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreReadSettingsAbility.php @@ -98,7 +98,7 @@ public function test_core_read_settings_ability_is_registered(): void { /** * The input schema exposes optional `group` and `fields` filters. * - * @ticket 64146 + * @ticket 64605 */ public function test_core_read_settings_input_schema_exposes_group_and_fields_filters(): void { $schema = wp_get_ability( 'core/read-settings' )->get_input_schema(); @@ -117,7 +117,7 @@ public function test_core_read_settings_input_schema_exposes_group_and_fields_fi /** * Without input the ability returns a flat map of correctly typed setting values. * - * @ticket 64146 + * @ticket 64605 */ public function test_core_read_settings_returns_flat_typed_values(): void { $this->become_admin(); @@ -137,7 +137,7 @@ public function test_core_read_settings_returns_flat_typed_values(): void { /** * The `group` filter narrows the response to a single settings group. * - * @ticket 64146 + * @ticket 64605 */ public function test_core_read_settings_filters_by_group(): void { $this->become_admin(); @@ -151,7 +151,7 @@ public function test_core_read_settings_filters_by_group(): void { /** * The `fields` filter narrows the response to the requested setting names. * - * @ticket 64146 + * @ticket 64605 */ public function test_core_read_settings_filters_by_fields(): void { $this->become_admin(); @@ -164,7 +164,7 @@ public function test_core_read_settings_filters_by_fields(): void { /** * Supplying both `group` and `fields` narrows the response to their intersection. * - * @ticket 64146 + * @ticket 64605 */ public function test_core_read_settings_combines_group_and_fields_filters(): void { $this->become_admin(); @@ -184,7 +184,7 @@ public function test_core_read_settings_combines_group_and_fields_filters(): voi /** * Users without `manage_options` cannot run the ability. * - * @ticket 64146 + * @ticket 64605 */ public function test_core_read_settings_requires_manage_options(): void { wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); @@ -198,7 +198,7 @@ public function test_core_read_settings_requires_manage_options(): void { /** * A setting registered with `show_in_abilities` (for example by a plugin) is exposed by the ability. * - * @ticket 64146 + * @ticket 64605 */ public function test_core_read_settings_exposes_a_custom_registered_setting(): void { $ability = wp_get_ability( 'core/read-settings' ); From 6dc47fc3726c87f0054bcf8dfaaa4006823fc156 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Fri, 3 Jul 2026 16:58:28 +0100 Subject: [PATCH 15/39] Abilities API: register initial settings before core/read-settings snapshots them. The wp_abilities_api_init hook fires lazily on first use of the abilities registry, which is not ordered relative to rest_api_init (where core registers its initial settings) and can happen without it entirely, e.g. on cron or WP-CLI. When the registry initialized first, core/read-settings captured an empty settings snapshot for the rest of the request. Ensure register_initial_settings() has run before the snapshot is computed, and register the ability under the previously broken ordering in the tests so they cover the regression. --- .../abilities/class-wp-settings-abilities.php | 23 ++++++-- src/wp-includes/option.php | 6 ++- .../wpRegisterCoreReadSettingsAbility.php | 52 +++++++++++++++++-- 3 files changed, 72 insertions(+), 9 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index c7fe8139646dd..102306ccac17a 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -22,10 +22,13 @@ * and reused by the input schema, the output schema, and the execute callback, and the * same helpers are meant to be shared with the future write ability. * - * The exposed settings are captured when the ability registers on `wp_abilities_api_init`, - * so a setting must be registered with `show_in_abilities` before that hook fires to be - * exposed. Core registers its own settings on `rest_api_init`, which always precedes the - * lazy initialization of the abilities registry during REST API requests. + * The exposed settings are captured when the ability registers on `wp_abilities_api_init`. + * That hook fires lazily on first use of the abilities registry, which is not ordered + * relative to `rest_api_init` (where core registers its own settings) and can happen + * without it entirely, e.g. on cron or WP-CLI. register() therefore ensures core's + * initial settings are registered before the snapshot is computed. Plugin settings + * flagged with `show_in_abilities` must be registered before the abilities registry is + * first used in a request; registering them on `init` is reliable. * * This class is part of WordPress' internal implementation of the core abilities and is * not part of the public API. It may be changed or removed at any time without notice. @@ -64,6 +67,18 @@ final class WP_Settings_Abilities { * @since 7.1.0 */ public function register(): void { + /* + * Core's initial settings register on `rest_api_init`, which fires lazily and + * independently of `wp_abilities_api_init`: on cron, WP-CLI, or any request where + * abilities are used before the REST server loads, it may not have fired — or may + * be mid-fire at a priority before register_initial_settings() runs. Ensure the + * core settings exist before the exposed-settings snapshot below is computed; + * re-registering them again later on `rest_api_init` is harmless. + */ + if ( ! did_action( 'rest_api_init' ) || doing_action( 'rest_api_init' ) ) { + register_initial_settings(); + } + $this->register_get_settings(); /* diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index 1a94baad29233..084295bbad363 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -3040,8 +3040,10 @@ function register_initial_settings() { * settings, this argument may optionally be an array with optional 'name' * and 'schema' keys, mirroring the `show_in_rest` shape. The set of * exposed settings is captured when the `core/read-settings` ability - * registers on the `wp_abilities_api_init` hook, so a setting must be - * registered before that hook fires to be exposed. + * registers on the `wp_abilities_api_init` hook, which fires on first use + * of the abilities registry, so a setting must be registered before that — + * registering it on `init` is reliable. Core's initial settings are always + * included. * @type mixed $default Default value when calling `get_option()`. * } */ diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreReadSettingsAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreReadSettingsAbility.php index 3f46d5a006104..0813af4d8b859 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreReadSettingsAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreReadSettingsAbility.php @@ -12,18 +12,38 @@ */ class Tests_Abilities_API_WpRegisterCoreReadSettingsAbility extends WP_UnitTestCase { + /** + * Backup of the `$wp_registered_settings` global, restored after the class. + * + * @var array|null + */ + private static $registered_settings_backup; + + /** + * Number of times `rest_api_init` had fired before the class ran, or null if never. + * + * @var int|null + */ + private static $rest_api_init_count; + /** * Set up before the class. * - * The core settings are registered on `rest_api_init`, so register them up front to - * mirror the request context in which the ability builds its schema and runs. + * The ability is registered under the ordering that used to break it: no settings + * registered yet and `rest_api_init` never fired, as on cron, WP-CLI, or any request + * that uses the Abilities API before the REST server loads. The ability must + * self-register core's initial settings (see WP_Settings_Abilities::register()). * * @since 7.1.0 */ public static function set_up_before_class(): void { parent::set_up_before_class(); - register_initial_settings(); + global $wp_registered_settings, $wp_actions; + self::$registered_settings_backup = $wp_registered_settings; + self::$rest_api_init_count = $wp_actions['rest_api_init'] ?? null; + $wp_registered_settings = array(); + unset( $wp_actions['rest_api_init'] ); // 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. @@ -67,6 +87,12 @@ public static function tear_down_after_class(): void { unregister_setting( 'general', 'core_read_settings_ability_test_option' ); + global $wp_registered_settings, $wp_actions; + $wp_registered_settings = self::$registered_settings_backup; + if ( null !== self::$rest_api_init_count ) { + $wp_actions['rest_api_init'] = self::$rest_api_init_count; + } + parent::tear_down_after_class(); } @@ -77,6 +103,26 @@ private function become_admin(): void { wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); } + /** + * Core settings are exposed even when the abilities registry initializes in a request + * where `rest_api_init` (which registers core's initial settings) has never fired. + * + * The class setup registers the ability with no settings registered up front, so this + * asserts that the ability took care of registering core's initial settings itself. + * + * @ticket 64605 + */ + public function test_core_read_settings_registers_initial_settings_without_rest_api_init(): void { + $ability = wp_get_ability( 'core/read-settings' ); + + $this->assertArrayHasKey( 'blogname', $ability->get_output_schema()['properties'] ); + + $this->become_admin(); + $result = $ability->execute( array( 'fields' => array( 'blogname' ) ) ); + + $this->assertArrayHasKey( 'blogname', $result ); + } + /** * The ability is registered in the `site` category and flagged read-only. * From c70593d0a19668888a8c7fcf252c0ff354401df2 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 7 Jul 2026 17:10:19 +0100 Subject: [PATCH 16/39] Abilities API: validate and sanitize core/read-settings values against their schema. Run each setting value through rest_validate_value_from_schema() and rest_sanitize_value_from_schema() instead of a type-only cast, mirroring WP_REST_Settings_Controller::prepare_value(). A value that does not conform to its setting's schema (for example an enum or format-constrained setting whose stored option is absent or has drifted) is now dropped from the response rather than left to fail output validation for the entire core/read-settings call, so one bad value can no longer make every setting unreadable. Removes the bespoke cast_value() helper. --- .../abilities/class-wp-settings-abilities.php | 46 ++++++------------- 1 file changed, 13 insertions(+), 33 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index 102306ccac17a..10b408fed2dc3 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -13,8 +13,8 @@ * Core class used to register settings-related abilities. * * Provides the read-only `core/read-settings` ability and the shared building blocks - * (exposed-settings discovery, schema generation, value casting) that are intended to - * also back a future write-oriented `core/manage-settings` ability. + * (exposed-settings discovery and schema generation) that are intended to also back a + * future write-oriented `core/manage-settings` ability. * * Unlike the other core abilities, which are self-contained closures registered directly * in wp_register_core_abilities(), the settings abilities live in a dedicated class @@ -83,7 +83,7 @@ public function register(): void { /* * A future write-oriented ability can be registered here, reusing the shared - * helpers below (get_exposed_settings(), value_schema(), cast_value()): + * helpers below (get_exposed_settings() and value_schema()): * * $this->register_manage_settings(); */ @@ -167,10 +167,18 @@ public function execute_get_settings( $input = array() ): array { continue; } - $type = isset( $setting['schema']['type'] ) && is_string( $setting['schema']['type'] ) ? $setting['schema']['type'] : 'string'; $value = get_option( $setting['option'], $setting['default'] ); - $result[ $exposed_name ] = $this->cast_value( $value, $type ); + /* + * Mirror WP_REST_Settings_Controller::prepare_value(): a stored value that + * does not conform to the setting's schema is dropped from the response rather + * than left to fail output validation for the entire core/read-settings call. + */ + if ( is_wp_error( rest_validate_value_from_schema( $value, $setting['schema'] ) ) ) { + continue; + } + + $result[ $exposed_name ] = rest_sanitize_value_from_schema( $value, $setting['schema'] ); } return $result; @@ -285,32 +293,4 @@ private function value_schema( array $args, $show ): array { 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. - */ - private function cast_value( $value, string $type ) { - switch ( $type ) { - case 'boolean': - return (bool) $value; - case 'integer': - return is_scalar( $value ) ? (int) $value : 0; - case 'number': - return is_scalar( $value ) ? (float) $value : 0.0; - case 'array': - 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; - } - } } From 7b311f0321693de8e8a3843e9b32398dbd83acd3 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Fri, 10 Jul 2026 17:03:46 +0100 Subject: [PATCH 17/39] Abilities API: Sync core/read-settings with the AI plugin --- src/wp-includes/abilities.php | 9 ++-- .../abilities/class-wp-settings-abilities.php | 46 +++++++++++++------ src/wp-includes/option.php | 8 +++- .../abilities-api/wpRegisterCoreAbilities.php | 6 +-- tests/phpunit/tests/option/registration.php | 1 + 5 files changed, 46 insertions(+), 24 deletions(-) diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index 96ad1bff38266..fe5c208a773cd 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -117,8 +117,7 @@ function wp_register_core_abilities(): void { ), ), 'additionalProperties' => false, - // Object (not array()) so the serialized schema default is {}, consistent with type:object. - 'default' => (object) array(), + 'default' => array(), ), 'output_schema' => array( 'type' => 'object', @@ -235,8 +234,7 @@ function wp_register_core_abilities(): void { ), ), 'additionalProperties' => false, - // Object (not array()) so the serialized schema default is {}, consistent with type:object. - 'default' => (object) array(), + 'default' => array(), ), 'output_schema' => array( 'type' => 'object', @@ -323,8 +321,7 @@ function wp_register_core_abilities(): void { ), ), 'additionalProperties' => false, - // Object (not array()) so the serialized schema default is {}, consistent with type:object. - 'default' => (object) array(), + 'default' => array(), ), 'output_schema' => array( 'type' => 'object', diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index 10b408fed2dc3..799f2d9eaf648 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -13,8 +13,8 @@ * Core class used to register settings-related abilities. * * Provides the read-only `core/read-settings` ability and the shared building blocks - * (exposed-settings discovery and schema generation) that are intended to also back a - * future write-oriented `core/manage-settings` ability. + * (exposed-settings discovery, schema generation, and value casting) that are intended to + * also back a future write-oriented `core/manage-settings` ability. * * Unlike the other core abilities, which are self-contained closures registered directly * in wp_register_core_abilities(), the settings abilities live in a dedicated class @@ -83,7 +83,7 @@ public function register(): void { /* * A future write-oriented ability can be registered here, reusing the shared - * helpers below (get_exposed_settings() and value_schema()): + * helpers below (get_exposed_settings(), value_schema(), cast_value()): * * $this->register_manage_settings(); */ @@ -167,18 +167,10 @@ public function execute_get_settings( $input = array() ): array { continue; } + $type = isset( $setting['schema']['type'] ) && is_string( $setting['schema']['type'] ) ? $setting['schema']['type'] : 'string'; $value = get_option( $setting['option'], $setting['default'] ); - /* - * Mirror WP_REST_Settings_Controller::prepare_value(): a stored value that - * does not conform to the setting's schema is dropped from the response rather - * than left to fail output validation for the entire core/read-settings call. - */ - if ( is_wp_error( rest_validate_value_from_schema( $value, $setting['schema'] ) ) ) { - continue; - } - - $result[ $exposed_name ] = rest_sanitize_value_from_schema( $value, $setting['schema'] ); + $result[ $exposed_name ] = $this->cast_value( $value, $type ); } return $result; @@ -293,4 +285,32 @@ private function value_schema( array $args, $show ): array { 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. + */ + private function cast_value( $value, string $type ) { + switch ( $type ) { + case 'boolean': + return (bool) $value; + case 'integer': + return is_scalar( $value ) ? (int) $value : 0; + case 'number': + return is_scalar( $value ) ? (float) $value : 0.0; + case 'array': + 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/src/wp-includes/option.php b/src/wp-includes/option.php index 084295bbad363..bfdb5f5674408 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -2734,12 +2734,13 @@ function set_site_transient( $transient, $value, $expiration = 0 ) { /** * Registers default settings available in WordPress. * - * The settings registered here are primarily useful for the REST API, so this - * does not encompass all settings available in WordPress. + * The settings registered here are primarily useful for the REST API and the + * Abilities API, so this does not encompass all settings available in WordPress. * * @since 4.7.0 * @since 6.0.1 The `show_on_front`, `page_on_front`, and `page_for_posts` options were added. * @since 7.2.0 The `wp_page_for_privacy_policy` option was registered, exposed as `page_for_privacy_policy`. + * @since 7.1.0 Added `show_in_abilities` support for the exposed settings. */ function register_initial_settings() { register_setting( @@ -3014,6 +3015,7 @@ function register_initial_settings() { * @since 5.5.0 `$new_whitelist_options` was renamed to `$new_allowed_options`. * Please consider writing more inclusive code. * @since 6.6.0 Added the `label` argument. + * @since 7.1.0 Added the `show_in_abilities` argument. * * @global array $new_allowed_options * @global array $wp_registered_settings @@ -3063,6 +3065,7 @@ function register_setting( $option_group, $option_name, $args = array() ) { 'description' => '', 'sanitize_callback' => null, 'show_in_rest' => false, + 'show_in_abilities' => false, ); // Back-compat: old sanitize callback is added. @@ -3246,6 +3249,7 @@ function unregister_setting( $option_group, $option_name, $deprecated = '' ) { * Retrieves an array of registered settings. * * @since 4.7.0 + * @since 7.1.0 Registered setting data includes the `show_in_abilities` argument. * * @global array $wp_registered_settings * diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php index de48eb247b985..85a4e5c1e82e4 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php @@ -82,7 +82,7 @@ public function test_core_get_site_info_ability_is_registered(): void { $this->assertSame( 'object', $input_schema['type'] ); $this->assertArrayHasKey( 'default', $input_schema ); - $this->assertEquals( (object) array(), $input_schema['default'] ); + $this->assertSame( array(), $input_schema['default'] ); $this->assertArrayHasKey( 'fields', $input_schema['properties'] ); $this->assertSame( 'array', $input_schema['properties']['fields']['type'] ); @@ -225,7 +225,7 @@ public function test_core_get_user_info_ability_is_registered(): void { $this->assertSame( 'object', $input_schema['type'] ); $this->assertArrayHasKey( 'default', $input_schema ); - $this->assertEquals( (object) array(), $input_schema['default'] ); + $this->assertSame( array(), $input_schema['default'] ); $this->assertArrayHasKey( 'fields', $input_schema['properties'] ); $this->assertSame( 'array', $input_schema['properties']['fields']['type'] ); @@ -331,7 +331,7 @@ public function test_core_get_environment_info_ability_is_registered(): void { $this->assertSame( 'object', $input_schema['type'] ); $this->assertArrayHasKey( 'default', $input_schema ); - $this->assertEquals( (object) array(), $input_schema['default'] ); + $this->assertSame( array(), $input_schema['default'] ); $this->assertArrayHasKey( 'fields', $input_schema['properties'] ); $this->assertSame( 'array', $input_schema['properties']['fields']['type'] ); diff --git a/tests/phpunit/tests/option/registration.php b/tests/phpunit/tests/option/registration.php index 850376498e91a..48ee025d2536d 100644 --- a/tests/phpunit/tests/option/registration.php +++ b/tests/phpunit/tests/option/registration.php @@ -20,6 +20,7 @@ public function test_register() { // Check defaults. $this->assertSame( 'string', $args['type'] ); $this->assertFalse( $args['show_in_rest'] ); + $this->assertFalse( $args['show_in_abilities'] ); $this->assertSame( '', $args['description'] ); } From ef1e0f3e8b56ad9ad0800b1b1992210373c5a552 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 10:15:17 +0100 Subject: [PATCH 18/39] Abilities API: Rename the core/read-settings ability to core/settings-get. Syncs the core PR with the WordPress/ai plugin, where the ability was renamed in https://github.com/WordPress/ai/pull/1087. Renames the ability, its label (Read Settings -> Settings Get), the docblock references in option.php and abilities.php, the test file, its test methods, and the custom test option. --- src/wp-includes/abilities.php | 2 +- .../abilities/class-wp-settings-abilities.php | 10 ++-- src/wp-includes/option.php | 6 +- ...p => wpRegisterCoreSettingsGetAbility.php} | 56 +++++++++---------- 4 files changed, 37 insertions(+), 37 deletions(-) rename tests/phpunit/tests/abilities-api/{wpRegisterCoreReadSettingsAbility.php => wpRegisterCoreSettingsGetAbility.php} (76%) diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index fe5c208a773cd..12b4a44d11670 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -363,6 +363,6 @@ function wp_register_core_abilities(): void { ) ); - // Register the settings abilities (currently the read-only `core/read-settings`). + // Register the settings abilities (currently the read-only `core/settings-get`). ( new 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 index 799f2d9eaf648..98acff0edb036 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -12,7 +12,7 @@ /** * Core class used to register settings-related abilities. * - * Provides the read-only `core/read-settings` ability and the shared building blocks + * Provides the read-only `core/settings-get` ability and the shared building blocks * (exposed-settings discovery, schema generation, and value casting) that are intended to * also back a future write-oriented `core/manage-settings` ability. * @@ -90,7 +90,7 @@ public function register(): void { } /** - * Registers the read-only `core/read-settings` ability. + * Registers the read-only `core/settings-get` ability. * * @since 7.1.0 */ @@ -111,9 +111,9 @@ private function register_get_settings(): void { } wp_register_ability( - 'core/read-settings', + 'core/settings-get', array( - 'label' => __( 'Read Settings' ), + 'label' => __( 'Settings Get' ), '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' => $this->get_settings_input_schema( $groups, $field_names ), @@ -138,7 +138,7 @@ private function register_get_settings(): void { } /** - * Executes the `core/read-settings` ability. + * Executes the `core/settings-get` ability. * * @since 7.1.0 * diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index bfdb5f5674408..4e3886e6197b2 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -3038,10 +3038,10 @@ function register_initial_settings() { * 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/read-settings` ability). When registering complex + * (e.g. the `core/settings-get` ability). When registering complex * settings, this argument may optionally be an array with optional 'name' * and 'schema' keys, mirroring the `show_in_rest` shape. The set of - * exposed settings is captured when the `core/read-settings` ability + * exposed settings is captured when the `core/settings-get` ability * registers on the `wp_abilities_api_init` hook, which fires on first use * of the abilities registry, so a setting must be registered before that — * registering it on `init` is reliable. Core's initial settings are always @@ -3270,7 +3270,7 @@ function unregister_setting( $option_group, $option_name, $deprecated = '' ) { * 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/read-settings` ability). May optionally be an array + * (e.g. the `core/settings-get` 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()`. Only present when the diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreReadSettingsAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php similarity index 76% rename from tests/phpunit/tests/abilities-api/wpRegisterCoreReadSettingsAbility.php rename to tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php index 0813af4d8b859..1ac925df8be07 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreReadSettingsAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php @@ -3,14 +3,14 @@ declare( strict_types=1 ); /** - * Tests for the core/read-settings ability shipped with the Abilities API. + * Tests for the core/settings-get ability shipped with the Abilities API. * * @covers wp_register_core_abilities * @covers WP_Settings_Abilities * * @group abilities-api */ -class Tests_Abilities_API_WpRegisterCoreReadSettingsAbility extends WP_UnitTestCase { +class Tests_Abilities_API_WpRegisterCoreSettingsGetAbility extends WP_UnitTestCase { /** * Backup of the `$wp_registered_settings` global, restored after the class. @@ -49,7 +49,7 @@ public static function set_up_before_class(): void { // setting (not just the core ones) is exposed by the ability. register_setting( 'general', - 'core_read_settings_ability_test_option', + 'core_settings_get_ability_test_option', array( 'type' => 'integer', 'label' => 'Custom Ability Setting', @@ -85,7 +85,7 @@ public static function tear_down_after_class(): void { wp_unregister_ability_category( $ability_category->get_slug() ); } - unregister_setting( 'general', 'core_read_settings_ability_test_option' ); + unregister_setting( 'general', 'core_settings_get_ability_test_option' ); global $wp_registered_settings, $wp_actions; $wp_registered_settings = self::$registered_settings_backup; @@ -112,8 +112,8 @@ private function become_admin(): void { * * @ticket 64605 */ - public function test_core_read_settings_registers_initial_settings_without_rest_api_init(): void { - $ability = wp_get_ability( 'core/read-settings' ); + public function test_core_settings_get_registers_initial_settings_without_rest_api_init(): void { + $ability = wp_get_ability( 'core/settings-get' ); $this->assertArrayHasKey( 'blogname', $ability->get_output_schema()['properties'] ); @@ -128,11 +128,11 @@ public function test_core_read_settings_registers_initial_settings_without_rest_ * * @ticket 64605 */ - public function test_core_read_settings_ability_is_registered(): void { - $ability = wp_get_ability( 'core/read-settings' ); + public function test_core_settings_get_ability_is_registered(): void { + $ability = wp_get_ability( 'core/settings-get' ); $this->assertInstanceOf( WP_Ability::class, $ability ); - $this->assertSame( 'core/read-settings', $ability->get_name() ); + $this->assertSame( 'core/settings-get', $ability->get_name() ); $this->assertSame( 'site', $ability->get_category() ); $this->assertTrue( $ability->get_meta_item( 'show_in_rest', false ) ); @@ -146,8 +146,8 @@ public function test_core_read_settings_ability_is_registered(): void { * * @ticket 64605 */ - public function test_core_read_settings_input_schema_exposes_group_and_fields_filters(): void { - $schema = wp_get_ability( 'core/read-settings' )->get_input_schema(); + public function test_core_settings_get_input_schema_exposes_group_and_fields_filters(): void { + $schema = wp_get_ability( 'core/settings-get' )->get_input_schema(); $this->assertSame( 'object', $schema['type'] ); $this->assertArrayHasKey( 'default', $schema ); @@ -165,14 +165,14 @@ public function test_core_read_settings_input_schema_exposes_group_and_fields_fi * * @ticket 64605 */ - public function test_core_read_settings_returns_flat_typed_values(): void { + public function test_core_settings_get_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/read-settings' )->execute( array() ); + $result = wp_get_ability( 'core/settings-get' )->execute( array() ); $this->assertIsArray( $result ); $this->assertSame( 'My Test Site', $result['blogname'] ); @@ -185,10 +185,10 @@ public function test_core_read_settings_returns_flat_typed_values(): void { * * @ticket 64605 */ - public function test_core_read_settings_filters_by_group(): void { + public function test_core_settings_get_filters_by_group(): void { $this->become_admin(); - $result = wp_get_ability( 'core/read-settings' )->execute( array( 'group' => 'reading' ) ); + $result = wp_get_ability( 'core/settings-get' )->execute( array( 'group' => 'reading' ) ); $this->assertArrayHasKey( 'posts_per_page', $result ); $this->assertArrayNotHasKey( 'blogname', $result ); @@ -199,10 +199,10 @@ public function test_core_read_settings_filters_by_group(): void { * * @ticket 64605 */ - public function test_core_read_settings_filters_by_fields(): void { + public function test_core_settings_get_filters_by_fields(): void { $this->become_admin(); - $result = wp_get_ability( 'core/read-settings' )->execute( array( 'fields' => array( 'blogname', 'posts_per_page' ) ) ); + $result = wp_get_ability( 'core/settings-get' )->execute( array( 'fields' => array( 'blogname', 'posts_per_page' ) ) ); $this->assertEqualSets( array( 'blogname', 'posts_per_page' ), array_keys( $result ) ); } @@ -212,12 +212,12 @@ public function test_core_read_settings_filters_by_fields(): void { * * @ticket 64605 */ - public function test_core_read_settings_combines_group_and_fields_filters(): void { + public function test_core_settings_get_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/read-settings' )->execute( + $result = wp_get_ability( 'core/settings-get' )->execute( array( 'group' => 'reading', 'fields' => array( 'blogname', 'posts_per_page' ), @@ -232,10 +232,10 @@ public function test_core_read_settings_combines_group_and_fields_filters(): voi * * @ticket 64605 */ - public function test_core_read_settings_requires_manage_options(): void { + public function test_core_settings_get_requires_manage_options(): void { wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); - $result = wp_get_ability( 'core/read-settings' )->execute( array() ); + $result = wp_get_ability( 'core/settings-get' )->execute( array() ); $this->assertWPError( $result ); $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); @@ -246,19 +246,19 @@ public function test_core_read_settings_requires_manage_options(): void { * * @ticket 64605 */ - public function test_core_read_settings_exposes_a_custom_registered_setting(): void { - $ability = wp_get_ability( 'core/read-settings' ); + public function test_core_settings_get_exposes_a_custom_registered_setting(): void { + $ability = wp_get_ability( 'core/settings-get' ); // Present in both the input `fields` enum and the output schema built at registration. - $this->assertContains( 'core_read_settings_ability_test_option', $ability->get_input_schema()['properties']['fields']['items']['enum'] ); - $this->assertArrayHasKey( 'core_read_settings_ability_test_option', $ability->get_output_schema()['properties'] ); + $this->assertContains( 'core_settings_get_ability_test_option', $ability->get_input_schema()['properties']['fields']['items']['enum'] ); + $this->assertArrayHasKey( 'core_settings_get_ability_test_option', $ability->get_output_schema()['properties'] ); // And returned, correctly typed, by execute. $this->become_admin(); - update_option( 'core_read_settings_ability_test_option', 7 ); + update_option( 'core_settings_get_ability_test_option', 7 ); - $result = $ability->execute( array( 'fields' => array( 'core_read_settings_ability_test_option' ) ) ); + $result = $ability->execute( array( 'fields' => array( 'core_settings_get_ability_test_option' ) ) ); - $this->assertSame( array( 'core_read_settings_ability_test_option' => 7 ), $result ); + $this->assertSame( array( 'core_settings_get_ability_test_option' => 7 ), $result ); } } From bdd59738d8a8ae593305a664cfabcc7ae383d36c Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 10:15:25 +0100 Subject: [PATCH 19/39] Abilities API: Preserve $new_allowed_options in core/settings-get. Ports https://github.com/WordPress/ai/pull/1080. When the abilities registry initializes before `rest_api_init`, register() calls register_initial_settings(), which also adds core's settings to $new_allowed_options. On an options.php request, those settings are then processed even though the submitted form does not include them (for example admin_email on Settings > General), which triggers a validation error. Restore $new_allowed_options after the early registration. --- .../abilities/class-wp-settings-abilities.php | 5 ++ .../wpRegisterCoreSettingsGetAbility.php | 55 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index 98acff0edb036..48bfa3cc85151 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -76,7 +76,12 @@ public function register(): void { * re-registering them again later on `rest_api_init` is harmless. */ if ( ! did_action( 'rest_api_init' ) || doing_action( 'rest_api_init' ) ) { + $prev_new_allowed_options = $GLOBALS['new_allowed_options'] ?? null; + register_initial_settings(); + + // Restore $new_allowed_options so early registration doesn't pollute options.php. + $GLOBALS['new_allowed_options'] = $prev_new_allowed_options; } $this->register_get_settings(); diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php index 1ac925df8be07..72aa64076b448 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php @@ -96,6 +96,27 @@ public static function tear_down_after_class(): void { parent::tear_down_after_class(); } + /** + * Registers the core/settings-get ability again inside a faked init action. + * + * The class setup has already registered it through wp_register_core_abilities(), so + * the existing copy is unregistered first. + */ + private function register_ability(): void { + global $wp_current_filter; + + if ( wp_has_ability( 'core/settings-get' ) ) { + wp_unregister_ability( 'core/settings-get' ); + } + + $wp_current_filter[] = 'wp_abilities_api_init'; + try { + ( new WP_Settings_Abilities() )->register(); + } finally { + array_pop( $wp_current_filter ); + } + } + /** * Logs in as an administrator so abilities gated behind `manage_options` can run. */ @@ -123,6 +144,40 @@ public function test_core_settings_get_registers_initial_settings_without_rest_a $this->assertArrayHasKey( 'blogname', $result ); } + /** + * Tests that registering initial settings for abilities does not pollute $new_allowed_options. + * + * @ticket 64605 + */ + public function test_register_preserves_new_allowed_options(): void { + global $new_allowed_options; + + $prev_actions_count = $GLOBALS['wp_actions']['rest_api_init'] ?? null; + $prev_allowed_backup = $new_allowed_options; + unset( $GLOBALS['wp_actions']['rest_api_init'] ); + + // Simulate an existing custom setting already in $new_allowed_options. + $new_allowed_options = array( + 'general' => array( 'my_custom_option' ), + ); + + try { + $this->register_ability(); + + // 'admin_email' must NOT be in $new_allowed_options['general']. + $this->assertNotContains( 'admin_email', $new_allowed_options['general'] ); + // Prior allowed options must be preserved. + $this->assertContains( 'my_custom_option', $new_allowed_options['general'] ); + } finally { + $new_allowed_options = $prev_allowed_backup; + if ( null === $prev_actions_count ) { + unset( $GLOBALS['wp_actions']['rest_api_init'] ); + } else { + $GLOBALS['wp_actions']['rest_api_init'] = $prev_actions_count; + } + } + } + /** * The ability is registered in the `site` category and flagged read-only. * From ba8a12aedd025902e123adaf31865e1c81ff4e0c Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 10:15:40 +0100 Subject: [PATCH 20/39] Abilities API: Update the core/settings-get @since tags to 7.2.0. WordPress 7.1 has shipped, so the ability and the `show_in_abilities` setting argument now target 7.2.0. --- .../abilities/class-wp-settings-abilities.php | 24 +++++++++---------- src/wp-includes/option.php | 6 ++--- .../wpRegisterCoreSettingsGetAbility.php | 4 ++-- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index 48bfa3cc85151..447cdbbb9afe5 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage Abilities API - * @since 7.1.0 + * @since 7.2.0 */ declare( strict_types = 1 ); @@ -34,7 +34,7 @@ * not part of the public API. It may be changed or removed at any time without notice. * Do not use it directly or rely on its existence. * - * @since 7.1.0 + * @since 7.2.0 * * @access private */ @@ -43,7 +43,7 @@ final class WP_Settings_Abilities { /** * The ability category used for settings abilities. * - * @since 7.1.0 + * @since 7.2.0 * @var string */ private const CATEGORY = 'site'; @@ -54,7 +54,7 @@ final class WP_Settings_Abilities { * 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 + * @since 7.2.0 * @var array}>|null */ private $exposed_settings = null; @@ -64,7 +64,7 @@ final class WP_Settings_Abilities { * * Must run on the `wp_abilities_api_init` hook. * - * @since 7.1.0 + * @since 7.2.0 */ public function register(): void { /* @@ -97,7 +97,7 @@ public function register(): void { /** * Registers the read-only `core/settings-get` ability. * - * @since 7.1.0 + * @since 7.2.0 */ private function register_get_settings(): void { // Compute once; execute_get_settings() reuses this exact structure. @@ -145,7 +145,7 @@ private function register_get_settings(): void { /** * Executes the `core/settings-get` ability. * - * @since 7.1.0 + * @since 7.2.0 * * @param mixed $input Optional. The ability input. Default empty array. * @return array Map of exposed setting name to current value. @@ -184,7 +184,7 @@ public function execute_get_settings( $input = array() ): array { /** * Checks whether the current user may use the settings abilities. * - * @since 7.1.0 + * @since 7.2.0 * * @return bool True if the current user can manage options. */ @@ -198,7 +198,7 @@ public function has_permission(): bool { * 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 + * @since 7.2.0 * * @param list $groups Available settings groups. * @param list $field_names Available exposed setting names. @@ -236,7 +236,7 @@ private function get_settings_input_schema( array $groups, array $field_names ): * underlying option name, the settings group, the registration default, and a JSON Schema * describing the value. * - * @since 7.1.0 + * @since 7.2.0 * * @return array}> Settings keyed by exposed name. */ @@ -266,7 +266,7 @@ private function get_exposed_settings(): array { /** * Builds the JSON Schema describing a single setting's value. * - * @since 7.1.0 + * @since 7.2.0 * * @param array $args The setting registration arguments. * @param bool|array $show The setting's `show_in_abilities` value. @@ -294,7 +294,7 @@ private function value_schema( array $args, $show ): array { /** * Casts a stored option value to the type declared in its settings registration. * - * @since 7.1.0 + * @since 7.2.0 * * @param mixed $value The raw option value. * @param string $type The registered setting type. diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index 4e3886e6197b2..58cf2475cdea0 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -2740,7 +2740,7 @@ function set_site_transient( $transient, $value, $expiration = 0 ) { * @since 4.7.0 * @since 6.0.1 The `show_on_front`, `page_on_front`, and `page_for_posts` options were added. * @since 7.2.0 The `wp_page_for_privacy_policy` option was registered, exposed as `page_for_privacy_policy`. - * @since 7.1.0 Added `show_in_abilities` support for the exposed settings. + * @since 7.2.0 Added `show_in_abilities` support for the exposed settings. */ function register_initial_settings() { register_setting( @@ -3015,7 +3015,7 @@ function register_initial_settings() { * @since 5.5.0 `$new_whitelist_options` was renamed to `$new_allowed_options`. * Please consider writing more inclusive code. * @since 6.6.0 Added the `label` argument. - * @since 7.1.0 Added the `show_in_abilities` argument. + * @since 7.2.0 Added the `show_in_abilities` argument. * * @global array $new_allowed_options * @global array $wp_registered_settings @@ -3249,7 +3249,7 @@ function unregister_setting( $option_group, $option_name, $deprecated = '' ) { * Retrieves an array of registered settings. * * @since 4.7.0 - * @since 7.1.0 Registered setting data includes the `show_in_abilities` argument. + * @since 7.2.0 Registered setting data includes the `show_in_abilities` argument. * * @global array $wp_registered_settings * diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php index 72aa64076b448..f8a336c192971 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php @@ -34,7 +34,7 @@ class Tests_Abilities_API_WpRegisterCoreSettingsGetAbility extends WP_UnitTestCa * that uses the Abilities API before the REST server loads. The ability must * self-register core's initial settings (see WP_Settings_Abilities::register()). * - * @since 7.1.0 + * @since 7.2.0 */ public static function set_up_before_class(): void { parent::set_up_before_class(); @@ -72,7 +72,7 @@ public static function set_up_before_class(): void { /** * Tear down after the class. * - * @since 7.1.0 + * @since 7.2.0 */ public static function tear_down_after_class(): void { add_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); From 077a487c5c9f205c73b3a65a1e8d8423a91c450d Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 14:55:59 +0100 Subject: [PATCH 21/39] Abilities API: Update the PHPStan baseline for `show_in_abilities`. Since [63420], PHPStan reads the `register_setting()` `$args` hash notation as an array shape, so the Twenty Eleven baseline entry has to include the new `show_in_abilities` argument. --- tests/phpstan/baselines/argument.type.neon | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpstan/baselines/argument.type.neon b/tests/phpstan/baselines/argument.type.neon index 193d2aa64f6f3..cdd8b0bac315e 100644 --- a/tests/phpstan/baselines/argument.type.neon +++ b/tests/phpstan/baselines/argument.type.neon @@ -429,7 +429,7 @@ parameters: count: 1 path: ../../../src/wp-content/themes/twentyeleven/inc/theme-options.php - - message: '#^Parameter \#3 \$args of function register_setting expects array\{type\?\: string, label\?\: string, description\?\: string, sanitize_callback\?\: \(callable\(\)\: mixed\)\|null, show_in_rest\?\: array\|bool, default\?\: mixed, \.\.\.\}, ''twentyeleven_theme…'' given\.$#' + message: '#^Parameter \#3 \$args of function register_setting expects array\{type\?\: string, label\?\: string, description\?\: string, sanitize_callback\?\: \(callable\(\)\: mixed\)\|null, show_in_rest\?\: array\|bool, show_in_abilities\?\: array\|bool, default\?\: mixed, \.\.\.\}, ''twentyeleven_theme…'' given\.$#' identifier: argument.type count: 1 path: ../../../src/wp-content/themes/twentyeleven/inc/theme-options.php From f7b6733ab409893328b2d784973e4458112d86da Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 15:59:53 +0100 Subject: [PATCH 22/39] Abilities API: Leave out core/settings-get values that fail their schema. `execute()` validates the whole output against the output schema, so one stored value that does not match its setting's schema (for example an out-of-enum `default_ping_status`) made the entire call fail. Validate each cast value against its schema and leave out only the ones it rejects, as https://github.com/WordPress/ai/pull/764 does for the plugin's copy of the ability. --- .../abilities/class-wp-settings-abilities.php | 14 +++++++++++--- .../wpRegisterCoreSettingsGetAbility.php | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index 447cdbbb9afe5..9e3bef6fdcb8d 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -119,7 +119,7 @@ private function register_get_settings(): void { 'core/settings-get', array( 'label' => __( 'Settings Get' ), - '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.' ), + '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. A setting whose value does not match its schema is left out.' ), 'category' => self::CATEGORY, 'input_schema' => $this->get_settings_input_schema( $groups, $field_names ), 'output_schema' => array( @@ -173,9 +173,17 @@ public function execute_get_settings( $input = array() ): array { } $type = isset( $setting['schema']['type'] ) && is_string( $setting['schema']['type'] ) ? $setting['schema']['type'] : 'string'; - $value = get_option( $setting['option'], $setting['default'] ); + $value = $this->cast_value( get_option( $setting['option'], $setting['default'] ), $type ); - $result[ $exposed_name ] = $this->cast_value( $value, $type ); + /* + * Leave out a value its schema rejects instead of failing output validation for + * every setting; the settings endpoint answers null for it. + */ + if ( is_wp_error( rest_validate_value_from_schema( $value, $setting['schema'] ) ) ) { + continue; + } + + $result[ $exposed_name ] = $value; } return $result; diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php index f8a336c192971..cd00b09669401 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php @@ -316,4 +316,22 @@ public function test_core_settings_get_exposes_a_custom_registered_setting(): vo $this->assertSame( array( 'core_settings_get_ability_test_option' => 7 ), $result ); } + + /** + * A value that does not match its schema is left out instead of failing the whole call. + * + * @ticket 64605 + */ + public function test_core_settings_get_drops_values_that_fail_their_schema(): void { + $this->become_admin(); + + // sanitize_option() only coerces '0' and '' to 'closed', so this out-of-enum value sticks. + update_option( 'default_ping_status', 'not-a-valid-status' ); + + $result = wp_get_ability( 'core/settings-get' )->execute( array() ); + + $this->assertNotWPError( $result, 'One bad value must not fail the whole ability.' ); + $this->assertArrayHasKey( 'blogname', $result, 'The other settings should still be returned.' ); + $this->assertArrayNotHasKey( 'default_ping_status', $result, 'Only the bad value should be left out.' ); + } } From 8f8b1a6f97ceba5d9dea35c26a9b78fc5f0f9ff4 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 16:01:04 +0100 Subject: [PATCH 23/39] Abilities API: Skip core/settings-get when no settings are exposed. With nothing exposed, the ability could not return anything and its schemas would carry empty enums and properties. Compute the exposed settings in register() and register nothing when there are none, as https://github.com/WordPress/ai/pull/764 does for the plugin's copy of the ability. --- .../abilities/class-wp-settings-abilities.php | 16 ++++++---- .../wpRegisterCoreSettingsGetAbility.php | 30 +++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index 9e3bef6fdcb8d..53f784932d9ff 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -62,7 +62,8 @@ final class WP_Settings_Abilities { /** * Registers all settings abilities. * - * Must run on the `wp_abilities_api_init` hook. + * Must run on the `wp_abilities_api_init` hook. Registers nothing when no setting is + * exposed to abilities. * * @since 7.2.0 */ @@ -84,6 +85,12 @@ public function register(): void { $GLOBALS['new_allowed_options'] = $prev_new_allowed_options; } + // Compute once; execute_get_settings() reuses this exact structure. + $this->exposed_settings = $this->get_exposed_settings(); + if ( empty( $this->exposed_settings ) ) { + return; + } + $this->register_get_settings(); /* @@ -100,10 +107,7 @@ public function register(): void { * @since 7.2.0 */ private function register_get_settings(): void { - // Compute once; execute_get_settings() reuses this exact structure. - $this->exposed_settings = $this->get_exposed_settings(); - - $settings = $this->exposed_settings; + $settings = (array) $this->exposed_settings; $field_names = array_keys( $settings ); $groups = array(); $properties = array(); @@ -155,7 +159,7 @@ public function execute_get_settings( $input = array() ): array { $settings = $this->exposed_settings; if ( null === $settings ) { - // The cache is populated in register_get_settings() before the ability is + // The cache is populated in register() before the ability is // registered, so this is unreachable in practice; bail defensively otherwise. return array(); } diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php index cd00b09669401..9b5721f1ee18f 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php @@ -178,6 +178,36 @@ public function test_register_preserves_new_allowed_options(): void { } } + /** + * Neither settings ability is registered when no setting is exposed to abilities. + * + * @ticket 64605 + */ + public function test_settings_abilities_are_not_registered_without_exposed_settings(): void { + global $wp_registered_settings, $wp_actions; + + $registered_settings_backup = $wp_registered_settings; + $rest_api_init_count = $wp_actions['rest_api_init'] ?? null; + $wp_registered_settings = array(); + $wp_actions['rest_api_init'] = 1; // Keeps register() from registering core's initial settings. + + try { + $this->register_ability(); + + $this->assertFalse( wp_has_ability( 'core/settings-get' ) ); + } finally { + $wp_registered_settings = $registered_settings_backup; + if ( null === $rest_api_init_count ) { + unset( $wp_actions['rest_api_init'] ); + } else { + $wp_actions['rest_api_init'] = $rest_api_init_count; + } + + // Register the ability again for the tests that follow. + $this->register_ability(); + } + } + /** * The ability is registered in the `site` category and flagged read-only. * From a9bf1486db5ba66b8d52230cf263d7046b34bb00 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 16:02:40 +0100 Subject: [PATCH 24/39] Abilities API: Rename WP_Settings_Abilities to WP_Abilities_Settings. Follows the `WP_Abilities_` prefix of the other Abilities API classes, such as WP_Abilities_Registry, and mirrors how the class would be namespaced (`WordPress\Abilities\Settings`). The file is renamed to match. --- src/wp-includes/abilities.php | 4 ++-- ...ttings-abilities.php => class-wp-abilities-settings.php} | 4 ++-- .../abilities-api/wpRegisterCoreSettingsGetAbility.php | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) rename src/wp-includes/abilities/{class-wp-settings-abilities.php => class-wp-abilities-settings.php} (99%) diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index 12b4a44d11670..6687519cf752b 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -9,7 +9,7 @@ declare( strict_types = 1 ); -require_once __DIR__ . '/abilities/class-wp-settings-abilities.php'; +require_once __DIR__ . '/abilities/class-wp-abilities-settings.php'; /** * Registers the core ability categories. @@ -364,5 +364,5 @@ function wp_register_core_abilities(): void { ); // Register the settings abilities (currently the read-only `core/settings-get`). - ( new WP_Settings_Abilities() )->register(); + ( new WP_Abilities_Settings() )->register(); } diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-abilities-settings.php similarity index 99% rename from src/wp-includes/abilities/class-wp-settings-abilities.php rename to src/wp-includes/abilities/class-wp-abilities-settings.php index 53f784932d9ff..11062958aab2a 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-abilities-settings.php @@ -1,6 +1,6 @@ register(); + ( new WP_Abilities_Settings() )->register(); } finally { array_pop( $wp_current_filter ); } From ed2bfee3acbb8fac1247fbf3df8c550994ee2967 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 16:03:13 +0100 Subject: [PATCH 25/39] Abilities API: Serialize empty input schema defaults as objects. Aligns the core/get-site-info, core/get-user-info, and core/get-environment-info input schema defaults with core/settings-get: (object) array() instead of array(), so the serialized default is {}, consistent with type:object. Their execute callbacks already fall back to an empty array for non-array input. --- src/wp-includes/abilities.php | 9 ++++++--- .../tests/abilities-api/wpRegisterCoreAbilities.php | 6 +++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index 6687519cf752b..b816d9dac82a5 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -117,7 +117,8 @@ function wp_register_core_abilities(): void { ), ), 'additionalProperties' => false, - 'default' => array(), + // Object (not array()) so the serialized schema default is {}, consistent with type:object. + 'default' => (object) array(), ), 'output_schema' => array( 'type' => 'object', @@ -234,7 +235,8 @@ function wp_register_core_abilities(): void { ), ), 'additionalProperties' => false, - 'default' => array(), + // Object (not array()) so the serialized schema default is {}, consistent with type:object. + 'default' => (object) array(), ), 'output_schema' => array( 'type' => 'object', @@ -321,7 +323,8 @@ function wp_register_core_abilities(): void { ), ), 'additionalProperties' => false, - 'default' => array(), + // Object (not array()) so the serialized schema default is {}, consistent with type:object. + 'default' => (object) array(), ), 'output_schema' => array( 'type' => 'object', diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php index 85a4e5c1e82e4..de48eb247b985 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php @@ -82,7 +82,7 @@ public function test_core_get_site_info_ability_is_registered(): void { $this->assertSame( 'object', $input_schema['type'] ); $this->assertArrayHasKey( 'default', $input_schema ); - $this->assertSame( array(), $input_schema['default'] ); + $this->assertEquals( (object) array(), $input_schema['default'] ); $this->assertArrayHasKey( 'fields', $input_schema['properties'] ); $this->assertSame( 'array', $input_schema['properties']['fields']['type'] ); @@ -225,7 +225,7 @@ public function test_core_get_user_info_ability_is_registered(): void { $this->assertSame( 'object', $input_schema['type'] ); $this->assertArrayHasKey( 'default', $input_schema ); - $this->assertSame( array(), $input_schema['default'] ); + $this->assertEquals( (object) array(), $input_schema['default'] ); $this->assertArrayHasKey( 'fields', $input_schema['properties'] ); $this->assertSame( 'array', $input_schema['properties']['fields']['type'] ); @@ -331,7 +331,7 @@ public function test_core_get_environment_info_ability_is_registered(): void { $this->assertSame( 'object', $input_schema['type'] ); $this->assertArrayHasKey( 'default', $input_schema ); - $this->assertSame( array(), $input_schema['default'] ); + $this->assertEquals( (object) array(), $input_schema['default'] ); $this->assertArrayHasKey( 'fields', $input_schema['properties'] ); $this->assertSame( 'array', $input_schema['properties']['fields']['type'] ); From b82f20f3d70f0de9453536e78ca7b3791cbf833e Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 17:00:50 +0100 Subject: [PATCH 26/39] Abilities API: Refer to the planned core/settings-update ability. The AI plugin named the write ability `core/settings-update` (see https://github.com/WordPress/ai/pull/764), so update the comments that still called it `core/manage-settings`. --- src/wp-includes/abilities/class-wp-abilities-settings.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-abilities-settings.php b/src/wp-includes/abilities/class-wp-abilities-settings.php index 11062958aab2a..bbf8bb1ccd675 100644 --- a/src/wp-includes/abilities/class-wp-abilities-settings.php +++ b/src/wp-includes/abilities/class-wp-abilities-settings.php @@ -14,7 +14,7 @@ * * Provides the read-only `core/settings-get` ability and the shared building blocks * (exposed-settings discovery, schema generation, and value casting) that are intended to - * also back a future write-oriented `core/manage-settings` ability. + * also back a future write-oriented `core/settings-update` ability. * * Unlike the other core abilities, which are self-contained closures registered directly * in wp_register_core_abilities(), the settings abilities live in a dedicated class @@ -97,7 +97,7 @@ public function register(): void { * A future write-oriented ability can be registered here, reusing the shared * helpers below (get_exposed_settings(), value_schema(), cast_value()): * - * $this->register_manage_settings(); + * $this->register_update_settings(); */ } From 60c7a8f938692f4cad35f8da7e777a29c9f4d9e5 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 17:05:25 +0100 Subject: [PATCH 27/39] Abilities API: Send empty object results as `{}` over REST. An empty PHP array is encoded as `[]`, so an ability whose output schema describes an object, such as `core/settings-get` when its filters match no setting, answered with a JSON array. When the output schema describes an object, send an empty object instead, so the response matches the schema. PHP callers still receive the plain array. Suggested in https://github.com/WordPress/ai/pull/764, where `core/settings-update` worked around it with an `(object)` cast. --- ...ss-wp-rest-abilities-v1-run-controller.php | 25 +++++++++ .../wpRestAbilitiesV1RunController.php | 54 +++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php index 9ba76fc2407b7..e88fd215249f8 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php @@ -75,6 +75,8 @@ public function register_routes(): void { * Executes an ability. * * @since 6.9.0 + * @since 7.2.0 An empty array result is sent as an empty object when the output schema + * describes an object. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. @@ -95,9 +97,32 @@ public function execute_ability( $request ) { return $result; } + /* + * An empty PHP array is encoded as a JSON array (`[]`). When the output schema + * describes an object, send an empty object (`{}`) instead, so the response matches + * the schema. Abilities can keep returning plain arrays to PHP callers. + */ + if ( array() === $result && $this->is_object_schema( $ability->get_output_schema() ) ) { + $result = (object) array(); + } + return rest_ensure_response( $result ); } + /** + * Determines whether a schema describes an object rather than an array. + * + * @since 7.2.0 + * + * @param array $schema The JSON Schema to check. + * @return bool True if the schema type allows an object but not an array. + */ + private function is_object_schema( array $schema ): bool { + $types = (array) ( $schema['type'] ?? array() ); + + return in_array( 'object', $types, true ) && ! in_array( 'array', $types, true ); + } + /** * Validates if the HTTP method matches the expected method for the ability based on its annotations. * diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php index 5edcb00690a99..df3f597d50348 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php @@ -1918,4 +1918,58 @@ public function test_nested_sanitize_error_falls_back_to_raw_input(): void { $this->assertSame( 200, $response->get_status() ); $this->assertSame( array( 'include' => array( '1', '01' ) ), $response->get_data()['value'] ); } + + /** + * Data provider for an empty array result sent for different output schema types. + * + * @return array, 1: string}> Output schema, and the + * expected JSON response. + */ + public function data_empty_array_result(): array { + return array( + 'object' => array( array( 'type' => 'object' ), '{}' ), + 'nullable object' => array( array( 'type' => array( 'object', 'null' ) ), '{}' ), + 'array' => array( array( 'type' => 'array' ), '[]' ), + 'object or array' => array( array( 'type' => array( 'object', 'array' ) ), '[]' ), + 'no type' => array( array(), '[]' ), + ); + } + + /** + * Tests that an empty array result is sent as an empty object when the output schema + * describes an object. + * + * @ticket 64605 + * + * @dataProvider data_empty_array_result + * + * @param array $output_schema Output schema for the ability. + * @param string $expected_json Expected JSON response. + */ + public function test_empty_array_result_matches_the_output_schema( array $output_schema, string $expected_json ): void { + $this->register_test_ability( + 'test/empty-result', + array( + 'label' => 'Empty Result', + 'description' => 'Returns an empty array.', + 'category' => 'general', + 'output_schema' => $output_schema, + 'execute_callback' => static function (): array { + return array(); + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + + $response = $this->dispatch_run( 'GET', 'test/empty-result' ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( $expected_json, wp_json_encode( $this->server->response_to_data( $response, false ) ) ); + } } From 315e1ef550ee6b5ea22c7be0d3c017d1684a14d7 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 17:21:20 +0100 Subject: [PATCH 28/39] Revert "Abilities API: Send empty object results as `{}` over REST." This reverts commit 60c7a8f938692f4cad35f8da7e777a29c9f4d9e5. Keep `core/settings-get` the same as in the AI plugin, which answers an empty result as a plain array. Sending empty object results as `{}` is a general Abilities API change, which can be proposed on its own. --- ...ss-wp-rest-abilities-v1-run-controller.php | 25 --------- .../wpRestAbilitiesV1RunController.php | 54 ------------------- 2 files changed, 79 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php index e88fd215249f8..9ba76fc2407b7 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php @@ -75,8 +75,6 @@ public function register_routes(): void { * Executes an ability. * * @since 6.9.0 - * @since 7.2.0 An empty array result is sent as an empty object when the output schema - * describes an object. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. @@ -97,32 +95,9 @@ public function execute_ability( $request ) { return $result; } - /* - * An empty PHP array is encoded as a JSON array (`[]`). When the output schema - * describes an object, send an empty object (`{}`) instead, so the response matches - * the schema. Abilities can keep returning plain arrays to PHP callers. - */ - if ( array() === $result && $this->is_object_schema( $ability->get_output_schema() ) ) { - $result = (object) array(); - } - return rest_ensure_response( $result ); } - /** - * Determines whether a schema describes an object rather than an array. - * - * @since 7.2.0 - * - * @param array $schema The JSON Schema to check. - * @return bool True if the schema type allows an object but not an array. - */ - private function is_object_schema( array $schema ): bool { - $types = (array) ( $schema['type'] ?? array() ); - - return in_array( 'object', $types, true ) && ! in_array( 'array', $types, true ); - } - /** * Validates if the HTTP method matches the expected method for the ability based on its annotations. * diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php index df3f597d50348..5edcb00690a99 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php @@ -1918,58 +1918,4 @@ public function test_nested_sanitize_error_falls_back_to_raw_input(): void { $this->assertSame( 200, $response->get_status() ); $this->assertSame( array( 'include' => array( '1', '01' ) ), $response->get_data()['value'] ); } - - /** - * Data provider for an empty array result sent for different output schema types. - * - * @return array, 1: string}> Output schema, and the - * expected JSON response. - */ - public function data_empty_array_result(): array { - return array( - 'object' => array( array( 'type' => 'object' ), '{}' ), - 'nullable object' => array( array( 'type' => array( 'object', 'null' ) ), '{}' ), - 'array' => array( array( 'type' => 'array' ), '[]' ), - 'object or array' => array( array( 'type' => array( 'object', 'array' ) ), '[]' ), - 'no type' => array( array(), '[]' ), - ); - } - - /** - * Tests that an empty array result is sent as an empty object when the output schema - * describes an object. - * - * @ticket 64605 - * - * @dataProvider data_empty_array_result - * - * @param array $output_schema Output schema for the ability. - * @param string $expected_json Expected JSON response. - */ - public function test_empty_array_result_matches_the_output_schema( array $output_schema, string $expected_json ): void { - $this->register_test_ability( - 'test/empty-result', - array( - 'label' => 'Empty Result', - 'description' => 'Returns an empty array.', - 'category' => 'general', - 'output_schema' => $output_schema, - 'execute_callback' => static function (): array { - return array(); - }, - 'permission_callback' => '__return_true', - 'meta' => array( - 'annotations' => array( - 'readonly' => true, - ), - 'show_in_rest' => true, - ), - ) - ); - - $response = $this->dispatch_run( 'GET', 'test/empty-result' ); - - $this->assertSame( 200, $response->get_status() ); - $this->assertSame( $expected_json, wp_json_encode( $this->server->response_to_data( $response, false ) ) ); - } } From fdecc4967245aabfc848522634ca444a47f39780 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 17:58:56 +0100 Subject: [PATCH 29/39] Abilities API: Register the initial settings when abilities initialize. Abilities can initialize before or without `rest_api_init`, where the initial settings are registered, for example on cron or WP-CLI. So far `WP_Abilities_Settings::register()` registered them itself. Register them from a `wp_abilities_api_init` callback at priority 1 instead, before the core abilities, so the settings ability no longer handles the settings bootstrap. The callback keeps the existing checks: it does nothing once the REST API has registered the settings, and it restores `$new_allowed_options`, so saving Settings > General does not try to save `admin_email` (see https://github.com/WordPress/ai/pull/1080). --- .../abilities/class-wp-abilities-settings.php | 26 +++----------- src/wp-includes/default-filters.php | 1 + src/wp-includes/option.php | 34 +++++++++++++++++++ .../wpRegisterCoreSettingsGetAbility.php | 24 +++++-------- 4 files changed, 49 insertions(+), 36 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-abilities-settings.php b/src/wp-includes/abilities/class-wp-abilities-settings.php index bbf8bb1ccd675..3d0e21bd04850 100644 --- a/src/wp-includes/abilities/class-wp-abilities-settings.php +++ b/src/wp-includes/abilities/class-wp-abilities-settings.php @@ -25,10 +25,11 @@ * The exposed settings are captured when the ability registers on `wp_abilities_api_init`. * That hook fires lazily on first use of the abilities registry, which is not ordered * relative to `rest_api_init` (where core registers its own settings) and can happen - * without it entirely, e.g. on cron or WP-CLI. register() therefore ensures core's - * initial settings are registered before the snapshot is computed. Plugin settings - * flagged with `show_in_abilities` must be registered before the abilities registry is - * first used in a request; registering them on `init` is reliable. + * without it entirely, e.g. on cron or WP-CLI. Core therefore also registers its initial + * settings on that hook, before the core abilities register (see + * _wp_register_initial_settings_for_abilities()). Plugin settings flagged with + * `show_in_abilities` must be registered before the abilities registry is first used in + * a request; registering them on `init` is reliable. * * This class is part of WordPress' internal implementation of the core abilities and is * not part of the public API. It may be changed or removed at any time without notice. @@ -68,23 +69,6 @@ final class WP_Abilities_Settings { * @since 7.2.0 */ public function register(): void { - /* - * Core's initial settings register on `rest_api_init`, which fires lazily and - * independently of `wp_abilities_api_init`: on cron, WP-CLI, or any request where - * abilities are used before the REST server loads, it may not have fired — or may - * be mid-fire at a priority before register_initial_settings() runs. Ensure the - * core settings exist before the exposed-settings snapshot below is computed; - * re-registering them again later on `rest_api_init` is harmless. - */ - if ( ! did_action( 'rest_api_init' ) || doing_action( 'rest_api_init' ) ) { - $prev_new_allowed_options = $GLOBALS['new_allowed_options'] ?? null; - - register_initial_settings(); - - // Restore $new_allowed_options so early registration doesn't pollute options.php. - $GLOBALS['new_allowed_options'] = $prev_new_allowed_options; - } - // Compute once; execute_get_settings() reuses this exact structure. $this->exposed_settings = $this->get_exposed_settings(); if ( empty( $this->exposed_settings ) ) { diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index cad7014e1f51e..2d3b6d027d684 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -555,6 +555,7 @@ // Abilities API. add_action( 'wp_abilities_api_categories_init', 'wp_register_core_ability_categories' ); +add_action( 'wp_abilities_api_init', '_wp_register_initial_settings_for_abilities', 1 ); add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); // Connectors API. diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index 58cf2475cdea0..a725263a45cb5 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -3005,6 +3005,40 @@ function register_initial_settings() { ); } +/** + * Registers the default settings when the Abilities API initializes. + * + * The default settings are registered on `rest_api_init`, which fires lazily and + * independently of `wp_abilities_api_init`: on cron, WP-CLI, or any request where + * abilities are used before the REST server loads, it may not have fired, or may be + * mid-fire at a priority before register_initial_settings() runs. This makes sure the + * settings exist before the core abilities read them. Registering them again later on + * `rest_api_init` is harmless. + * + * @since 7.2.0 + * @access private + * + * @global array $new_allowed_options + */ +function _wp_register_initial_settings_for_abilities(): void { + global $new_allowed_options; + + if ( did_action( 'rest_api_init' ) && ! doing_action( 'rest_api_init' ) ) { + return; + } + + $allowed_options = $new_allowed_options; + + register_initial_settings(); + + /* + * Registering a setting also allows it on the options screen of its group. Restore + * the list, so saving Settings > General does not try to save `admin_email`, which + * that screen sends as `new_admin_email`. + */ + $new_allowed_options = $allowed_options; +} + /** * Registers a setting and its data. * diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php index 2f575ec5a6c33..3edeae6738cb1 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php @@ -6,6 +6,7 @@ * Tests for the core/settings-get ability shipped with the Abilities API. * * @covers wp_register_core_abilities + * @covers _wp_register_initial_settings_for_abilities * @covers WP_Abilities_Settings * * @group abilities-api @@ -31,8 +32,8 @@ class Tests_Abilities_API_WpRegisterCoreSettingsGetAbility extends WP_UnitTestCa * * The ability is registered under the ordering that used to break it: no settings * registered yet and `rest_api_init` never fired, as on cron, WP-CLI, or any request - * that uses the Abilities API before the REST server loads. The ability must - * self-register core's initial settings (see WP_Abilities_Settings::register()). + * that uses the Abilities API before the REST server loads. Core must register its + * initial settings when abilities initialize (see _wp_register_initial_settings_for_abilities()). * * @since 7.2.0 */ @@ -129,11 +130,11 @@ private function become_admin(): void { * where `rest_api_init` (which registers core's initial settings) has never fired. * * The class setup registers the ability with no settings registered up front, so this - * asserts that the ability took care of registering core's initial settings itself. + * asserts that core registered its initial settings when abilities initialized. * * @ticket 64605 */ - public function test_core_settings_get_registers_initial_settings_without_rest_api_init(): void { + public function test_core_settings_get_exposes_initial_settings_without_rest_api_init(): void { $ability = wp_get_ability( 'core/settings-get' ); $this->assertArrayHasKey( 'blogname', $ability->get_output_schema()['properties'] ); @@ -162,7 +163,7 @@ public function test_register_preserves_new_allowed_options(): void { ); try { - $this->register_ability(); + _wp_register_initial_settings_for_abilities(); // 'admin_email' must NOT be in $new_allowed_options['general']. $this->assertNotContains( 'admin_email', $new_allowed_options['general'] ); @@ -184,12 +185,10 @@ public function test_register_preserves_new_allowed_options(): void { * @ticket 64605 */ public function test_settings_abilities_are_not_registered_without_exposed_settings(): void { - global $wp_registered_settings, $wp_actions; + global $wp_registered_settings; - $registered_settings_backup = $wp_registered_settings; - $rest_api_init_count = $wp_actions['rest_api_init'] ?? null; - $wp_registered_settings = array(); - $wp_actions['rest_api_init'] = 1; // Keeps register() from registering core's initial settings. + $registered_settings_backup = $wp_registered_settings; + $wp_registered_settings = array(); try { $this->register_ability(); @@ -197,11 +196,6 @@ public function test_settings_abilities_are_not_registered_without_exposed_setti $this->assertFalse( wp_has_ability( 'core/settings-get' ) ); } finally { $wp_registered_settings = $registered_settings_backup; - if ( null === $rest_api_init_count ) { - unset( $wp_actions['rest_api_init'] ); - } else { - $wp_actions['rest_api_init'] = $rest_api_init_count; - } // Register the ability again for the tests that follow. $this->register_ability(); From 3cd6bba1927cac29bbc492094eef0b93edb21f65 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 22:05:14 +0100 Subject: [PATCH 30/39] Revert "Abilities API: Serialize empty input schema defaults as objects." This reverts commit ed2bfee3acbb8fac1247fbf3df8c550994ee2967. `core/get-site-info`, `core/get-user-info`, and `core/get-environment-info` shipped in 7.1 with an `array()` input default. As an object, the default reaches `wp_ability_normalize_input` filters as a `stdClass`, so a filter that reads it as an array breaks, and a filter that changes it changes the schema default for every later call. Aligning these defaults is a general Abilities API change, which can be proposed on its own. --- src/wp-includes/abilities.php | 9 +++------ .../tests/abilities-api/wpRegisterCoreAbilities.php | 6 +++--- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index b816d9dac82a5..6687519cf752b 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -117,8 +117,7 @@ function wp_register_core_abilities(): void { ), ), 'additionalProperties' => false, - // Object (not array()) so the serialized schema default is {}, consistent with type:object. - 'default' => (object) array(), + 'default' => array(), ), 'output_schema' => array( 'type' => 'object', @@ -235,8 +234,7 @@ function wp_register_core_abilities(): void { ), ), 'additionalProperties' => false, - // Object (not array()) so the serialized schema default is {}, consistent with type:object. - 'default' => (object) array(), + 'default' => array(), ), 'output_schema' => array( 'type' => 'object', @@ -323,8 +321,7 @@ function wp_register_core_abilities(): void { ), ), 'additionalProperties' => false, - // Object (not array()) so the serialized schema default is {}, consistent with type:object. - 'default' => (object) array(), + 'default' => array(), ), 'output_schema' => array( 'type' => 'object', diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php index de48eb247b985..85a4e5c1e82e4 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php @@ -82,7 +82,7 @@ public function test_core_get_site_info_ability_is_registered(): void { $this->assertSame( 'object', $input_schema['type'] ); $this->assertArrayHasKey( 'default', $input_schema ); - $this->assertEquals( (object) array(), $input_schema['default'] ); + $this->assertSame( array(), $input_schema['default'] ); $this->assertArrayHasKey( 'fields', $input_schema['properties'] ); $this->assertSame( 'array', $input_schema['properties']['fields']['type'] ); @@ -225,7 +225,7 @@ public function test_core_get_user_info_ability_is_registered(): void { $this->assertSame( 'object', $input_schema['type'] ); $this->assertArrayHasKey( 'default', $input_schema ); - $this->assertEquals( (object) array(), $input_schema['default'] ); + $this->assertSame( array(), $input_schema['default'] ); $this->assertArrayHasKey( 'fields', $input_schema['properties'] ); $this->assertSame( 'array', $input_schema['properties']['fields']['type'] ); @@ -331,7 +331,7 @@ public function test_core_get_environment_info_ability_is_registered(): void { $this->assertSame( 'object', $input_schema['type'] ); $this->assertArrayHasKey( 'default', $input_schema ); - $this->assertEquals( (object) array(), $input_schema['default'] ); + $this->assertSame( array(), $input_schema['default'] ); $this->assertArrayHasKey( 'fields', $input_schema['properties'] ); $this->assertSame( 'array', $input_schema['properties']['fields']['type'] ); From dad5624fa8abc8e4fdfd0ef61b29b786b32863b4 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 22:05:16 +0100 Subject: [PATCH 31/39] Abilities API: Read setting values as the settings endpoint does. `cast_value()` cast each stored value to its type before validating it, so some values were read differently from `/wp/v2/settings`: a stored `'false'` came back as `true`, a `stdClass` as `{}`, a list with gaps as a JSON object, and `'abc'` in an integer setting as `0` instead of being left out. As `WP_REST_Settings_Controller::prepare_value()` does, validate the stored value against its schema, leave it out when the schema rejects it, and sanitize it otherwise. Object values are still cast to objects, so an empty one is sent as `{}`. A plain `get_option()` already returns the registered default through `filter_default_option`, so the exposed settings no longer keep it. --- .../abilities/class-wp-abilities-settings.php | 66 +++++-------------- .../wpRegisterCoreSettingsGetAbility.php | 61 +++++++++++++++++ 2 files changed, 77 insertions(+), 50 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-abilities-settings.php b/src/wp-includes/abilities/class-wp-abilities-settings.php index 3d0e21bd04850..a0cb89fa02598 100644 --- a/src/wp-includes/abilities/class-wp-abilities-settings.php +++ b/src/wp-includes/abilities/class-wp-abilities-settings.php @@ -13,8 +13,8 @@ * Core class used to register settings-related abilities. * * Provides the read-only `core/settings-get` ability and the shared building blocks - * (exposed-settings discovery, schema generation, and value casting) that are intended to - * also back a future write-oriented `core/settings-update` ability. + * (exposed-settings discovery and schema generation) that are intended to also back a + * future write-oriented `core/settings-update` ability. * * Unlike the other core abilities, which are self-contained closures registered directly * in wp_register_core_abilities(), the settings abilities live in a dedicated class @@ -56,7 +56,7 @@ final class WP_Abilities_Settings { * structure, and {@see get_registered_settings()} is only walked once per request. * * @since 7.2.0 - * @var array}>|null + * @var array}>|null */ private $exposed_settings = null; @@ -76,13 +76,6 @@ public function register(): void { } $this->register_get_settings(); - - /* - * A future write-oriented ability can be registered here, reusing the shared - * helpers below (get_exposed_settings(), value_schema(), cast_value()): - * - * $this->register_update_settings(); - */ } /** @@ -160,18 +153,21 @@ public function execute_get_settings( $input = array() ): array { continue; } - $type = isset( $setting['schema']['type'] ) && is_string( $setting['schema']['type'] ) ? $setting['schema']['type'] : 'string'; - $value = $this->cast_value( get_option( $setting['option'], $setting['default'] ), $type ); + $value = get_option( $setting['option'] ); /* - * Leave out a value its schema rejects instead of failing output validation for - * every setting; the settings endpoint answers null for it. + * As the settings endpoint does, validate the stored value before sanitizing it, and + * leave out a value its schema rejects instead of failing output validation for every + * setting; the settings endpoint answers null for it. */ if ( is_wp_error( rest_validate_value_from_schema( $value, $setting['schema'] ) ) ) { continue; } - $result[ $exposed_name ] = $value; + $value = rest_sanitize_value_from_schema( $value, $setting['schema'] ); + + // Object (not array()) so an empty object value is serialized as {}, consistent with type:object. + $result[ $exposed_name ] = 'object' === $setting['schema']['type'] ? (object) $value : $value; } return $result; @@ -229,12 +225,11 @@ private function get_settings_input_schema( array $groups, array $field_names ): * * 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. + * underlying option name, the settings group, and a JSON Schema describing the value. * * @since 7.2.0 * - * @return array}> Settings keyed by exposed name. + * @return array}> Settings keyed by exposed name. */ private function get_exposed_settings(): array { $settings = array(); @@ -249,10 +244,9 @@ private function get_exposed_settings(): array { $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'] ) && is_string( $args['group'] ) ? $args['group'] : '', - 'default' => array_key_exists( 'default', $args ) ? $args['default'] : false, - 'schema' => $this->value_schema( $args, $show ), + 'option' => $option_name, + 'group' => isset( $args['group'] ) && is_string( $args['group'] ) ? $args['group'] : '', + 'schema' => $this->value_schema( $args, $show ), ); } @@ -286,32 +280,4 @@ private function value_schema( array $args, $show ): array { return $schema; } - - /** - * Casts a stored option value to the type declared in its settings registration. - * - * @since 7.2.0 - * - * @param mixed $value The raw option value. - * @param string $type The registered setting type. - * @return mixed The value cast to the declared type. - */ - private function cast_value( $value, string $type ) { - switch ( $type ) { - case 'boolean': - return (bool) $value; - case 'integer': - return is_scalar( $value ) ? (int) $value : 0; - case 'number': - return is_scalar( $value ) ? (float) $value : 0.0; - case 'array': - 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/wpRegisterCoreSettingsGetAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php index 3edeae6738cb1..410505370f394 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php @@ -358,4 +358,65 @@ public function test_core_settings_get_drops_values_that_fail_their_schema(): vo $this->assertArrayHasKey( 'blogname', $result, 'The other settings should still be returned.' ); $this->assertArrayNotHasKey( 'default_ping_status', $result, 'Only the bad value should be left out.' ); } + + /** + * Stored values are read as the settings endpoint reads them: validated against their schema, + * left out when it rejects them, and sanitized otherwise. + * + * @ticket 64605 + * + * @dataProvider data_stored_values + * + * @param string $type The setting type. + * @param mixed $stored The stored option value. + * @param string|null $expected The value as JSON, or null when it is left out. + */ + public function test_core_settings_get_reads_stored_values_as_the_settings_endpoint( string $type, $stored, ?string $expected ): void { + $option = 'core_settings_get_ability_value_test_option'; + + register_setting( + 'general', + $option, + array( + 'type' => $type, + 'show_in_abilities' => true, + ) + ); + update_option( $option, $stored ); + + try { + $this->register_ability(); + $this->become_admin(); + + $result = wp_get_ability( 'core/settings-get' )->execute( array( 'fields' => array( $option ) ) ); + } finally { + unregister_setting( 'general', $option ); + $this->register_ability(); + } + + $this->assertSame( $expected, isset( $result[ $option ] ) ? wp_json_encode( $result[ $option ] ) : null ); + } + + /** + * Data provider. + * + * @return array Stored values, and the JSON they are read as. + */ + public static function data_stored_values(): array { + return array( + '"false" for a boolean' => array( 'boolean', 'false', 'false' ), + 'a stdClass for an object' => array( 'object', (object) array( 'a' => 1 ), '{"a":1}' ), + 'an empty array for an object' => array( 'object', array(), '{}' ), + 'a list with gaps for an array' => array( + 'array', + array( + 0 => 'a', + 2 => 'b', + ), + '["a","b"]', + ), + 'a numeric string for an integer' => array( 'integer', '7', '7' ), + 'a non-numeric string for an integer' => array( 'integer', 'abc', null ), + ); + } } From 4516a8fd69439f77299e1899ee52ba7321f830bb Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 22:05:18 +0100 Subject: [PATCH 32/39] Abilities API: Skip settings of an unsupported type in core/settings-get. A setting registered with a type outside the JSON types, such as `foo`, was added to the output schema and triggered `_doing_it_wrong()` on every run. As `WP_REST_Settings_Controller::get_registered_options()` does, only expose settings of a type the settings endpoint supports. --- .../abilities/class-wp-abilities-settings.php | 12 +++++-- .../wpRegisterCoreSettingsGetAbility.php | 32 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-abilities-settings.php b/src/wp-includes/abilities/class-wp-abilities-settings.php index a0cb89fa02598..040578b2c089f 100644 --- a/src/wp-includes/abilities/class-wp-abilities-settings.php +++ b/src/wp-includes/abilities/class-wp-abilities-settings.php @@ -224,8 +224,9 @@ private function get_settings_input_schema( array $groups, array $field_names ): * 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, and a JSON Schema describing the value. + * `show_in_abilities` argument, of a type the settings endpoint supports. Each entry is + * keyed by its exposed name and carries the underlying option name, the settings group, + * and a JSON Schema describing the value. * * @since 7.2.0 * @@ -240,13 +241,18 @@ private function get_exposed_settings(): array { continue; } + $schema = $this->value_schema( $args, $show ); + if ( ! in_array( $schema['type'], array( 'number', 'integer', 'string', 'boolean', 'array', 'object' ), true ) ) { + continue; + } + $option_name = (string) $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'] ) && is_string( $args['group'] ) ? $args['group'] : '', - 'schema' => $this->value_schema( $args, $show ), + 'schema' => $schema, ); } diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php index 410505370f394..48bb0d061d1be 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php @@ -419,4 +419,36 @@ public static function data_stored_values(): array { 'a non-numeric string for an integer' => array( 'integer', 'abc', null ), ); } + + /** + * A setting of a type the settings endpoint does not support is not exposed. + * + * @ticket 64605 + */ + public function test_core_settings_get_skips_a_setting_with_an_unsupported_type(): void { + $option = 'core_settings_get_ability_type_test_option'; + + register_setting( + 'general', + $option, + array( + 'type' => 'foo', + 'show_in_abilities' => true, + ) + ); + update_option( $option, 'value' ); + + try { + $this->register_ability(); + $this->become_admin(); + + $ability = wp_get_ability( 'core/settings-get' ); + + $this->assertArrayNotHasKey( $option, $ability->get_output_schema()['properties'] ); + $this->assertArrayNotHasKey( $option, $ability->execute( array() ) ); + } finally { + unregister_setting( 'general', $option ); + $this->register_ability(); + } + } } From b763d953ff7771c87e802daa902f222bd136ccae Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 22:05:20 +0100 Subject: [PATCH 33/39] Abilities API: Expose the privacy policy page setting to abilities. `wp_page_for_privacy_policy` is registered in the `reading` group next to `page_on_front` and `page_for_posts`, which `core/settings-get` already exposes. Flag it with `show_in_abilities` too. --- src/wp-includes/option.php | 7 ++++--- .../abilities-api/wpRegisterCoreSettingsGetAbility.php | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index a725263a45cb5..d98a6eb33f69c 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -2957,11 +2957,12 @@ function register_initial_settings() { 'reading', 'wp_page_for_privacy_policy', array( - 'show_in_rest' => array( + 'show_in_rest' => array( 'name' => 'page_for_privacy_policy', ), - 'type' => 'integer', - 'description' => __( 'The ID of the page that should be displayed as the privacy policy page' ), + 'show_in_abilities' => true, + 'type' => 'integer', + 'description' => __( 'The ID of the page that should be displayed as the privacy policy page' ), ) ); diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php index 48bb0d061d1be..ff019bcf014a1 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php @@ -237,6 +237,7 @@ public function test_core_settings_get_input_schema_exposes_group_and_fields_fil $this->assertContains( 'blogname', $schema['properties']['fields']['items']['enum'] ); $this->assertContains( 'posts_per_page', $schema['properties']['fields']['items']['enum'] ); + $this->assertContains( 'wp_page_for_privacy_policy', $schema['properties']['fields']['items']['enum'] ); } /** From d60e3cbe913d99d25ba7ae57bd8208704d9667c2 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 22:05:21 +0100 Subject: [PATCH 34/39] Abilities API: Simplify WP_Abilities_Settings. - Start the exposed settings as an empty array, which removes the unreachable null check in `execute_get_settings()`. - Collect the groups and the output schema properties with `array_column()` and `wp_list_pluck()` instead of a loop. - Read the setting type and group without the checks that `register_setting()` already guarantees, and the exposed name as the settings endpoint does. - Check `$show['schema']` with `isset()` alone. - Use the `site` category directly, as the other core abilities do, instead of a constant used once. --- .../abilities/class-wp-abilities-settings.php | 53 +++++-------------- 1 file changed, 13 insertions(+), 40 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-abilities-settings.php b/src/wp-includes/abilities/class-wp-abilities-settings.php index 040578b2c089f..9b888ec01fece 100644 --- a/src/wp-includes/abilities/class-wp-abilities-settings.php +++ b/src/wp-includes/abilities/class-wp-abilities-settings.php @@ -41,14 +41,6 @@ */ final class WP_Abilities_Settings { - /** - * The ability category used for settings abilities. - * - * @since 7.2.0 - * @var string - */ - private const CATEGORY = 'site'; - /** * Settings exposed through the Abilities API, computed once at registration. * @@ -56,9 +48,9 @@ final class WP_Abilities_Settings { * structure, and {@see get_registered_settings()} is only walked once per request. * * @since 7.2.0 - * @var array}>|null + * @var array}> */ - private $exposed_settings = null; + private $exposed_settings = array(); /** * Registers all settings abilities. @@ -84,29 +76,19 @@ public function register(): void { * @since 7.2.0 */ private function register_get_settings(): void { - $settings = (array) $this->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']; - } + $groups = array_values( array_unique( array_filter( array_column( $this->exposed_settings, 'group' ) ) ) ); wp_register_ability( 'core/settings-get', array( 'label' => __( 'Settings Get' ), '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. A setting whose value does not match its schema is left out.' ), - 'category' => self::CATEGORY, - 'input_schema' => $this->get_settings_input_schema( $groups, $field_names ), + 'category' => 'site', + 'input_schema' => $this->get_settings_input_schema( $groups, array_keys( $this->exposed_settings ) ), 'output_schema' => array( 'type' => 'object', 'description' => __( 'A map of setting name to its current value.' ), - 'properties' => $properties, + 'properties' => wp_list_pluck( $this->exposed_settings, 'schema' ), 'additionalProperties' => false, ), 'execute_callback' => array( $this, 'execute_get_settings' ), @@ -132,20 +114,12 @@ private function register_get_settings(): void { * @return array Map of exposed setting name to current value. */ public function execute_get_settings( $input = array() ): array { - $input = is_array( $input ) ? $input : array(); - - $settings = $this->exposed_settings; - if ( null === $settings ) { - // The cache is populated in register() before the ability is - // registered, so this is unreachable in practice; bail defensively otherwise. - return array(); - } - + $input = is_array( $input ) ? $input : array(); $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 ) { + foreach ( $this->exposed_settings as $exposed_name => $setting ) { if ( '' !== $group && $setting['group'] !== $group ) { continue; } @@ -246,12 +220,11 @@ private function get_exposed_settings(): array { continue; } - $option_name = (string) $option_name; - $exposed_name = is_array( $show ) && isset( $show['name'] ) && is_string( $show['name'] ) && '' !== $show['name'] ? $show['name'] : $option_name; + $option_name = (string) $option_name; - $settings[ $exposed_name ] = array( + $settings[ empty( $show['name'] ) ? $option_name : $show['name'] ] = array( 'option' => $option_name, - 'group' => isset( $args['group'] ) && is_string( $args['group'] ) ? $args['group'] : '', + 'group' => $args['group'] ?? '', 'schema' => $schema, ); } @@ -270,7 +243,7 @@ private function get_exposed_settings(): array { */ private function value_schema( array $args, $show ): array { $schema = array( - 'type' => isset( $args['type'] ) && is_string( $args['type'] ) ? $args['type'] : 'string', + 'type' => $args['type'], ); if ( ! empty( $args['label'] ) ) { $schema['title'] = $args['label']; @@ -278,7 +251,7 @@ private function value_schema( array $args, $show ): array { if ( ! empty( $args['description'] ) ) { $schema['description'] = $args['description']; } - if ( is_array( $show ) && isset( $show['schema'] ) && is_array( $show['schema'] ) ) { + if ( isset( $show['schema'] ) && is_array( $show['schema'] ) ) { /** @var array $show_schema */ $show_schema = $show['schema']; $schema = array_merge( $schema, $show_schema ); From 7f149dfc93088b126d277e765bf6814c8fac40f6 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 22:05:23 +0100 Subject: [PATCH 35/39] Abilities API: Label the settings ability "Get Settings". "Settings Get" follows the ability name but does not read as a label. The other core abilities start with the verb, as in "Get Site Information". The AI plugin uses the same label (see https://github.com/WordPress/ai/pull/764). --- src/wp-includes/abilities/class-wp-abilities-settings.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-abilities-settings.php b/src/wp-includes/abilities/class-wp-abilities-settings.php index 9b888ec01fece..a0308fc4dd05d 100644 --- a/src/wp-includes/abilities/class-wp-abilities-settings.php +++ b/src/wp-includes/abilities/class-wp-abilities-settings.php @@ -81,7 +81,7 @@ private function register_get_settings(): void { wp_register_ability( 'core/settings-get', array( - 'label' => __( 'Settings Get' ), + '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, by setting name, or both. A setting whose value does not match its schema is left out.' ), 'category' => 'site', 'input_schema' => $this->get_settings_input_schema( $groups, array_keys( $this->exposed_settings ) ), From 6ebbabf730bde532c77533162eb6ee8710e93aa5 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 22:05:25 +0100 Subject: [PATCH 36/39] Abilities API: Trim the `show_in_abilities` docs. Describe the `register_setting()` argument by its shape and the one rule integrators need, registering the setting on `init` or earlier. Drop a comment that would go stale once more settings abilities are added. --- src/wp-includes/abilities.php | 2 +- src/wp-includes/option.php | 18 +++++------------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index 6687519cf752b..7057939b9026b 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -363,6 +363,6 @@ function wp_register_core_abilities(): void { ) ); - // Register the settings abilities (currently the read-only `core/settings-get`). + // Register the settings abilities. ( new WP_Abilities_Settings() )->register(); } diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index d98a6eb33f69c..6e51021e313b9 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -3072,15 +3072,9 @@ function _wp_register_initial_settings_for_abilities(): void { * @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-get` ability). When registering complex - * settings, this argument may optionally be an array with optional 'name' - * and 'schema' keys, mirroring the `show_in_rest` shape. The set of - * exposed settings is captured when the `core/settings-get` ability - * registers on the `wp_abilities_api_init` hook, which fires on first use - * of the abilities registry, so a setting must be registered before that — - * registering it on `init` is reliable. Core's initial settings are always - * included. + * @type bool|array $show_in_abilities Whether this setting should be exposed through the Abilities API. + * Like `$show_in_rest`, it may be an array with 'name' and 'schema' keys. + * Register the setting on `init` or earlier, before abilities initialize. * @type mixed $default Default value when calling `get_option()`. * } */ @@ -3304,10 +3298,8 @@ 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-get` ability). May optionally be an array - * with optional 'name' and 'schema' keys, mirroring the `show_in_rest` - * shape. + * @type bool|array $show_in_abilities Whether this setting should be exposed through the Abilities API. + * Like `$show_in_rest`, it may be an array with 'name' and 'schema' keys. * @type mixed $default Default value when calling `get_option()`. Only present when the * setting was registered with a default. * } From 9c23f9ff2207c1a81e36dfe621b278bfac62c966 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 22:08:40 +0100 Subject: [PATCH 37/39] Revert "Abilities API: Label the settings ability "Get Settings"." This reverts commit 7f149dfc930. The AI plugin labels the ability "Settings Get", in line with its other object-first abilities, since https://github.com/WordPress/ai/pull/1087, which this PR carries over. --- src/wp-includes/abilities/class-wp-abilities-settings.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-abilities-settings.php b/src/wp-includes/abilities/class-wp-abilities-settings.php index a0308fc4dd05d..9b888ec01fece 100644 --- a/src/wp-includes/abilities/class-wp-abilities-settings.php +++ b/src/wp-includes/abilities/class-wp-abilities-settings.php @@ -81,7 +81,7 @@ private function register_get_settings(): void { wp_register_ability( 'core/settings-get', array( - 'label' => __( 'Get Settings' ), + 'label' => __( 'Settings Get' ), '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. A setting whose value does not match its schema is left out.' ), 'category' => 'site', 'input_schema' => $this->get_settings_input_schema( $groups, array_keys( $this->exposed_settings ) ), From 656b4531ba6971e673b5dd34da901e14a5b5b28a Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 22:09:03 +0100 Subject: [PATCH 38/39] Abilities API: Default the `core/settings-get` input to an empty array. The other core abilities default their object input to `array()`, so use the same default here, instead of an object. --- src/wp-includes/abilities/class-wp-abilities-settings.php | 3 +-- .../tests/abilities-api/wpRegisterCoreSettingsGetAbility.php | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-abilities-settings.php b/src/wp-includes/abilities/class-wp-abilities-settings.php index 9b888ec01fece..e4b19f2275aa8 100644 --- a/src/wp-includes/abilities/class-wp-abilities-settings.php +++ b/src/wp-includes/abilities/class-wp-abilities-settings.php @@ -173,8 +173,7 @@ public function has_permission(): bool { private function get_settings_input_schema( array $groups, array $field_names ): array { return array( 'type' => 'object', - // Object (not array()) so the serialized schema default is {}, consistent with type:object. - 'default' => (object) array(), + 'default' => array(), 'properties' => array( 'group' => array( 'type' => 'string', diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php index ff019bcf014a1..6b557c942aef0 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php @@ -229,7 +229,7 @@ public function test_core_settings_get_input_schema_exposes_group_and_fields_fil $schema = wp_get_ability( 'core/settings-get' )->get_input_schema(); $this->assertSame( 'object', $schema['type'] ); - $this->assertArrayHasKey( 'default', $schema ); + $this->assertSame( array(), $schema['default'] ); $this->assertArrayNotHasKey( 'oneOf', $schema ); $this->assertContains( 'general', $schema['properties']['group']['enum'] ); From 6b5f569817c55f559b0f8d963935c2e464775101 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 6 Oct 2026 22:53:27 +0100 Subject: [PATCH 39/39] Abilities API: Default the `core/settings-get` input to an empty object. Restore the default that 656b4531ba changed to `array()`. An object is serialized as `{}` wherever the schema is read, and it keeps the class identical to the AI plugin's, which supports WordPress 7.0, where the abilities endpoints send an empty array default as `[]`. --- src/wp-includes/abilities/class-wp-abilities-settings.php | 3 ++- .../tests/abilities-api/wpRegisterCoreSettingsGetAbility.php | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-abilities-settings.php b/src/wp-includes/abilities/class-wp-abilities-settings.php index e4b19f2275aa8..9b888ec01fece 100644 --- a/src/wp-includes/abilities/class-wp-abilities-settings.php +++ b/src/wp-includes/abilities/class-wp-abilities-settings.php @@ -173,7 +173,8 @@ public function has_permission(): bool { private function get_settings_input_schema( array $groups, array $field_names ): array { return array( 'type' => 'object', - 'default' => array(), + // Object (not array()) so the serialized schema default is {}, consistent with type:object. + 'default' => (object) array(), 'properties' => array( 'group' => array( 'type' => 'string', diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php index 6b557c942aef0..db89f12ff41b5 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php @@ -229,7 +229,7 @@ public function test_core_settings_get_input_schema_exposes_group_and_fields_fil $schema = wp_get_ability( 'core/settings-get' )->get_input_schema(); $this->assertSame( 'object', $schema['type'] ); - $this->assertSame( array(), $schema['default'] ); + $this->assertEquals( (object) array(), $schema['default'] ); $this->assertArrayNotHasKey( 'oneOf', $schema ); $this->assertContains( 'general', $schema['properties']['group']['enum'] );