Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: xwp/stream
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: develop
Choose a base ref
...
head repository: xwp/stream
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: master
Choose a head ref
Checking mergeability… Don’t worry, you can still create the pull request.
  • 1 commit
  • 0 files changed
  • 7 contributors

Commits on May 28, 2026

  1. Release 4.2.0 (#1893)

    * Comments Connector: Avoid a PHP Warning on pingbacks.
    
    * Respect the comment_author_email user details if set
    
    In some cases `require_name_email` and `comment_registration` will both be enabled, the details from the email lookup should be respected before falling back to the comment author name.
    
    * chore(deps): update dependency @types/node to ^22.19.12 (#1845)
    
    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
    
    * chore(deps): update dependency @types/node to ^22.19.13 (#1846)
    
    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
    
    * chore(deps): update node.js to ^22.22.1 (#1848)
    
    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
    
    * chore(deps): update dependency @types/node to ^22.19.15 (#1849)
    
    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
    
    * chore(deps): update node.js to ^22.22.2 (#1850)
    
    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
    
    * chore(deps): update dependency @playwright/test to ^1.59.0 (#1851)
    
    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
    
    * chore(deps): update dependency @playwright/test to ^1.59.1 (#1852)
    
    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
    
    * add missing workflow trigger
    
    * chore(deps): update dependency @types/node to ^22.19.17 (#1854)
    
    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
    
    * Scaffold Stream Abilities API loader
    
    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.
    
    * Add read-only Stream abilities
    
    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 Stream write abilities
    
    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 destructive Stream abilities
    
    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.
    
    * Add Abilities loader and base class tests
    
    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
    
    * Fix lint errors in abilities tests and purge ability
    
    * Register stream ability category and fix get-record lookup
    
    - 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 ability instructions, REST integration tests, fix HTTP routing
    
    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.
    
    * Default $input to null in Ability::execute() signatures
    
    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.
    
    * Harden Abilities API against critical CR findings
    
    - 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.
    
    * Extract Trait_View_Stream_Permission for read abilities
    
    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.
    
    * Tighten get-records schema: orderby enum, __in maxItems
    
    - 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.
    
    * create-alert: validate alert_type, set real post_title, split connector-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).
    
    * Fix Abilities is_enabled() to honor network option on network-activated 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.
    
    * Fix PHPCS errors in CR-fix tests
    
    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.
    
    * Correct misleading comment on IP validation in create-exclusion-rule
    
    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.
    
    * chore(deps): update dependency uuid to ^11.1.1 (#1860)
    
    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
    
    * Fix PHPCS: add @param tags to execute/permission_callback, whitelist view_stream cap
    
    * Address Copilot review: multisite purge scope, alert_meta shape, orderby 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).
    
    * Address Copilot follow-up: REST scoping on network-activated multisite, 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.
    
    * Address Copilot follow-up: combine stream + meta deletion into one statement
    
    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).
    
    * Address Copilot follow-up: refresh settings via get_options() and tighten 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.
    
    * Fix alert_meta JSON shape for empty meta in stream/get-alerts
    
    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).
    
    * Add docblocks to REST integration test methods to satisfy PHPCS
    
    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.
    
    * Address Copilot follow-up: bool checkboxes, purge error surfacing, idempotent 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.
    
    * chore(deps): update dependency @types/node to ^22.19.18 (#1862)
    
    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
    
    * chore(deps): update dependency @playwright/test to ^1.60.0 (#1863)
    
    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
    
    * chore(deps): update dependency @types/node to ^22.19.19 (#1864)
    
    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
    
    * Bump tar-fs from 3.0.6 to 3.0.9 (#1754)
    
    Bumps [tar-fs](https://github.com/mafintosh/tar-fs) from 3.0.6 to 3.0.9.
    - [Commits](https://github.com/mafintosh/tar-fs/compare/v3.0.6...v3.0.9)
    
    ---
    updated-dependencies:
    - dependency-name: tar-fs
      dependency-version: 3.0.9
      dependency-type: indirect
    ...
    
    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    
    * Bump http-proxy-middleware from 2.0.6 to 2.0.9 (#1729)
    
    Bumps [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware) from 2.0.6 to 2.0.9.
    - [Release notes](https://github.com/chimurai/http-proxy-middleware/releases)
    - [Changelog](https://github.com/chimurai/http-proxy-middleware/blob/v2.0.9/CHANGELOG.md)
    - [Commits](https://github.com/chimurai/http-proxy-middleware/compare/v2.0.6...v2.0.9)
    
    ---
    updated-dependencies:
    - dependency-name: http-proxy-middleware
      dependency-version: 2.0.9
      dependency-type: indirect
    ...
    
    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    
    * Bump axios from 1.7.9 to 1.8.4 (#1723)
    
    Bumps [axios](https://github.com/axios/axios) from 1.7.9 to 1.8.4.
    - [Release notes](https://github.com/axios/axios/releases)
    - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
    - [Commits](https://github.com/axios/axios/compare/v1.7.9...v1.8.4)
    
    ---
    updated-dependencies:
    - dependency-name: axios
      dependency-type: indirect
    ...
    
    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    
    * chore(deps): update dependency npm-run-all2 to v8 (#1735)
    
    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
    
    * chore(deps): update dependency copy-webpack-plugin to v14 (#1847)
    
    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
    
    * chore: replace uuid dependency with native crypto.randomUUID() (#1866)
    
    The uuid package was used in exactly one place (an E2E test) to generate
    a unique post title. Node 22 (and all modern browsers) ship crypto.randomUUID()
    natively, so the dependency is unnecessary.
    
    Also unblocks XWPENG-29: uuid v14 dropped CJS exports, which broke
    eslint-plugin-import's resolver chain (eslint-import-resolver-node 0.3.9
    does not understand ESM-only "exports" maps). Removing uuid avoids
    needing to upgrade the resolver chain.
    
    Refs XWPENG-29
    Supersedes #1861
    
    * chore(deps): update dependency eslint-plugin-react-hooks to v7 (#1802)
    
    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
    
    * chore(deps): update dependency @wordpress/e2e-test-utils-playwright to ^1.45.0 (#1833)
    
    * chore(deps): update dependency @wordpress/e2e-test-utils-playwright to ^1.45.0
    
    * fix: override @types/node peer for e2e-test-utils-playwright
    
    @wordpress/e2e-test-utils-playwright@1.45.0 declares a peer of
    @types/node@^20.17.10, but Stream uses @types/node@^22.x. Use npm
    overrides to satisfy the peer with our v22 types — types-only, no
    runtime impact.
    
    ---------
    
    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
    Co-authored-by: Utkarsh Patel <itismeutkarsh@gmail.com>
    
    * chore(deps): update Node.js to v24 (#1867)
    
    Bumps the Node.js requirement from v22 to v24:
    - engines.node: ^22.22.2 -> ^24.15.0
    - .nvmrc: 22 -> 24
    - @types/node: ^22.19.19 -> ^24.12.4
    
    Supersedes #1811 (which was stale against develop).
    
    * chore(deps): update wordpress monorepo (major) (#1868)
    
    * chore(deps): update wordpress monorepo (major)
    
    Bumps:
    - @wordpress/eslint-plugin: ^22.22.0 -> ^25.1.0
    - @wordpress/scripts: ^30.27.0 -> ^32.1.0
    
    Brings ESLint 8 -> 9 via @wordpress/eslint-plugin@25.
    Legacy .eslintrc.js still works under ESLint 9 (deprecation
    warnings only). Migration to eslint.config.js will be required
    before ESLint 10.
    
    Validated locally on Node 24: npm install, npm run build, and
    npm run lint:js all pass.
    
    Supersedes #1820 (stale against develop). Merging this PR
    auto-closes #1820.
    
    Closes #1820
    
    * chore: migrate to ESLint flat config
    
    @wordpress/scripts@32 forces a flat eslint config; legacy .eslintrc.js
    is ignored entirely by the new lint-js wrapper. The previous CI hang
    was caused by wp-scripts injecting its default flat config and our
    custom rules being dropped.
    
    Changes:
    - Replace .eslintrc.js + .eslintignore with eslint.config.js
    - Add 'globals' as an explicit devDependency
    - Drop DEBUG=eslint:cli-engine from lint:js script
    - Autofix trailing commas across src/js and ui/js (comma-dangle rule)
    
    * fix(e2e): repair new-post spec and add Playwright E2E job to CI (#1873)
    
    * fix(e2e): use editor helpers and dismiss welcome dialog in new-post spec
    
    The editor-new-post E2E spec was failing on develop because:
    - Gutenberg's first-visit welcome dialog blocked the title locator.
    - The publish flow is now two-step (top bar + pre-publish panel).
    - The obsolete meta-box-loader response wait timed out (no meta boxes
      registered, so that request never fires).
    - The Stream plugin is deactivated by the shared setup, so post-publish
      was not being logged.
    
    Refactor to use the official @wordpress/e2e-test-utils-playwright Editor
    helpers (setContent, publishPost), reactivate Stream in beforeAll, and
    deactivate it again in afterAll to keep the network spec's preconditions.
    
    Closes #1872
    
    * ci: add Playwright E2E job to lint-and-test workflow
    
    Runs the Playwright E2E suite against the Dockerized WordPress stack
    after the lint job succeeds. The job:
    
    - maps stream.wpenv.net to localhost so the existing hardcoded test
      URLs resolve;
    - installs Playwright's Chromium with system deps;
    - builds the plugin so the mounted build/ directory contains the
      current assets;
    - starts the Docker stack, waits for MySQL and HTTP readiness, then
      runs wp core multisite-install;
    - runs npm run test-e2e;
    - uploads the playwright-report directory as a workflow artifact on
      both success and failure for easier triage.
    
    * ci: fix MySQL wait, bump login-action, opt actions into Node 24, set timeouts
    
    - Use mariadb-admin instead of mysqladmin in the readiness probe; the
      mariadb:11.4.2 image only ships the mariadb-admin binary.
    - Bump docker/login-action from v2 to v3 to drop a Node 20 runtime.
    - Set FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true at the workflow level so
      the remaining v4 JavaScript actions execute under Node 24 ahead of
      the June 2026 forced switch.
    - Add timeout-minutes to both jobs (lint 10, e2e 12) so a hung step
      fails fast instead of consuming the default 6h limit.
    
    * ci: add diagnostics to the WordPress HTTP wait step
    
    Print the HTTP status on each attempt and dump 'docker compose ps' plus
    recent wordpress container logs on timeout so the next CI failure
    gives us something actionable.
    
    * ci: install Composer deps in the E2E job so wp-cli is available
    
    The WordPress container's PATH includes the project's vendor/bin
    directory, where wp-cli/wp-cli-bundle installs the 'wp' binary. Without
    composer install, 'docker compose run wordpress wp ...' fails with
    'wp: not found'.
    
    * ci: probe MySQL with a real connection instead of mariadb-admin ping
    
    mariadb-admin ping returns success as soon as the server process is
    up, even while the server is still refusing TCP connections during
    init. Run an actual 'SELECT 1' as the wordpress user against the
    wordpress database so the wait reflects what wp-cli will see.
    
    * ci: use xwp/wait-for for MySQL and WordPress readiness probes
    
    The project already ships xwp/wait-for as a dev dependency. Replace the
    hand-rolled curl/docker exec loops with a single PHP one-liner that
    uses XWP\\Wait_For\\Tcp_Connection to verify both services are
    accepting TCP connections, and dump container state in a separate
    on-failure step.
    
    * ci: wait for MySQL from inside the wordpress container
    
    The host-mapped 3306 port becomes available before the in-network
    'mysql' hostname is reachable from a freshly created sibling
    container. wp-cli runs inside a one-shot 'docker compose run wordpress'
    which connects over the docker network, so the readiness check needs
    to happen there too. Use 'npm run cli -- php -r ...' to run the
    wait-for probe in the same context wp-cli will use.
    
    * ci: run container-state dump after install/e2e steps
    
    Previously the diagnostic step sat between wait-for and install-wordpress,
    so it only triggered on a wait-for failure. Move it after the e2e step
    so any failure in install-wordpress or test-e2e also dumps state.
    
    * chore(deps): update dependency jquery to v4 (#1834)
    
    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
    
    * test(e2e): add admin UI smoke spec for jQuery-dependent widgets (#1874)
    
    Covers the Stream records list table, jQuery UI datepicker, select2
    filter, Settings tab, and Alerts tab, and asserts that no uncaught JS
    errors are emitted during navigation. Designed to catch regressions
    from upstream jQuery, jQuery UI, or select2 version bumps that the
    existing unit and integration suites do not exercise.
    
    * chore(deps): bump composer/installers to v2 and mercator to dev-master (#1875)
    
    Widens composer/installers from ~1.0 to ^1.0 || ^2.0 and updates
    humanmade/mercator (require-dev) from ^1.0 to "dev-master as 1.1.0".
    The tagged mercator 1.0.3 pins composer/installers to ~1.0 and blocks
    v2; dev-master allows ~1.0 || ~2.0. mercator is dev-only and is not
    shipped in the release zip.
    
    Lock now resolves to composer/installers v2.3.0 and mercator
    dev-master (5adf68c, 2024-12-06). Plugin install paths under the v2
    installer are unchanged.
    
    Closes #1821
    
    * Address review feedback: route abilities through existing classes
    
    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.
    
    * chore(deps): update wordpress monorepo (#1880)
    
    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
    
    * Address second-round review feedback: multisite scoping and option routing
    
    - 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.
    
    * Expose admin-only connectors to abilities
    
    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".
    
    * Route ability reads through the network option on network-activated multisite
    
    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.
    
    * Address review-body nits: defensive output coercion and test cleanup
    
    - 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.
    
    * Centralize Trait_View_Stream_Permission loading
    
    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.
    
    * feat(purge): add Action Scheduler constants for auto-purge migration
    
    Refs XWPENG-28
    
    * feat(purge): wire Admin to AS-based auto-purge callbacks
    
    Adds stub method bodies so the action registration resolves; the real
    implementations land in subsequent commits.
    
    Refs XWPENG-28
    
    * feat(purge): schedule auto-purge via Action Scheduler
    
    Replaces the legacy twicedaily WP-Cron event with a recurring AS action
    scheduled at 12h intervals. Clears any pre-existing legacy event on
    upgrade so the two cannot double-fire. Idempotent: re-running the
    setup while a recurring action is pending is a no-op.
    
    Refs XWPENG-28
    
    * feat(purge): replace inline DELETE with AS chain enqueue
    
    The recurring action now snapshots the TTL cutoff in UTC, fires
    wp_stream_auto_purge for back-compat, applies an overlap guard, and
    enqueues the first batch into the auto-purge chain. Multisite scoping
    (per-site activation) is encoded as blog_id; 0 means 'all blogs'.
    Defaults are merged into the options array via wp_parse_args so a
    partially-saved option still gets the missing keys.
    
    Refs XWPENG-28
    
    * feat(purge): add batched auto_purge_batch worker
    
    Window-based deletion (ID range \u2264 wp_stream_batch_size) joined against
    stream_meta in a single statement, mirroring erase_large_records().
    Snapshotted UTC cutoff is threaded through each batch in the chain.
    blog_id == 0 means 'all blogs' for network-activated installs; non-zero
    scopes to that blog. Schedules the orphan reaper as the terminal step
    when no more rows are eligible.
    
    Refs XWPENG-28
    
    * feat(purge): add terminal orphan reaper to auto-purge chain
    
    Runs delete_orphaned_meta() once at the end of every chain so installs
    that already had orphan meta from historical timed-out purges heal
    over time without operator intervention. Lifts delete_orphaned_meta()
    visibility from private to protected so the reaper can call it.
    
    Refs XWPENG-28
    
    * feat(purge): add manual 'Clean Orphaned Meta' link on Settings \u2192 Advanced
    
    Settings UI link is nonced for users with WP_STREAM_SETTINGS_CAPABILITY;
    the ajax handler schedules a one-shot reaper via Action Scheduler.
    Idempotent: re-clicking while a reaper is pending is a no-op. Bails out
    early under WP_STREAM_TESTS so PHPUnit doesn't exit the worker.
    
    Refs XWPENG-28
    
    * test(e2e): cover manual orphan-meta cleanup link
    
    Asserts the Clean Orphaned Meta link renders on Settings \u2192 Advanced,
    points at admin-ajax.php with the expected action + nonce, and that
    following the link redirects to the settings page with a confirmation
    marker in the URL. Also fixes the redirect target in the handler to
    use network_settings_page_slug on network-activated installs.
    
    Refs XWPENG-28
    
    * fix(purge): ensure forward progress on tables with concurrent writes
    
    The previous auto_purge_batch SELECT used 'WHERE created < cutoff ORDER
    BY ID DESC LIMIT 1' to find the next window's top, but on hosts that
    are actively logging during a chain the ID space is sparse (eligible
    rows interleaved with fresh rows whose created > cutoff). That caused
    each subsequent batch to find a top only ~30 IDs below the previous,
    stalling progress.
    
    Match Admin::erase_large_records()'s pattern instead: pass last_entry
    (the lower bound of the previous window) through the chain and use
    'WHERE ID < last_entry' to guarantee the next batch starts strictly
    below the previous window. Stride is now exactly wp_stream_batch_size
    IDs per batch.
    
    Verified end-to-end on a multisite install seeded to ~320k aged
    records: chain drains in ~35 batches at batch_size=10000 and
    terminates with zero orphans.
    
    Refs XWPENG-28
    
    * style: address PHPCS findings in auto-purge implementation
    
    - Replace interpolated-SQL pattern in auto_purge_batch with explicit
      prepared statements per (blog_id, last_entry) combination.
    - Correct @return doctype on wp_ajax_clean_orphan_meta to bool|void.
    - Settings UI array alignment.
    - Add @param to set_records_ttl test helper.
    
    Refs XWPENG-28
    
    * docs(changelog): note XWPENG-28 auto-purge migration
    
    Refs XWPENG-28
    
    * fix(purge): harden missing-option fallback against filtered defaults
    
    Settings::get_defaults() runs every field through wp_stream_settings_option_fields,
    which Network::get_network_admin_fields() uses to strip 'records_ttl' from the
    per-site option's defaults set. Outside any admin context (Action Scheduler,
    WP-CLI, system cron) the per-site option_key is in effect, so the filtered
    defaults never contained general_records_ttl. Without this fallback the
    auto-purge silently no-ops on every install where the option is missing,
    defeating the whole point of fixing this on bloated sites.
    
    Hardcoded fallback to 30 days (the documented default on the settings field
    itself) when general_records_ttl is absent after the merge.
    
    Caught by section 9 of the e2e plan.
    
    Refs XWPENG-28
    
    * feat(purge): consult wp_stream_is_large_records_table for small-table fast path
    
    The acceptance criteria require the auto-purge to honor the same
    wp_stream_is_large_records_table filter the manual reset uses, so
    ops only have one knob to tune table-size semantics.
    
    The recurring callback now counts eligible rows once, passes the count
    through Plugin::is_large_records_table(), and:
    
    - Small table (filter returns false): runs a single inline multi-table
      DELETE for the eligible rows, then enqueues the orphan reaper as a
      terminal AS action so the heal step stays observable in
      Tools \u2192 Scheduled Actions.
    - Large table (filter returns true, default for record_count > 1M):
      enqueues the batched chain as before.
    
    Tests:
    - New test_purge_scheduled_action_small_table_fast_path covering the
      inline-DELETE branch.
    - New test_purge_scheduled_action_large_table_uses_batched_chain
      exercising the batched branch via the filter.
    - Existing batched-path tests now opt into the chain explicitly via
      add_filter('wp_stream_is_large_records_table','__return_true').
    
    Refs XWPENG-28
    
    * feat(purge): expose Admin::is_running_auto_purge() state probe
    
    Mirrors is_running_async_deletion() but checks the auto-purge group
    (batch + reaper). The recurring scheduler is intentionally excluded
    from the probe so it doesn't always report 'running' under normal
    operation. Settings UI uses this in the next commit to render an
    'Auto-purge currently running' notice on Settings \u2192 Advanced.
    
    Refs XWPENG-28
    
    * feat(purge): hide manual Clean Orphaned Meta link while chain is running
    
    When the auto-purge chain is active (batch worker or reaper pending),
    the manual Settings \u2192 Advanced cleanup link is hidden and the field
    description is swapped to explain that the reaper will run as part of
    the active cycle. Mirrors how Reset Stream Database hides itself
    during async deletion.
    
    Refs XWPENG-28
    
    * test(e2e): cover purge-active and purge-idle UI states
    
    Two new specs exercise the Settings \u2192 Advanced field behaviour driven
    by Admin::is_running_auto_purge():
    
    - Active state: seed a pending reaper action via wp-cli, assert the
      Clean Orphaned Meta link is removed from the DOM and the swapped
      description ('Auto-purge is currently running') is visible.
    - Idle state: drain the seeded action and assert the link is restored.
    
    Both specs use a small wp-cli helper (execSync into the wordpress
    container) to seed/clear AS state, keeping the test free of any
    browser-side timing on the AS worker.
    
    Refs XWPENG-28
    
    * test(e2e): harden orphan-cleanup spec against activation races
    
    - wpEval helper now swallows non-zero exits from the wordpress
      container instead of throwing into the test runner. State seeding
      is best-effort; the test's own assertions are the source of truth.
    - beforeAll waits for the post-activation navigation and explicitly
      confirms 'Network Deactivate Stream' is visible before any test
      runs, failing fast instead of letting every test silently hit the
      'Sorry, you are not allowed to access this page' redirect when a
      prior suite leaves Stream deactivated.
    
    In-isolation: spec is stable across 3 consecutive runs. Pre-existing
    cross-spec activation races (editor-new-post, admin-ui-smoke) remain
    out of scope here.
    
    Refs XWPENG-28
    
    * docs(changelog): note small-table fast path + running-state UX
    
    Refs XWPENG-28
    
    * fix(e2e): drop unused catch binding for ESLint
    
    CI runs ESLint with no-unused-vars; the wpEval helper's catch block
    declared 'err' but never referenced it. Use the optional binding form
    'catch {}' which is supported on the runner's Node version (22+).
    
    Refs XWPENG-28
    
    * test: drop two low-value auto-purge unit tests
    
    - test_auto_purge_action_constants_exist was a tautology: it asserted
      that four constants equal the string values they are declared with.
      The test catches nothing that static analysis or a typo in the
      consumer wouldn't catch.
    
    - test_auto_purge_batch_respects_wp_stream_batch_size_filter only
      verified the filter was *called* (invocation counter), not that the
      returned value affected behaviour. test_auto_purge_batch_deletes_
      window_and_chains_next_batch already drives the chain with a custom
      batch_size and asserts on the resulting chain, which is the
      functional contract that matters.
    
    No coverage lost.
    
    Refs XWPENG-28
    
    * review: address code review feedback
    
    1. auto_purge_batch() now throws InvalidArgumentException on empty
       cutoff instead of silently returning. A bare 'return' caused AS to
       mark the action complete; throwing makes AS log it as failed and
       surface it in Tools > Scheduled Actions. New PHPUnit test covers
       the throw path. In practice this branch is unreachable because
       purge_scheduled_action() always populates the cutoff, but the
       guard exists for third-party code that may enqueue with bad input.
    
    2. wp_ajax_clean_orphan_meta() now uses $this->settings_cap to match
       the rest of the file (wp_ajax_reset, ajax_filters). The bare
       WP_STREAM_SETTINGS_CAPABILITY constant could break installs that
       override the capability via the property after construction.
    
    3. The Settings 'Clean Orphaned Meta' field now calls
       Admin::is_running_auto_purge() once per render instead of twice.
       Extracted the field-building logic into a small helper so the
       state probe runs once and both the 'type' and 'desc' branches
       reuse the result.
    
    Refs XWPENG-28
    
    * Expose Stream abilities via MCP when the WordPress MCP Adapter is present
    
    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.
    
    * Add HTTPS to the local dev environment via mkcert and an Apache SSL vhost
    
    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.
    
    * Publish the mkcert helper image via docker-images.yml
    
    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.
    
    * Extract mcp-adapter post-install hook into a dedicated PHP script
    
    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.
    
    * Force HTTPS across the local dev environment and tests
    
    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.
    
    * Rebuild the WordPress image in CI so branch Dockerfile changes apply
    
    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.
    
    * Simplify the Enable Abilities API setting description
    
    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").
    
    * review: address second round of code review feedback
    
    1. Settings::updated_option_ttl_remove_records() now triggers an
       immediate purge directly. Previously it relied on the legacy
       wp_stream_auto_purge action being hooked to purge_scheduled_action,
       which this PR severed. Without this fix, shortening the TTL did not
       take effect until the next 12h recurring tick.
    
    2. TTL fallback uses isset() instead of empty(), so an explicit '0'
       set via CLI/SQL is no longer silently overridden to 30. Added an
       explicit short-circuit: a non-positive TTL bails out of the cycle
       entirely. The UI enforces min=1; the only paths to 0 are operator
       error, and bailing out (records stop being purged) is a less
       destructive failure mode than honoring it (records get wiped
       repeatedly every 12h).
    
    3. Overlap guard now reuses Admin::is_running_auto_purge(), so a
       pending reaper also blocks a new chain. Previously only a pending
       batch action blocked the guard, leaving a small window between
       chain completion and reaper completion where a new chain could
       stack.
    
    Three new PHPUnit tests cover each behaviour.
    
    Refs XWPENG-28
    
    * review: address third round of code review feedback
    
    - Move wp_stream_auto_purge BC action to after all bail-out checks so it
      fires only when a purge actually runs (was firing on every recurring
      tick regardless of whether work happened).
    - Extend is_running_auto_purge() to also check IN-PROGRESS actions via
      as_get_scheduled_actions() so the overlap guard cannot let a second
      chain stack against rows the running batch worker is still touching.
    - Settings TTL-shortened path now enqueues AUTO_PURGE_ACTION via
      as_enqueue_async_action() so the immediate purge serializes through
      Action Scheduler instead of bypassing the overlap guard with an
      inline call. Inline fallback retained for when AS is unavailable.
    - Render an admin notice for wp_stream_message=orphan_meta_cleanup_scheduled
      on both admin_notices and network_admin_notices so the post-redirect
      UX is actually visible (was a half-built feature: redirect happened,
      no notice rendered).
    - E2E wpEval(): switch from 'docker compose run --rm' to 'docker
      compose exec -T' to attach to the long-lived container (~3-5s saved
      per call) and surface failures via console.warn instead of silently
      swallowing errors.
    - PHPUnit: add coverage for both the BC-action-suppressed-on-bailout
      path and the in-progress-action overlap guard. Update the
      TTL-shortened test to assert the AS enqueue path.
    
    * fix(e2e): pass --user www-data to docker compose exec
    
    The previous quick-win switch from 'docker compose run --rm --user
    $(id -u)' to 'docker compose exec -T' dropped the user mapping. On
    local dev that happened to work because the wordpress container's
    default exec user is what the runtime image inherits; on CI it defaults
    to root and wp-cli refuses to run with:
    
      YIKES! It looks like you're running this as root.
    
    $(id -u) only worked on the host because UID 1000 mapped to www-data
    inside the container — that's not portable to the GitHub Actions runner
    (UID 1001). Pin the exec user explicitly to www-data, which owns the
    WordPress files inside the container.
    
    Failing run: actions/runs/26144103763
    
    * chore(deps-dev): bump johnbillion/query-monitor from 3.16.3 to 3.20.4 (#1879)
    
    Bumps [johnbillion/query-monitor](https://github.com/johnbillion/query-monitor) from 3.16.3 to 3.20.4.
    - [Release notes](https://github.com/johnbillion/query-monitor/releases)
    - [Commits](https://github.com/johnbillion/query-monitor/compare/3.16.3...3.20.4)
    
    ---
    updated-dependencies:
    - dependency-name: johnbillion/query-monitor
      dependency-version: 3.20.4
      dependency-type: direct:development
    ...
    
    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    
    * Merge pull request #1878 from xwp/dependabot/composer/symfony/process-5.4.51
    
    chore(deps-dev): bump symfony/process from 5.4.46 to 5.4.51
    
    * Bump phpunit/phpunit from 9.6.20 to 9.6.33 (#1876)
    
    Bumps [phpunit/phpunit](https://github.com/sebastianbergmann/phpunit) from 9.6.20 to 9.6.33.
    - [Release notes](https://github.com/sebastianbergmann/phpunit/releases)
    - [Changelog](https://github.com/sebastianbergmann/phpunit/blob/9.6.33/ChangeLog-9.6.md)
    - [Commits](https://github.com/sebastianbergmann/phpunit/compare/9.6.20...9.6.33)
    
    ---
    updated-dependencies:
    - dependency-name: phpunit/phpunit
      dependency-version: 9.6.33
      dependency-type: direct:development
    ...
    
    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    
    * Merge pull request #1869 from xwp/dependabot/npm_and_yarn/axios-1.16.0
    
    Bump axios from 1.8.4 to 1.16.0
    
    * chore(deps-dev): bump composer/composer from 2.7.7 to 2.9.8 (#1877)
    
    Bumps [composer/composer](https://github.com/composer/composer) from 2.7.7 to 2.9.8.
    - [Release notes](https://github.com/composer/composer/releases)
    - [Changelog](https://github.com/composer/composer/blob/main/CHANGELOG.md)
    - [Commits](https://github.com/composer/composer/compare/2.7.7...2.9.8)
    
    ---
    updated-dependencies:
    - dependency-name: composer/composer
      dependency-version: 2.9.8
      dependency-type: indirect
    ...
    
    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    
    * chore(deps): update dependency npm-run-all2 to v9 (#1881)
    
    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
    
    * refactor(e2e): use Playwright baseURL for admin URLs
    
    Set baseURL in playwright.config.js and replace absolute
    https://stream.wpenv.net references in every spec with relative
    paths. Also drops the docker-exec state seeding from
    admin-orphan-cleanup.spec.js so it matches the browser-only
    convention of the rest of the suite; the running-state UX it
    seeded is covered by PHPUnit.
    
    * Switch dev dependencies from WPackagist to WP Packages (#1858)
    
    Co-authored-by: Utkarsh Patel <itismeutkarsh@gmail.com>
    
    * Update Node.js to ^24.16.0 (#1883)
    
    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
    
    * Update dependency npm-run-all2 to ^9.0.1 (#1886)
    
    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
    
    * review: address fourth round of code review feedback
    
    1. wp_ajax_clean_orphan_meta() now uses is_running_auto_purge() for its
       idempotency check instead of as_has_scheduled_action(), which only
       probes PENDING. The new probe checks PENDING + RUNNING across the
       batch worker and reaper, matching the UI hide condition and closing
       the small CSRF/stale-URL window where a duplicate reaper could be
       enqueued while a chain is mid-flight. function_exists() guard
       updated to as_get_scheduled_actions accordingly.
    
    2. Add explicit case 'none' in Settings::render_field() so the intent
       of the running-state UI swap (hide the value column, let the
       description carry the message) is captured in code instead of
       relying on the switch's implicit fall-through. Used by both the
       Reset Stream Database row and the new Clean Orphaned Meta row.
    
    3. Expand auto_purge_batch() docblock to acknowledge the
       last_entry-based forward-progress trade-off: any eligible row that
       lands inside the already-touched ID range after that batch ran is
       skipped by the current chain and picked up on the next tick. Sources
       are bounded (dev seeders, importers, clock skew); steady-state
       logging is monotonic so this is a no-op for production traffic.
    
    4. Changelog clarifies that the wp_stream_auto_purge BC action's
       semantics have changed: it used to fire on every WP-Cron tick
       regardless of whether work happened, now fires only after all
       bail-out checks pass. Integrations that relied on the legacy
       tick-rate semantics should switch to AUTO_PURGE_ACTION.
    
    5. New PHPUnit test test_settings_ttl_shortened_via_option_update_enqueues_purge
       exercises the full hook wiring (update_option / update_site_option
       → Settings::updated_option_ttl_remove_records → AS enqueue) end-to-
       end. The existing unit test calls the method directly and would
       still pass if Network::__construct() lost its
       update_site_option_wp_stream_network hook registration.
    
    6. New PHPUnit test test_ajax_clean_orphan_meta_denies_users_without_settings_cap
       covers the security boundary on the manual cleanup handler. Uses
       _handleAjax() so WP_Ajax_UnitTestCase's buffer machinery runs.
    
    7. New PHPUnit test test_clean_orphan_meta_field_reflects_running_state
       asserts the integration between is_running_auto_purge() and the
       field-rendering decision in Settings::build_clean_orphan_meta_field().
       Replaces the e2e specs removed in b4c8f287 for activation-race
       fragility with deterministic PHPUnit coverage.
    
    8. New PHPUnit test test_purge_scheduled_action_scopes_to_current_blog_when_not_network_activated
       forces the multisite-not-network-activated branch via a Plugin stub
       and asserts the enqueued batch carries get_current_blog_id() rather
       than 0. The acceptance criterion ("per-site activations only purge
       the current blog") was only covered at the batch-worker layer; the
       routing decision in purge_scheduled_action() was untested.
    
    Refs XWPENG-28
    
    * test(phpunit): load Trait_View_Stream_Permission in bootstrap (#1888)
    
    Production loads the trait via Abilities::load_abilities() before any
    class-ability-*.php file is included, but PHPUnit's coverage
    post-processor with processUncoveredFiles="true" walks the
    <coverage><include> directories directly via include_once and bypasses
    the plugin's loader. On PHP versions where the order surfaces the
    issue, each read ability fatals on `use Trait_View_Stream_Permission`
    during coverage generation, breaking the clover.xml emit step and
    failing the test target with a non-zero exit even when all tests pass.
    
    Require the trait at bootstrap so it is available before any include_once
    walks the abilities directory. Mirrors the production chokepoint without
    reintroducing per-file require_once at the top of each ability class.
    
    * Skip async deletion AS query outside admin (#1885)
    
    * Skip async deletion AS query outside admin and memoise it
    
    `Admin::is_running_async_deletion()` is called from `Settings::get_fields()`
    and `Settings::get_deletion_warning()` to decide whether to render a
    "deletion in progress" warning on the Stream settings screen. Because
    `Settings::__construct` eagerly calls `get_options()` on `init`, and
    `get_options()` walks the field definition via `get_defaults()`, the AS
    query runs twice on every pageload — front-end included — even though the
    result is only ever consumed by admin UI.
    
    Short-circuit when `is_admin()` is false (the only callers are admin UI
    render paths) and memoise per request to collapse the duplicate within an
    admin pageload.
    
    Fixes #1884
    
    * Lift AS gate into Settings; also skip auto-purge query on front-end
    
    The previous commit short-circuited Admin::is_running_async_deletion()
    outside admin and memoised it. That works for the immediate cost, but
    has two issues:
    
    1. It changes the semantic contract of a public static m…
    7 people authored May 28, 2026
    Configuration menu
    Copy the full SHA
    e47dc08 View commit details
    Browse the repository at this point in the history
Loading