Skip to content

Refactor: Extract Application from the shared actor pipeline#2926

Open
pfefferle wants to merge 27 commits into
trunkfrom
refactor/extract-application-from-actor-system
Open

Refactor: Extract Application from the shared actor pipeline#2926
pfefferle wants to merge 27 commits into
trunkfrom
refactor/extract-application-from-actor-system

Conversation

@pfefferle

@pfefferle pfefferle commented Feb 16, 2026

Copy link
Copy Markdown
Member

Fixes #

Proposed changes:

  • Extract the Application actor from the shared actor pipeline into a standalone utility class (\Activitypub\Application) and self-contained REST controller.
  • The Application is no longer treated as a "virtual user" with ID -1 flowing through Actors::get_by_id(). It exists only as a JSON-LD document and HTTP signing identity.
  • Remove Actors::APPLICATION_USER_ID constant and all -1 guard clauses from Mailer, Follow handler, CLI, Health Check, Outbox, Stream integration, etc.
  • Add migration to rename keypair option from activitypub_keypair_for_-1 to activitypub_application_keypair, with legacy fallback.
  • Deprecate \Activitypub\Model\Application with _deprecated_class() notice pointing to the new classes.
  • Add dedicated @application rewrite rule so the pretty URL resolves outside the actor routing system.
  • Fix WP_Query parameter (numberposts_per_page) and guard strtotime() against empty post_date_gmt.
  • Explicitly Reject Follow requests aimed at the Application actor: Follow::reject_application_follow() sends a directly-delivered Reject signed with the Application key (Http::post() accepts null as user ID for that), so remote follow requests don't sit "pending" forever.
  • Retire pending pre-upgrade application outbox items instead of misattributing them to the Blog actor (they share post_author 0).
  • Reserve the application identifier in the blog-identifier sanitizer to avoid handle collisions with the Application actor.
  • Deduplicate get_icon()/get_published() into the Application utility class (controller and deprecated model delegate) and tune the published-date WP_Query.
  • Pass a hydrated Generic_Object to the activitypub_activity_object_array filters instead of null, keeping the documented filter contract.
  • Fix a fatal in the Fediverse Stats block fallback that still referenced the removed APPLICATION_USER_ID constant.

Why handle the Application separately?

This is the first step of a larger effort to move the local actor model off magic IDs (0 for the blog actor, -1 for the application actor) and onto real, WordPress-native identity. The blog/group actor and the application actor need different treatments, and this PR settles the easy half so the harder blog-actor work can focus on a single problem.

The application actor has no actor surface to preserve:

  • no post_author rows (it authors no content),
  • no outbox content,
  • no followers / following,
  • no inbox recipient entries,
  • no images / extra fields,
  • no profile UI.

It exists only as a JSON-LD document and an HTTP-signing identity for signed fetches / secure mode. Because there is nothing to migrate and nothing that needs a wp_users row, the cheapest correct move is to take it out of the actor abstraction entirely (this PR) rather than give it a real user or keep it as a resolver alias.

The blog/group actor is the opposite case and is intentionally not touched here: it owns content (post_author), followers, inbox entries, images, and external /actors/0/... URLs that must stay resolvable. That actor is the subject of separate work and will move toward a real WP_User with 0 kept only as a compatibility alias. Rule of thumb established by this PR: extraction for identities with no actor surface (application); alias-then-real-user for identities that own content and must stay resolvable (blog).

Other information:

  • Have you written new tests for your changes, if applicable?

Testing instructions:

  • Verify https://example.com/wp-json/activitypub/1.0/application returns a valid Application actor with publicKey, inbox, outbox, and proper @context.
  • Verify https://example.com/@application serves the Application actor JSON directly (an internal rewrite to the /wp-json/activitypub/1.0/application endpoint; it resolves in place, it does not issue an HTTP redirect).
  • Verify WebFinger for acct:application@example.com returns correct data.
  • On an existing installation, verify the Application keypair is preserved after upgrade (keys should not change).
  • Verify HTTP Signature verification still works for outbound GET requests (e.g., fetching remote actor profiles).
  • From a remote instance (e.g. Mastodon), try to follow application@example.com — the follow request should be explicitly rejected instead of staying "pending".
  • Try to set the blog profile ID to application in the settings — it should be refused as reserved.
  • Run full PHPUnit suite — all tests should pass.

Changelog entry

  • Automatically create a changelog entry from the details below.
Changelog Entry Details

Significance

  • Major

Type

  • Changed - for changes in existing functionality

Message

Improve the internal handling of the Application actor used for server-to-server requests.

The Application actor is not a real actor — it cannot be followed,
addressed, or interacted with. It exists only as a JSON-LD document
and a signing identity for outbound HTTP GET requests.

This introduces a standalone `Application` utility class with static
methods for identity (`get_id()`, `get_url()`, `get_webfinger()`) and
key management (`get_key_id()`, `get_public_key()`, `get_private_key()`).

- Remove `APPLICATION_USER_ID` (-1) from `Actors` collection
- Remove Application cases from `user_can_activitypub()`, Follow handler,
  Mailer, Outbox, CLI, Health Check, and Stream connector
- Make `Application_Controller` delegate to the new `Application` class
- Deprecate `Model\Application` (kept for backward compatibility)
- Rename option from `activitypub_keypair_for_-1` to
  `activitypub_application_keypair` with migration
- Add self-contained WebFinger discovery via `webfinger_data` filter,
  handling `acct:`, `/@application`, and REST API URL patterns
- Add missing backslash prefix on \_deprecated_class() call.
- Fix WP_Query parameter from 'number' to 'posts_per_page'.
- Guard strtotime() against false when post_date_gmt is empty.
- Add 'invisible' property to Application REST schema.
- Use pretty URL for Application 'url' field.
- Add @SInCE unreleased tags to new Application class.
- Add comment explaining @Application rewrite rule ordering.
- Remove Application user (ID -1) from Following test.
- Add backslash prefix to wp_cache_flush() in Migration.
- Add backslash prefix to is_string() in Application.
Run the Application WebFinger filter at priority 2 (after
Integration\Webfinger::add_pseudo_user_discovery at priority 1) so
the Application JRD is not overwritten by a WP_Error. Register a
/application/outbox route returning an empty OrderedCollection to
back the URL already advertised in the Application actor document.
- Backslash-prefix `is_string()` calls in Application::generate_key_pair().
- Use `update_option()` instead of `add_option()` to prevent keypair race.
- Use Application::get_webfinger() in health check so it works regardless
  of actor mode or authentication context.
- Add `@since unreleased` to generate_key_pair(), check_legacy_key_pair(),
  and Application_Controller::get_item().
- Add negative test for `acct:application@host` resolving to WP_Error in
  the Actors collection.
- Probe the Application endpoint (GET-readable) instead of the shared
  inbox (POST-only) in is_rest_api_accessible() health check.
- Guard strrchr() return in is_application_resource() to avoid passing
  false to substr() (PHP 8 deprecation).
- Fix migration docblock: clarify that legacy separate key options are
  migrated lazily, not by this function. Add missing @SInCE tag.
…lication-from-actor-system

# Conflicts:
#	includes/class-migration.php
#	includes/class-router.php
#	tests/phpunit/tests/includes/class-test-migration.php
#	tests/phpunit/tests/includes/collection/class-test-following.php
- Restore Actors::APPLICATION_USER_ID as a deprecated back-compat constant
  (class-stats-image.php still references it; removing it caused a fatal).
- Cover the activitypub_keypair_for_-1 option in check_legacy_key_pair() so
  the Application signing key is never regenerated when get_keypair() runs
  before the keypair migration.
- Add changelog entry.
- following: drop the stray $accept_5 block (no $outbox_item_5 setup; left
  over from the trunk merge) that caused an undefined-variable error and phpcs
  warnings.
- feature-request: resolve the Application id via \Activitypub\Application::get_id()
  instead of Actors::get_by_id( -1 ) (which now returns WP_Error → get_id() fatal).
- interaction-policy: declare the expected Model\Application deprecation notice.
- stats-image: resolve the Application webfinger via the new
  \Activitypub\Application::get_webfinger() instead of instantiating the
  deprecated Model\Application (which emitted a deprecation notice).
- application: move class constants to the top of the class.
- migration: drop the orphaned activitypub_keypair_for_-1 row when the
  destination keypair option already exists (early get_keypair() read).
- Reserve the 'application' username in Actors::get_id_by_username() so the
  advertised /@Application handle can't be hijacked by a local user named
  'application' during shared-inbox recipient resolution (the special-case was
  dropped when the Application left the Actors collection).
- Re-apply the activitypub_activity_object_array and
  activitypub_activity_application_object_array filters in the /application
  endpoint so integrations that add actor fields via those hooks still apply.
The Application authors no posts and is not in Actors::get_all_ids(), so the
annual stats image always bails with no_stats before reaching the
APPLICATION_USER_ID webfinger branch. Drop the dead branch and the now-unused
Application import.
The Application is no longer an actor and -1 must not resolve through the actor
pipeline. Drop the (unreleased) compatibility constant entirely; get_by_id( -1 )
already returns a 404 with no special-casing. Adds a test that locks in the
hard 404 so a back-compat shim is not silently reintroduced.
@pfefferle pfefferle marked this pull request as ready for review June 25, 2026 11:09
…CLI delete, simplify get_url

- Replace wp_cache_flush() in migrate_application_keypair_option() with targeted option cache deletes.
- Reject invalid (negative) actor IDs in 'wp activitypub actor delete' with a friendly error.
- Return the canonical Application id from Application::get_url().
…ation

Reuse Webfinger::get_identifier_and_host() in is_application_resource()
instead of re-deriving the scheme/path/host parsing from
Actors::get_id_by_resource(). This also fixes a host-confusion bug where
any URL ending in /@Application matched regardless of host. Adds direct
test coverage for the matcher.

Move the legacy Application key-pair fallback out of the runtime
get_keypair() path into a dedicated migrate_legacy_application_keys()
migration and drop check_legacy_key_pair(), so reads no longer carry
migration logic.

@jeherve jeherve left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verify https://example.com/@application redirects/resolves to the Application endpoint.

The json gets served, but the redirect doesn't happen for me.

On an existing installation, verify the Application keypair is preserved after upgrade (keys should not change).

They do change for me when I switch from one branch to the other.


Everything else lines up and looks good for me right now.

Reading the key only from the new option and relying on the migration to
rename the legacy one is timing-dependent: if get_keypair() runs before the
migration (or the version doesn't change, as when switching branches), it
generated a fresh key and the migration then dropped the legacy row. Restore
check_legacy_key_pair() in get_keypair() so the existing key is recovered on
read regardless of migration timing, and drop the now-redundant
migrate_legacy_application_keys() migration.
Copilot AI review requested due to automatic review settings July 1, 2026 06:02

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors the plugin’s “Application actor” so it no longer flows through the shared actor pipeline as a virtual user (-1). Instead, it becomes a standalone utility class (\Activitypub\Application) for signing/key management plus a dedicated REST controller for serving the Application actor JSON-LD (including an empty outbox).

Changes:

  • Introduces \Activitypub\Application and a self-contained REST controller with /application and /application/outbox routes, plus a dedicated @application rewrite rule.
  • Removes the old Actors::APPLICATION_USER_ID path and updates callers (HTTP signing, health check, stream integration, handlers, mailer, etc.) accordingly.
  • Adds/updates migrations and tests, including renaming the Application keypair option and adding WebFinger coverage.

Reviewed changes

Copilot reviewed 27 out of 27 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/phpunit/tests/integration/class-test-webfinger.php Adds coverage ensuring Application WebFinger discovery overrides pseudo-user errors; updates filter cleanup.
tests/phpunit/tests/includes/rest/class-test-application-controller.php Updates Application REST endpoint assertions and adds outbox + key management tests.
tests/phpunit/tests/includes/model/class-test-interaction-policy.php Adjusts for deprecated Model\Application by expecting deprecation notice.
tests/phpunit/tests/includes/handler/class-test-follow.php Removes obsolete follow test case targeting the former application actor ID.
tests/phpunit/tests/includes/handler/class-test-feature-request.php Updates feature-request handler test to target Application::get_id() instead of actor -1.
tests/phpunit/tests/includes/collection/class-test-following.php Removes application-actor follow/unfollow scenarios.
tests/phpunit/tests/includes/collection/class-test-actors.php Ensures Actors::get_by_id(-1) and acct:application@host no longer resolve via Actors collection.
tests/phpunit/tests/includes/class-test-migration.php Updates migration tests to use -1 literals and adds tests for renaming the application keypair option.
tests/phpunit/tests/includes/class-test-application.php Adds unit tests for recognizing Application resources (acct, handle, pretty URL, REST ID).
integration/stream/class-connector.php Removes display handling for the removed “Application User” actor ID.
includes/wp-admin/class-health-check.php Switches health checks from Actors(-1) to Application/WebFinger + dedicated REST URL.
includes/rest/class-application-controller.php Implements the Application actor document and empty outbox routes; adds schema fields.
includes/model/class-application.php Deprecates the old model, redirects key-related behavior to the new utility, updates inbox/outbox URLs, and adjusts query args.
includes/handler/class-follow.php Removes special-case rejection for the former application actor ID.
includes/functions-user.php Removes “application user always enabled” logic tied to APPLICATION_USER_ID.
includes/collection/class-outbox.php Removes support for resolving outbox actor as “application” via Actors collection.
includes/collection/class-actors.php Removes APPLICATION_USER_ID constant; reserves application username and prevents resolving it to a normal user/actor.
includes/cli/class-actor-command.php Updates delete guardrails so negative IDs (including former -1) are treated as non-deletable/invalid.
includes/class-router.php Adds a dedicated @application rewrite rule ahead of the generic @username rule.
includes/class-query.php Updates documentation comment to reflect only the blog actor remains a virtual object.
includes/class-migration.php Adds migration to rename activitypub_keypair_for_-1 to activitypub_application_keypair and flush rewrite rules.
includes/class-mailer.php Removes special-case “don’t email the application user” logic tied to APPLICATION_USER_ID.
includes/class-http.php Switches outbound HTTP signing identity from Actors(-1) to Application key utilities.
includes/class-application.php Adds new \Activitypub\Application utility: WebFinger integration + keypair/legacy fallback + resource matching.
activitypub.php Hooks Application::init() on init.
.github/changelog/refactor-extract-application Adds changelog entry describing the refactor.

Comment thread includes/rest/class-application-controller.php Outdated
Comment thread includes/model/class-application.php Outdated
@pfefferle pfefferle requested a review from jeherve July 1, 2026 07:37
@pfefferle

Copy link
Copy Markdown
Member Author

@jeherve Thanks for testing! Two follow-ups:

Keypair changing: Fixed in the latest push. There's now a runtime legacy fallback that reads the old activitypub_keypair_for_-1 option when the new activitypub_application_keypair isn't set yet, so the key survives an upgrade even before the rename migration has run. One caveat worth flagging: switching back to trunk will still regenerate, because trunk doesn't know about the new option name. That's a dev branch-hopping artifact, not something a real upgrade hits.

@application redirect: That one's working as designed. Unlike author actors, the Application is a system actor with no human-facing profile page, so there's nothing to redirect a browser to. The rewrite rule resolves @application directly to the REST endpoint and serves the JSON-LD. So "JSON served, no redirect" is the expected behavior here.

Mind giving the keypair path another spin after the latest commits?

jeherve
jeherve previously approved these changes Jul 2, 2026

@jeherve jeherve left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking good to me!

pfefferle added 2 commits July 2, 2026 13:00
- Fix fatal in the stats block: remove the deleted APPLICATION_USER_ID
  constant reference from src/stats/render.php (and the build copy).
- Retire legacy 'application' outbox items in Outbox::get_actor() instead
  of misattributing them to the Blog actor.
- Reject Follows aimed at the Application actor: new
  Follow::reject_application_follow() sends a direct Reject signed with
  the Application key (Http::post() now accepts null for that).
- Reserve the 'application' blog identifier in Sanitize::blog_identifier().
- Deduplicate get_icon()/get_published() into the Application class and
  tune the published-date WP_Query.
- Pass a hydrated Generic_Object to the activitypub_activity_*_array
  filters instead of null.
- Use Application::get_id() in the Health Check instead of an inline
  namespace reference.
- Declare the changelog entry as major: the removed -1 actor paths are a
  compatibility break.
@github-actions github-actions Bot added the [Focus] Editor Changes to the ActivityPub experience in the block editor label Jul 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants