Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 49 additions & 8 deletions src/wp-includes/abilities-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@
* @since 6.9.0
* @since 7.1.0 Added the `public` meta argument.
* @since 7.2.0 The `category` argument is now optional and defaults to `uncategorized`.
* @since 7.2.0 Added the `eligibility_callback` argument.
*
* @see WP_Abilities_Registry::register()
* @see wp_register_ability_category()
Expand All @@ -258,6 +259,14 @@
* Receives optional mixed input data (same as `execute_callback`) and
* must return `true`/`false` for simple checks, or `WP_Error` for
* detailed error responses.
* @type callable $eligibility_callback Optional. A callback function that decides whether the ability
* applies in a given usage context, such as a specific admin screen
* or frontend page. Receives an associative array of context values
* and must return a boolean. Consulted only when listing abilities,
* never on execution, and it is not a security boundary because the
* context values come from the caller unverified. When the context
* lacks a key the callback cares about, the callback should return
* true, so passing more context can only narrow a result.
* @type array<string, mixed> $input_schema Optional. JSON Schema definition for validating the ability's input.
* Must be a valid JSON Schema object defining the structure and
* constraints for input data. Used for automatic validation and
Expand Down Expand Up @@ -423,13 +432,15 @@ function wp_get_ability( string $name ): ?WP_Ability {
*
* 1. Declarative filters (`category`, `namespace`, `meta`) — per-item, AND logic between
* arg types.
* 2. `item_include_callback` — per-item, caller-scoped. Return true to include, false to exclude.
* 3. `wp_get_abilities_item_include` filter — per-item, ecosystem-scoped. Plugins can enforce
* 2. Eligibility — per-item, ability-scoped. When an `eligibility_context` is passed, each
* ability's `eligibility_callback` decides whether the ability applies in that context.
* 3. `item_include_callback` — per-item, caller-scoped. Return true to include, false to exclude.
* 4. `wp_get_abilities_item_include` filter — per-item, ecosystem-scoped. Plugins can enforce
* universal inclusion rules regardless of what the caller passed.
* 4. `result_callback` — on the full matched array, caller-scoped. Sort, slice, or reshape.
* 5. `wp_get_abilities_result` filter — on the full array, ecosystem-scoped.
* 5. `result_callback` — on the full matched array, caller-scoped. Sort, slice, or reshape.
* 6. `wp_get_abilities_result` filter — on the full array, ecosystem-scoped.
*
* Steps 1–3 run inside a single loop over the registry — no extra iteration.
* Steps 1–4 run inside a single loop over the registry — no extra iteration.
*
* Examples:
*
Expand All @@ -452,6 +463,14 @@ function wp_get_ability( string $name ): ?WP_Ability {
* 'meta' => array( 'show_in_rest' => true ),
* ) );
*
* // Narrow to abilities eligible in a usage context.
* $abilities = wp_get_abilities( array(
* 'eligibility_context' => array(
* 'surface' => 'webmcp-admin',
* 'post_type' => 'product',
* ),
* ) );
*
* // Caller-scoped per-item callback.
* $abilities = wp_get_abilities( array(
* 'item_include_callback' => function ( WP_Ability $ability ) {
Expand All @@ -475,6 +494,7 @@ function wp_get_ability( string $name ): ?WP_Ability {
*
* @since 6.9.0
* @since 7.1.0 Added the `$args` parameter for filtering support.
* @since 7.2.0 Added the `eligibility_context` argument.
*
* @see WP_Abilities_Registry::get_all_registered()
*
Expand All @@ -489,6 +509,16 @@ function wp_get_ability( string $name ): ?WP_Ability {
* @type array $meta Filter by meta key/value pairs. All conditions must
* match (AND logic). Supports nested arrays for structured
* meta, e.g. `array( 'mcp' => array( 'public' => true ) )`.
* @type array $eligibility_context Optional. An associative array describing the caller's
* usage context, e.g. `array( 'surface' => 'webmcp-admin' )`.
* Core does not define the keys. Callers and ability authors
* agree on them, and plugin specific keys should be
* namespaced like `plugin-slug/key`. Abilities whose
* `eligibility_callback` returns false for this context are
* excluded. When omitted or empty, eligibility is not
* evaluated and every ability is kept, so a context can
* only narrow the result. The context values come from the
* caller unverified, so this is not a security boundary.
* @type callable $item_include_callback Optional. A callback invoked per ability after declarative
* filters. Receives a WP_Ability instance, returns bool.
* Return true to include, false to exclude.
Expand All @@ -512,6 +542,7 @@ function wp_get_abilities( array $args = array() ): array {
$category = isset( $args['category'] ) && is_string( $args['category'] ) ? $args['category'] : '';
$namespace = isset( $args['namespace'] ) && is_string( $args['namespace'] ) ? rtrim( $args['namespace'], '/' ) . '/' : '';
$meta = isset( $args['meta'] ) && is_array( $args['meta'] ) ? $args['meta'] : array();
$eligibility_context = isset( $args['eligibility_context'] ) && is_array( $args['eligibility_context'] ) ? $args['eligibility_context'] : array();
$item_include_callback = isset( $args['item_include_callback'] ) && is_callable( $args['item_include_callback'] ) ? $args['item_include_callback'] : null;
$result_callback = isset( $args['result_callback'] ) && is_callable( $args['result_callback'] ) ? $args['result_callback'] : null;

Expand All @@ -533,7 +564,16 @@ function wp_get_abilities( array $args = array() ): array {
continue;
}

// Step 2: Caller-scoped per-item callback.
/*
* Step 2: Ability-scoped eligibility for the given usage context.
* Skipped entirely when no context is passed, so a context can only
* narrow the result relative to a call without one.
*/
if ( array() !== $eligibility_context && ! $ability->is_eligible( $eligibility_context ) ) {
continue;
}

// Step 3: Caller-scoped per-item callback.
$include = true;
if ( null !== $item_include_callback ) {
$include = (bool) call_user_func( $item_include_callback, $ability );
Expand All @@ -542,7 +582,8 @@ function wp_get_abilities( array $args = array() ): array {
/**
* Filters whether an individual ability should be included in the result set.
*
* Fires after the declarative filters and the caller-scoped item_include_callback.
* Fires after the declarative filters, the eligibility check, and the
* caller-scoped item_include_callback.
* Plugins can use this to enforce universal inclusion rules regardless of
* what the caller passed in $args.
*
Expand All @@ -559,7 +600,7 @@ function wp_get_abilities( array $args = array() ): array {
}
}

// Step 4: Caller-scoped result callback.
// Step 5: Caller-scoped result callback.
if ( null !== $result_callback ) {
$matched = (array) call_user_func( $result_callback, $matched );
}
Expand Down
86 changes: 86 additions & 0 deletions src/wp-includes/abilities-api/class-wp-ability.php
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,14 @@ class WP_Ability {
*/
protected $permission_callback;

/**
* The optional ability eligibility callback.
*
* @since 7.2.0
* @var callable(array<string, mixed>): bool
*/
protected $eligibility_callback;

/**
* The optional ability metadata.
*
Expand All @@ -140,6 +148,7 @@ class WP_Ability {
*
* @since 6.9.0
* @since 7.1.0 Added the `public` meta argument.
* @since 7.2.0 Added the `eligibility_callback` argument.
*
* @see wp_register_ability()
*
Expand All @@ -154,6 +163,9 @@ class WP_Ability {
* Receives optional mixed input and returns mixed result or WP_Error.
* @type callable $permission_callback A callback function to check permissions before execution.
* Receives optional mixed input and returns bool or WP_Error.
* @type callable $eligibility_callback Optional. A callback function that decides whether the ability
* applies in a given usage context. Receives an associative array
* of context values and must return a boolean.
* @type array<string, mixed> $input_schema Optional. JSON Schema definition for the ability's input.
* @type array<string, mixed> $output_schema Optional. JSON Schema definition for the ability's output.
* @type array<string, mixed> $meta {
Expand Down Expand Up @@ -211,6 +223,7 @@ public function __construct( string $name, array $args ) {
*
* @since 6.9.0
* @since 7.1.0 Added the `public` meta argument.
* @since 7.2.0 Added the `eligibility_callback` argument.
*
* @see WP_Abilities_Registry::register()
*
Expand All @@ -224,6 +237,9 @@ public function __construct( string $name, array $args ) {
* Receives optional mixed input and returns mixed result or WP_Error.
* @type callable $permission_callback A callback function to check permissions before execution.
* Receives optional mixed input and returns bool or WP_Error.
* @type callable $eligibility_callback Optional. A callback function that decides whether the ability
* applies in a given usage context. Receives an associative array
* of context values and must return a boolean.
* @type array<string, mixed> $input_schema Optional. JSON Schema definition for the ability's input. Required if ability accepts an input.
* @type array<string, mixed> $output_schema Optional. JSON Schema definition for the ability's output.
* @type array<string, mixed> $meta {
Expand Down Expand Up @@ -257,6 +273,9 @@ public function __construct( string $name, array $args ) {
* Receives optional mixed input and returns mixed result or WP_Error.
* @type callable $permission_callback A callback function to check permissions before execution.
* Receives optional mixed input and returns bool or WP_Error.
* @type callable $eligibility_callback Optional. A callback function that decides whether the ability
* applies in a given usage context. Receives an associative array
* of context values and must return a boolean.
* @type array<string, mixed> $input_schema Optional. JSON Schema definition for the ability's input.
* @type array<string, mixed> $output_schema Optional. JSON Schema definition for the ability's output.
* @type array<string, mixed> $meta {
Expand Down Expand Up @@ -315,6 +334,12 @@ protected function prepare_properties( array $args ): array {
}

// Optional args only need to be of the correct type if they are present.
if ( isset( $args['eligibility_callback'] ) && ! is_callable( $args['eligibility_callback'] ) ) {
throw new InvalidArgumentException(
__( 'The ability properties should provide a valid `eligibility_callback` function.' )
);
}

if ( isset( $args['input_schema'] ) && ! is_array( $args['input_schema'] ) ) {
throw new InvalidArgumentException(
__( 'The ability properties should provide a valid `input_schema` definition.' )
Expand Down Expand Up @@ -660,6 +685,67 @@ public function check_permissions( $input = null ) {
return $result;
}

/**
* Checks whether the ability applies in the given usage context.
*
* Eligibility is consulted when listing abilities, for example by `wp_get_abilities()`
* when it receives an `eligibility_context` argument. It is not consulted when the
* ability is executed.
*
* Eligibility is not a security boundary. The context values come from the caller and
* are not verified, so nothing security related may depend on them. Permission checks
* belong in the `permission_callback` instead.
*
* An ability without an `eligibility_callback` is always eligible. A callback that
* returns anything other than a boolean is treated as eligible. When the context lacks
* a key the callback cares about, the callback should return true. This way adding
* context keys can only narrow a result, never expand it.
*
* The {@see 'wp_ability_eligibility_result'} filter fires after the registered
* `eligibility_callback` returns, allowing plugins to override the result.
*
* @since 7.2.0
*
* @param array<string, mixed> $eligibility_context Optional. An associative array describing the caller's
* usage context. Default empty array.
* @return bool Whether the ability applies in the given usage context.
*/
public function is_eligible( array $eligibility_context = array() ): bool {
$is_eligible = true;
if ( is_callable( $this->eligibility_callback ) ) {
$is_eligible = call_user_func( $this->eligibility_callback, $eligibility_context );
}

/**
* Filters the eligibility result for an ability.
*
* Fires after the registered `eligibility_callback` returns, or with `true` when the
* ability does not declare one. Plugins can use this to adjust in which usage contexts
* an ability is listed.
*
* Eligibility is not a security boundary. The context values come from the caller
* and are not verified.
*
* Filters can return `true` to keep the ability eligible or `false` to exclude it.
* Any other return value is treated as eligible.
*
* @since 7.2.0
*
* @param mixed $is_eligible The eligibility result returned by `eligibility_callback`,
* or `true` when the ability does not declare one.
* @param string $ability_name The name of the ability.
* @param array<string, mixed> $eligibility_context The usage context being evaluated.
* @param WP_Ability $ability The ability instance.
*/
$result = apply_filters( 'wp_ability_eligibility_result', $is_eligible, $this->name, $eligibility_context, $this );

if ( ! is_bool( $result ) ) {
return true;
}

return $result;
}

/**
* Executes the ability callback.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ public function register_routes(): void {
* Retrieves all abilities.
*
* @since 6.9.0
* @since 7.2.0 Added support for the `eligibility_context` query parameter.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response Response object on success.
Expand All @@ -103,6 +104,10 @@ public function get_items( $request ) {
$query_args['meta'] = array_merge( $request['meta'], $query_args['meta'] );
}

if ( ! empty( $request['eligibility_context'] ) ) {
$query_args['eligibility_context'] = $request['eligibility_context'];
}

$abilities = wp_get_abilities( $query_args );

$page = $request['page'];
Expand Down Expand Up @@ -332,38 +337,44 @@ public function get_item_schema(): array {
*
* @since 6.9.0
* @since 7.1.0 Added the `namespace` and `meta` parameters and the `rest_abilities_collection_params` filter.
* @since 7.2.0 Added the `eligibility_context` parameter.
*
* @return array<string, mixed> Collection parameters.
*/
public function get_collection_params(): array {
$query_params = array(
'context' => $this->get_context_param( array( 'default' => 'view' ) ),
'page' => array(
'context' => $this->get_context_param( array( 'default' => 'view' ) ),
'page' => array(
'description' => __( 'Current page of the collection.' ),
'type' => 'integer',
'default' => 1,
'minimum' => 1,
),
'per_page' => array(
'per_page' => array(
'description' => __( 'Maximum number of items to be returned in result set.' ),
'type' => 'integer',
'default' => 50,
'minimum' => 1,
'maximum' => 100,
),
'category' => array(
'category' => array(
'description' => __( 'Limit results to abilities in specific ability category.' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_key',
'validate_callback' => 'rest_validate_request_arg',
),
'namespace' => array(
'namespace' => array(
'description' => __( 'Limit results to abilities in a specific namespace.' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_key',
'validate_callback' => 'rest_validate_request_arg',
),
'meta' => array(
'eligibility_context' => array(
'description' => __( 'Limit results to abilities eligible in the given usage context. The keys and values are agreed on between clients and ability authors. They are provided by the client and are not verified.' ),
'type' => 'object',
'additionalProperties' => true,
),
'meta' => array(
'description' => __( 'Limit results to abilities matching all of the given meta fields.' ),
'type' => 'object',
'properties' => array(
Expand Down
Loading
Loading