From feb5a196bfd1c9606ee065fb699eab944f9c71f6 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 23 Feb 2026 17:56:18 +0100 Subject: [PATCH 1/6] Add WordPress Abilities API integration Register ActivityPub abilities (discovery, social) for the WordPress Abilities API (WP 6.9+), enabling other plugins to discover and call ActivityPub operations without depending on internal class structures. --- activitypub.php | 4 + includes/ability/class-actor.php | 145 ++++++++++++ includes/ability/class-followers.php | 162 +++++++++++++ includes/ability/class-following.php | 340 +++++++++++++++++++++++++++ includes/ability/class-webfinger.php | 87 +++++++ includes/class-abilities.php | 98 ++++++++ 6 files changed, 836 insertions(+) create mode 100644 includes/ability/class-actor.php create mode 100644 includes/ability/class-followers.php create mode 100644 includes/ability/class-following.php create mode 100644 includes/ability/class-webfinger.php create mode 100644 includes/class-abilities.php diff --git a/activitypub.php b/activitypub.php index 6bdfd04589..c8f9405fa5 100644 --- a/activitypub.php +++ b/activitypub.php @@ -107,6 +107,10 @@ function plugin_init() { \add_action( 'init', array( __NAMESPACE__ . '\Relay', 'init' ) ); } + // WordPress Abilities API (WP 6.9+). These hooks only fire when the API is available. + \add_action( 'wp_abilities_api_categories_init', array( __NAMESPACE__ . '\Abilities', 'register_categories' ) ); + \add_action( 'wp_abilities_api_init', array( __NAMESPACE__ . '\Abilities', 'register_abilities' ) ); + // Load development tools. if ( 'local' === wp_get_environment_type() ) { $loader_file = __DIR__ . '/local/load.php'; diff --git a/includes/ability/class-actor.php b/includes/ability/class-actor.php new file mode 100644 index 0000000000..455746ab79 --- /dev/null +++ b/includes/ability/class-actor.php @@ -0,0 +1,145 @@ + \__( 'Get Actor Info', 'activitypub' ), + 'description' => \__( 'Fetch profile information for a remote ActivityPub actor.', 'activitypub' ), + 'category' => 'activitypub-discovery', + 'execute_callback' => array( self::class, 'get_actor_info' ), + 'permission_callback' => array( self::class, 'permission_callback' ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'actor' => array( + 'type' => 'string', + 'description' => \__( 'Actor URL or WebFinger handle', 'activitypub' ), + ), + ), + 'required' => array( 'actor' ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'type' => array( + 'type' => 'string', + ), + 'name' => array( + 'type' => 'string', + ), + 'preferredUsername' => array( + 'type' => 'string', + ), + 'summary' => array( + 'type' => 'string', + ), + 'inbox' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'outbox' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'followers' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'following' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'icon' => array( + 'type' => 'object', + ), + ), + ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + } + + /** + * Permission callback. + * + * @since unreleased + * + * @param mixed $input Input parameters (unused). + * @return bool + */ + public static function permission_callback( $input = null ) { + return \current_user_can( 'activitypub' ); + } + + /** + * Get actor information. + * + * @since unreleased + * + * @param array $input Input parameters. + * @return array|\WP_Error + */ + public static function get_actor_info( $input ) { + $actor_input = \sanitize_text_field( $input['actor'] ); + + $post = Remote_Actors::fetch_by_various( $actor_input ); + if ( \is_wp_error( $post ) ) { + return $post; + } + + $actor = Remote_Actors::get_actor( $post ); + if ( \is_wp_error( $actor ) ) { + return $actor; + } + + return array( + 'id' => $actor->get_id(), + 'type' => $actor->get_type(), + 'name' => $actor->get_name(), + 'preferredUsername' => $actor->get_preferred_username(), + 'summary' => $actor->get_summary(), + 'inbox' => $actor->get_inbox(), + 'outbox' => $actor->get_outbox(), + 'followers' => $actor->get_followers(), + 'following' => $actor->get_following(), + 'icon' => $actor->get_icon(), + ); + } +} diff --git a/includes/ability/class-followers.php b/includes/ability/class-followers.php new file mode 100644 index 0000000000..a390b54149 --- /dev/null +++ b/includes/ability/class-followers.php @@ -0,0 +1,162 @@ + \__( 'Get Followers', 'activitypub' ), + 'description' => \__( 'List followers for a local actor.', 'activitypub' ), + 'category' => 'activitypub-social', + 'execute_callback' => array( self::class, 'get_followers' ), + 'permission_callback' => array( self::class, 'permission_callback' ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'user_id' => array( + 'type' => 'integer', + 'description' => \__( 'The local actor user ID.', 'activitypub' ), + ), + 'page' => array( + 'type' => 'integer', + 'description' => \__( 'Page number for pagination.', 'activitypub' ), + ), + 'per_page' => array( + 'type' => 'integer', + 'description' => \__( 'Number of results per page.', 'activitypub' ), + ), + ), + 'required' => array( 'user_id' ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'followers' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'type' => array( + 'type' => 'string', + ), + 'name' => array( + 'type' => 'string', + ), + 'preferredUsername' => array( + 'type' => 'string', + ), + 'followers' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'following' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'icon' => array( + 'type' => 'object', + ), + ), + ), + ), + 'total' => array( + 'type' => 'integer', + ), + ), + ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + } + + /** + * Permission callback. + * + * @since unreleased + * + * @param mixed $input Input parameters (unused). + * @return bool + */ + public static function permission_callback( $input = null ) { + return \current_user_can( 'activitypub' ); + } + + /** + * Get followers for a local actor. + * + * @since unreleased + * + * @param array $input Input parameters. + * @return array|\WP_Error + */ + public static function get_followers( $input ) { + $user_id = \absint( $input['user_id'] ); + + if ( ! $user_id ) { + return new \WP_Error( 'activitypub_invalid_user_id', \__( 'Invalid user ID.', 'activitypub' ), array( 'status' => 400 ) ); + } + + $per_page = isset( $input['per_page'] ) ? \min( \absint( $input['per_page'] ), 100 ) : 20; + $page = isset( $input['page'] ) ? \absint( $input['page'] ) : 1; + + $data = Followers_Collection::query( $user_id, $per_page, $page ); + + $followers = array(); + foreach ( $data['followers'] as $post ) { + $actor = Remote_Actors::get_actor( $post ); + if ( \is_wp_error( $actor ) ) { + continue; + } + $followers[] = array( + 'id' => $actor->get_id(), + 'type' => $actor->get_type(), + 'name' => $actor->get_name(), + 'preferredUsername' => $actor->get_preferred_username(), + 'followers' => $actor->get_followers(), + 'following' => $actor->get_following(), + 'icon' => $actor->get_icon(), + ); + } + + return array( + 'followers' => $followers, + 'total' => $data['total'], + ); + } +} diff --git a/includes/ability/class-following.php b/includes/ability/class-following.php new file mode 100644 index 0000000000..1b8d45e182 --- /dev/null +++ b/includes/ability/class-following.php @@ -0,0 +1,340 @@ + \__( 'Get Following', 'activitypub' ), + 'description' => \__( 'List accounts being followed by a local actor.', 'activitypub' ), + 'category' => 'activitypub-social', + 'execute_callback' => array( self::class, 'get_following' ), + 'permission_callback' => array( self::class, 'permission_callback' ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'user_id' => array( + 'type' => 'integer', + 'description' => \__( 'The local actor user ID.', 'activitypub' ), + ), + 'page' => array( + 'type' => 'integer', + 'description' => \__( 'Page number for pagination.', 'activitypub' ), + ), + 'per_page' => array( + 'type' => 'integer', + 'description' => \__( 'Number of results per page.', 'activitypub' ), + ), + ), + 'required' => array( 'user_id' ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'following' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'type' => array( + 'type' => 'string', + ), + 'name' => array( + 'type' => 'string', + ), + 'preferredUsername' => array( + 'type' => 'string', + ), + 'followers' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'following' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'icon' => array( + 'type' => 'object', + ), + ), + ), + ), + 'total' => array( + 'type' => 'integer', + ), + ), + ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + } + + /** + * Register the follow ability. + * + * @since unreleased + */ + private static function register_follow() { + \wp_register_ability( + 'activitypub/follow', + array( + 'label' => \__( 'Follow', 'activitypub' ), + 'description' => \__( 'Follow a remote actor.', 'activitypub' ), + 'category' => 'activitypub-social', + 'execute_callback' => array( self::class, 'follow' ), + 'permission_callback' => array( self::class, 'permission_callback' ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'actor' => array( + 'type' => 'string', + 'description' => \__( 'Actor URL or WebFinger handle to follow.', 'activitypub' ), + ), + 'user_id' => array( + 'type' => 'integer', + 'description' => \__( 'The local actor user ID. Defaults to the current user.', 'activitypub' ), + ), + ), + 'required' => array( 'actor' ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'outbox_item_id' => array( + 'type' => 'integer', + ), + 'status' => array( + 'type' => 'string', + ), + ), + ), + 'meta' => array( + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + 'idempotent' => false, + ), + 'show_in_rest' => true, + ), + ) + ); + } + + /** + * Register the unfollow ability. + * + * @since unreleased + */ + private static function register_unfollow() { + \wp_register_ability( + 'activitypub/unfollow', + array( + 'label' => \__( 'Unfollow', 'activitypub' ), + 'description' => \__( 'Unfollow a remote actor.', 'activitypub' ), + 'category' => 'activitypub-social', + 'execute_callback' => array( self::class, 'unfollow' ), + 'permission_callback' => array( self::class, 'permission_callback' ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'actor' => array( + 'type' => 'string', + 'description' => \__( 'Actor URL or WebFinger handle to unfollow.', 'activitypub' ), + ), + 'user_id' => array( + 'type' => 'integer', + 'description' => \__( 'The local actor user ID. Defaults to the current user.', 'activitypub' ), + ), + ), + 'required' => array( 'actor' ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( + 'type' => 'boolean', + ), + ), + ), + 'meta' => array( + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + 'idempotent' => false, + ), + 'show_in_rest' => true, + ), + ) + ); + } + + /** + * Permission callback. + * + * @since unreleased + * + * @param mixed $input Input parameters (unused). + * @return bool + */ + public static function permission_callback( $input = null ) { + return \current_user_can( 'activitypub' ); + } + + /** + * Get following for a local actor. + * + * @since unreleased + * + * @param array $input Input parameters. + * @return array|\WP_Error + */ + public static function get_following( $input ) { + $user_id = \absint( $input['user_id'] ); + + if ( ! $user_id ) { + return new \WP_Error( 'activitypub_invalid_user_id', \__( 'Invalid user ID.', 'activitypub' ), array( 'status' => 400 ) ); + } + + $per_page = isset( $input['per_page'] ) ? \min( \absint( $input['per_page'] ), 100 ) : 20; + $page = isset( $input['page'] ) ? \absint( $input['page'] ) : 1; + + $data = Following_Collection::query( $user_id, $per_page, $page ); + + $following = array(); + foreach ( $data['following'] as $post ) { + $actor = Remote_Actors::get_actor( $post ); + if ( \is_wp_error( $actor ) ) { + continue; + } + $following[] = array( + 'id' => $actor->get_id(), + 'type' => $actor->get_type(), + 'name' => $actor->get_name(), + 'preferredUsername' => $actor->get_preferred_username(), + 'followers' => $actor->get_followers(), + 'following' => $actor->get_following(), + 'icon' => $actor->get_icon(), + ); + } + + return array( + 'following' => $following, + 'total' => $data['total'], + ); + } + + /** + * Follow a remote actor. + * + * @since unreleased + * + * @param array $input Input parameters. + * @return array|\WP_Error + */ + public static function follow( $input ) { + $actor = \sanitize_text_field( $input['actor'] ); + $user_id = isset( $input['user_id'] ) ? \absint( $input['user_id'] ) : \get_current_user_id(); + + if ( \get_current_user_id() !== $user_id && ! \current_user_can( 'activitypub' ) ) { + return new \WP_Error( + 'activitypub_forbidden', + \__( 'You are not allowed to act on behalf of another user.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + + $result = follow( $actor, $user_id ); + + if ( \is_wp_error( $result ) ) { + return $result; + } + + return array( + 'outbox_item_id' => $result, + 'status' => 'pending', + ); + } + + /** + * Unfollow a remote actor. + * + * @since unreleased + * + * @param array $input Input parameters. + * @return array|\WP_Error + */ + public static function unfollow( $input ) { + $actor = \sanitize_text_field( $input['actor'] ); + $user_id = isset( $input['user_id'] ) ? \absint( $input['user_id'] ) : \get_current_user_id(); + + if ( \get_current_user_id() !== $user_id && ! \current_user_can( 'activitypub' ) ) { + return new \WP_Error( + 'activitypub_forbidden', + \__( 'You are not allowed to act on behalf of another user.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + + $result = unfollow( $actor, $user_id ); + + if ( \is_wp_error( $result ) ) { + return $result; + } + + return array( + 'success' => true, + ); + } +} diff --git a/includes/ability/class-webfinger.php b/includes/ability/class-webfinger.php new file mode 100644 index 0000000000..7d505c3a6c --- /dev/null +++ b/includes/ability/class-webfinger.php @@ -0,0 +1,87 @@ + \__( 'Resolve WebFinger Handle', 'activitypub' ), + 'description' => \__( 'Resolve a WebFinger handle to an ActivityPub actor URL.', 'activitypub' ), + 'category' => 'activitypub-discovery', + 'execute_callback' => array( self::class, 'resolve_handle' ), + 'permission_callback' => array( self::class, 'permission_callback' ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'handle' => array( + 'type' => 'string', + 'description' => \__( 'WebFinger handle (e.g., user@example.com)', 'activitypub' ), + ), + ), + 'required' => array( 'handle' ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + } + + /** + * Permission callback. + * + * @since unreleased + * + * @param mixed $input Input parameters (unused). + * @return bool + */ + public static function permission_callback( $input = null ) { + return \current_user_can( 'activitypub' ); + } + + /** + * Resolve a WebFinger handle to an actor URL. + * + * @since unreleased + * + * @param array $input Input parameters. + * @return array|\WP_Error + */ + public static function resolve_handle( $input ) { + $handle = \sanitize_text_field( $input['handle'] ); + + return Webfinger_Util::get_data( $handle ); + } +} diff --git a/includes/class-abilities.php b/includes/class-abilities.php new file mode 100644 index 0000000000..267f6680b6 --- /dev/null +++ b/includes/class-abilities.php @@ -0,0 +1,98 @@ + \__( 'Discovery', 'activitypub' ), + 'description' => \__( 'Look up and discover remote actors in the Fediverse.', 'activitypub' ), + ) + ); + + \wp_register_ability_category( + 'activitypub-social', + array( + 'label' => \__( 'Social', 'activitypub' ), + 'description' => \__( 'Manage followers, following, and social connections.', 'activitypub' ), + ) + ); + + \wp_register_ability_category( + 'activitypub-publish', + array( + 'label' => \__( 'Publish', 'activitypub' ), + 'description' => \__( 'Publish and share content to the Fediverse.', 'activitypub' ), + ) + ); + + \wp_register_ability_category( + 'activitypub-moderation', + array( + 'label' => \__( 'Moderation', 'activitypub' ), + 'description' => \__( 'Moderate actors, domains, and activity delivery.', 'activitypub' ), + ) + ); + + /** + * Fires after built-in ability categories are registered. + * + * Use this hook to register additional ability categories. + * + * @since unreleased + */ + \do_action( 'activitypub_register_ability_categories' ); + } + + /** + * Register all ActivityPub abilities. + * + * Hooked into `wp_abilities_api_init`. + * + * @since unreleased + */ + public static function register_abilities() { + Actor::register(); + Followers::register(); + Following::register(); + Webfinger::register(); + + /** + * Fires after built-in abilities are registered. + * + * Use this hook to register additional abilities. + * + * @since unreleased + */ + \do_action( 'activitypub_register_abilities' ); + } +} From 658bb639bc4ece5c3ae32f908043172b2d151fe1 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Mon, 23 Feb 2026 18:00:08 +0100 Subject: [PATCH 2/6] Remove unused $input parameter from permission callbacks --- includes/ability/class-actor.php | 3 +-- includes/ability/class-followers.php | 3 +-- includes/ability/class-following.php | 3 +-- includes/ability/class-webfinger.php | 3 +-- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/includes/ability/class-actor.php b/includes/ability/class-actor.php index 455746ab79..85c6fb65e1 100644 --- a/includes/ability/class-actor.php +++ b/includes/ability/class-actor.php @@ -101,10 +101,9 @@ public static function register() { * * @since unreleased * - * @param mixed $input Input parameters (unused). * @return bool */ - public static function permission_callback( $input = null ) { + public static function permission_callback() { return \current_user_can( 'activitypub' ); } diff --git a/includes/ability/class-followers.php b/includes/ability/class-followers.php index a390b54149..663aa393ad 100644 --- a/includes/ability/class-followers.php +++ b/includes/ability/class-followers.php @@ -110,10 +110,9 @@ public static function register() { * * @since unreleased * - * @param mixed $input Input parameters (unused). * @return bool */ - public static function permission_callback( $input = null ) { + public static function permission_callback() { return \current_user_can( 'activitypub' ); } diff --git a/includes/ability/class-following.php b/includes/ability/class-following.php index 1b8d45e182..142070716e 100644 --- a/includes/ability/class-following.php +++ b/includes/ability/class-following.php @@ -225,10 +225,9 @@ private static function register_unfollow() { * * @since unreleased * - * @param mixed $input Input parameters (unused). * @return bool */ - public static function permission_callback( $input = null ) { + public static function permission_callback() { return \current_user_can( 'activitypub' ); } diff --git a/includes/ability/class-webfinger.php b/includes/ability/class-webfinger.php index 7d505c3a6c..f6e2ae60c8 100644 --- a/includes/ability/class-webfinger.php +++ b/includes/ability/class-webfinger.php @@ -64,10 +64,9 @@ public static function register() { * * @since unreleased * - * @param mixed $input Input parameters (unused). * @return bool */ - public static function permission_callback( $input = null ) { + public static function permission_callback() { return \current_user_can( 'activitypub' ); } From 80c297953e916e5c401efeca817766e8cc2a6c1a Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 24 Feb 2026 11:31:16 +0100 Subject: [PATCH 3/6] Fix ability security issues and clean up naming - Gate follow/unfollow abilities behind `activitypub_following_ui` option - Require `manage_options` for cross-user operations (follow, unfollow, get-following) - Rename `activitypub/get-actor-info` to `activitypub/get-actor` - Remove empty placeholder categories (publish, moderation) --- ABILITIES-API.md | 163 +++++++++++++++++++++++++++ includes/ability/class-actor.php | 4 +- includes/ability/class-following.php | 28 ++++- includes/class-abilities.php | 16 --- 4 files changed, 191 insertions(+), 20 deletions(-) create mode 100644 ABILITIES-API.md diff --git a/ABILITIES-API.md b/ABILITIES-API.md new file mode 100644 index 0000000000..0b7978bad8 --- /dev/null +++ b/ABILITIES-API.md @@ -0,0 +1,163 @@ +# ActivityPub Abilities API + +WordPress Abilities API (WP 6.9+) integration for the ActivityPub plugin. Exposes ActivityPub functionality as discoverable, permissioned abilities for automation, workflows, and plugin interoperability. + +## Why? + +- **Plugin interoperability**: Other plugins can discover and call abilities in a consistent way +- **Automation & workflows**: WP-Cron, WP-CLI, CI/CD, low-code tools can call abilities safely +- **Self-documenting endpoints**: JSON Schema validation, easier tooling/testing +- **REST exposure**: Standard `/wp-json/wp-abilities/v1/` endpoints + +## Categories + +| Slug | Label | Description | +|------|-------|-------------| +| `activitypub-discovery` | Discovery | Look up and discover remote actors in the Fediverse. | +| `activitypub-social` | Social | Manage followers, following, and social connections. | + +## Implemented Abilities + +### Discovery (`activitypub-discovery`) + +| Ability | Description | Class | +|---------|-------------|-------| +| `activitypub/resolve-handle` | Look up WebFinger data for a handle. | `Ability\Webfinger` | +| `activitypub/get-actor` | Fetch profile information for a remote actor. | `Ability\Actor` | + +### Social (`activitypub-social`) + +| Ability | Description | Class | +|---------|-------------|-------| +| `activitypub/get-followers` | List followers for a local actor. | `Ability\Followers` | +| `activitypub/get-following` | List accounts being followed by a local actor. | `Ability\Following` | +| `activitypub/follow` | Follow a remote actor. | `Ability\Following` | +| `activitypub/unfollow` | Unfollow a remote actor. | `Ability\Following` | + +### Planned + +| Ability | Category | Description | +|---------|----------|-------------| +| `activitypub/publish-note` | publish | Publish a Note to the Fediverse. | +| `activitypub/announce-post` | publish | Boost/announce an existing post. | +| `activitypub/block-actor` | moderation | Block a specific actor. | +| `activitypub/block-domain` | moderation | Block an entire domain. | +| `activitypub/unblock` | moderation | Unblock actor or domain. | +| `activitypub/get-inbox` | moderation | Get recent inbox activities. | +| `activitypub/retry-delivery` | moderation | Retry failed activity delivery. | + +## Architecture + +### File Structure + +``` +activitypub.php # Hooks into wp_abilities_api_*_init +includes/ +├── class-abilities.php # Category registration + orchestration + extensibility hooks +├── ability/ +│ ├── class-actor.php # activitypub/get-actor +│ ├── class-followers.php # activitypub/get-followers +│ ├── class-following.php # activitypub/get-following, follow, unfollow +│ └── class-webfinger.php # activitypub/resolve-handle +``` + +### Registration Flow + +1. `activitypub.php` hooks `Abilities::register_categories` into `wp_abilities_api_categories_init` +2. `activitypub.php` hooks `Abilities::register_abilities` into `wp_abilities_api_init` +3. `Abilities::register_categories()` registers categories, then fires `activitypub_register_ability_categories` +4. `Abilities::register_abilities()` calls each ability class's `register()`, then fires `activitypub_register_abilities` + +Ability classes are grouped by domain (e.g. `Webfinger`, `Actor`), not one-per-ability. A single class can register abilities across multiple categories. + +### Ability Class Template + +```php + \__( 'Example Ability', 'activitypub' ), + 'description' => \__( 'Does something useful.', 'activitypub' ), + 'category' => 'activitypub-discovery', + 'execute_callback' => array( self::class, 'execute' ), + 'permission_callback' => array( self::class, 'permission_callback' ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'param' => array( + 'type' => 'string', + 'description' => \__( 'A required parameter.', 'activitypub' ), + ), + ), + 'required' => array( 'param' ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + } + + public static function permission_callback( $input = null ) { + return \current_user_can( 'activitypub' ); + } + + public static function execute( $input ) { + // Implementation here. + return array( 'result' => 'value' ); + } +} +``` + +### Extensibility + +Third-party plugins can register additional categories and abilities: + +```php +\add_action( 'activitypub_register_ability_categories', function () { + \wp_register_ability_category( 'my-plugin-category', array( ... ) ); +} ); + +\add_action( 'activitypub_register_abilities', function () { + \wp_register_ability( 'my-plugin/my-ability', array( ... ) ); +} ); +``` + +## Existing Services + +- `Webfinger::get_data()` — WebFinger lookups +- `Collection\Remote_Actors::fetch_by_various()`, `::get_actor()` — Remote actor profiles +- `Collection\Followers::query()` — Follower lists +- `Collection\Following::query()` — Following lists +- `Collection\Outbox::reschedule()` — Retry delivery +- `Moderation` class — Block/unblock logic + +## Testing + +```bash +# List categories +curl -u admin:PASSWORD http://localhost:8888/wp-json/wp-abilities/v1/categories + +# List discovery abilities +curl -u admin:PASSWORD 'http://localhost:8888/wp-json/wp-abilities/v1/abilities?category=activitypub-discovery' + +# Resolve a handle (GET — readonly abilities require GET) +curl -u admin:PASSWORD 'http://localhost:8888/wp-json/wp-abilities/v1/abilities/activitypub/resolve-handle/run?input[handle]=user@example.com' + +# Get actor +curl -u admin:PASSWORD 'http://localhost:8888/wp-json/wp-abilities/v1/abilities/activitypub/get-actor/run?input[actor]=user@example.com' +``` diff --git a/includes/ability/class-actor.php b/includes/ability/class-actor.php index 85c6fb65e1..7f5ef9624d 100644 --- a/includes/ability/class-actor.php +++ b/includes/ability/class-actor.php @@ -26,9 +26,9 @@ class Actor { */ public static function register() { \wp_register_ability( - 'activitypub/get-actor-info', + 'activitypub/get-actor', array( - 'label' => \__( 'Get Actor Info', 'activitypub' ), + 'label' => \__( 'Get Actor', 'activitypub' ), 'description' => \__( 'Fetch profile information for a remote ActivityPub actor.', 'activitypub' ), 'category' => 'activitypub-discovery', 'execute_callback' => array( self::class, 'get_actor_info' ), diff --git a/includes/ability/class-following.php b/includes/ability/class-following.php index 142070716e..dca9cb11e2 100644 --- a/includes/ability/class-following.php +++ b/includes/ability/class-following.php @@ -246,6 +246,14 @@ public static function get_following( $input ) { return new \WP_Error( 'activitypub_invalid_user_id', \__( 'Invalid user ID.', 'activitypub' ), array( 'status' => 400 ) ); } + if ( \get_current_user_id() !== $user_id && ! \current_user_can( 'manage_options' ) ) { + return new \WP_Error( + 'activitypub_forbidden', + \__( 'You are not allowed to view another user\'s following list.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + $per_page = isset( $input['per_page'] ) ? \min( \absint( $input['per_page'] ), 100 ) : 20; $page = isset( $input['page'] ) ? \absint( $input['page'] ) : 1; @@ -283,10 +291,18 @@ public static function get_following( $input ) { * @return array|\WP_Error */ public static function follow( $input ) { + if ( '1' !== \get_option( 'activitypub_following_ui', '0' ) ) { + return new \WP_Error( + 'activitypub_following_disabled', + \__( 'Following feature is disabled.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + $actor = \sanitize_text_field( $input['actor'] ); $user_id = isset( $input['user_id'] ) ? \absint( $input['user_id'] ) : \get_current_user_id(); - if ( \get_current_user_id() !== $user_id && ! \current_user_can( 'activitypub' ) ) { + if ( \get_current_user_id() !== $user_id && ! \current_user_can( 'manage_options' ) ) { return new \WP_Error( 'activitypub_forbidden', \__( 'You are not allowed to act on behalf of another user.', 'activitypub' ), @@ -315,10 +331,18 @@ public static function follow( $input ) { * @return array|\WP_Error */ public static function unfollow( $input ) { + if ( '1' !== \get_option( 'activitypub_following_ui', '0' ) ) { + return new \WP_Error( + 'activitypub_following_disabled', + \__( 'Following feature is disabled.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + $actor = \sanitize_text_field( $input['actor'] ); $user_id = isset( $input['user_id'] ) ? \absint( $input['user_id'] ) : \get_current_user_id(); - if ( \get_current_user_id() !== $user_id && ! \current_user_can( 'activitypub' ) ) { + if ( \get_current_user_id() !== $user_id && ! \current_user_can( 'manage_options' ) ) { return new \WP_Error( 'activitypub_forbidden', \__( 'You are not allowed to act on behalf of another user.', 'activitypub' ), diff --git a/includes/class-abilities.php b/includes/class-abilities.php index 267f6680b6..3246f38d2d 100644 --- a/includes/class-abilities.php +++ b/includes/class-abilities.php @@ -47,22 +47,6 @@ public static function register_categories() { ) ); - \wp_register_ability_category( - 'activitypub-publish', - array( - 'label' => \__( 'Publish', 'activitypub' ), - 'description' => \__( 'Publish and share content to the Fediverse.', 'activitypub' ), - ) - ); - - \wp_register_ability_category( - 'activitypub-moderation', - array( - 'label' => \__( 'Moderation', 'activitypub' ), - 'description' => \__( 'Moderate actors, domains, and activity delivery.', 'activitypub' ), - ) - ); - /** * Fires after built-in ability categories are registered. * From 69d4804e8aae733838289fef52c2376d18e264c9 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 2 Jun 2026 12:52:30 +0200 Subject: [PATCH 4/6] Move Abilities API doc into docs/ so it isn't shipped in the plugin zip A top-level ABILITIES-API.md is not export-ignored and would be bundled into the WordPress.org plugin distribution. Move it to docs/abilities-api.md (already export-ignored via /docs/) and link it from the developer docs index. --- ABILITIES-API.md => docs/abilities-api.md | 0 docs/developer-docs.md | 7 +++++++ 2 files changed, 7 insertions(+) rename ABILITIES-API.md => docs/abilities-api.md (100%) diff --git a/ABILITIES-API.md b/docs/abilities-api.md similarity index 100% rename from ABILITIES-API.md rename to docs/abilities-api.md diff --git a/docs/developer-docs.md b/docs/developer-docs.md index 6db44bc87f..61f0ffb6cb 100644 --- a/docs/developer-docs.md +++ b/docs/developer-docs.md @@ -4,6 +4,7 @@ - [Introduction](#introduction) - [Snippets](#snippets) - [Extending the Settings Interface](#extending-the-settings-interface) +- [Abilities API](#abilities-api) ## Introduction This documentation provides information for developers who want to extend and build upon the ActivityPub plugin. Whether you're developing a complementary plugin or integrating ActivityPub features into your existing WordPress plugin, this guide will help you understand the available hooks and customization options. @@ -77,3 +78,9 @@ add_action( 'admin_enqueue_scripts', function( $hook ) { } } ); ``` + +## Abilities API + +The plugin registers its operations as WordPress Abilities (WP 6.9+), giving other plugins a stable, discoverable way to interact with ActivityPub without depending on internal classes. + +See [Abilities API](./abilities-api.md) for the available abilities, their input and output schemas, and usage examples. From 407e563b95f4f9f5491dac4986cfdc17e5ba5637 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 2 Jun 2026 13:06:31 +0200 Subject: [PATCH 5/6] Restructure the Abilities API doc into a full reference Add a per-ability reference (inputs, outputs, permissions, annotations, errors), an authorization section, a table of contents, and REST examples for both readonly (GET) and write (POST) abilities. --- docs/abilities-api.md | 337 +++++++++++++++++++++++++++--------------- 1 file changed, 219 insertions(+), 118 deletions(-) diff --git a/docs/abilities-api.md b/docs/abilities-api.md index 0b7978bad8..74132ad357 100644 --- a/docs/abilities-api.md +++ b/docs/abilities-api.md @@ -1,13 +1,68 @@ # ActivityPub Abilities API -WordPress Abilities API (WP 6.9+) integration for the ActivityPub plugin. Exposes ActivityPub functionality as discoverable, permissioned abilities for automation, workflows, and plugin interoperability. +The ActivityPub plugin exposes its functionality through the [WordPress Abilities API](https://make.wordpress.org/core/) (WordPress 6.9+). Abilities are discoverable, schema-validated, permissioned operations that other plugins, automation tools, WP-CLI, and the REST API can call by name — without depending on the plugin's internal classes. -## Why? +## Contents -- **Plugin interoperability**: Other plugins can discover and call abilities in a consistent way -- **Automation & workflows**: WP-Cron, WP-CLI, CI/CD, low-code tools can call abilities safely -- **Self-documenting endpoints**: JSON Schema validation, easier tooling/testing -- **REST exposure**: Standard `/wp-json/wp-abilities/v1/` endpoints +- [Why use abilities](#why-use-abilities) +- [Quick start](#quick-start) +- [Authorization](#authorization) +- [Categories](#categories) +- [Ability reference](#ability-reference) + - [Discovery](#discovery) + - [Social](#social) +- [REST API access](#rest-api-access) +- [Architecture](#architecture) +- [Extending the API](#extending-the-api) +- [Roadmap](#roadmap) + +## Why use abilities + +- **Stable contract.** Call `activitypub/get-followers` by name; the plugin can refactor `Collection\Followers` freely without breaking you. +- **Discoverable.** Abilities advertise their input/output JSON Schema, category, and annotations, so tooling can introspect them. +- **Permissioned.** Every ability runs through a permission callback before executing. +- **Multiple surfaces.** The same ability is reachable from PHP (`wp_run_ability()`), the REST API (`/wp-json/wp-abilities/v1/`), and any future Abilities-aware client. + +## Quick start + +Check availability before calling — the API only exists on WordPress 6.9+ and when the plugin is active: + +```php +if ( function_exists( 'wp_get_ability' ) && wp_get_ability( 'activitypub/get-followers' ) ) { + // The ability is registered and can be run. +} +``` + +Run an ability and handle the `WP_Error` result: + +```php +$result = wp_run_ability( + 'activitypub/get-followers', + array( + 'user_id' => get_current_user_id(), + 'per_page' => 20, + 'page' => 1, + ) +); + +if ( ! is_wp_error( $result ) ) { + foreach ( $result['followers'] as $follower ) { + printf( "%s (%s)\n", $follower['name'], $follower['preferredUsername'] ); + } + printf( "Total: %d\n", $result['total'] ); +} +``` + +## Authorization + +Every ability requires the current user to have the `activitypub` capability (`current_user_can( 'activitypub' )`). Some abilities apply additional rules: + +| Rule | Applies to | Behavior | +|------|-----------|----------| +| **Ownership** | `get-following`, `follow`, `unfollow` | Acting on another user's data requires `manage_options`, otherwise returns `403 activitypub_forbidden`. `user_id` defaults to the current user. | +| **Feature flag** | `follow`, `unfollow` | Requires the Following UI to be enabled (`activitypub_following_ui` option). Otherwise returns `403 activitypub_following_disabled`. | + +Each ability also declares behavioral annotations (`readonly`, `destructive`, `idempotent`) so clients can reason about side effects before running them. ## Categories @@ -16,148 +71,194 @@ WordPress Abilities API (WP 6.9+) integration for the ActivityPub plugin. Expose | `activitypub-discovery` | Discovery | Look up and discover remote actors in the Fediverse. | | `activitypub-social` | Social | Manage followers, following, and social connections. | -## Implemented Abilities +## Ability reference -### Discovery (`activitypub-discovery`) +Every input is an object; `additionalProperties` is disallowed. All abilities return either the documented array or a `WP_Error`. -| Ability | Description | Class | -|---------|-------------|-------| -| `activitypub/resolve-handle` | Look up WebFinger data for a handle. | `Ability\Webfinger` | -| `activitypub/get-actor` | Fetch profile information for a remote actor. | `Ability\Actor` | +### Discovery -### Social (`activitypub-social`) +#### `activitypub/resolve-handle` -| Ability | Description | Class | -|---------|-------------|-------| -| `activitypub/get-followers` | List followers for a local actor. | `Ability\Followers` | -| `activitypub/get-following` | List accounts being followed by a local actor. | `Ability\Following` | -| `activitypub/follow` | Follow a remote actor. | `Ability\Following` | -| `activitypub/unfollow` | Unfollow a remote actor. | `Ability\Following` | +Resolve a WebFinger handle to its ActivityPub data. *Readonly · idempotent.* -### Planned +| Input | Type | Required | Notes | +|-------|------|----------|-------| +| `handle` | string | yes | WebFinger handle, e.g. `user@example.com`. | -| Ability | Category | Description | -|---------|----------|-------------| -| `activitypub/publish-note` | publish | Publish a Note to the Fediverse. | -| `activitypub/announce-post` | publish | Boost/announce an existing post. | -| `activitypub/block-actor` | moderation | Block a specific actor. | -| `activitypub/block-domain` | moderation | Block an entire domain. | -| `activitypub/unblock` | moderation | Unblock actor or domain. | -| `activitypub/get-inbox` | moderation | Get recent inbox activities. | -| `activitypub/retry-delivery` | moderation | Retry failed activity delivery. | +**Returns:** the WebFinger document (object). **Errors:** `WP_Error` if the handle cannot be resolved. -## Architecture +```php +$data = wp_run_ability( 'activitypub/resolve-handle', array( 'handle' => 'alice@example.com' ) ); +``` -### File Structure +#### `activitypub/get-actor` -``` -activitypub.php # Hooks into wp_abilities_api_*_init -includes/ -├── class-abilities.php # Category registration + orchestration + extensibility hooks -├── ability/ -│ ├── class-actor.php # activitypub/get-actor -│ ├── class-followers.php # activitypub/get-followers -│ ├── class-following.php # activitypub/get-following, follow, unfollow -│ └── class-webfinger.php # activitypub/resolve-handle +Fetch profile information for a remote actor. *Readonly · idempotent.* + +| Input | Type | Required | Notes | +|-------|------|----------|-------| +| `actor` | string | yes | Actor URL or WebFinger handle. | + +**Returns:** + +```jsonc +{ + "id": "https://example.com/users/alice", // uri + "type": "Person", + "name": "Alice", + "preferredUsername": "alice", + "summary": "…", + "inbox": "https://example.com/users/alice/inbox", + "outbox": "https://example.com/users/alice/outbox", + "followers": "https://example.com/users/alice/followers", + "following": "https://example.com/users/alice/following", + "icon": { /* … */ } +} ``` -### Registration Flow +**Errors:** `WP_Error` if the actor cannot be fetched or parsed. -1. `activitypub.php` hooks `Abilities::register_categories` into `wp_abilities_api_categories_init` -2. `activitypub.php` hooks `Abilities::register_abilities` into `wp_abilities_api_init` -3. `Abilities::register_categories()` registers categories, then fires `activitypub_register_ability_categories` -4. `Abilities::register_abilities()` calls each ability class's `register()`, then fires `activitypub_register_abilities` +### Social -Ability classes are grouped by domain (e.g. `Webfinger`, `Actor`), not one-per-ability. A single class can register abilities across multiple categories. +#### `activitypub/get-followers` -### Ability Class Template +List followers for a local actor. *Readonly · idempotent.* -```php - \__( 'Example Ability', 'activitypub' ), - 'description' => \__( 'Does something useful.', 'activitypub' ), - 'category' => 'activitypub-discovery', - 'execute_callback' => array( self::class, 'execute' ), - 'permission_callback' => array( self::class, 'permission_callback' ), - 'input_schema' => array( - 'type' => 'object', - 'properties' => array( - 'param' => array( - 'type' => 'string', - 'description' => \__( 'A required parameter.', 'activitypub' ), - ), - ), - 'required' => array( 'param' ), - 'additionalProperties' => false, - ), - 'output_schema' => array( - 'type' => 'object', - ), - 'meta' => array( - 'annotations' => array( - 'readonly' => true, - 'destructive' => false, - 'idempotent' => true, - ), - 'show_in_rest' => true, - ), - ) - ); - } +| Input | Type | Required | Default | Notes | +|-------|------|----------|---------|-------| +| `user_id` | integer | yes | — | The local actor's user ID. | +| `page` | integer | no | `1` | Page number. | +| `per_page` | integer | no | `20` | Results per page (capped at `100`). | - public static function permission_callback( $input = null ) { - return \current_user_can( 'activitypub' ); - } +**Returns:** `{ "followers": Actor[], "total": integer }`, where each follower has `id`, `type`, `name`, `preferredUsername`, `followers`, `following`, `icon`. **Errors:** `400 activitypub_invalid_user_id`. - public static function execute( $input ) { - // Implementation here. - return array( 'result' => 'value' ); - } -} -``` +> **Note:** unlike `get-following`, this ability does not restrict access to the requesting user's own followers. -### Extensibility +#### `activitypub/get-following` -Third-party plugins can register additional categories and abilities: +List accounts a local actor follows. *Readonly · idempotent.* -```php -\add_action( 'activitypub_register_ability_categories', function () { - \wp_register_ability_category( 'my-plugin-category', array( ... ) ); -} ); +| Input | Type | Required | Default | Notes | +|-------|------|----------|---------|-------| +| `user_id` | integer | yes | — | The local actor's user ID. | +| `page` | integer | no | `1` | Page number. | +| `per_page` | integer | no | `20` | Results per page (capped at `100`). | -\add_action( 'activitypub_register_abilities', function () { - \wp_register_ability( 'my-plugin/my-ability', array( ... ) ); -} ); +**Returns:** `{ "following": Actor[], "total": integer }` (same `Actor` shape as above). **Errors:** `400 activitypub_invalid_user_id`, `403 activitypub_forbidden` (another user's list without `manage_options`). + +#### `activitypub/follow` + +Follow a remote actor. *Not readonly · not idempotent.* Requires the Following feature to be enabled. + +| Input | Type | Required | Default | Notes | +|-------|------|----------|---------|-------| +| `actor` | string | yes | — | Actor URL or WebFinger handle to follow. | +| `user_id` | integer | no | current user | Local actor to act as (ownership rules apply). | + +**Returns:** `{ "outbox_item_id": integer, "status": "pending" }`. **Errors:** `403 activitypub_following_disabled`, `403 activitypub_forbidden`, or a `WP_Error` from the follow request. + +```php +$result = wp_run_ability( 'activitypub/follow', array( 'actor' => 'organizer@events.example.com' ) ); ``` -## Existing Services +#### `activitypub/unfollow` -- `Webfinger::get_data()` — WebFinger lookups -- `Collection\Remote_Actors::fetch_by_various()`, `::get_actor()` — Remote actor profiles -- `Collection\Followers::query()` — Follower lists -- `Collection\Following::query()` — Following lists -- `Collection\Outbox::reschedule()` — Retry delivery -- `Moderation` class — Block/unblock logic +Unfollow a remote actor. *Not readonly · not idempotent.* Requires the Following feature to be enabled. -## Testing +| Input | Type | Required | Default | Notes | +|-------|------|----------|---------|-------| +| `actor` | string | yes | — | Actor URL or WebFinger handle to unfollow. | +| `user_id` | integer | no | current user | Local actor to act as (ownership rules apply). | + +**Returns:** `{ "success": true }`. **Errors:** `403 activitypub_following_disabled`, `403 activitypub_forbidden`, or a `WP_Error` from the unfollow request. + +## REST API access + +Abilities with `show_in_rest` are reachable under `/wp-json/wp-abilities/v1/`. Readonly abilities run via `GET`; others via `POST`. ```bash -# List categories +# List categories. curl -u admin:PASSWORD http://localhost:8888/wp-json/wp-abilities/v1/categories -# List discovery abilities +# List Discovery abilities. curl -u admin:PASSWORD 'http://localhost:8888/wp-json/wp-abilities/v1/abilities?category=activitypub-discovery' -# Resolve a handle (GET — readonly abilities require GET) -curl -u admin:PASSWORD 'http://localhost:8888/wp-json/wp-abilities/v1/abilities/activitypub/resolve-handle/run?input[handle]=user@example.com' +# Run a readonly ability (GET). +curl -u admin:PASSWORD 'http://localhost:8888/wp-json/wp-abilities/v1/abilities/activitypub/get-actor/run?input[actor]=alice@example.com' + +# Run a write ability (POST). +curl -u admin:PASSWORD -X POST -H 'Content-Type: application/json' \ + -d '{"input":{"actor":"alice@example.com"}}' \ + http://localhost:8888/wp-json/wp-abilities/v1/abilities/activitypub/follow/run +``` + +## Architecture + +``` +activitypub.php # Hooks Abilities::register_* into wp_abilities_api_*_init +includes/ +├── class-abilities.php # Category registration, orchestration, extensibility hooks +└── ability/ + ├── class-actor.php # activitypub/get-actor + ├── class-followers.php # activitypub/get-followers + ├── class-following.php # activitypub/get-following, follow, unfollow + └── class-webfinger.php # activitypub/resolve-handle +``` + +Registration flow: + +1. `activitypub.php` hooks `Abilities::register_categories()` into `wp_abilities_api_categories_init` and `Abilities::register_abilities()` into `wp_abilities_api_init`. These hooks only fire when the Abilities API is available, so no version check is needed. +2. `register_categories()` registers the categories, then fires `activitypub_register_ability_categories`. +3. `register_abilities()` calls each ability class's `register()`, then fires `activitypub_register_abilities`. -# Get actor -curl -u admin:PASSWORD 'http://localhost:8888/wp-json/wp-abilities/v1/abilities/activitypub/get-actor/run?input[actor]=user@example.com' +Ability classes are grouped by domain (`Webfinger`, `Actor`, `Followers`, `Following`), not one class per ability — a single class can register several abilities, even across categories. + +## Extending the API + +Third-party plugins register their own categories and abilities on the same hooks: + +```php +add_action( 'activitypub_register_ability_categories', function () { + wp_register_ability_category( 'my-plugin', array( + 'label' => __( 'My Plugin', 'my-plugin' ), + 'description' => __( 'Custom abilities.', 'my-plugin' ), + ) ); +} ); + +add_action( 'activitypub_register_abilities', function () { + wp_register_ability( 'my-plugin/do-thing', array( + 'label' => __( 'Do Thing', 'my-plugin' ), + 'description' => __( 'Does the thing.', 'my-plugin' ), + 'category' => 'my-plugin', + 'execute_callback' => 'my_plugin_do_thing', + 'permission_callback' => '__return_true', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'param' => array( 'type' => 'string' ), + ), + 'required' => array( 'param' ), + 'additionalProperties' => false, + ), + 'output_schema' => array( 'type' => 'object' ), + 'meta' => array( + 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true ), + 'show_in_rest' => true, + ), + ) ); +} ); ``` + +## Roadmap + +Abilities under consideration for future releases: + +| Ability | Category | Description | +|---------|----------|-------------| +| `activitypub/publish-note` | Publish | Publish a Note to the Fediverse. | +| `activitypub/announce-post` | Publish | Boost/announce an existing post. | +| `activitypub/block-actor` | Moderation | Block a specific actor. | +| `activitypub/block-domain` | Moderation | Block an entire domain. | +| `activitypub/unblock` | Moderation | Unblock an actor or domain. | +| `activitypub/get-inbox` | Moderation | Get recent inbox activities. | +| `activitypub/retry-delivery` | Moderation | Retry failed activity delivery. | From fa06f443ebc2285f2338e16100102dd4a72d6c15 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Tue, 2 Jun 2026 14:11:13 +0200 Subject: [PATCH 6/6] Add abilities tests, dedup actor mapping, restrict get-followers - Add PHPUnit coverage for the follow/follower/actor abilities (permission, ownership, feature-flag gating, actor mapping, shared schema). - Extract the shared actor response shape and item schema into Actor::to_array() and Actor::item_schema(), reused across abilities. - Require ownership (or manage_options) to read another user's followers, matching the following ability. - Add a changelog entry and document the authorization rules. --- .github/changelog/add-abilities-api | 4 + docs/abilities-api.md | 6 +- includes/ability/class-actor.php | 66 +++++++- includes/ability/class-followers.php | 50 ++---- includes/ability/class-following.php | 42 +----- .../tests/ability/class-test-actor.php | 79 ++++++++++ .../tests/ability/class-test-followers.php | 78 ++++++++++ .../tests/ability/class-test-following.php | 142 ++++++++++++++++++ 8 files changed, 382 insertions(+), 85 deletions(-) create mode 100644 .github/changelog/add-abilities-api create mode 100644 tests/phpunit/tests/ability/class-test-actor.php create mode 100644 tests/phpunit/tests/ability/class-test-followers.php create mode 100644 tests/phpunit/tests/ability/class-test-following.php diff --git a/.github/changelog/add-abilities-api b/.github/changelog/add-abilities-api new file mode 100644 index 0000000000..de747cd98b --- /dev/null +++ b/.github/changelog/add-abilities-api @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add a WordPress Abilities API integration (WordPress 6.9+) so other plugins can discover and use ActivityPub features such as resolving handles, fetching actors, and managing followers through a stable API. diff --git a/docs/abilities-api.md b/docs/abilities-api.md index 74132ad357..4440c99451 100644 --- a/docs/abilities-api.md +++ b/docs/abilities-api.md @@ -59,7 +59,7 @@ Every ability requires the current user to have the `activitypub` capability (`c | Rule | Applies to | Behavior | |------|-----------|----------| -| **Ownership** | `get-following`, `follow`, `unfollow` | Acting on another user's data requires `manage_options`, otherwise returns `403 activitypub_forbidden`. `user_id` defaults to the current user. | +| **Ownership** | `get-followers`, `get-following`, `follow`, `unfollow` | Acting on another user's data requires `manage_options`, otherwise returns `403 activitypub_forbidden`. `user_id` defaults to the current user. | | **Feature flag** | `follow`, `unfollow` | Requires the Following UI to be enabled (`activitypub_following_ui` option). Otherwise returns `403 activitypub_following_disabled`. | Each ability also declares behavioral annotations (`readonly`, `destructive`, `idempotent`) so clients can reason about side effects before running them. @@ -130,9 +130,7 @@ List followers for a local actor. *Readonly · idempotent.* | `page` | integer | no | `1` | Page number. | | `per_page` | integer | no | `20` | Results per page (capped at `100`). | -**Returns:** `{ "followers": Actor[], "total": integer }`, where each follower has `id`, `type`, `name`, `preferredUsername`, `followers`, `following`, `icon`. **Errors:** `400 activitypub_invalid_user_id`. - -> **Note:** unlike `get-following`, this ability does not restrict access to the requesting user's own followers. +**Returns:** `{ "followers": Actor[], "total": integer }`, where each follower has `id`, `type`, `name`, `preferredUsername`, `followers`, `following`, `icon`. **Errors:** `400 activitypub_invalid_user_id`, `403 activitypub_forbidden` (another user's list without `manage_options`). #### `activitypub/get-following` diff --git a/includes/ability/class-actor.php b/includes/ability/class-actor.php index 7f5ef9624d..143362a89e 100644 --- a/includes/ability/class-actor.php +++ b/includes/ability/class-actor.php @@ -128,17 +128,77 @@ public static function get_actor_info( $input ) { return $actor; } + return \array_merge( + self::to_array( $actor ), + array( + 'summary' => $actor->get_summary(), + 'inbox' => $actor->get_inbox(), + 'outbox' => $actor->get_outbox(), + ) + ); + } + + /** + * Map a remote actor to the common ability response shape. + * + * Shared by the actor, followers, and following abilities so they all + * expose the same actor fields. + * + * @since unreleased + * + * @param \Activitypub\Activity\Actor $actor The actor object. + * @return array + */ + public static function to_array( $actor ) { return array( 'id' => $actor->get_id(), 'type' => $actor->get_type(), 'name' => $actor->get_name(), 'preferredUsername' => $actor->get_preferred_username(), - 'summary' => $actor->get_summary(), - 'inbox' => $actor->get_inbox(), - 'outbox' => $actor->get_outbox(), 'followers' => $actor->get_followers(), 'following' => $actor->get_following(), 'icon' => $actor->get_icon(), ); } + + /** + * JSON Schema for a single actor item in ability output. + * + * Shared by abilities that return lists of actors. + * + * @since unreleased + * + * @return array + */ + public static function item_schema() { + return array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'type' => array( + 'type' => 'string', + ), + 'name' => array( + 'type' => 'string', + ), + 'preferredUsername' => array( + 'type' => 'string', + ), + 'followers' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'following' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'icon' => array( + 'type' => 'object', + ), + ), + ); + } } diff --git a/includes/ability/class-followers.php b/includes/ability/class-followers.php index 663aa393ad..9f30f07b06 100644 --- a/includes/ability/class-followers.php +++ b/includes/ability/class-followers.php @@ -58,35 +58,7 @@ public static function register() { 'properties' => array( 'followers' => array( 'type' => 'array', - 'items' => array( - 'type' => 'object', - 'properties' => array( - 'id' => array( - 'type' => 'string', - 'format' => 'uri', - ), - 'type' => array( - 'type' => 'string', - ), - 'name' => array( - 'type' => 'string', - ), - 'preferredUsername' => array( - 'type' => 'string', - ), - 'followers' => array( - 'type' => 'string', - 'format' => 'uri', - ), - 'following' => array( - 'type' => 'string', - 'format' => 'uri', - ), - 'icon' => array( - 'type' => 'object', - ), - ), - ), + 'items' => Actor::item_schema(), ), 'total' => array( 'type' => 'integer', @@ -131,8 +103,16 @@ public static function get_followers( $input ) { return new \WP_Error( 'activitypub_invalid_user_id', \__( 'Invalid user ID.', 'activitypub' ), array( 'status' => 400 ) ); } + if ( \get_current_user_id() !== $user_id && ! \current_user_can( 'manage_options' ) ) { + return new \WP_Error( + 'activitypub_forbidden', + \__( 'You are not allowed to view another user\'s followers list.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + $per_page = isset( $input['per_page'] ) ? \min( \absint( $input['per_page'] ), 100 ) : 20; - $page = isset( $input['page'] ) ? \absint( $input['page'] ) : 1; + $page = isset( $input['page'] ) ? \max( 1, \absint( $input['page'] ) ) : 1; $data = Followers_Collection::query( $user_id, $per_page, $page ); @@ -142,15 +122,7 @@ public static function get_followers( $input ) { if ( \is_wp_error( $actor ) ) { continue; } - $followers[] = array( - 'id' => $actor->get_id(), - 'type' => $actor->get_type(), - 'name' => $actor->get_name(), - 'preferredUsername' => $actor->get_preferred_username(), - 'followers' => $actor->get_followers(), - 'following' => $actor->get_following(), - 'icon' => $actor->get_icon(), - ); + $followers[] = Actor::to_array( $actor ); } return array( diff --git a/includes/ability/class-following.php b/includes/ability/class-following.php index dca9cb11e2..c3175067d7 100644 --- a/includes/ability/class-following.php +++ b/includes/ability/class-following.php @@ -72,35 +72,7 @@ private static function register_get_following() { 'properties' => array( 'following' => array( 'type' => 'array', - 'items' => array( - 'type' => 'object', - 'properties' => array( - 'id' => array( - 'type' => 'string', - 'format' => 'uri', - ), - 'type' => array( - 'type' => 'string', - ), - 'name' => array( - 'type' => 'string', - ), - 'preferredUsername' => array( - 'type' => 'string', - ), - 'followers' => array( - 'type' => 'string', - 'format' => 'uri', - ), - 'following' => array( - 'type' => 'string', - 'format' => 'uri', - ), - 'icon' => array( - 'type' => 'object', - ), - ), - ), + 'items' => Actor::item_schema(), ), 'total' => array( 'type' => 'integer', @@ -255,7 +227,7 @@ public static function get_following( $input ) { } $per_page = isset( $input['per_page'] ) ? \min( \absint( $input['per_page'] ), 100 ) : 20; - $page = isset( $input['page'] ) ? \absint( $input['page'] ) : 1; + $page = isset( $input['page'] ) ? \max( 1, \absint( $input['page'] ) ) : 1; $data = Following_Collection::query( $user_id, $per_page, $page ); @@ -265,15 +237,7 @@ public static function get_following( $input ) { if ( \is_wp_error( $actor ) ) { continue; } - $following[] = array( - 'id' => $actor->get_id(), - 'type' => $actor->get_type(), - 'name' => $actor->get_name(), - 'preferredUsername' => $actor->get_preferred_username(), - 'followers' => $actor->get_followers(), - 'following' => $actor->get_following(), - 'icon' => $actor->get_icon(), - ); + $following[] = Actor::to_array( $actor ); } return array( diff --git a/tests/phpunit/tests/ability/class-test-actor.php b/tests/phpunit/tests/ability/class-test-actor.php new file mode 100644 index 0000000000..1d8ce1d64c --- /dev/null +++ b/tests/phpunit/tests/ability/class-test-actor.php @@ -0,0 +1,79 @@ +assertFalse( Actor::permission_callback() ); + + $user = self::factory()->user->create_and_get(); + $user->add_cap( 'activitypub' ); + \wp_set_current_user( $user->ID ); + $this->assertTrue( Actor::permission_callback() ); + } + + /** + * The shared item schema exposes the common actor properties. + * + * @covers ::item_schema + */ + public function test_item_schema_properties() { + $schema = Actor::item_schema(); + + $this->assertSame( 'object', $schema['type'] ); + $this->assertSame( + array( 'id', 'type', 'name', 'preferredUsername', 'followers', 'following', 'icon' ), + \array_keys( $schema['properties'] ) + ); + } + + /** + * Maps the shared actor fields and omits detail-only fields. + * + * @covers ::to_array + */ + public function test_to_array_maps_common_fields() { + $model = new \Activitypub\Activity\Actor(); + $model->set_id( 'https://example.com/users/alice' ); + $model->set_type( 'Person' ); + $model->set_name( 'Alice' ); + $model->set_preferred_username( 'alice' ); + $model->set_followers( 'https://example.com/users/alice/followers' ); + $model->set_following( 'https://example.com/users/alice/following' ); + $model->set_icon( array( 'type' => 'Image' ) ); + + $array = Actor::to_array( $model ); + + $this->assertSame( 'https://example.com/users/alice', $array['id'] ); + $this->assertSame( 'Person', $array['type'] ); + $this->assertSame( 'Alice', $array['name'] ); + $this->assertSame( 'alice', $array['preferredUsername'] ); + $this->assertSame( 'https://example.com/users/alice/followers', $array['followers'] ); + $this->assertSame( 'https://example.com/users/alice/following', $array['following'] ); + $this->assertSame( array( 'type' => 'Image' ), $array['icon'] ); + + // Detail-only fields belong to get-actor, not the shared shape. + $this->assertArrayNotHasKey( 'summary', $array ); + $this->assertArrayNotHasKey( 'inbox', $array ); + $this->assertArrayNotHasKey( 'outbox', $array ); + } +} diff --git a/tests/phpunit/tests/ability/class-test-followers.php b/tests/phpunit/tests/ability/class-test-followers.php new file mode 100644 index 0000000000..27fb18daa5 --- /dev/null +++ b/tests/phpunit/tests/ability/class-test-followers.php @@ -0,0 +1,78 @@ +assertFalse( Followers::permission_callback() ); + + $user = self::factory()->user->create_and_get(); + $user->add_cap( 'activitypub' ); + \wp_set_current_user( $user->ID ); + $this->assertTrue( Followers::permission_callback() ); + } + + /** + * An invalid user ID is rejected. + * + * @covers ::get_followers + */ + public function test_get_followers_rejects_invalid_user_id() { + $result = Followers::get_followers( array( 'user_id' => 0 ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'activitypub_invalid_user_id', $result->get_error_code() ); + } + + /** + * A non-admin cannot read another user's followers list. + * + * @covers ::get_followers + */ + public function test_get_followers_forbids_other_user() { + $current = self::factory()->user->create(); + $other = self::factory()->user->create(); + \wp_set_current_user( $current ); + + $result = Followers::get_followers( array( 'user_id' => $other ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'activitypub_forbidden', $result->get_error_code() ); + } + + /** + * An administrator can read another user's followers list. + * + * @covers ::get_followers + */ + public function test_get_followers_allows_admin_for_other_user() { + $admin = self::factory()->user->create( array( 'role' => 'administrator' ) ); + $other = self::factory()->user->create(); + \wp_set_current_user( $admin ); + + $result = Followers::get_followers( array( 'user_id' => $other ) ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'followers', $result ); + $this->assertArrayHasKey( 'total', $result ); + } +} diff --git a/tests/phpunit/tests/ability/class-test-following.php b/tests/phpunit/tests/ability/class-test-following.php new file mode 100644 index 0000000000..725fe1fa8a --- /dev/null +++ b/tests/phpunit/tests/ability/class-test-following.php @@ -0,0 +1,142 @@ +assertFalse( Following::permission_callback() ); + + $user = self::factory()->user->create_and_get(); + $user->add_cap( 'activitypub' ); + \wp_set_current_user( $user->ID ); + $this->assertTrue( Following::permission_callback() ); + } + + /** + * An invalid user ID is rejected. + * + * @covers ::get_following + */ + public function test_get_following_rejects_invalid_user_id() { + $result = Following::get_following( array( 'user_id' => 0 ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'activitypub_invalid_user_id', $result->get_error_code() ); + } + + /** + * A non-admin cannot read another user's following list. + * + * @covers ::get_following + */ + public function test_get_following_forbids_other_user() { + $current = self::factory()->user->create(); + $other = self::factory()->user->create(); + \wp_set_current_user( $current ); + + $result = Following::get_following( array( 'user_id' => $other ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'activitypub_forbidden', $result->get_error_code() ); + } + + /** + * A user can read their own following list. + * + * @covers ::get_following + */ + public function test_get_following_allows_own_user() { + $user = self::factory()->user->create(); + \wp_set_current_user( $user ); + + $result = Following::get_following( array( 'user_id' => $user ) ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'following', $result ); + $this->assertArrayHasKey( 'total', $result ); + } + + /** + * Follow is rejected when the following feature is disabled. + * + * @covers ::follow + */ + public function test_follow_requires_following_feature() { + \update_option( 'activitypub_following_ui', '0' ); + + $user = self::factory()->user->create( array( 'role' => 'administrator' ) ); + \wp_set_current_user( $user ); + + $result = Following::follow( array( 'actor' => 'alice@example.com' ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'activitypub_following_disabled', $result->get_error_code() ); + } + + /** + * Unfollow is rejected when the following feature is disabled. + * + * @covers ::unfollow + */ + public function test_unfollow_requires_following_feature() { + \update_option( 'activitypub_following_ui', '0' ); + + $user = self::factory()->user->create( array( 'role' => 'administrator' ) ); + \wp_set_current_user( $user ); + + $result = Following::unfollow( array( 'actor' => 'alice@example.com' ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'activitypub_following_disabled', $result->get_error_code() ); + } + + /** + * A non-admin cannot follow on behalf of another user. + * + * @covers ::follow + */ + public function test_follow_forbids_acting_for_other_user() { + \update_option( 'activitypub_following_ui', '1' ); + + $current = self::factory()->user->create(); + $other = self::factory()->user->create(); + \wp_set_current_user( $current ); + + $result = Following::follow( + array( + 'actor' => 'alice@example.com', + 'user_id' => $other, + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'activitypub_forbidden', $result->get_error_code() ); + } +}