Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
49743f6
Gitignore docs/superpowers/ for local-only superpowers artifacts.
pfefferle May 8, 2026
263f7ce
Register FEP-7aa9 vocabulary and FeatureRequest activity type.
pfefferle May 8, 2026
dc8e041
Use durable IRI for FEP-7aa9 reference.
pfefferle May 8, 2026
b001144
Add Feature_Authorization extended object for FEP-7aa9 stamps.
pfefferle May 8, 2026
51bf5ea
Strengthen Feature_Authorization context test, document standalone sh…
pfefferle May 8, 2026
adb8c94
Register activitypub_default_feature_policy option.
pfefferle May 8, 2026
4a7bb8f
Add settings UI for default feature collection policy.
pfefferle May 8, 2026
87db0b2
Add Feature_Request handler skeleton with validation and blocked-path.
pfefferle May 8, 2026
bd9301d
Strengthen Feature_Request handler tests after code review.
pfefferle May 8, 2026
6ea8cd8
Implement FeatureRequest policy branch with idempotent Accept stamps.
pfefferle May 8, 2026
8573370
Register Feature_Request handler during plugin bootstrap.
pfefferle May 8, 2026
c9bbd8c
Advertise interactionPolicy.canFeature on actor JSON.
pfefferle May 8, 2026
3bdc1c1
Resolve ?actor=ID&stamp=ID URLs to FeatureAuthorization JSON.
pfefferle May 8, 2026
cf57b9f
Add changelog entry for feature collections consent.
pfefferle May 8, 2026
937a920
Fix actor stamp URL routing collision after final review.
pfefferle May 8, 2026
bd1dfc0
Address final review on the consent layer.
pfefferle May 8, 2026
c992f94
Address review follow-ups: tests + router comment.
pfefferle May 8, 2026
5a20a43
Address Copilot review and fix Test_Outbox CI failure.
pfefferle May 8, 2026
bb72aef
Merge branch 'trunk' into add/featured-collections-consent
pfefferle May 9, 2026
283c95d
Merge branch 'trunk' into add/featured-collections-consent
pfefferle May 12, 2026
cf167f2
Merge branch 'trunk' into add/featured-collections-consent
pfefferle May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/changelog/feature-collections-consent
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Add an opt-in setting to consent to inclusion in Starter Kits (also called Starter Packs or Featured Collections). Off by default. Find it under Settings, ActivityPub, Activities.
8 changes: 5 additions & 3 deletions includes/activity/class-activity.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,10 @@ 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',
'blurhash' => 'toot:blurhash',
'toot' => 'http://joinmastodon.org/ns#',
'QuoteRequest' => 'toot:QuoteRequest',
'blurhash' => 'toot:blurhash',
'FeatureRequest' => 'https://w3id.org/fep/7aa9#FeatureRequest',
),
);

Expand Down Expand Up @@ -71,6 +72,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://w3id.org/fep/7aa9 (FEP-7aa9 draft)
'Read',
'Reject',
'Remove',
Expand Down
52 changes: 52 additions & 0 deletions includes/activity/class-actor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -369,4 +382,43 @@ 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
*
* @since unreleased
*
* @return array
*/
public function get_interaction_policy() {
return array_merge( (array) parent::get_interaction_policy(), array( 'canFeature' => $this->build_can_feature_policy() ) );
}
Comment thread
pfefferle marked this conversation as resolved.

/**
* 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() ) );
}
}
}
74 changes: 74 additions & 0 deletions includes/activity/extended-object/class-feature-authorization.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php
/**
* Feature_Authorization is an implementation of the FeatureAuthorization activity type,
* as defined in FEP-7aa9 (https://w3id.org/fep/7aa9).
*
* This class represents a FeatureAuthorization activity for ActivityPub implementations.
*
* @package Activitypub
*/

namespace Activitypub\Activity\Extended_Object;

use Activitypub\Activity\Base_Object;

/**
* Class representing a FeatureAuthorization activity.
*
* @see https://w3id.org/fep/7aa9
*
* @since unreleased
*
* @method Base_Object|string|array|null get_interacting_object() Gets the interacting object property of the object.
* @method Base_Object|string|array|null get_interaction_target() Gets the interaction target property of the object.
*
* @method Feature_Authorization set_interacting_object( string|array|Base_Object|null $data ) Sets the interacting object property of the object.
* @method Feature_Authorization set_interaction_target( string|array|Base_Object|null $data ) Sets the interaction target property of the object.
*/
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(
'https://www.w3.org/ns/activitystreams',
array(
'FeatureAuthorization' => '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;
}
1 change: 1 addition & 0 deletions includes/class-handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
18 changes: 18 additions & 0 deletions includes/class-options.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
70 changes: 68 additions & 2 deletions includes/class-query.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
}
12 changes: 10 additions & 2 deletions includes/class-router.php
Original file line number Diff line number Diff line change
Expand Up @@ -290,8 +290,16 @@ public static function template_redirect() {
exit;
}

$actor = \get_query_var( 'actor', null );
if ( $actor ) {
/*
* 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 );
$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();
Expand Down
Loading