Skip to content
This repository was archived by the owner on Feb 5, 2026. It is now read-only.
8 changes: 4 additions & 4 deletions docs/3.registering-abilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ The `$args` array accepts the following keys:
- `execute_callback` (`callable`, **Required**): The PHP function or method to execute when this ability is called.
- The callback receives one optional argument: it can have any type as defined in the input schema (e.g., `array`, `object`, `string`, etc.).
- The callback should return the result of the ability's operation or return a `WP_Error` object on failure.
- `permission_callback` (`callable`|`null`, **Optional**): A callback function to check if the current user has permission to execute this ability.
- Should return `true` if the user has permission, or a `WP_Error` object otherwise.
- If not provided, the ability will only validate input parameters before execution.
- This defaults to `true` if not set.
- `permission_callback` (`callable`, **Required**): A callback function to check if the current user has permission to execute this ability.
- The callback receives one optional argument: it can have any type as defined in the input schema (e.g., `array`, `object`, `string`, etc.).
- The callback should return a boolean (`true` if the user has permission, `false` otherwise), or a `WP_Error` object on failure.
- If the input does not validate against the input schema, the permission callback will not be called, and a `WP_Error` will be returned instead.
- `meta` (`array`, **Optional**): An associative array for storing arbitrary additional metadata about the ability.

**Ability ID Convention**
Expand Down
4 changes: 2 additions & 2 deletions includes/abilities-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@
* @phpstan-param array{
* label?: string,
* description?: string,
* input_schema?: array<string,mixed>,
* output_schema?: array<string,mixed>,
Comment thread
justlevine marked this conversation as resolved.
* execute_callback?: callable( mixed $input= ): (mixed|\WP_Error),
* permission_callback?: callable( mixed $input= ): (bool|\WP_Error),
* input_schema?: array<string,mixed>,
* output_schema?: array<string,mixed>,
* meta?: array<string,mixed>,
* ability_class?: class-string<\WP_Ability>,
* ...<string, mixed>
Expand Down
9 changes: 4 additions & 5 deletions includes/abilities-api/class-wp-abilities-registry.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,18 +46,17 @@ final class WP_Abilities_Registry {
* @param string $name The name of the ability. The name must be a string containing a namespace
* prefix, i.e. `my-plugin/my-ability`. It can only contain lowercase
* alphanumeric characters, dashes and the forward slash.
* @param array<string,mixed> $args An associative array of arguments for the ability. This should include
Comment thread
justlevine marked this conversation as resolved.
* `label`, `description`, `input_schema`, `output_schema`,
* `execute_callback`, `permission_callback`, `meta`, and ability_class.
* @param array<string,mixed> $args An associative array of arguments for the ability. See wp_register_ability() for
* details.
* @return ?\WP_Ability The registered ability instance on success, null on failure.
*
* @phpstan-param array{
* label?: string,
* description?: string,
* execute_callback?: callable( mixed $input= ): (mixed|\WP_Error),
* permission_callback?: callable( mixed $input= ): (bool|\WP_Error),
* input_schema?: array<string,mixed>,
* output_schema?: array<string,mixed>,
* execute_callback?: callable( mixed $input= ): (mixed|\WP_Error),
* permission_callback?: ?callable( mixed $input= ): (bool|\WP_Error),
* meta?: array<string,mixed>,
* ability_class?: class-string<\WP_Ability>,
* ...<string, mixed>
Expand Down
35 changes: 17 additions & 18 deletions includes/abilities-api/class-wp-ability.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,9 @@ class WP_Ability {
* The optional ability permission callback.
*
* @since 0.1.0
* @var ?callable( mixed $input= ): (bool|\WP_Error)
* @var callable( mixed $input= ): (bool|\WP_Error)
*/
protected $permission_callback = null;
protected $permission_callback;

/**
* The optional ability metadata.
Expand Down Expand Up @@ -143,15 +143,16 @@ public function __construct( string $name, array $args ) {
* @phpstan-return array{
* label: string,
* description: string,
* execute_callback: callable( mixed $input= ): (mixed|\WP_Error),
* permission_callback: callable( mixed $input= ): (bool|\WP_Error),
* input_schema?: array<string,mixed>,
* output_schema?: array<string,mixed>,
* execute_callback: callable( mixed $input= ): (mixed|\WP_Error),
* permission_callback?: ?callable( mixed $input= ): (bool|\WP_Error),
* meta?: array<string,mixed>,
* ...<string, mixed>,
* } $args
*/
protected function prepare_properties( array $args ): array {
// Required args must be present and of the correct type.
if ( empty( $args['label'] ) || ! is_string( $args['label'] ) ) {
throw new \InvalidArgumentException(
esc_html__( 'The ability properties must contain a `label` string.' )
Expand All @@ -164,27 +165,28 @@ protected function prepare_properties( array $args ): array {
);
}

if ( isset( $args['input_schema'] ) && ! is_array( $args['input_schema'] ) ) {
if ( empty( $args['execute_callback'] ) || ! is_callable( $args['execute_callback'] ) ) {
throw new \InvalidArgumentException(
esc_html__( 'The ability properties should provide a valid `input_schema` definition.' )
esc_html__( 'The ability properties must contain a valid `execute_callback` function.' )
);
}

if ( isset( $args['output_schema'] ) && ! is_array( $args['output_schema'] ) ) {
if ( empty( $args['permission_callback'] ) || ! is_callable( $args['permission_callback'] ) ) {
throw new \InvalidArgumentException(
esc_html__( 'The ability properties should provide a valid `output_schema` definition.' )
esc_html__( 'The ability properties must provide a valid `permission_callback` function.' )
);
}

if ( empty( $args['execute_callback'] ) || ! is_callable( $args['execute_callback'] ) ) {
// Optional args only need to be of the correct type if they are present.
if ( isset( $args['input_schema'] ) && ! is_array( $args['input_schema'] ) ) {
throw new \InvalidArgumentException(
esc_html__( 'The ability properties must contain a valid `execute_callback` function.' )
esc_html__( 'The ability properties should provide a valid `input_schema` definition.' )
);
}

if ( isset( $args['permission_callback'] ) && ! is_callable( $args['permission_callback'] ) ) {
if ( isset( $args['output_schema'] ) && ! is_array( $args['output_schema'] ) ) {
throw new \InvalidArgumentException(
esc_html__( 'The ability properties should provide a valid `permission_callback` function.' )
esc_html__( 'The ability properties should provide a valid `output_schema` definition.' )
);
}

Expand Down Expand Up @@ -278,6 +280,7 @@ protected function validate_input( $input = null ) {
if ( null === $input ) {
return true;
}

return new \WP_Error(
'ability_missing_input_schema',
sprintf(
Expand Down Expand Up @@ -306,8 +309,8 @@ protected function validate_input( $input = null ) {

/**
* Checks whether the ability has the necessary permissions.
* If the permission callback is not set, the default behavior is to allow access
* when the input provided passes validation.
*
* The input is validated against the input schema before it is passed to to permission callback.
*
* @since 0.1.0
*
Expand All @@ -320,10 +323,6 @@ public function has_permission( $input = null ) {
return $is_valid;
}

if ( ! is_callable( $this->permission_callback ) ) {
return true;
}

if ( empty( $this->get_input_schema() ) ) {
return call_user_func( $this->permission_callback );
}
Expand Down
26 changes: 26 additions & 0 deletions tests/unit/abilities-api/wpAbilitiesRegistry.php
Comment thread
justlevine marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ public function test_register_invalid_uppercase_characters_in_name() {
* Should reject ability registration without a label.
*
* @covers WP_Abilities_Registry::register
* @covers WP_Ability::prepare_properties
*
* @expectedIncorrectUsage WP_Abilities_Registry::register
*/
Expand All @@ -127,6 +128,7 @@ public function test_register_invalid_missing_label() {
* Should reject ability registration with invalid label type.
*
* @covers WP_Abilities_Registry::register
* @covers WP_Ability::prepare_properties
*
* @expectedIncorrectUsage WP_Abilities_Registry::register
*/
Expand All @@ -141,6 +143,7 @@ public function test_register_invalid_label_type() {
* Should reject ability registration without a description.
*
* @covers WP_Abilities_Registry::register
* @covers WP_Ability::prepare_properties
*
* @expectedIncorrectUsage WP_Abilities_Registry::register
*/
Expand All @@ -156,6 +159,7 @@ public function test_register_invalid_missing_description() {
* Should reject ability registration with invalid description type.
*
* @covers WP_Abilities_Registry::register
* @covers WP_Ability::prepare_properties
*
* @expectedIncorrectUsage WP_Abilities_Registry::register
*/
Expand All @@ -170,6 +174,7 @@ public function test_register_invalid_description_type() {
* Should reject ability registration without an execute callback.
*
* @covers WP_Abilities_Registry::register
* @covers WP_Ability::prepare_properties
*
* @expectedIncorrectUsage WP_Abilities_Registry::register
*/
Expand All @@ -185,6 +190,7 @@ public function test_register_invalid_missing_execute_callback() {
* Should reject ability registration if the execute callback is not a callable.
*
* @covers WP_Abilities_Registry::register
* @covers WP_Ability::prepare_properties
*
* @expectedIncorrectUsage WP_Abilities_Registry::register
*/
Expand All @@ -195,10 +201,27 @@ public function test_register_incorrect_execute_callback_type() {
$this->assertNull( $result );
}

/**
* Should reject ability registration without an execute callback.
*
* @covers WP_Abilities_Registry::register
* @covers WP_Ability::prepare_properties
*
* @expectedIncorrectUsage WP_Abilities_Registry::register
*/
public function test_register_invalid_missing_permission_callback() {
// Remove the permission_callback from the args.
unset( self::$test_ability_args['permission_callback'] );

$result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
$this->assertNull( $result );
}

/**
* Should reject ability registration if the permission callback is not a callable.
*
* @covers WP_Abilities_Registry::register
* @covers WP_Ability::prepare_properties
*
* @expectedIncorrectUsage WP_Abilities_Registry::register
*/
Expand All @@ -213,6 +236,7 @@ public function test_register_incorrect_permission_callback_type() {
* Should reject ability registration if the input schema is not an array.
*
* @covers WP_Abilities_Registry::register
* @covers WP_Ability::prepare_properties
*
* @expectedIncorrectUsage WP_Abilities_Registry::register
*/
Expand All @@ -227,6 +251,7 @@ public function test_register_incorrect_input_schema_type() {
* Should reject ability registration if the output schema is not an array.
*
* @covers WP_Abilities_Registry::register
* @covers WP_Ability::prepare_properties
*
* @expectedIncorrectUsage WP_Abilities_Registry::register
*/
Expand All @@ -241,6 +266,7 @@ public function test_register_incorrect_output_schema_type() {
* Should reject ability registration with invalid meta type.
*
* @covers WP_Abilities_Registry::register
* @covers WP_Ability::prepare_properties
*
* @expectedIncorrectUsage WP_Abilities_Registry::register
*/
Expand Down
1 change: 0 additions & 1 deletion tests/unit/abilities-api/wpRegisterAbility.php
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,6 @@ public function test_register_ability_custom_ability_class(): void {
);
}


/**
* Tests executing an ability with input not matching schema.
*/
Expand Down
32 changes: 0 additions & 32 deletions tests/unit/rest-api/wpRestAbilitiesRunController.php
Original file line number Diff line number Diff line change
Expand Up @@ -703,38 +703,6 @@ public function test_ability_without_type_defaults_to_tool(): void {
$this->assertEquals( 200, $post_response->get_status() );
}

/**
* Test permission check with null permission callback.
*/
public function test_permission_check_passes_when_callback_not_set(): void {
Comment thread
justlevine marked this conversation as resolved.
// Register ability without permission callback.
wp_register_ability(
'test/no-permission-callback',
array(
'label' => 'No Permission Callback',
'description' => 'Ability without permission callback',
'execute_callback' => static function () {
return array( 'executed' => true );
},
'meta' => array( 'type' => 'tool' ),
// No permission_callback set
)
);

wp_set_current_user( 0 ); // Not logged in

$request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/no-permission-callback/run' );
$request->set_header( 'Content-Type', 'application/json' );

$response = $this->server->dispatch( $request );

// Should succeed when no permission callback is set
$this->assertEquals( 200, $response->get_status() );

// Restore user for other tests
wp_set_current_user( self::$user_id );
}

/**
* Test edge case with empty input for both GET and POST.
*/
Expand Down