Register Stream operations as WordPress Abilities#1859
Conversation
Add abstract Ability base class, Abilities loader with WP 6.9 + setting gating, and a new "Enable Abilities API" toggle under the existing Advanced settings section. The loader hooks wp_abilities_api_init and will register concrete abilities once they are added in subsequent commits. Falls back silently on WordPress < 6.9.
Implements the six read-only abilities under stream/* namespace: get-records, get-record, get-settings, get-alerts, get-connectors, and get-exclusion-rules. Each ability has hand-written JSON Schemas, delegates to existing Stream APIs, and ships with a PHPUnit test covering name, schema, permission gating, and execution. Tests skip themselves on WordPress < 6.9 via the shared Abilities_TestCase base class.
Add three abilities that mutate Stream state through the existing internal APIs: stream/create-alert (creates a wp_stream_alerts CPT post with alert_type and alert_meta), stream/update-settings (partial-merge update to the wp_stream option), and stream/create-exclusion-rule (appends to the parallel-array exclude_rules option columns Stream already uses). Each ability is gated behind the manage_options capability. Hand-written JSON schemas describe inputs and outputs for AI consumers; create-alert requires the four trigger fields, create-exclusion-rule requires at least one filter property, and update-settings requires a non-empty settings map. Each ability ships with PHPUnit coverage that verifies permissions, schema shape, and end-to-end execution against the option or post store.
Add the two destructive abilities required by the ticket: stream/purge-records (filtered DELETE against the Stream records table with a cascading meta delete that mirrors Admin::erase()) and stream/delete-alert (force-delete a wp_stream_alerts post by ID). purge-records refuses to run unless confirm: true is supplied AND at least one filter (older_than_days, connector, context, action) is set, preventing an accidental full table wipe. The row count is computed before the DELETE so the response is meaningful even though the multi-table DELETE returns the combined affected rows. delete-alert returns a 404 WP_Error when the ID is unknown or refers to a non-alert post type, which makes the ability safely idempotent. Both abilities ship with PHPUnit coverage that exercises permissions, schema validation, the happy path, the refusal paths, and (for purge-records) the meta cascade.
Cover the two infrastructure pieces left untested by the per-ability suites: tests/phpunit/test-class-ability.php exercises the Ability abstract base via an in-file Fake_Ability_For_Test subclass (verifies get_meta() emits category and show_in_rest, conditionally adds an annotations key, and that the default permission_callback denies subscribers and grants admins; also asserts register() makes the ability retrievable via wp_get_ability() when the API is available). tests/phpunit/test-class-abilities.php covers the loader: is_available() tracks the WP_Ability class presence, is_enabled() reflects the advanced_enable_abilities_api option, the constructor only hooks wp_abilities_api_init when both gates pass, get_ability_slugs() lists all eleven slugs, load_abilities() instantiates each, and register_abilities() does not double-load on a second invocation. Resolves: XWPENG-13
- Register 'stream' category on wp_abilities_api_categories_init so abilities with category=stream pass core's category-existence check - Move 'category' from meta to top-level args in Ability::register(), matching the wp_register_ability() contract in WP 6.9 - Replace get-record's broken DB::get_records(['record' => $id]) call (Query class never implemented the singular 'record' arg) with a direct $wpdb single-row lookup - Snapshot/restore $plugin->settings->options in Abilities_TestCase so in-memory mutations from write-ability tests don't leak across tests - Update tests to satisfy the doing_action() guards on wp_register_ability() and wp_register_ability_category()
Add a per-ability 'instructions' annotation: a 1-2 sentence note for AI agents about when and how to call each ability, distinct from the description (which describes what it does). Add tests/phpunit/abilities/test-rest-integration.php covering all three ability types end-to-end: dispatches actual WP_REST_Requests through WP_REST_Server and asserts 200/403/404/405 paths plus the list-abilities endpoint exposes all 11 stream/* abilities. Catches breakage in the real REST stack that direct execute() tests miss. Add idempotent: true to purge-records annotations. WP core's REST router only routes to DELETE when destructive AND idempotent are both true; without idempotent the controller expects POST. Refactor test action-firing to use the documented core test pattern of pushing onto $wp_current_filter rather than registering callbacks through add_action(). Cleaner, no global hook pollution, matches the convention used in WordPress core's own abilities-api tests. Make Abilities::register_abilities() defensive: skip per-ability register() calls when the ability is already registered, preventing spurious _doing_it_wrong notices when load_abilities() runs more than once in the same process.
WP core's WP_Ability::invoke_callback() spreads zero arguments into the execute callback when the ability declares no input_schema (see wp-includes/abilities-api/class-wp-ability.php:506-512). Our previous 'execute($input)' signature required one argument, so any GET request to a no-input-schema ability raised a fatal ArgumentCountError and returned HTTP 500 to the caller. Add '$input = null' as the default on the abstract Ability::execute() plus all 11 concrete subclasses and the test fake. Null matches WP core's own conventions (their invoke_callback and check_permissions both default $input to null). Abilities that DO declare an input_schema continue to receive the parsed value verbatim from core, so the default sits unused for those. Caught by live e2e testing against WP 6.9.1 (Phase 4 of XWPENG-13-e2e.md): get-settings, get-connectors, and get-exclusion-rules previously fataled.
- Authorization: read abilities use 'view_stream'; base default uses
WP_STREAM_SETTINGS_CAPABILITY. Abilities registers a user_has_cap
filter for REST contexts where Admin (and its filter) isn't loaded,
so allowed roles can call read abilities consistently with the UI.
- update-settings: allowlist {section}_{field} keys from registered
fields, run incoming values through Settings::sanitize_settings(),
reject payloads with no recognized keys.
- create-exclusion-rule: schema gains format:ip and maxLength bounds;
execute() sanitizes via sanitize_text_field(), validates IPs with
FILTER_VALIDATE_IP, validates connector against registered slugs,
rejects all-empty payloads.
- purge-records: use rows_affected from the DELETE itself (no stale
pre-count); run orphan-meta sweep after; fix MySQL alias syntax.
- get-record: kept direct query (Query::query has a real array_shift
bug with record__in) but adds explicit blog_id scoping on multisite
so cross-site record leakage cannot occur.
The 5 read-only abilities (get-records, get-record, get-alerts, get-connectors, get-exclusion-rules) all carried an identical permission_callback() returning current_user_can( 'view_stream' ) with the same rationale docblock. Move it into a shared trait so the authorization rule lives in one place. Each ability file require_once's the trait directly so per-test loaders (which require ability files individually) keep working without any autoloader changes. Net -35 LOC. Single-site and multisite Ability suites unchanged: 316 tests pass with the same skipped/incomplete counts as before.
- orderby: add enum bound to Query::query()'s actual sortable columns, and change the default from 'date' (not a real Stream column; silently fell back to ID) to 'created'. This makes the silent fallback impossible at the schema layer for REST callers and surfaces the contract for direct PHP callers. - user_id__in / connector__in: add maxItems: 100 so a caller cannot force an unbounded IN(...) clause from a single request. Tests cover schema shape, REST schema validation (orderby=date now rejected, 101 items rejected), and a behavioral regression that seeds two records with out-of-order created/ID and asserts orderby=created ASC actually orders by created -- not by ID, which is what the old silent fallback was doing.
…or-context Mirror the admin form's create-alert flow (classes/class-alerts.php:766- 806) so API-created alerts behave identically to UI-created ones: - Validate alert_type against $plugin->alerts->alert_types (the registered notifier slugs). Schema can't enum these because wp_stream_alert_types is a filter -- a hardcoded enum would lock out 3rd-party notifiers. Reject unknown slugs with stream_unknown_alert_type / status 400 BEFORE inserting the post. - Split 'connector-context' input into trigger_connector + trigger_context meta keys, exactly like the admin form does. Without the split, Alert_Trigger_Context::check_record() silently let any connector through because trigger_connector was never populated -- alerts created via the API were effectively connector-agnostic. - Build an Alert model from the split meta and use $alert->get_title() for post_title, so the admin list shows a meaningful title instead of 'Auto Draft'. Tests cover the title regression, the connector-dash-context split, and the alert_type rejection path (including no-side-effects: no post is inserted when validation fails).
…ed multisite On a network-activated multisite install, the Abilities API toggle is saved to the wp_stream_network site option via Network::update_site_option(). However, Settings::get_options() only reads from get_site_option() when is_network_admin() is true; in REST and frontend contexts $plugin->settings->options reflects the (typically empty) per-site option. As a result, is_enabled() returned false in REST even when the network admin had enabled the API, making the entire Abilities API silently unreachable on network-activated sites. Read the network option directly via get_site_option($settings->network_options_key) when is_multisite() && $plugin->is_network_activated(), and fall back to the existing in-memory per-site options otherwise (preserves single-site and per-site-activated behavior). Adds two regression tests: - test_is_enabled_reads_network_option_when_network_activated (@group ms-required) flips the wp_stream_is_network_activated filter and proves is_enabled() follows the network option even when in-memory options say disabled. - test_is_enabled_reads_per_site_options_when_not_network_activated proves the network option is ignored when the plugin isn't network-activated.
Add //end try comments to satisfy Squiz.Commenting.LongConditionClosingComment on the new is_enabled() multisite tests, and lift the inline 'not a registered notifier' comment above the array literal so it doesn't trip Squiz.Commenting.PostStatementComment in the unknown-alert_type test.
The comment claimed format:ip is a hint not enforced by rest_validate_value_from_schema(), which is wrong. WP core's rest_is_ip_address() in wp-includes/rest-api.php DOES validate the format and rejects bogus IPs at the schema layer with ability_invalid_input before our execute() runs. Reframe the in-method check as defense-in-depth for direct PHP callers who invoke $ability->execute() outside the REST stack.
…view_stream cap
There was a problem hiding this comment.
Pull request overview
Integrates Stream with the WordPress 6.9 Abilities API by introducing an ability base class + loader, registering 11 Stream operations under the stream/ namespace (gated behind an Advanced setting), and adding a comprehensive PHPUnit suite to validate schemas, permissions, and REST routing.
Changes:
- Added
Ability(abstract base) andAbilities(loader/gating + category registration) and wired the loader intoPlugin::init(). - Implemented multiple
stream/*abilities (read/write/destructive) backed by existing Stream internals, plus sharedview_streampermission handling for read-only abilities. - Added PHPUnit infrastructure and per-ability tests, updated coverage configuration, and extended PHPCS configuration for Stream’s custom capability.
Review Findings (issues to address)
1) Critical issues
stream/purge-recordscan delete other sites’ logs on multisite when Stream is not network-activated- Why it matters: Stream’s tables are shared (base prefix) and rely on
blog_idfor separation; without ablog_idconstraint this endpoint can purge records across the network. - Suggested fix: Add a
stream.blog_id = %dconstraint whenis_multisite()and$this->plugin->is_multisite_not_network_activated()are true (mirroringAdmin::erase_stream_records()/ scheduled purge behavior).if ( $this->plugin->is_multisite_not_network_activated() ) { $where[] = 'stream.blog_id = %d'; $params[] = get_current_blog_id(); }
- WP VIP ref: Multisite scoping via
get_current_blog_id(); safe SQL construction via$wpdb->prepare().
- Why it matters: Stream’s tables are shared (base prefix) and rely on
2) High issues
stream/get-alertscan returnalert_metain a shape that violates its declared output schema- Why it matters: When
alert_metais missing,get_post_meta(..., true)returns''; casting to(array)yields a numerically-indexed array which JSON-encodes as a list, not an object, breaking schema expectations and clients. - Suggested fix: Normalize
alert_metato an associative array/object: only return the meta value if it’s already an array, otherwise return an empty array. - WP VIP ref: Data consistency for REST outputs;
get_post_meta()return-type handling.
- Why it matters: When
3) Medium/Low issues
-
test_orderby_created_actually_orders_by_created_not_id()doesn’t reliably detect anorderbyfallback-to-ID regression- Why it matters: The test data currently makes
createdorder align with insertion/ID order, so it can pass even iforderby=createdis ignored. - Suggested fix: Insert records such that ID ordering conflicts with
createdordering (e.g., insert the “newer timestamp” record first and the “older timestamp” record second) and assert against that conflict. - WP VIP ref: PHPUnit test correctness (ensuring assertions can fail for the intended regression).
- Why it matters: The test data currently makes
-
Abilities API toggle description lists an endpoint shape that conflicts with this PR’s REST integration tests
- Why it matters: The admin-facing setting description references
/wp-abilities/v1/stream/*, while tests exercise/wp-abilities/v1/abilities/stream/{slug}/run; this can mislead admins during manual verification. - Suggested fix: Update the UI description text to match the actual route structure used by the Abilities API implementation being targeted.
- WP VIP ref: Admin UI clarity / accurate operational documentation.
- Why it matters: The admin-facing setting description references
Reviewed changes
Copilot reviewed 36 out of 36 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/phpunit/test-class-ability.php | Unit tests for the abstract Ability base behavior (meta, permissions, registration). |
| tests/phpunit/test-class-abilities.php | Unit tests for loader gating, multisite enablement behavior, and slug list. |
| tests/phpunit/fake-ability.php | Concrete fake ability used by base-class tests. |
| tests/phpunit/abilities/test-rest-integration.php | REST-level integration tests for routing/method enforcement and discovery. |
| tests/phpunit/abilities/test-class-ability-update-settings.php | Tests for partial settings updates, key allowlisting, and memory refresh. |
| tests/phpunit/abilities/test-class-ability-purge-records.php | Tests for destructive purge safety (confirm + filters) and cascade cleanup. |
| tests/phpunit/abilities/test-class-ability-get-settings.php | Tests for settings read ability output and permissions. |
| tests/phpunit/abilities/test-class-ability-get-records.php | Tests for records querying, schema allowlists/bounds, and ordering behavior. |
| tests/phpunit/abilities/test-class-ability-get-record.php | Tests for single-record fetch behavior and not-found handling. |
| tests/phpunit/abilities/test-class-ability-get-exclusion-rules.php | Tests for exclusion-rule pivoting and internal-key stripping. |
| tests/phpunit/abilities/test-class-ability-get-connectors.php | Tests for connector discovery output shape. |
| tests/phpunit/abilities/test-class-ability-get-alerts.php | Tests for alert listing and status filtering. |
| tests/phpunit/abilities/test-class-ability-delete-alert.php | Tests for deletion safety, idempotency, and post-type validation. |
| tests/phpunit/abilities/test-class-ability-create-exclusion-rule.php | Tests for rule append semantics, sanitization, and validation. |
| tests/phpunit/abilities/test-class-ability-create-alert.php | Tests for alert creation, title generation, and alert-type validation. |
| tests/phpunit/abilities/abilities-testcase.php | Shared Abilities test base (WP 6.9 gating, schema assertions, helpers). |
| tests/bootstrap.php | Loads the new Abilities test base. |
| phpunit.xml | Adds abilities/ directory to coverage include paths. |
| phpunit-multisite.xml | Adds abilities/ directory to multisite coverage include paths. |
| phpcs.xml.dist | Configures PHPCS to recognize view_stream as a custom capability. |
| classes/class-settings.php | Adds the “Enable Abilities API” Advanced setting (WP 6.9+ only). |
| classes/class-plugin.php | Wires Abilities loader into plugin initialization. |
| classes/class-ability.php | Introduces the Ability abstract base and registration helper. |
| classes/class-abilities.php | Introduces the Abilities loader (gating, category registration, instantiation). |
| abilities/trait-view-stream-permission.php | Shared view_stream permission callback for read-only abilities. |
| abilities/class-ability-update-settings.php | Implements stream/update-settings ability. |
| abilities/class-ability-purge-records.php | Implements stream/purge-records ability (destructive). |
| abilities/class-ability-get-settings.php | Implements stream/get-settings ability. |
| abilities/class-ability-get-records.php | Implements stream/get-records ability with schema allowlists and paging. |
| abilities/class-ability-get-record.php | Implements stream/get-record ability with multisite scoping guard. |
| abilities/class-ability-get-exclusion-rules.php | Implements stream/get-exclusion-rules ability with pivoted output. |
| abilities/class-ability-get-connectors.php | Implements stream/get-connectors ability (connector/context/action discovery). |
| abilities/class-ability-get-alerts.php | Implements stream/get-alerts ability (status-filtered listing). |
| abilities/class-ability-delete-alert.php | Implements stream/delete-alert ability (destructive + idempotent). |
| abilities/class-ability-create-exclusion-rule.php | Implements stream/create-exclusion-rule ability with sanitization/validation. |
| abilities/class-ability-create-alert.php | Implements stream/create-alert ability with alert-type validation and title generation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…rby test, UI text
- purge-records: scope DELETE by blog_id on non-network-activated multisite
to prevent cross-site record deletion; mirrors Admin::erase_stream_records().
- get-alerts: coerce missing alert_meta to {} instead of [""] so the response
matches the declared object output schema.
- test_orderby_created_actually_orders_by_created_not_id: invert insertion
order so ID-order conflicts with created-order; the test would now fail if
the implementation silently fell back to ORDER BY ID.
- Settings UI: drop the specific /wp-abilities/v1/stream/* path from the
toggle description (the actual route is owned by core's Abilities API).
- Add regression tests for the missing alert_meta normalization and for the
per-blog purge isolation (multisite-only).
…e, UTC cutoff, settings UX, hook isolation
- get-record / purge-records: scope by blog_id = get_current_blog_id() on
any multisite request that is not is_network_admin() (REST is never
network-admin). Replaces the previous is_multisite_not_network_activated()
predicate, which left network-activated installs unprotected against a
per-site admin reading or purging another site's records via REST.
Mirrors Network::network_query_args() default scoping.
- purge-records: compute the older_than_days cutoff as a UTC DateTime in
PHP and bind as %s, mirroring Admin::purge_scheduled_action(). The
previous DATE_SUB(NOW(), INTERVAL %d DAY) used the MySQL server timezone
while Stream's created column is UTC, so it could over- or under-purge
on hosts where the server timezone is not UTC.
- Settings: hide the "Enable Abilities API" toggle on per-site settings
screens when Stream is network-activated. The setting is read from the
network option in that mode, so a per-site checkbox would have been a
silent no-op.
- Tests: snapshot and restore $wp_filter['wp_abilities_api_init'] in
Test_Abilities::setUp/tearDown so the existing remove_all_actions()
calls inside individual tests don't pollute the global hook registry
for subsequent tests in the same process.
- Tests: lock the new behaviors with regression coverage:
* get-record returns stream_record_not_found for foreign-blog IDs on
multisite (must not leak via guessing).
* purge-records does not cross blog boundaries on multisite (rename
drops the _when_not_network_activated suffix).
* purge-records older_than_days uses a UTC cutoff: rows seeded with
explicit UTC timestamps purge correctly regardless of server tz.
* Settings field is visible on non-network-activated installs.
| // Delete stream rows first and capture rows_affected so the response reflects | ||
| // the actual count (rather than a stale pre-DELETE COUNT). $params is guaranteed | ||
| // non-empty here by the count( $where ) === 1 guard above. MySQL requires the | ||
| // "DELETE alias FROM tbl AS alias" form when the WHERE references an alias. | ||
| // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery | ||
| $delete_sql = "DELETE stream FROM {$wpdb->stream} AS stream WHERE {$where_sql}"; | ||
| // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.NotPrepared | ||
| $wpdb->query( $wpdb->prepare( $delete_sql, $params ) ); | ||
| $deleted = (int) $wpdb->rows_affected; | ||
|
|
||
| if ( 0 === $deleted ) { | ||
| return array( 'deleted' => 0 ); | ||
| } | ||
|
|
||
| // Sweep orphaned meta rows whose parent record was just deleted. Idempotent | ||
| // and safe to run unconditionally; the LEFT JOIN scopes the cleanup to | ||
| // orphans across the whole streammeta table, which also catches any prior | ||
| // orphans without growing this query's blast radius beyond a single sweep. | ||
| // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared | ||
| $wpdb->query( | ||
| "DELETE meta FROM {$wpdb->streammeta} AS meta | ||
| LEFT JOIN {$wpdb->stream} AS stream ON stream.ID = meta.record_id | ||
| WHERE stream.ID IS NULL" | ||
| ); | ||
|
|
| 'properties' => array( | ||
| 'deleted' => array( | ||
| 'type' => 'integer', | ||
| 'description' => 'Number of stream records deleted (meta rows are cascaded by record_id).', |
| // the actual count (rather than a stale pre-DELETE COUNT). $params is guaranteed | ||
| // non-empty here by the count( $where ) === 1 guard above. MySQL requires the | ||
| // "DELETE alias FROM tbl AS alias" form when the WHERE references an alias. |
| * Verifies that abilities registered through Abilities API actually serve HTTP | ||
| * requests at /wp-abilities/v1/abilities/stream/{slug}/run with correct status | ||
| * codes and method routing. Complements the per-ability unit tests, which only | ||
| * exercise execute() in isolation. |
…empotent category, doc fix Four issues from the latest Copilot review on this branch: - update-settings: PHP booleans on checkbox keys were silently coerced to '' by Settings::sanitize_setting_by_field_type() (it gates on is_numeric()). JSON clients naturally send true/false for checkbox-typed settings, so the round-trip would store '' instead of 0/1. Walk the registered fields to identify checkbox keys and normalize bools to 0/1 before sanitization. Add a regression test that round-trips both true and false. - purge-records: the multi-table DELETE result was discarded, so a database-side failure (lock-wait timeout, deadlock, etc.) would still return the pre-counted 'deleted' as if the purge had succeeded. Check $wpdb->query()'s return value and surface a 500 WP_Error on false. - class-abilities: register_category() now bails when the category is already registered, mirroring the idempotency pattern in register_abilities() and avoiding a core _doing_it_wrong notice when multiple loader instances exist (which the test harness already works around). - get-records: the orderby description claimed unknown values fall back to ID in Query::query(), but the schema enum rejects them at REST validation. Tighten the description to match.
bartoszgadomski
left a comment
There was a problem hiding this comment.
@PatelUtkarsh Thank you for working on this exciting new feature!
Please see my feedback below - it's mostly related to separation of concerns and de-duplicating logic.
Keep abilities thin and delegate to the existing data-flow classes so the admin UI and the Abilities API stay on one code path. - Alerts: add STATUS_ENABLED / STATUS_DISABLED constants and a get_alerts() listing method; consume both from get-alerts ability. - Alert: add delete() method; consume from delete-alert ability. - create-alert ability now delegates the insert + meta save to Alert::save() instead of duplicating wp_insert_post / update_post_meta. - Connectors: add get_all() and get_slugs() helpers; consume from get-connectors and create-exclusion-rule abilities. - Record: add static get_by_id($id, $blog_id) for single-row + meta fetch; consume from get-record ability (multisite scoping kept in the ability layer, where the REST/network-admin distinction belongs). - Settings: add get_setting_value() that transparently reads the network option on network-activated multisite; Abilities loader uses it instead of its own multisite branching.
|
Thanks for the thorough review! @bartoszgadomski Addressed all the feedback in 8e4637a. The main theme was routing abilities through existing classes |
bartoszgadomski
left a comment
There was a problem hiding this comment.
@PatelUtkarsh Thank you for implementing the requested changes!
I have a few more findings - note these are AI generated, so please double-check whether there's no any false alarms. Beside the inline comments shared below for critical issues, here are other findings:
Medium
advanced_enable_abilities_api is unreachable through stream/update-settings on network-activated multisite
Settings::get_fields() only appends the toggle when ! is_network_activated() || is_network_admin(). REST is never is_network_admin(), so on a network-activated install the field is missing from get_fields() everywhere except the network-admin settings screen. update-settings builds its allowlist from get_fields(), so the key gets array_intersect_key-stripped and the call fails with stream_no_valid_settings. This is arguably intentional (force the toggle through the network admin UI), but it should be either documented or — better — whitelisted explicitly so an MCP client can still flip it. Same gap applies to get-settings, where the toggle's current value will never appear in the response on network-activated installs.
stream/get-connectors returns a frontend-only subset, breaking stream/create-exclusion-rule validation
Connectors::load_connectors() honors register_frontend = false and skips those connectors when ! is_admin() — REST satisfies ! is_admin(). The following connectors set register_frontend = false: blogs, taxonomies, settings, editor, menus, installer, jetpack, mercator. Two visible consequences:
get-connectorsreturns a list that's a strict subset of the connectors an admin sees in the UI, which is misleading for AI clients trying to enumerate what's tracked on the site.create-exclusion-rulevalidatesconnectoragainst$plugin->connectors->get_slugs(). Trying to excludeconnector: "settings"orconnector: "editor"from the REST API will hitstream_unknown_connector, even though those connectors are real, log records, and are valid in the UI.
The cleanest fix is to either force register_frontend = true for connectors that should be discoverable via abilities, or to drive the validation list from a non-context-dependent source.
update-settings re-triggers the TTL purge hook
Settings::__construct() hooks update_option_$option_key → updated_option_ttl_remove_records. update-settings writes through update_option, so any REST call that changes general_records_ttl (or even just touches the option) will fire that hook in the REST request. Likely desired, but worth confirming — historically that hook scheduled an async cascade purge, and it now runs in user-facing REST latency. Either reuse the existing path explicitly or document.
Lower priority / nits
-
Ability_Get_Alerts::execute()casts(string) $alert->statusand returns it. The schema enums to[STATUS_ENABLED, STATUS_DISABLED], butAlert::__constructactually keeps whatever the post status is (e.g.trash). Since the listing only fetcheswp_stream_enabled/wp_stream_disabledfromAlerts::get_alerts(), this is fine today — but if a third-party plugin trashes an alert and you later expandget_alerts()to include other statuses, the output will violate the enum. Cheap defensive coercion or anif ( ! in_array( $status, ..., true ) ) continue;would future-proof this. -
Ability_Create_Alertoutput schema vs. reality: the response buildsalert_typeviaget_post_meta(...)which returns''(notnull) when meta is missing. Schema istype: [string, null], so an empty string is fine — butalert_metais cast(array) get_post_meta(...)and ifAlert::save()ever leaves meta unsaved (e.g. a failedupdate_post_meta),(array) ''becomes[ 0 => '' ], a list. Same JSON-shape bug class as theget-recordmeta shape. Currently unreachable becausesave()always writes a non-emptyalert_meta, but worth a defensive normalize before returning. -
Trait_View_Stream_Permissionlives inabilities/trait-view-stream-permission.phpand isrequire_once'd at the top of every ability file that uses it. With the loader'sinclude_onceand PHPUnit's per-testrequire_once, this works, but it's also fragile: rename the file or change the autoload prefix and the abilities silently lose their permission callback (and fall back to theWP_STREAM_SETTINGS_CAPABILITYdefault — which would over-restrict reads, not under-restrict). Consider moving the trait toclasses/so the existing PSR-4-ish autoloader picks it up viaTrait_View_Stream_Permission→class-trait-view-stream-permission.php, or rename to live under the existing autoload conventions. -
Abilities::register_category/register_abilities/filter_user_capscallbacks are registered asarray( $this, '...' ). The workspace coding rule (Avoid Anonymous Functions in PHP) discourages instance-method callbacks in favor of static or named functions, but the rest of Stream consistently usesarray( $this, '...' )(e.g.Settings::__construct(),Admin::__construct()). New code is therefore consistent with the repo but inconsistent with the stated standard. Worth a project-level decision before this lands; not a blocker. -
Test_Ability_Update_Settings::test_partial_update_preserves_other_keysand friends pass only because they exercise the single-site code path. Once the multisite write issue is addressed, you'll want a@group ms-requiredtest that proves a write lands inwp_stream_networkon network-activated installs (mirroringtest_is_enabled_reads_network_option_when_network_activated). -
Test_Abilities::test_load_abilities_instantiates_each_slugandtest_load_abilities_populates_all_slugsare functionally duplicates — both assert count 11 + iterate the slugs. Worth collapsing. -
Ability_Get_Settingsdoesn't override the base permission_callback, so it usesWP_STREAM_SETTINGS_CAPABILITY. The PR description says read abilities useview_streamand only write/destructive abilities use the settings cap; the doc is slightly off —get-settingsis read but gated on the settings cap. Reasonable choice (settings can include role allowlists), but worth documenting why this one read ability is treated differently. -
Ability::register()includes afinalqualifier, which is great for the contract, but it means a subclass that ever needed to inject e.g. a precondition (register_only_if_woocommerce_loaded) has nowhere to hook. Add apre_register()hook method or removefinallater if needed; minor.
…uting - Add Settings::get_all_setting_values() and update_all_setting_values() that route through the network option on network-activated multisite. Previously, update-settings and create-exclusion-rule wrote to the per-site option in REST contexts, while is_enabled() and the admin UI read from the network option. Writes ghost-saved. - Route Abilities::filter_user_caps() through get_setting_value() so general_role_access is honored on network-activated REST contexts. Previously, editors granted view_stream via the network admin's Role Access setting silently got 403 from read abilities. - Scope get-records to the current blog when is_multisite() and not in network admin. Mirrors guards already in get-record and purge-records. The wp_stream_query_args filter that normally injects this is only registered inside Admin, which doesn't load in REST. - Coerce empty record meta to stdClass in Ability_Get_Record so the response satisfies the declared meta: object output schema. Same fix class as the earlier alert_meta normalization. - Stop hiding the Abilities API toggle from network admin UI on network-activated installs (keep it hidden only from the per-site settings screen, where saving would be a no-op). REST/CLI clients can now flip it via update-settings since writes are routed correctly.
Connectors::load_connectors() skips connectors whose register_frontend is false when the request isn't wp-admin. REST is ! is_admin(), so connectors like settings, editor, menus, taxonomies, blogs, installer, jetpack, and mercator were absent from stream/get-connectors and unknown to stream/create-exclusion-rule's validation enum -- even though those connectors are real, configurable in the wp-admin UI, and log records on the site. Add Connectors::get_all_including_admin_only() and get_all_slugs_including_admin_only(), which walk the full connector class list and return metadata for every dependency-satisfied connector, ignoring the is_admin() gate. They never call register() and do not mutate the live $this->connectors registry, so hooks fire exactly as before. Route the two affected abilities through the new methods. Verified against the live xwp-demo deployment: stream/get-connectors was returning 6 entries; with this fix it returns all 14+ that an admin sees in the UI, and stream/create-exclusion-rule now accepts connector slugs like "settings".
…ultisite E2E surfaced a read/write mismatch: update-settings and create-exclusion-rule correctly wrote to wp_stream_network on network-activated installs, but get-settings and get-exclusion-rules still read from $plugin->settings->options (per-site, populated via Settings::get_options() which gates on is_network_admin() and falls back to get_option() in REST). After a write, the read endpoint returned the old per-site value instead of the just- persisted network value. Route both read abilities through Settings::get_all_setting_values() and make sure the in-memory cache that update_all_setting_values() refreshes also reads from the network option (Settings::get_options() can't be trusted on network-activated REST because is_network_admin() is always false there). Also return from update-settings via get_all_setting_values() so the response reflects the authoritative store.
- Filter Ability_Get_Alerts results to the declared status enum (STATUS_ENABLED / STATUS_DISABLED) so a third-party-trashed alert or future broadening of Alerts::get_alerts() doesn't leak a non-enum status into the response and violate the output schema. - Normalize empty alert_meta to stdClass in Ability_Create_Alert output, mirroring the coerce in Ability_Get_Alerts. Currently unreachable because Alert::save() always writes the merged trigger keys, but the coerce is cheap defense and keeps the JSON output consistent. - Add Test_Ability_Update_Settings::test_write_targets_network_option_when_network_activated -- mirror of the read-side coverage in Test_Abilities::test_is_enabled_reads_network_option_when_network_activated, proves update-settings writes land in wp_stream_network on network- activated multisite and do not touch the per-site option. - Merge duplicate Test_Abilities::test_load_abilities_instantiates_each_slug and test_load_abilities_populates_all_slugs into a single test that covers both population and instantiation in one pass.
Each of the 5 read abilities (get-records, get-record, get-alerts, get-connectors, get-exclusion-rules) previously did its own require_once for trait-view-stream-permission.php at the top of the class file. A new read ability that forgot the require would silently fall back to Ability::permission_callback() and gate on WP_STREAM_SETTINGS_CAPABILITY instead of view_stream. Move the require_once into the two chokepoints that actually load ability files: Abilities::load_abilities() for production and Abilities_TestCase::setUp() for PHPUnit. New abilities now get the trait available automatically -- no per-file require to forget. Verified via PHP introspection (trait_exists, class_uses on all 5 ability classes) and a live REST smoke test that admin GETs return 200 and subscriber GETs return 403 across all five read abilities.
|
Thanks for the second pass! All five inline points addressed in 71d6443. @bartoszgadomski Medium
Nits addressed in d1fc2e0:
Deferring:
|
|
@PatelUtkarsh thank you for your excellent work on this! I tested this locally and have a few observations:
|
…sent Stream advertises every ability with meta.mcp.public = true so that the WordPress MCP Adapter's default server picks them up via its discover-abilities tool without Stream having to load or initialize the adapter itself. Bartosz's local test showed an empty list of abilities because the adapter plugin was installed but its sub-dependency vendor/ was missing, so its autoloader bailed and McpAdapter never booted. - Add meta.mcp.public to Ability::get_meta() so the flag is set on every registered ability via wp_register_ability(). - Add wordpress/mcp-adapter to require-dev so composer install drops it into local/public/wp-content/plugins/mcp-adapter as a wordpress-plugin. Contributors only need to activate it. - Wire a composer post-install/post-update script that recursively installs the adapter's own sub-dependencies (wordpress/php-mcp-schema). Without this the adapter's Autoloader returns false and its Plugin bootstrap silently skips. - Update the Advanced settings field label and description to make the MCP exposure explicit: enabling the Abilities API also publishes abilities via MCP when the adapter is installed. - Document the MCP setup steps in contributing.md. The MCP HTTP transport at /wp-json/mcp/mcp-adapter-default-server now exposes all 11 stream/* abilities through the adapter's default server discover-abilities tool.
…host WordPress Application Passwords and several MCP clients require HTTPS, so contributors had to either set up TLS manually or work around the limitation. Wire a dedicated mkcert Docker service (adopted from xwp/vip-site-template) that generates a locally-trusted cert + key into local/certs/, and configure the WordPress container's Apache to serve the site on :443 using those files. - New mkcert service in docker-compose.yml: builds local/docker/mkcert/ (golang:alpine + mkcert v1.4.4), generates cert.pem + key.pem into local/certs/ once per environment, skips if the files already exist. - WordPress service depends on mkcert and mounts ./local/certs read-only into /etc/ssl/certs/stream-local. - New Apache vhost local/docker/wordpress/apache-ssl.conf serves the same DocumentRoot on :443 with the mounted cert. Dockerfile enables mod_ssl and a2ensite's the new vhost. HTTP on :80 keeps working alongside. - local/certs/ is gitignored. Contributors still need to run `mkcert -install` on the host once to add the local CA to their system trust store; without it HTTPS works but the browser shows an untrusted-cert warning. The certificate generation itself happens inside the container, so contributors do not need to install mkcert on the host just to spin up the environment.
Add a mkcert build target to docker-compose.build.yml so the existing "Build and Publish Docker Images" workflow rebuilds and pushes ghcr.io/xwp/stream-mkcert alongside the stream-wordpress images on master pushes. Reference the published image from docker-compose.yml (with the local build context retained as a fallback for fresh contributor checkouts and PR branches that haven't been published yet). The lint-and-test E2E job pulls both wordpress and mkcert with --ignore-pull-failures so first pushes of feature branches stay green even before docker-images.yml has uploaded the new mkcert image to the registry; Docker Compose then falls back to the local build context for any image it couldn't pull.
The inline @php -r string in composer.json had grown into an unreadable nested-quote mess. Move the same logic into local/scripts/install-mcp-adapter-deps.php with proper comments explaining the three branches: - Adapter directory absent (production --no-dev releases): silent no-op. - Adapter directory present, vendor populated: silent no-op. - Adapter directory present, vendor missing (typical dev install): run `composer install --no-dev` inside the adapter directory so its Autoloader can find vendor/autoload.php on plugin activation. No behavior change for end users. Release safety is unchanged: --no-dev installs skip wordpress/mcp-adapter entirely, so the adapter directory never exists in shipped artifacts and the script's first guard returns silently. /local/ is also excluded by .distignore as a second backstop.
Move the dev environment from HTTP to HTTPS so it matches what WordPress Application Passwords and most MCP clients expect by default. - wp-cli.yml: set url to https://stream.wpenv.net so wp core multisite-install writes https values to wp_options at install time. No wp-config overrides needed -- the DB values are authoritative. - Playwright: swap all http://stream.wpenv.net references to https://. Add ignoreHTTPSErrors: true so Playwright's Chromium accepts the mkcert-issued certificate without requiring `mkcert -install` on the runner / host. - CI workflow: wait for TCP :443 instead of :80 in the E2E job since HTTPS is now the canonical entry point. - contributing.md: dev-environment URL is now https; documented one-line wp search-replace for existing checkouts to upgrade their DB values without a full reinstall. No Apache HTTP->HTTPS redirect added; with siteurl and home both https in the DB, WordPress already generates https URLs everywhere and redirects http wp-admin / wp-login hits via the canonical-URL machinery, so the extra redirect would only catch direct `curl http://` cases that are useful to keep working for quick TCP probes.
The published ghcr.io/xwp/stream-wordpress image is refreshed only by docker-images.yml on master pushes. When this branch updates local/docker/wordpress/Dockerfile (e.g. `a2enmod ssl` + the stream-ssl vhost), `docker compose pull wordpress` happily fetches the stale published image and skips the local build context. Add an explicit `docker compose build wordpress` step after the pull so the branch's Dockerfile actually lands in the running container. Without this, CI's HTTPS smoke fails with ERR_CONNECTION_CLOSED on :443 because Apache has no SSL vhost configured. Docker's layer cache keeps the rebuild cheap when the Dockerfile is unchanged from the published base.
| # :443 with ERR_CONNECTION_CLOSED. Build locally so the branch's | ||
| # Dockerfile actually applies; Docker layer caching keeps this cheap | ||
| # when the Dockerfile hasn't changed since the last published build. | ||
| run: docker compose build wordpress |
There was a problem hiding this comment.
Note this needs to be removed once PR is merged to master.
|
@bartoszgadomski You should able to use this as MCP now. More details here: https://github.com/xwp/stream/blob/feature/XWPENG-13-abilities-api/contributing.md#mcp-model-context-protocol-integration |
The previous description packed three sentences and an internal URL into a single line, making the Stream settings UI visually dense and forcing users to scan past technical details to find the primary action. Trim to one sentence that surfaces what enabling does, treats MCP as a parenthetical secondary fact, and keeps the WP version requirement. Title is unchanged (still "Enable Abilities API and MCP").
bartoszgadomski
left a comment
There was a problem hiding this comment.
I tested this locally, works well. Excellent work @PatelUtkarsh, thank you!

Resolves XWPENG-13.
Integrates Stream with the WordPress 6.9 Abilities API. Eleven Stream operations are registered as abilities under the
stream/namespace, exposed via the core Abilities REST controller at/wp-abilities/v1/abilities/stream/{slug}/runonce a site owner explicitly opts in.Approach
Mirrors Stream's existing connector/alert plugin extension layout: an abstract base class, a loader gated on environment + opt-in setting, and one file per ability that delegates to existing Stream internals (no business logic duplicated).
Permission model — read abilities use Stream's
view_streamcapability (via a shared trait) so editors and other roles configured under "Role Access" can call them, matching the admin UI. Write and destructive abilities require the Stream settings capability (WP_STREAM_SETTINGS_CAPABILITY, filterable; defaults tomanage_options).Schemas are hand-written with AI-readable
descriptionstrings rather than auto-derived, decoupling the public API from internal arg names.Abilities registered:
stream/get-recordsview_streamstream/get-recordview_streamstream/get-settingsstream/get-alertsview_streamwp_stream_alertsposts + metastream/get-connectorsview_streamstream/get-exclusion-rulesview_streamstream/create-alertwp_stream_alertspoststream/update-settingsstream/create-exclusion-rulestream/purge-recordsstream/delete-alertwp_stream_alertspostRouting follows the WP core Abilities REST controller defaults: read-only abilities are served as
GET, write abilities asPOST, and destructive + idempotent abilities asDELETE. Non-matching methods return405 Method Not Allowed.Gating
register()checksfunction_exists( 'wp_register_ability' )defensively. No admin notice is shown (per direction).advanced_enable_abilities_apicheckbox in the existing Advanced section of Stream settings. Defaults to0. Only renders on WP 6.9+. Honors network option on network-activated multisite.Safety
view_stream; write/destructive abilities use the Stream settings capability (filterable).stream/purge-recordsrequiresconfirm: trueAND at least one filter (older_than_days,connector,context,action); a confirm-only payload returns a400WP_Errorrather than truncating the table. On any multisite request that is not running inside Network Admin (REST never is), the purge is scoped toblog_id = get_current_blog_id()so it cannot wipe other sites' records.stream/get-recordapplies the same per-blog scoping so aview_streamuser on one site can't read records from other sites by guessing IDs.stream/delete-alertis idempotent — the second call returns the same404WP_Erroras a missing ID. Refuses to delete posts that are not ofwp_stream_alertstype.stream/create-alertvalidatesalert_typeagainst registered alert types and rejects unknown types.stream/get-recordsschema tightened:orderbyis an enum,*__inarrays havemaxItemscaps.stream/create-exclusion-rulevalidates IP format at the application layer.older_than_dayscutoffs are computed as a UTCDateTimein PHP (matchingAdmin::purge_scheduled_action()) so behavior is independent of MySQL server timezone.Manual REST QA
After merging and enabling the toggle on a WP 6.9+ site:
Checklist
contributing.md).Release Changelog
stream/namespace, gated behind a new "Enable Abilities API" advanced setting (default off). Read abilities respect Stream'sview_streamcapability; write abilities require the Stream settings capability.