Register Stream operations as WordPress Abilities#1859
Open
PatelUtkarsh wants to merge 24 commits intodevelopfrom
Open
Register Stream operations as WordPress Abilities#1859PatelUtkarsh wants to merge 24 commits intodevelopfrom
PatelUtkarsh wants to merge 24 commits intodevelopfrom
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.
Comment on lines
+174
to
+198
| // 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).', |
Comment on lines
+175
to
+177
| // 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. |
Comment on lines
+5
to
+8
| * 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. |
…atement Replace the post-DELETE orphan sweep over the entire streammeta table with a single multi-table DELETE that removes matching stream rows and their meta in one statement, mirroring Admin::purge_scheduled_action(). Capture the parent count up-front so the response still reports records-deleted independent of how many meta rows were attached. Also drop a stale comment that referenced a guard pattern no longer in the code, and update the output-schema description so it matches the implementation (no more 'cascade' wording, since there is no FK).
Comment on lines
+118
to
+121
| // Refresh in-memory copy so subsequent abilities see the change. | ||
| $this->plugin->settings->options = $merged; | ||
|
|
||
| return $merged; |
| 'properties' => array( | ||
| 'settings' => array( | ||
| 'type' => 'object', | ||
| 'description' => 'Partial settings map keyed by {section}_{field} (e.g. general_records_ttl). Unknown keys are rejected; values are normalized through Stream\'s settings sanitizer. Omitted keys are preserved.', |
Comment on lines
+215
to
+224
| $options['exclude_rules'] = $rules; | ||
| update_option( $option_key, $options ); | ||
|
|
||
| // Refresh in-memory copy. | ||
| $this->plugin->settings->options = $options; | ||
|
|
||
| return array( | ||
| 'index' => $index, | ||
| 'rule' => $rule, | ||
| ); |
| return array( | ||
| 'type' => 'object', | ||
| 'additionalProperties' => true, | ||
| 'description' => 'Settings keyed by {section}_{field} (e.g. general_records_ttl, advanced_enable_abilities_api).', |
…hten schema descriptions After update_option() the raw option array is sparse (omits defaults). Both update-settings and create-exclusion-rule were assigning that sparse array directly to $plugin->settings->options, leaving default-only keys missing for any later code in the same request. Refresh the in-memory copy via Settings::get_options() so defaults are merged in. Also align two schema descriptions with actual behavior: - update-settings now says unknown keys are *ignored* (the request only fails when no key matches a registered setting). This matches the array_intersect_key() filtering in execute(). - get-settings no longer promises advanced_enable_abilities_api in every response; that field is only registered on WP 6.9+ and, on network-activated multisite, only from network admin.
When a wp_stream_alerts post has no alert_meta postmeta row, the ability
was emitting an empty PHP array(), which JSON-encodes as [] and violates
the declared 'type: object' output schema.
Replace the empty array with a stdClass instance so wp_json_encode()
emits {} as the output schema requires. Also strengthen the regression
test to assert against the JSON-encoded payload (the previous PHP-level
array() comparison was passing despite the wire-format bug).
Squiz.Commenting.FunctionComment.WrongStyle was flagging the three test methods that had only inline section comments above them, and Generic.Commenting.DocComment.MissingShort was flagging the @expectedIncorrectUsage-only docblock on test_unknown_ability_returns_404.
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.