From 49743f6454445698dd2ad28ca5e0962f9a80e0a6 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 8 May 2026 15:14:45 +0200 Subject: [PATCH 01/18] Gitignore docs/superpowers/ for local-only superpowers artifacts. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e17f7962e2..dd3e0d7d9e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ _site .claude/**/*.local* .claude/summaries/ .agents/**/*.local* +docs/superpowers/ .cursor .DS_Store .php_cs.cache From 263f7cedca5a1d86ae5dd4a24c7c4711a8255376 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 8 May 2026 15:20:41 +0200 Subject: [PATCH 02/18] Register FEP-7aa9 vocabulary and FeatureRequest activity type. --- includes/activity/class-activity.php | 6 ++-- includes/activity/class-base-object.php | 45 ++++++++++++++++--------- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/includes/activity/class-activity.php b/includes/activity/class-activity.php index 28a5e30ae6..ff3f749c52 100644 --- a/includes/activity/class-activity.php +++ b/includes/activity/class-activity.php @@ -38,8 +38,9 @@ class Activity extends Base_Object { const JSON_LD_CONTEXT = array( 'https://www.w3.org/ns/activitystreams', array( - 'toot' => 'http://joinmastodon.org/ns#', - 'QuoteRequest' => 'toot:QuoteRequest', + 'toot' => 'http://joinmastodon.org/ns#', + 'QuoteRequest' => 'toot:QuoteRequest', + 'FeatureRequest' => 'https://w3id.org/fep/7aa9#FeatureRequest', ), ); @@ -70,6 +71,7 @@ class Activity extends Base_Object { 'Move', 'Offer', 'QuoteRequest', // @see https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md + 'FeatureRequest', // @see https://github.com/mastodon/featured_collections/pull/1 (FEP-7aa9 draft) 'Read', 'Reject', 'Remove', diff --git a/includes/activity/class-base-object.php b/includes/activity/class-base-object.php index 5668cd0ed8..f09d89687c 100644 --- a/includes/activity/class-base-object.php +++ b/includes/activity/class-base-object.php @@ -123,45 +123,60 @@ class Base_Object extends Generic_Object { const JSON_LD_CONTEXT = array( 'https://www.w3.org/ns/activitystreams', array( - 'Hashtag' => 'as:Hashtag', - 'sensitive' => 'as:sensitive', - 'dcterms' => 'http://purl.org/dc/terms/', - 'gts' => 'https://gotosocial.org/ns#', - 'schema' => 'http://schema.org/', - 'exifData' => 'schema:exifData', - 'PropertyValue' => 'schema:PropertyValue', - 'interactionPolicy' => array( + 'Hashtag' => 'as:Hashtag', + 'sensitive' => 'as:sensitive', + 'dcterms' => 'http://purl.org/dc/terms/', + 'gts' => 'https://gotosocial.org/ns#', + 'schema' => 'http://schema.org/', + 'exifData' => 'schema:exifData', + 'PropertyValue' => 'schema:PropertyValue', + 'interactionPolicy' => array( '@id' => 'gts:interactionPolicy', '@type' => '@id', ), - 'canQuote' => array( + 'canQuote' => array( '@id' => 'gts:canQuote', '@type' => '@id', ), - 'canReply' => array( + 'canFeature' => array( + '@id' => 'https://w3id.org/fep/7aa9#canFeature', + '@type' => '@id', + ), + 'canReply' => array( '@id' => 'gts:canReply', '@type' => '@id', ), - 'canLike' => array( + 'canLike' => array( '@id' => 'gts:canLike', '@type' => '@id', ), - 'canAnnounce' => array( + 'canAnnounce' => array( '@id' => 'gts:canAnnounce', '@type' => '@id', ), - 'automaticApproval' => array( + 'automaticApproval' => array( '@id' => 'gts:automaticApproval', '@type' => '@id', ), - 'manualApproval' => array( + 'manualApproval' => array( '@id' => 'gts:manualApproval', '@type' => '@id', ), - 'always' => array( + 'always' => array( '@id' => 'gts:always', '@type' => '@id', ), + 'FeaturedCollection' => 'https://w3id.org/fep/7aa9#FeaturedCollection', + 'FeaturedItem' => 'https://w3id.org/fep/7aa9#FeaturedItem', + 'featuredObject' => array( + '@id' => 'https://w3id.org/fep/7aa9#featuredObject', + '@type' => '@id', + ), + 'featuredObjectType' => 'https://w3id.org/fep/7aa9#featuredObjectType', + 'featureAuthorization' => array( + '@id' => 'https://w3id.org/fep/7aa9#featureAuthorization', + '@type' => '@id', + ), ), ); From dc8e041c9c64aff8e7c9df2f8dd58fe1bd881531 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 8 May 2026 15:25:43 +0200 Subject: [PATCH 03/18] Use durable IRI for FEP-7aa9 reference. --- includes/activity/class-activity.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/activity/class-activity.php b/includes/activity/class-activity.php index ff3f749c52..940821a262 100644 --- a/includes/activity/class-activity.php +++ b/includes/activity/class-activity.php @@ -71,7 +71,7 @@ class Activity extends Base_Object { 'Move', 'Offer', 'QuoteRequest', // @see https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md - 'FeatureRequest', // @see https://github.com/mastodon/featured_collections/pull/1 (FEP-7aa9 draft) + 'FeatureRequest', // @see https://w3id.org/fep/7aa9 (FEP-7aa9 draft) 'Read', 'Reject', 'Remove', From b0011446e42121f852365c0ca38532d5c045014c Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 8 May 2026 15:32:41 +0200 Subject: [PATCH 04/18] Add Feature_Authorization extended object for FEP-7aa9 stamps. --- .../class-feature-authorization.php | 70 +++++++++++++++++++ .../class-test-feature-authorization.php | 60 ++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 includes/activity/extended-object/class-feature-authorization.php create mode 100644 tests/phpunit/tests/includes/activity/extended-object/class-test-feature-authorization.php diff --git a/includes/activity/extended-object/class-feature-authorization.php b/includes/activity/extended-object/class-feature-authorization.php new file mode 100644 index 0000000000..b57b21a723 --- /dev/null +++ b/includes/activity/extended-object/class-feature-authorization.php @@ -0,0 +1,70 @@ + 'https://w3id.org/fep/7aa9#FeatureAuthorization', + 'gts' => 'https://gotosocial.org/ns#', + 'interactingObject' => array( + '@id' => 'gts:interactingObject', + '@type' => '@id', + ), + 'interactionTarget' => array( + '@id' => 'gts:interactionTarget', + '@type' => '@id', + ), + ), + ); + + /** + * The type of the object. + * + * @var string + */ + protected $type = 'FeatureAuthorization'; + + /** + * The object that is being interacted with. + * + * @var Base_Object|string|array|null + */ + protected $interacting_object; + + /** + * The target of the interaction. + * + * @var Base_Object|string|array|null + */ + protected $interaction_target; +} diff --git a/tests/phpunit/tests/includes/activity/extended-object/class-test-feature-authorization.php b/tests/phpunit/tests/includes/activity/extended-object/class-test-feature-authorization.php new file mode 100644 index 0000000000..c9cce89bbc --- /dev/null +++ b/tests/phpunit/tests/includes/activity/extended-object/class-test-feature-authorization.php @@ -0,0 +1,60 @@ +assertSame( 'FeatureAuthorization', $object->get_type() ); + } + + /** + * Test that interactingObject and interactionTarget round-trip. + */ + public function test_interaction_properties_round_trip() { + $object = new Feature_Authorization(); + $object->set_id( 'https://example.com/users/alice/feature-stamps/12' ); + $object->set_interacting_object( 'https://other.example.com/users/bob/featured/23' ); + $object->set_interaction_target( 'https://example.com/users/alice' ); + + $array = $object->to_array(); + + $this->assertSame( 'FeatureAuthorization', $array['type'] ); + $this->assertSame( 'https://other.example.com/users/bob/featured/23', $array['interactingObject'] ); + $this->assertSame( 'https://example.com/users/alice', $array['interactionTarget'] ); + } + + /** + * Test that the JSON-LD context includes the FEP-7aa9 namespace. + */ + public function test_json_ld_context_includes_fep_7aa9() { + $object = new Feature_Authorization(); + $array = $object->to_array(); + + $found = false; + foreach ( (array) $array['@context'] as $entry ) { + if ( is_array( $entry ) && isset( $entry['FeatureAuthorization'] ) ) { + $this->assertSame( 'https://w3id.org/fep/7aa9#FeatureAuthorization', $entry['FeatureAuthorization'] ); + $found = true; + } + } + $this->assertTrue( $found, 'JSON-LD context must include FeatureAuthorization mapping.' ); + } +} From 51bf5eaab3037359f717e83090904dfa800a5659 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 8 May 2026 15:45:06 +0200 Subject: [PATCH 05/18] Strengthen Feature_Authorization context test, document standalone shape. Adds explicit assertions for the gts:interactingObject and gts:interactionTarget mappings in the JSON-LD context test, and a docblock note explaining the deliberately minimal context. --- .../extended-object/class-feature-authorization.php | 4 ++++ .../extended-object/class-test-feature-authorization.php | 8 +++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/includes/activity/extended-object/class-feature-authorization.php b/includes/activity/extended-object/class-feature-authorization.php index b57b21a723..e615e89316 100644 --- a/includes/activity/extended-object/class-feature-authorization.php +++ b/includes/activity/extended-object/class-feature-authorization.php @@ -29,6 +29,10 @@ class Feature_Authorization extends Base_Object { /** * The JSON-LD context for the object. * + * Intentionally minimal: stamps are always served standalone at their own + * URL, so we ship only the vocabulary the stamp document itself uses. + * Mirrors the Quote_Authorization (FEP-044f) approach. + * * @var array */ const JSON_LD_CONTEXT = array( diff --git a/tests/phpunit/tests/includes/activity/extended-object/class-test-feature-authorization.php b/tests/phpunit/tests/includes/activity/extended-object/class-test-feature-authorization.php index c9cce89bbc..d9f4e9e3f1 100644 --- a/tests/phpunit/tests/includes/activity/extended-object/class-test-feature-authorization.php +++ b/tests/phpunit/tests/includes/activity/extended-object/class-test-feature-authorization.php @@ -42,7 +42,8 @@ public function test_interaction_properties_round_trip() { } /** - * Test that the JSON-LD context includes the FEP-7aa9 namespace. + * Test that the JSON-LD context includes the FEP-7aa9 namespace and + * the gts:-namespaced stamp link properties. */ public function test_json_ld_context_includes_fep_7aa9() { $object = new Feature_Authorization(); @@ -52,6 +53,11 @@ public function test_json_ld_context_includes_fep_7aa9() { foreach ( (array) $array['@context'] as $entry ) { if ( is_array( $entry ) && isset( $entry['FeatureAuthorization'] ) ) { $this->assertSame( 'https://w3id.org/fep/7aa9#FeatureAuthorization', $entry['FeatureAuthorization'] ); + $this->assertSame( 'https://gotosocial.org/ns#', $entry['gts'] ); + $this->assertSame( 'gts:interactingObject', $entry['interactingObject']['@id'] ); + $this->assertSame( '@id', $entry['interactingObject']['@type'] ); + $this->assertSame( 'gts:interactionTarget', $entry['interactionTarget']['@id'] ); + $this->assertSame( '@id', $entry['interactionTarget']['@type'] ); $found = true; } } From adb8c946c28b6964490ee951cb6b6c92af00c094 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 8 May 2026 16:02:58 +0200 Subject: [PATCH 06/18] Register activitypub_default_feature_policy option. --- includes/class-options.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/includes/class-options.php b/includes/class-options.php index 083dc60e95..c589733854 100644 --- a/includes/class-options.php +++ b/includes/class-options.php @@ -201,6 +201,24 @@ public static function register_settings() { ) ); + \register_setting( + 'activitypub', + 'activitypub_default_feature_policy', + array( + 'type' => 'string', + 'description' => 'Default policy for who can include this site\'s actors in featured collections (FEP-7aa9).', + 'default' => ACTIVITYPUB_INTERACTION_POLICY_ME, + 'sanitize_callback' => static function ( $value ) { + $allowed = array( + ACTIVITYPUB_INTERACTION_POLICY_ANYONE, + ACTIVITYPUB_INTERACTION_POLICY_FOLLOWERS, + ACTIVITYPUB_INTERACTION_POLICY_ME, + ); + return \in_array( $value, $allowed, true ) ? $value : ACTIVITYPUB_INTERACTION_POLICY_ME; + }, + ) + ); + \register_setting( 'activitypub', 'activitypub_relays', From 4a7bb8f5dbb567f5b4c1e20694381f21ef18bd9e Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 8 May 2026 16:04:06 +0200 Subject: [PATCH 07/18] Add settings UI for default feature collection policy. --- includes/wp-admin/class-settings-fields.php | 26 +++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/includes/wp-admin/class-settings-fields.php b/includes/wp-admin/class-settings-fields.php index fd08d34aff..f35ee99d46 100644 --- a/includes/wp-admin/class-settings-fields.php +++ b/includes/wp-admin/class-settings-fields.php @@ -122,6 +122,15 @@ public static function register_settings_fields() { array( 'label_for' => 'activitypub_default_quote_policy' ) ); + add_settings_field( + 'activitypub_default_feature_policy', + __( 'Featured collection requests', 'activitypub' ), + array( self::class, 'render_default_feature_policy_field' ), + 'activitypub_settings', + 'activitypub_activities', + array( 'label_for' => 'activitypub_default_feature_policy' ) + ); + add_settings_field( 'activitypub_use_hashtags', __( 'Hashtags', 'activitypub' ), @@ -399,6 +408,23 @@ public static function render_default_quote_policy_field() { + +

+ +

+ Date: Fri, 8 May 2026 16:30:04 +0200 Subject: [PATCH 08/18] Add Feature_Request handler skeleton with validation and blocked-path. --- includes/handler/class-feature-request.php | 129 ++++++++++++++++ .../handler/class-test-feature-request.php | 145 ++++++++++++++++++ 2 files changed, 274 insertions(+) create mode 100644 includes/handler/class-feature-request.php create mode 100644 tests/phpunit/tests/includes/handler/class-test-feature-request.php diff --git a/includes/handler/class-feature-request.php b/includes/handler/class-feature-request.php new file mode 100644 index 0000000000..8c235a932e --- /dev/null +++ b/includes/handler/class-feature-request.php @@ -0,0 +1,129 @@ + 1, + 'type' => 1, + 'actor' => 1, + 'object' => 1, + 'instrument' => 1, + ) + ); + + $activity = new Activity(); + $activity->set_type( 'Reject' ); + $activity->set_actor( $actor->get_id() ); + $activity->set_object( $activity_object ); + $activity->add_to( object_to_uri( $activity_object['actor'] ) ); + + add_to_outbox( $activity, null, $user_id, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE ); + } + + /** + * Validate the object on incoming FeatureRequest activities. + * + * @param bool $valid The current validation state. + * @param string $param The object parameter name. + * @param \WP_REST_Request $request The request object. + * + * @return bool + */ + public static function validate_object( $valid, $param, $request ) { + $activity = $request->get_json_params(); + + if ( empty( $activity['type'] ) ) { + return false; + } + + if ( 'FeatureRequest' !== $activity['type'] ) { + return $valid; + } + + if ( ! isset( $activity['actor'], $activity['object'], $activity['instrument'] ) ) { + return false; + } + + return $valid; + } +} diff --git a/tests/phpunit/tests/includes/handler/class-test-feature-request.php b/tests/phpunit/tests/includes/handler/class-test-feature-request.php new file mode 100644 index 0000000000..f78a736547 --- /dev/null +++ b/tests/phpunit/tests/includes/handler/class-test-feature-request.php @@ -0,0 +1,145 @@ + 'https://remote.example.com/activities/feat-1', + 'type' => 'FeatureRequest', + 'actor' => $actor_uri, + 'object' => \Activitypub\Collection\Actors::get_by_id( self::$user_id )->get_id(), + 'instrument' => 'https://remote.example.com/users/curator/featured/42', + ); + } + + /** + * Test that validate_object accepts a well-formed FeatureRequest. + * + * @covers ::validate_object + */ + public function test_validate_object_passes_for_valid_feature_request() { + $activity = $this->create_feature_request_activity(); + + $request = new \WP_REST_Request( 'POST', '/inbox' ); + $request->set_body( wp_json_encode( $activity ) ); + $request->set_header( 'Content-Type', 'application/json' ); + + $valid = Feature_Request::validate_object( true, 'object', $request ); + $this->assertTrue( $valid ); + } + + /** + * Test that validate_object rejects a FeatureRequest missing required keys. + * + * @covers ::validate_object + */ + public function test_validate_object_fails_for_missing_instrument() { + $activity = $this->create_feature_request_activity(); + unset( $activity['instrument'] ); + + $request = new \WP_REST_Request( 'POST', '/inbox' ); + $request->set_body( wp_json_encode( $activity ) ); + $request->set_header( 'Content-Type', 'application/json' ); + + $valid = Feature_Request::validate_object( true, 'object', $request ); + $this->assertFalse( $valid ); + } + + /** + * Test that validate_object passes through unrelated activity types unchanged. + * + * @covers ::validate_object + */ + public function test_validate_object_passes_through_other_types() { + $activity = array( + 'type' => 'Follow', + 'actor' => 'https://x', + 'object' => 'https://y', + ); + + $request = new \WP_REST_Request( 'POST', '/inbox' ); + $request->set_body( wp_json_encode( $activity ) ); + $request->set_header( 'Content-Type', 'application/json' ); + + $valid = Feature_Request::validate_object( true, 'object', $request ); + $this->assertTrue( $valid ); + } + + /** + * Test the blocked-request path emits a Reject for FeatureRequest activities. + * + * @covers ::handle_blocked_request + */ + public function test_handle_blocked_request_rejects_feature_request() { + $activity = $this->create_feature_request_activity(); + + Feature_Request::handle_blocked_request( $activity, self::$user_id, 'FeatureRequest' ); + + $outbox = get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'pending', + 'author' => self::$user_id, + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_activitypub_activity_type', + 'value' => 'Reject', + ), + ), + ) + ); + $this->assertNotEmpty( $outbox, 'Reject activity should be queued for blocked FeatureRequest.' ); + } + + /** + * Test the blocked-request path ignores unrelated activity types. + * + * @covers ::handle_blocked_request + */ + public function test_handle_blocked_request_ignores_other_types() { + $activity = $this->create_feature_request_activity(); + + Feature_Request::handle_blocked_request( $activity, self::$user_id, 'Follow' ); + + $outbox = get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'pending', + 'author' => self::$user_id, + ) + ); + $this->assertEmpty( $outbox, 'No outbox activity should be created for unrelated types.' ); + } +} From bd9301d454acdd18173c3c689d0082b1ad2d5029 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 8 May 2026 16:33:26 +0200 Subject: [PATCH 09/18] Strengthen Feature_Request handler tests after code review. - Replace inline namespace reference with a use import. - Add explicit queue_reject coverage (visibility, recipient, key trim). - Cover the snake_case 'feature_request' alias accepted by handle_blocked_request. --- .../handler/class-test-feature-request.php | 73 ++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/tests/includes/handler/class-test-feature-request.php b/tests/phpunit/tests/includes/handler/class-test-feature-request.php index f78a736547..5098762da1 100644 --- a/tests/phpunit/tests/includes/handler/class-test-feature-request.php +++ b/tests/phpunit/tests/includes/handler/class-test-feature-request.php @@ -7,6 +7,7 @@ namespace Activitypub\Tests\Handler; +use Activitypub\Collection\Actors; use Activitypub\Collection\Outbox; use Activitypub\Handler\Feature_Request; use Activitypub\Tests\ActivityPub_Outbox_TestCase; @@ -38,7 +39,7 @@ private function create_feature_request_activity( $actor_uri = 'https://remote.e 'id' => 'https://remote.example.com/activities/feat-1', 'type' => 'FeatureRequest', 'actor' => $actor_uri, - 'object' => \Activitypub\Collection\Actors::get_by_id( self::$user_id )->get_id(), + 'object' => Actors::get_by_id( self::$user_id )->get_id(), 'instrument' => 'https://remote.example.com/users/curator/featured/42', ); } @@ -142,4 +143,74 @@ public function test_handle_blocked_request_ignores_other_types() { ); $this->assertEmpty( $outbox, 'No outbox activity should be created for unrelated types.' ); } + + /** + * Test that the snake_case 'feature_request' type alias is accepted. + * + * The inbox dispatcher snake-cases activity types before firing per-type + * actions, so handle_blocked_request must accept both spellings. + * + * @covers ::handle_blocked_request + */ + public function test_handle_blocked_request_accepts_snake_case_alias() { + $activity = $this->create_feature_request_activity(); + + Feature_Request::handle_blocked_request( $activity, self::$user_id, 'feature_request' ); + + $outbox = get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'pending', + 'author' => self::$user_id, + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_activitypub_activity_type', + 'value' => 'Reject', + ), + ), + ) + ); + $this->assertNotEmpty( $outbox, 'Reject activity should be queued when dispatcher uses snake_case type.' ); + } + + /** + * Test that queue_reject creates a private Reject activity addressed to the requester. + * + * @covers ::queue_reject + */ + public function test_queue_reject_emits_minimal_private_activity() { + $activity = $this->create_feature_request_activity(); + + Feature_Request::queue_reject( $activity, self::$user_id ); + + $outbox = get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'pending', + 'author' => self::$user_id, + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_activitypub_activity_type', + 'value' => 'Reject', + ), + ), + ) + ); + $this->assertNotEmpty( $outbox, 'Reject activity should be created.' ); + + $payload = json_decode( $outbox[0]->post_content, true ); + $visibility = get_post_meta( $outbox[0]->ID, 'activitypub_content_visibility', true ); + + $this->assertSame( 'Reject', $payload['type'] ); + $this->assertSame( 'private', $visibility ); + $this->assertContains( $activity['actor'], $payload['to'] ); + + // Object payload should be trimmed to a stable allow-list. + $expected_keys = array( 'id', 'type', 'object', 'actor', 'instrument' ); + $actual_keys = array_keys( $payload['object'] ); + $this->assertEmpty( array_diff( $expected_keys, $actual_keys ), 'All expected keys should be present.' ); + $this->assertEmpty( array_diff( $actual_keys, $expected_keys ), 'No unexpected keys should be present.' ); + } } From 6ea8cd8e61ebefde683ebc0ff31d3fe5b52cc286 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 8 May 2026 16:36:11 +0200 Subject: [PATCH 10/18] Implement FeatureRequest policy branch with idempotent Accept stamps. --- includes/handler/class-feature-request.php | 123 +++++++++++++++- .../handler/class-test-feature-request.php | 132 ++++++++++++++++++ 2 files changed, 249 insertions(+), 6 deletions(-) diff --git a/includes/handler/class-feature-request.php b/includes/handler/class-feature-request.php index 8c235a932e..d94e7777ae 100644 --- a/includes/handler/class-feature-request.php +++ b/includes/handler/class-feature-request.php @@ -9,6 +9,8 @@ use Activitypub\Activity\Activity; use Activitypub\Collection\Actors; +use Activitypub\Collection\Followers; +use Activitypub\Collection\Remote_Actors; use function Activitypub\add_to_outbox; use function Activitypub\object_to_uri; @@ -33,14 +35,55 @@ public static function init() { /** * Handle FeatureRequest activities. * - * Behavior is filled in in the next task. Skeleton method exists so the - * action callback resolves during init. - * * @param array $activity The activity object. - * @param int|int[] $user_ids The user ID(s). + * @param int|int[] $user_ids The user ID(s) targeted by the inbox dispatch. */ - public static function handle_feature_request( $activity, $user_ids ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable - // Implemented in Task 6. + public static function handle_feature_request( $activity, $user_ids ) { + $state = true; + $object_uri = object_to_uri( $activity['object'] ); + $target = Actors::get_by_resource( $object_uri ); + + if ( \is_wp_error( $target ) ) { + $user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids; + self::queue_reject( $activity, $user_id ); + return; + } + + $user_id = $target->get__id(); + + $policy = \get_option( 'activitypub_default_feature_policy', ACTIVITYPUB_INTERACTION_POLICY_ME ); + + switch ( $policy ) { + case ACTIVITYPUB_INTERACTION_POLICY_ANYONE: + self::queue_accept( $activity, $user_id ); + break; + + case ACTIVITYPUB_INTERACTION_POLICY_FOLLOWERS: + $follower = Remote_Actors::get_by_uri( object_to_uri( $activity['actor'] ) ); + if ( ! \is_wp_error( $follower ) && Followers::follows( $follower->ID, $user_id ) ) { + self::queue_accept( $activity, $user_id ); + } else { + self::queue_reject( $activity, $user_id ); + $state = false; + } + break; + + case ACTIVITYPUB_INTERACTION_POLICY_ME: + default: + self::queue_reject( $activity, $user_id ); + $state = false; + break; + } + + /** + * Fires after an ActivityPub FeatureRequest activity has been handled. + * + * @param array $activity The ActivityPub activity data. + * @param int[] $user_ids The local user IDs. + * @param bool $state True on accept, false otherwise. + * @param string $policy The active site policy. + */ + \do_action( 'activitypub_handled_feature_request', $activity, (array) $user_ids, $state, $policy ); } /** @@ -59,6 +102,74 @@ public static function handle_blocked_request( $activity, $user_ids, $type ) { self::queue_reject( $activity, $user_id ); } + /** + * Send an Accept activity in response to the FeatureRequest, issuing a stamp. + * + * Idempotent: a second call with the same instrument for the same user reuses + * the existing umeta row instead of minting a duplicate stamp. + * + * @param array $activity_object The activity object. + * @param int $user_id The local user ID being featured. + */ + public static function queue_accept( $activity_object, $user_id ) { + if ( ! user_can_activitypub( $user_id ) ) { + $user_id = Actors::BLOG_USER_ID; + } + + $actor = Actors::get_by_id( $user_id ); + if ( \is_wp_error( $actor ) ) { + return; + } + + $activity_object['instrument'] = object_to_uri( $activity_object['instrument'] ); + + // Idempotent stamp creation. + $existing = \get_user_meta( $user_id, '_activitypub_featured_by', false ); + if ( \in_array( $activity_object['instrument'], (array) $existing, true ) ) { + global $wpdb; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $umeta_id = $wpdb->get_var( + $wpdb->prepare( + "SELECT umeta_id FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key = %s AND meta_value = %s LIMIT 1", + $user_id, + '_activitypub_featured_by', + $activity_object['instrument'] + ) + ); + } else { + $umeta_id = \add_user_meta( $user_id, '_activitypub_featured_by', $activity_object['instrument'] ); + } + + // Send minimal activity object back. + $activity_object = \array_intersect_key( + $activity_object, + array( + 'id' => 1, + 'type' => 1, + 'actor' => 1, + 'object' => 1, + 'instrument' => 1, + ) + ); + + $stamp_url = \add_query_arg( + array( + 'actor' => $user_id, + 'stamp' => $umeta_id, + ), + \home_url( '/' ) + ); + + $activity = new Activity(); + $activity->set_type( 'Accept' ); + $activity->set_actor( $actor->get_id() ); + $activity->set_object( $activity_object ); + $activity->set_result( $stamp_url ); + $activity->add_to( object_to_uri( $activity_object['actor'] ) ); + + add_to_outbox( $activity, null, $user_id, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE ); + } + /** * Send a Reject activity in response to the FeatureRequest. * diff --git a/tests/phpunit/tests/includes/handler/class-test-feature-request.php b/tests/phpunit/tests/includes/handler/class-test-feature-request.php index 5098762da1..91bd53d21f 100644 --- a/tests/phpunit/tests/includes/handler/class-test-feature-request.php +++ b/tests/phpunit/tests/includes/handler/class-test-feature-request.php @@ -8,6 +8,7 @@ namespace Activitypub\Tests\Handler; use Activitypub\Collection\Actors; +use Activitypub\Collection\Followers; use Activitypub\Collection\Outbox; use Activitypub\Handler\Feature_Request; use Activitypub\Tests\ActivityPub_Outbox_TestCase; @@ -213,4 +214,135 @@ public function test_queue_reject_emits_minimal_private_activity() { $this->assertEmpty( array_diff( $expected_keys, $actual_keys ), 'All expected keys should be present.' ); $this->assertEmpty( array_diff( $actual_keys, $expected_keys ), 'No unexpected keys should be present.' ); } + + /** + * Data provider for policy tests. + * + * @return array Test cases keyed by name. + */ + public function policy_test_data() { + return array( + 'default (me) - reject' => array( '', null, 'Reject' ), + 'me policy - reject' => array( ACTIVITYPUB_INTERACTION_POLICY_ME, null, 'Reject' ), + 'anyone policy - accept' => array( ACTIVITYPUB_INTERACTION_POLICY_ANYONE, null, 'Accept' ), + 'followers policy with follower - accept' => array( + ACTIVITYPUB_INTERACTION_POLICY_FOLLOWERS, + 'add_follower', + 'Accept', + ), + 'followers policy without follower - reject' => array( + ACTIVITYPUB_INTERACTION_POLICY_FOLLOWERS, + null, + 'Reject', + ), + ); + } + + /** + * Test handle_feature_request branches. + * + * @dataProvider policy_test_data + * @covers ::handle_feature_request + * + * @param string $policy Site policy to set, or '' to leave default. + * @param string|null $setup Optional setup callback name. + * @param string $expected_type Activity type expected in the outbox. + */ + public function test_handle_feature_request_policies( $policy, $setup, $expected_type ) { + if ( '' !== $policy ) { + update_option( 'activitypub_default_feature_policy', $policy ); + } else { + delete_option( 'activitypub_default_feature_policy' ); + } + + $activity = $this->create_feature_request_activity(); + $actor = $activity['actor']; + + $pre = function () use ( $actor ) { + return array( + 'id' => $actor, + 'type' => 'Person', + 'inbox' => str_replace( '/users/', '/inbox/', $actor ), + ); + }; + add_filter( 'pre_get_remote_metadata_by_actor', $pre ); + + if ( 'add_follower' === $setup ) { + $follower_id = Followers::add( self::$user_id, $actor ); + $this->assertNotFalse( $follower_id ); + } + + Feature_Request::handle_feature_request( $activity, self::$user_id ); + + $outbox = get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'pending', + 'author' => self::$user_id, + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_activitypub_activity_type', + 'value' => $expected_type, + ), + ), + ) + ); + $this->assertNotEmpty( $outbox, "{$expected_type} activity should be queued." ); + + remove_filter( 'pre_get_remote_metadata_by_actor', $pre ); + } + + /** + * Test that queue_accept stores a stamp and emits an Accept with the stamp URL. + * + * @covers ::queue_accept + */ + public function test_queue_accept_stores_stamp_and_emits_result() { + $activity = $this->create_feature_request_activity(); + + Feature_Request::queue_accept( $activity, self::$user_id ); + + // Verify usermeta row was created. + $stored = get_user_meta( self::$user_id, '_activitypub_featured_by', false ); + $this->assertContains( $activity['instrument'], $stored, 'Instrument URL should be recorded in user meta.' ); + + // Verify Accept activity in outbox carries a `result` URL containing actor and stamp params. + $outbox = get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'pending', + 'author' => self::$user_id, + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_activitypub_activity_type', + 'value' => 'Accept', + ), + ), + ) + ); + $this->assertNotEmpty( $outbox ); + $payload = json_decode( $outbox[0]->post_content, true ); + $this->assertSame( 'Accept', $payload['type'] ); + $this->assertNotEmpty( $payload['result'] ); + $this->assertStringContainsString( 'actor=' . self::$user_id, $payload['result'] ); + $this->assertStringContainsString( 'stamp=', $payload['result'] ); + } + + /** + * Test that queue_accept is idempotent: calling it twice with the same instrument + * reuses the existing usermeta row and does not duplicate stamps. + * + * @covers ::queue_accept + */ + public function test_queue_accept_idempotent() { + $activity = $this->create_feature_request_activity(); + + Feature_Request::queue_accept( $activity, self::$user_id ); + Feature_Request::queue_accept( $activity, self::$user_id ); + + $stored = get_user_meta( self::$user_id, '_activitypub_featured_by', false ); + $this->assertCount( 1, $stored, 'Duplicate FeatureRequests for the same instrument must not produce multiple stamps.' ); + } } From 8573370a2013b60a0abd898e592231d932f3c655 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 8 May 2026 16:36:48 +0200 Subject: [PATCH 11/18] Register Feature_Request handler during plugin bootstrap. --- includes/class-handler.php | 1 + 1 file changed, 1 insertion(+) diff --git a/includes/class-handler.php b/includes/class-handler.php index d7fa5d2056..b18e605a59 100644 --- a/includes/class-handler.php +++ b/includes/class-handler.php @@ -28,6 +28,7 @@ public static function register_handlers() { Handler\Collection_Sync::init(); Handler\Create::init(); Handler\Delete::init(); + Handler\Feature_Request::init(); Handler\Follow::init(); Handler\Like::init(); Handler\Move::init(); From c9bbd8c72f536d140db1623cb7bae4e885f49ef7 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 8 May 2026 16:39:44 +0200 Subject: [PATCH 12/18] Advertise interactionPolicy.canFeature on actor JSON. --- includes/activity/class-actor.php | 37 +++++++ .../model/class-test-interaction-policy.php | 96 +++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 tests/phpunit/tests/includes/model/class-test-interaction-policy.php diff --git a/includes/activity/class-actor.php b/includes/activity/class-actor.php index 064ab4183a..184fb9e09b 100644 --- a/includes/activity/class-actor.php +++ b/includes/activity/class-actor.php @@ -369,4 +369,41 @@ class Actor extends Base_Object { * @var boolean|null */ protected $invisible = null; + + /** + * Get the actor-level interaction policy. + * + * Overrides the magic property accessor on Base_Object so that we always + * compute the policy from the current site setting rather than returning a + * cached property value. Currently only emits `canFeature` (FEP-7aa9). + * Driven by the site option `activitypub_default_feature_policy` and + * defaults to denying all featured-collection requests, in line with + * FEP-7aa9's "absence of policy = no consent" rule. + * + * @see https://w3id.org/fep/7aa9 + * + * @return array + */ + public function get_interaction_policy() { + return array( 'canFeature' => $this->build_can_feature_policy() ); + } + + /** + * Build the `canFeature` policy array from the site option. + * + * @return array + */ + protected function build_can_feature_policy() { + $policy = \get_option( 'activitypub_default_feature_policy', ACTIVITYPUB_INTERACTION_POLICY_ME ); + + switch ( $policy ) { + case ACTIVITYPUB_INTERACTION_POLICY_ANYONE: + return array( 'automaticApproval' => array( 'https://www.w3.org/ns/activitystreams#Public' ) ); + case ACTIVITYPUB_INTERACTION_POLICY_FOLLOWERS: + return array( 'automaticApproval' => array( $this->get_followers() ) ); + case ACTIVITYPUB_INTERACTION_POLICY_ME: + default: + return array( 'automaticApproval' => array( $this->get_id() ) ); + } + } } diff --git a/tests/phpunit/tests/includes/model/class-test-interaction-policy.php b/tests/phpunit/tests/includes/model/class-test-interaction-policy.php new file mode 100644 index 0000000000..78fdfb1dd8 --- /dev/null +++ b/tests/phpunit/tests/includes/model/class-test-interaction-policy.php @@ -0,0 +1,96 @@ +user->create( array( 'role' => 'author' ) ); + \get_user_by( 'id', self::$user_id )->add_cap( 'activitypub' ); + } + + /** + * Reset the option after each test so other tests are unaffected. + */ + public function tear_down() { + delete_option( 'activitypub_default_feature_policy' ); + parent::tear_down(); + } + + /** + * Default policy is ME (denied). The actor MUST advertise its own id as + * the only automatic-approval target — explicit denial per FEP-7aa9. + */ + public function test_user_actor_emits_canfeature_me_by_default() { + $user = new User( self::$user_id ); + $policy = $user->get_interaction_policy(); + + $this->assertIsArray( $policy ); + $this->assertArrayHasKey( 'canFeature', $policy ); + $this->assertSame( array( $user->get_id() ), $policy['canFeature']['automaticApproval'] ); + } + + /** + * `anyone` policy emits the AS2 Public collection. + */ + public function test_user_actor_emits_canfeature_anyone_when_opted_in() { + update_option( 'activitypub_default_feature_policy', ACTIVITYPUB_INTERACTION_POLICY_ANYONE ); + + $user = new User( self::$user_id ); + $policy = $user->get_interaction_policy(); + + $this->assertSame( + array( 'https://www.w3.org/ns/activitystreams#Public' ), + $policy['canFeature']['automaticApproval'] + ); + } + + /** + * `followers` policy emits the actor's followers collection URL. + */ + public function test_user_actor_emits_canfeature_followers() { + update_option( 'activitypub_default_feature_policy', ACTIVITYPUB_INTERACTION_POLICY_FOLLOWERS ); + + $user = new User( self::$user_id ); + $policy = $user->get_interaction_policy(); + + $this->assertSame( + array( $user->get_followers() ), + $policy['canFeature']['automaticApproval'] + ); + } + + /** + * Blog actor inherits the same canFeature behavior. + */ + public function test_blog_actor_emits_canfeature() { + $blog = new Blog(); + $policy = $blog->get_interaction_policy(); + + $this->assertIsArray( $policy ); + $this->assertArrayHasKey( 'canFeature', $policy ); + } +} From 3bdc1c12360413cc2786f9d6958e96c633b9cd6a Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 8 May 2026 16:43:41 +0200 Subject: [PATCH 13/18] Resolve ?actor=ID&stamp=ID URLs to FeatureAuthorization JSON. --- includes/class-query.php | 70 +++++++++++- .../class-test-query-feature-stamp.php | 104 ++++++++++++++++++ 2 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 tests/phpunit/tests/includes/class-test-query-feature-stamp.php diff --git a/includes/class-query.php b/includes/class-query.php index 89c443903b..87520b64e1 100644 --- a/includes/class-query.php +++ b/includes/class-query.php @@ -7,6 +7,7 @@ namespace Activitypub; +use Activitypub\Activity\Extended_Object\Feature_Authorization; use Activitypub\Activity\Extended_Object\Quote_Authorization; use Activitypub\Collection\Actors; use Activitypub\Collection\Outbox; @@ -139,8 +140,14 @@ public function get_activitypub_object_id() { private function prepare_activitypub_data() { $queried_object = $this->get_queried_object(); - if ( $queried_object instanceof \WP_Post && \get_query_var( 'stamp' ) ) { - return $this->maybe_get_stamp(); + if ( \get_query_var( 'stamp' ) ) { + if ( $queried_object instanceof \WP_Post ) { + return $this->maybe_get_stamp(); + } + + if ( $queried_object instanceof \WP_User || \get_query_var( 'actor' ) ) { + return $this->maybe_get_actor_stamp(); + } } // Check for Outbox Activity. @@ -433,4 +440,63 @@ private function maybe_get_stamp() { return true; } + + /** + * Maybe get a FeatureAuthorization object from an actor-scoped stamp. + * + * Resolves URLs of the form `?actor=USER_ID&stamp=UMETA_ID` against the + * `_activitypub_featured_by` user meta. The umeta_id doubles as the stamp + * identifier; ownership is enforced by checking the row's user_id matches + * the queried actor. + * + * @return bool True if a FeatureAuthorization was prepared, false otherwise. + */ + private function maybe_get_actor_stamp() { + $stamp_id = (int) \get_query_var( 'stamp' ); + $actor_id = (int) \get_query_var( 'actor' ); + + if ( ! $stamp_id ) { + return false; + } + + if ( ! $actor_id ) { + $queried = $this->get_queried_object(); + if ( $queried instanceof \WP_User ) { + $actor_id = (int) $queried->ID; + } + } + + if ( ! $actor_id ) { + return false; + } + + $meta = \get_metadata_by_mid( 'user', $stamp_id ); + if ( ! $meta || '_activitypub_featured_by' !== $meta->meta_key || (int) $meta->user_id !== $actor_id ) { + return false; + } + + $actor = Actors::get_by_id( $actor_id ); + if ( \is_wp_error( $actor ) ) { + return false; + } + + $stamp_url = \add_query_arg( + array( + 'actor' => $actor_id, + 'stamp' => $meta->umeta_id, + ), + \home_url( '/' ) + ); + + $authorization = new Feature_Authorization(); + $authorization->set_id( $stamp_url ); + $authorization->set_attributed_to( $actor->get_id() ); + $authorization->set_interacting_object( $meta->meta_value ); + $authorization->set_interaction_target( $actor->get_id() ); + + $this->activitypub_object = $authorization; + $this->activitypub_object_id = $authorization->get_id(); + + return true; + } } diff --git a/tests/phpunit/tests/includes/class-test-query-feature-stamp.php b/tests/phpunit/tests/includes/class-test-query-feature-stamp.php new file mode 100644 index 0000000000..e858a456e9 --- /dev/null +++ b/tests/phpunit/tests/includes/class-test-query-feature-stamp.php @@ -0,0 +1,104 @@ +user->create( array( 'role' => 'author' ) ); + \get_user_by( 'id', self::$user_id )->add_cap( 'activitypub' ); + } + + /** + * Reset the Query singleton between tests. + */ + public function tear_down() { + $instance_property = new \ReflectionProperty( Query::class, 'instance' ); + $instance_property->setAccessible( true ); + $instance_property->setValue( null, null ); + + parent::tear_down(); + } + + /** + * A valid actor+stamp pair resolves to a FeatureAuthorization object. + * + * @covers ::get_activitypub_object + */ + public function test_actor_stamp_resolves_to_feature_authorization() { + $instrument = 'https://other.example.com/users/curator/featured/77'; + $umeta_id = add_user_meta( self::$user_id, '_activitypub_featured_by', $instrument ); + + set_query_var( 'actor', self::$user_id ); + set_query_var( 'stamp', $umeta_id ); + $GLOBALS['wp_query']->queried_object = get_user_by( 'id', self::$user_id ); + $GLOBALS['wp_query']->queried_object_id = self::$user_id; + + $object = Query::get_instance()->get_activitypub_object(); + + $this->assertInstanceOf( Feature_Authorization::class, $object ); + $array = $object->to_array(); + $this->assertSame( 'FeatureAuthorization', $array['type'] ); + $this->assertSame( $instrument, $array['interactingObject'] ); + } + + /** + * A stamp belonging to a different actor is rejected. + * + * @covers ::get_activitypub_object + */ + public function test_cross_actor_stamp_id_is_rejected() { + $other_user = self::factory()->user->create( array( 'role' => 'author' ) ); + \get_user_by( 'id', $other_user )->add_cap( 'activitypub' ); + + $umeta_id = add_user_meta( $other_user, '_activitypub_featured_by', 'https://x/y/1' ); + + set_query_var( 'actor', self::$user_id ); + set_query_var( 'stamp', $umeta_id ); + $GLOBALS['wp_query']->queried_object = get_user_by( 'id', self::$user_id ); + $GLOBALS['wp_query']->queried_object_id = self::$user_id; + + $object = Query::get_instance()->get_activitypub_object(); + $this->assertNotInstanceOf( Feature_Authorization::class, $object ); + } + + /** + * A non-existent stamp ID returns a non-FeatureAuthorization object. + * + * @covers ::get_activitypub_object + */ + public function test_missing_stamp_returns_null_object() { + set_query_var( 'actor', self::$user_id ); + set_query_var( 'stamp', 999999999 ); + $GLOBALS['wp_query']->queried_object = get_user_by( 'id', self::$user_id ); + $GLOBALS['wp_query']->queried_object_id = self::$user_id; + + $object = Query::get_instance()->get_activitypub_object(); + $this->assertNotInstanceOf( Feature_Authorization::class, $object ); + } +} From cf57b9fe338cd544ef7d523b933d25210c36ca1a Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 8 May 2026 16:44:23 +0200 Subject: [PATCH 14/18] Add changelog entry for feature collections consent. --- .github/changelog/feature-collections-consent | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .github/changelog/feature-collections-consent diff --git a/.github/changelog/feature-collections-consent b/.github/changelog/feature-collections-consent new file mode 100644 index 0000000000..692b4f98ba --- /dev/null +++ b/.github/changelog/feature-collections-consent @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add an opt-in setting to consent to inclusion in featured collections (Mastodon Starter Packs and similar). Off by default. Find it under Settings, ActivityPub, Activities. From 937a92060a7c8abc4b88aabbadfda49d41d4af5d Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 8 May 2026 16:58:06 +0200 Subject: [PATCH 15/18] Fix actor stamp URL routing collision after final review. Without this guard the router's actor-username lookup runs first on ?actor=ID&stamp=ID URLs and returns 404 for any user whose username isn't a number, breaking FeatureAuthorization stamp verification end to end. The guard tells the router to leave the request alone when a stamp is also requested so content negotiation can resolve the stamp. --- includes/class-router.php | 2 +- .../tests/includes/class-test-router.php | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/includes/class-router.php b/includes/class-router.php index e51cced7d1..298c215974 100644 --- a/includes/class-router.php +++ b/includes/class-router.php @@ -291,7 +291,7 @@ public static function template_redirect() { } $actor = \get_query_var( 'actor', null ); - if ( $actor ) { + if ( $actor && ! \get_query_var( 'stamp' ) ) { $actor = Actors::get_by_username( $actor ); if ( ! $actor || \is_wp_error( $actor ) ) { $wp_query->set_404(); diff --git a/tests/phpunit/tests/includes/class-test-router.php b/tests/phpunit/tests/includes/class-test-router.php index 5e686985f8..c71f697fbe 100644 --- a/tests/phpunit/tests/includes/class-test-router.php +++ b/tests/phpunit/tests/includes/class-test-router.php @@ -578,4 +578,28 @@ public function test_filter_adds_custom_taxonomy_to_redirects() { \wp_delete_term( $term_id, 'custom_tax' ); \unregister_taxonomy( 'custom_tax' ); } + + /** + * Test that ?actor=ID&stamp=ID URLs do not 404 in template_redirect. + * + * Stamp URLs use a numeric actor ID that does not match any username, + * so without a guard the router would 404 every FeatureAuthorization + * stamp before content negotiation runs. + * + * @covers ::template_redirect + */ + public function test_template_redirect_passes_through_stamp_urls() { + \set_query_var( 'actor', '2' ); + \set_query_var( 'stamp', '47' ); + + global $wp_query; + $wp_query->is_404 = false; + + Router::template_redirect(); + + $this->assertFalse( $wp_query->is_404(), 'Stamp URLs must not be 404\'d by the actor branch.' ); + + \set_query_var( 'actor', null ); + \set_query_var( 'stamp', null ); + } } From bd1dfc0b9f406a1e00e6069156654633f127fe66 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 8 May 2026 16:59:46 +0200 Subject: [PATCH 16/18] Address final review on the consent layer. - Merge with parent in Actor::get_interaction_policy so future canQuote / canReply additions on actors do not get dropped silently. - Rename the feature-policy "Just me" option to "No one" since for featured-collection consent the deny choice is operationally "nobody at all", not "self-service". - Drop the Phase 2 FeaturedCollection / FeaturedItem / featuredObject / featuredObjectType / featureAuthorization terms from the global Base_Object context. They are sender-side vocabulary that is not used in this phase, and including them bloated every JSON-LD document the plugin emits. They will be re-added in Phase 2 when the plugin actually emits FeaturedCollection objects. --- includes/activity/class-actor.php | 4 +- includes/activity/class-base-object.php | 43 ++++++++------------- includes/wp-admin/class-settings-fields.php | 2 +- 3 files changed, 20 insertions(+), 29 deletions(-) diff --git a/includes/activity/class-actor.php b/includes/activity/class-actor.php index 184fb9e09b..547e0bfe2c 100644 --- a/includes/activity/class-actor.php +++ b/includes/activity/class-actor.php @@ -382,10 +382,12 @@ class Actor extends Base_Object { * * @see https://w3id.org/fep/7aa9 * + * @since unreleased + * * @return array */ public function get_interaction_policy() { - return array( 'canFeature' => $this->build_can_feature_policy() ); + return array_merge( (array) parent::get_interaction_policy(), array( 'canFeature' => $this->build_can_feature_policy() ) ); } /** diff --git a/includes/activity/class-base-object.php b/includes/activity/class-base-object.php index f09d89687c..cfd38e24ed 100644 --- a/includes/activity/class-base-object.php +++ b/includes/activity/class-base-object.php @@ -123,60 +123,49 @@ class Base_Object extends Generic_Object { const JSON_LD_CONTEXT = array( 'https://www.w3.org/ns/activitystreams', array( - 'Hashtag' => 'as:Hashtag', - 'sensitive' => 'as:sensitive', - 'dcterms' => 'http://purl.org/dc/terms/', - 'gts' => 'https://gotosocial.org/ns#', - 'schema' => 'http://schema.org/', - 'exifData' => 'schema:exifData', - 'PropertyValue' => 'schema:PropertyValue', - 'interactionPolicy' => array( + 'Hashtag' => 'as:Hashtag', + 'sensitive' => 'as:sensitive', + 'dcterms' => 'http://purl.org/dc/terms/', + 'gts' => 'https://gotosocial.org/ns#', + 'schema' => 'http://schema.org/', + 'exifData' => 'schema:exifData', + 'PropertyValue' => 'schema:PropertyValue', + 'interactionPolicy' => array( '@id' => 'gts:interactionPolicy', '@type' => '@id', ), - 'canQuote' => array( + 'canQuote' => array( '@id' => 'gts:canQuote', '@type' => '@id', ), - 'canFeature' => array( + 'canFeature' => array( '@id' => 'https://w3id.org/fep/7aa9#canFeature', '@type' => '@id', ), - 'canReply' => array( + 'canReply' => array( '@id' => 'gts:canReply', '@type' => '@id', ), - 'canLike' => array( + 'canLike' => array( '@id' => 'gts:canLike', '@type' => '@id', ), - 'canAnnounce' => array( + 'canAnnounce' => array( '@id' => 'gts:canAnnounce', '@type' => '@id', ), - 'automaticApproval' => array( + 'automaticApproval' => array( '@id' => 'gts:automaticApproval', '@type' => '@id', ), - 'manualApproval' => array( + 'manualApproval' => array( '@id' => 'gts:manualApproval', '@type' => '@id', ), - 'always' => array( + 'always' => array( '@id' => 'gts:always', '@type' => '@id', ), - 'FeaturedCollection' => 'https://w3id.org/fep/7aa9#FeaturedCollection', - 'FeaturedItem' => 'https://w3id.org/fep/7aa9#FeaturedItem', - 'featuredObject' => array( - '@id' => 'https://w3id.org/fep/7aa9#featuredObject', - '@type' => '@id', - ), - 'featuredObjectType' => 'https://w3id.org/fep/7aa9#featuredObjectType', - 'featureAuthorization' => array( - '@id' => 'https://w3id.org/fep/7aa9#featureAuthorization', - '@type' => '@id', - ), ), ); diff --git a/includes/wp-admin/class-settings-fields.php b/includes/wp-admin/class-settings-fields.php index f35ee99d46..9405751da4 100644 --- a/includes/wp-admin/class-settings-fields.php +++ b/includes/wp-admin/class-settings-fields.php @@ -417,7 +417,7 @@ public static function render_default_feature_policy_field() {

From c992f94e01e4bd4c2818426ee2004d73f0a4867e Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 8 May 2026 17:12:15 +0200 Subject: [PATCH 17/18] Address review follow-ups: tests + router comment. - Document the router stamp guard inline so a future maintainer doesn't accidentally remove it. - Cover the unresolvable target user path with an explicit Reject test. - Strengthen the blog actor canFeature test with a value assertion (default-deny shape). - Add an end-to-end stamp resolution test that goes through go_to() and template_redirect() so the layers can never silently disagree again. --- includes/class-router.php | 7 ++++ .../class-test-query-feature-stamp.php | 38 +++++++++++++++++++ .../handler/class-test-feature-request.php | 31 +++++++++++++++ .../model/class-test-interaction-policy.php | 7 +++- 4 files changed, 82 insertions(+), 1 deletion(-) diff --git a/includes/class-router.php b/includes/class-router.php index 298c215974..9b26063065 100644 --- a/includes/class-router.php +++ b/includes/class-router.php @@ -290,6 +290,13 @@ public static function template_redirect() { exit; } + /* + * Skip the actor branch when `stamp` is also set: those URLs are + * actor-scoped FEP-7aa9 stamps (`?actor=USER_ID&stamp=UMETA_ID`) + * resolved by Activitypub\Query, not Mastodon-style profile URLs. + * Without this guard the username lookup runs against the numeric + * USER_ID, fails, and 404s the stamp before content negotiation. + */ $actor = \get_query_var( 'actor', null ); if ( $actor && ! \get_query_var( 'stamp' ) ) { $actor = Actors::get_by_username( $actor ); diff --git a/tests/phpunit/tests/includes/class-test-query-feature-stamp.php b/tests/phpunit/tests/includes/class-test-query-feature-stamp.php index e858a456e9..e892e4125f 100644 --- a/tests/phpunit/tests/includes/class-test-query-feature-stamp.php +++ b/tests/phpunit/tests/includes/class-test-query-feature-stamp.php @@ -9,6 +9,7 @@ use Activitypub\Activity\Extended_Object\Feature_Authorization; use Activitypub\Query; +use Activitypub\Router; /** * Test class for FeatureAuthorization stamp resolution via Query. @@ -101,4 +102,41 @@ public function test_missing_stamp_returns_null_object() { $object = Query::get_instance()->get_activitypub_object(); $this->assertNotInstanceOf( Feature_Authorization::class, $object ); } + + /** + * End-to-end: a stamp URL goes through the same lifecycle as a real + * request (go_to → template_redirect → Query) and produces a + * FeatureAuthorization. This guards against the router reverting to + * the pre-guard state where it would 404 the request before content + * negotiation could resolve the stamp. + * + * @covers ::get_activitypub_object + * @covers \Activitypub\Router::template_redirect + */ + public function test_stamp_url_routes_and_resolves_end_to_end() { + $instrument = 'https://other.example.com/users/curator/featured/integration'; + $umeta_id = add_user_meta( self::$user_id, '_activitypub_featured_by', $instrument ); + + $stamp_url = add_query_arg( + array( + 'actor' => self::$user_id, + 'stamp' => $umeta_id, + ), + home_url( '/' ) + ); + + $this->go_to( $stamp_url ); + + /* + * The pre-guard router would have called set_404() here for any + * non-numeric-username site. Just calling template_redirect without + * exception means the guard fired and let the request through to + * content negotiation. + */ + Router::template_redirect(); + + $object = Query::get_instance()->get_activitypub_object(); + $this->assertInstanceOf( Feature_Authorization::class, $object ); + $this->assertSame( $instrument, $object->to_array()['interactingObject'] ); + } } diff --git a/tests/phpunit/tests/includes/handler/class-test-feature-request.php b/tests/phpunit/tests/includes/handler/class-test-feature-request.php index 91bd53d21f..c28ee593ab 100644 --- a/tests/phpunit/tests/includes/handler/class-test-feature-request.php +++ b/tests/phpunit/tests/includes/handler/class-test-feature-request.php @@ -345,4 +345,35 @@ public function test_queue_accept_idempotent() { $stored = get_user_meta( self::$user_id, '_activitypub_featured_by', false ); $this->assertCount( 1, $stored, 'Duplicate FeatureRequests for the same instrument must not produce multiple stamps.' ); } + + /** + * Test that an unresolvable target (no local actor matches the activity object) + * still produces a Reject so the curator gets a definitive answer. + * + * @covers ::handle_feature_request + */ + public function test_handle_feature_request_rejects_unresolvable_target() { + update_option( 'activitypub_default_feature_policy', ACTIVITYPUB_INTERACTION_POLICY_ANYONE ); + + $activity = $this->create_feature_request_activity(); + $activity['object'] = 'https://this-host-does-not-host.example/users/ghost'; + + Feature_Request::handle_feature_request( $activity, self::$user_id ); + + $outbox = get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'pending', + 'author' => self::$user_id, + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_activitypub_activity_type', + 'value' => 'Reject', + ), + ), + ) + ); + $this->assertNotEmpty( $outbox, 'Unresolvable target should still produce a Reject activity.' ); + } } diff --git a/tests/phpunit/tests/includes/model/class-test-interaction-policy.php b/tests/phpunit/tests/includes/model/class-test-interaction-policy.php index 78fdfb1dd8..c9deaba265 100644 --- a/tests/phpunit/tests/includes/model/class-test-interaction-policy.php +++ b/tests/phpunit/tests/includes/model/class-test-interaction-policy.php @@ -84,7 +84,7 @@ public function test_user_actor_emits_canfeature_followers() { } /** - * Blog actor inherits the same canFeature behavior. + * Blog actor inherits the same canFeature behavior, including default-deny. */ public function test_blog_actor_emits_canfeature() { $blog = new Blog(); @@ -92,5 +92,10 @@ public function test_blog_actor_emits_canfeature() { $this->assertIsArray( $policy ); $this->assertArrayHasKey( 'canFeature', $policy ); + $this->assertSame( + array( $blog->get_id() ), + $policy['canFeature']['automaticApproval'], + 'Blog actor must default to explicit denial (its own id as the only approved target).' + ); } } From 5a20a43b3cbddb66b52428273000fdd01774d45b Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 8 May 2026 17:47:08 +0200 Subject: [PATCH 18/18] Address Copilot review and fix Test_Outbox CI failure. - Move canFeature out of Base_Object's JSON-LD context. The term is only emitted by the actor (via Actor::get_interaction_policy), so carrying it on every Note / Article / Activity bloated their @context and silently broke Test_Outbox::test_add by altering the snapshot every Outbox object renders. - Add interactionPolicy, canFeature, automaticApproval, and the gts: prefix to Actor::JSON_LD_CONTEXT so actor JSON now actually defines the terms it emits, rather than hand-waving them through inheritance. - Tighten the router stamp guard so it only bypasses the actor branch when actor is numeric (the stamp pattern). Non-numeric actors still get the regular Mastodon-style profile lookup. --- includes/activity/class-actor.php | 13 +++++++++++++ includes/activity/class-base-object.php | 4 ---- includes/class-router.php | 15 ++++++++------- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/includes/activity/class-actor.php b/includes/activity/class-actor.php index 547e0bfe2c..9e516c87c3 100644 --- a/includes/activity/class-actor.php +++ b/includes/activity/class-actor.php @@ -74,6 +74,7 @@ class Actor extends Base_Object { 'toot' => 'http://joinmastodon.org/ns#', 'lemmy' => 'https://join-lemmy.org/ns#', 'litepub' => 'http://litepub.social/ns#', + 'gts' => 'https://gotosocial.org/ns#', 'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers', 'PropertyValue' => 'schema:PropertyValue', 'value' => 'schema:value', @@ -107,6 +108,18 @@ class Actor extends Base_Object { '@type' => '@id', '@container' => '@list', ), + 'interactionPolicy' => array( + '@id' => 'gts:interactionPolicy', + '@type' => '@id', + ), + 'canFeature' => array( + '@id' => 'https://w3id.org/fep/7aa9#canFeature', + '@type' => '@id', + ), + 'automaticApproval' => array( + '@id' => 'gts:automaticApproval', + '@type' => '@id', + ), 'postingRestrictedToMods' => 'lemmy:postingRestrictedToMods', 'discoverable' => 'toot:discoverable', 'indexable' => 'toot:indexable', diff --git a/includes/activity/class-base-object.php b/includes/activity/class-base-object.php index cfd38e24ed..5668cd0ed8 100644 --- a/includes/activity/class-base-object.php +++ b/includes/activity/class-base-object.php @@ -138,10 +138,6 @@ class Base_Object extends Generic_Object { '@id' => 'gts:canQuote', '@type' => '@id', ), - 'canFeature' => array( - '@id' => 'https://w3id.org/fep/7aa9#canFeature', - '@type' => '@id', - ), 'canReply' => array( '@id' => 'gts:canReply', '@type' => '@id', diff --git a/includes/class-router.php b/includes/class-router.php index 9b26063065..a94b0541a8 100644 --- a/includes/class-router.php +++ b/includes/class-router.php @@ -291,14 +291,15 @@ public static function template_redirect() { } /* - * Skip the actor branch when `stamp` is also set: those URLs are - * actor-scoped FEP-7aa9 stamps (`?actor=USER_ID&stamp=UMETA_ID`) - * resolved by Activitypub\Query, not Mastodon-style profile URLs. - * Without this guard the username lookup runs against the numeric - * USER_ID, fails, and 404s the stamp before content negotiation. + * Skip the actor branch when this looks like an actor-scoped FEP-7aa9 + * stamp URL: numeric `actor` paired with a `stamp`. Those resolve to a + * FeatureAuthorization via Activitypub\Query, not via the username + * lookup which would 404 the numeric ID. Non-numeric actors fall + * through to the regular Mastodon-style profile lookup. */ - $actor = \get_query_var( 'actor', null ); - if ( $actor && ! \get_query_var( 'stamp' ) ) { + $actor = \get_query_var( 'actor', null ); + $is_stamp_url = $actor && \get_query_var( 'stamp' ) && \ctype_digit( (string) $actor ); + if ( $actor && ! $is_stamp_url ) { $actor = Actors::get_by_username( $actor ); if ( ! $actor || \is_wp_error( $actor ) ) { $wp_query->set_404();