Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.95% covered (success)
91.95%
297 / 323
72.73% covered (warning)
72.73%
8 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Boost_Abilities
91.95% covered (success)
91.95%
297 / 323
72.73% covered (warning)
72.73%
8 / 11
59.75
0.00% covered (danger)
0.00%
0 / 1
 get_category_slug
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_category_definition
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 get_abilities
100.00% covered (success)
100.00%
162 / 162
100.00% covered (success)
100.00%
1 / 1
1
 can_view_modules
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 can_manage_modules
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 build_module_index
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 render_module
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 get_modules
65.96% covered (warning)
65.96%
31 / 47
0.00% covered (danger)
0.00%
0 / 1
41.09
 set_module_status
93.22% covered (success)
93.22%
55 / 59
0.00% covered (danger)
0.00%
0 / 1
13.05
 get_speed_score
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
8
 clear_page_cache
53.85% covered (warning)
53.85%
7 / 13
0.00% covered (danger)
0.00%
0 / 1
3.88
1<?php
2/**
3 * Jetpack Boost Abilities Registration
4 *
5 * Registers Jetpack Boost abilities with the WordPress Abilities API so AI
6 * agents can read module state, toggle modules, fetch the latest speed score,
7 * and clear the page cache through the standard `wp-abilities/v1` REST surface.
8 *
9 * @package automattic/jetpack-boost
10 */
11
12// @phan-file-suppress PhanUndeclaredFunction, PhanUndeclaredClassMethod @phan-suppress-current-line UnusedSuppression -- Abilities API added in WP 6.9. We guard with function_exists() checks so the class is safe on older WP. @todo Remove this line when the minimum supported WordPress version is 6.9.
13
14namespace Automattic\Jetpack_Boost\Abilities;
15
16use Automattic\Jetpack\Boost_Speed_Score\Speed_Score_History;
17use Automattic\Jetpack\WP_Abilities\Registrar;
18use Automattic\Jetpack_Boost\Modules\Features_Index;
19use Automattic\Jetpack_Boost\Modules\Module;
20use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Page_Cache;
21use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Boost_Cache;
22
23/**
24 * Registers Jetpack Boost abilities with the WordPress Abilities API.
25 *
26 * Surface (4 abilities, all under the `jetpack-boost/` namespace):
27 *
28 * - get-modules        — filtered read of every Boost module + submodule.
29 * - set-module-status  — declarative toggle, idempotent.
30 * - get-speed-score    — latest mobile/desktop scores from history.
31 * - clear-page-cache   — flush the Boost page cache for the home URL.
32 *
33 * @since $$next-version$$
34 */
35class Boost_Abilities extends Registrar {
36
37    /**
38     * @inheritDoc
39     */
40    public static function get_category_slug(): string {
41        return 'jetpack-boost';
42    }
43
44    /**
45     * @inheritDoc
46     */
47    public static function get_category_definition(): array {
48        return array(
49            // "Jetpack Boost" is a product name and is not translated.
50            'label'       => 'Jetpack Boost',
51            'description' => __( 'Abilities for inspecting and managing Jetpack Boost performance modules, speed scores, and page cache.', 'jetpack-boost' ),
52        );
53    }
54
55    /**
56     * @inheritDoc
57     */
58    public static function get_abilities(): array {
59        $module_object_schema = array(
60            'type'       => 'object',
61            'properties' => array(
62                'slug'       => array( 'type' => 'string' ),
63                'active'     => array( 'type' => 'boolean' ),
64                'available'  => array( 'type' => 'boolean' ),
65                'optimizing' => array( 'type' => 'boolean' ),
66            ),
67        );
68
69        return array(
70            'jetpack-boost/get-modules'       => array(
71                'label'               => __( 'Get Boost modules', 'jetpack-boost' ),
72                'description'         => __( 'List Jetpack Boost performance modules and submodules. Returns an array of { slug, active, available, optimizing }. Slugs use underscores (e.g. "critical_css", "page_cache", "image_cdn"). Pass slug to fetch a single module (returns a 0- or 1-element array; unknown slugs yield an empty array, never an error). Pass status to filter by active/inactive/available/optimizing. Use the returned slugs as input to jetpack-boost/set-module-status.', 'jetpack-boost' ),
73                'input_schema'        => array(
74                    'type'                 => 'object',
75                    'default'              => array(),
76                    'properties'           => array(
77                        'slug'   => array(
78                            'type'        => 'string',
79                            'description' => __( 'Return a single module by its slug (e.g. "critical_css", "page_cache"). Unknown slugs yield an empty array.', 'jetpack-boost' ),
80                            'minLength'   => 1,
81                        ),
82                        'status' => array(
83                            'type'        => 'string',
84                            'description' => __( 'Filter by lifecycle state. "active" = enabled by the user. "inactive" = available but disabled. "available" = currently loadable on this site (regardless of enabled state). "optimizing" = active and currently serving optimized output.', 'jetpack-boost' ),
85                            'enum'        => array( 'active', 'inactive', 'available', 'optimizing' ),
86                        ),
87                        'search' => array(
88                            'type'        => 'string',
89                            'description' => __( 'Case-insensitive substring match against the module slug.', 'jetpack-boost' ),
90                            'minLength'   => 1,
91                        ),
92                    ),
93                    'additionalProperties' => false,
94                ),
95                'output_schema'       => array(
96                    'type'  => 'array',
97                    'items' => $module_object_schema,
98                ),
99                'execute_callback'    => array( __CLASS__, 'get_modules' ),
100                'permission_callback' => array( __CLASS__, 'can_view_modules' ),
101                'meta'                => array(
102                    'annotations'  => array(
103                        'readonly'    => true,
104                        'destructive' => false,
105                        'idempotent'  => true,
106                    ),
107                    'show_in_rest' => true,
108                    'mcp'          => array(
109                        'public' => true,
110                        'type'   => 'tool',
111                    ),
112                ),
113            ),
114
115            'jetpack-boost/set-module-status' => array(
116                'label'               => __( 'Set Boost module status', 'jetpack-boost' ),
117                'description'         => __( 'Enable or disable a single Jetpack Boost module by slug. Required: { slug, active }. Returns { slug, active, changed }. Idempotent: setting a module to its current state returns changed=false. Slugs use underscores (e.g. "critical_css", "page_cache"). Unknown slugs return jetpack_boost_invalid_slug; modules not loadable on this site return jetpack_boost_module_unavailable; always-on modules cannot be disabled and return jetpack_boost_module_always_on — call jetpack-boost/get-modules to enumerate available slugs. Toggling a parent module also drives submodule lifecycle.', 'jetpack-boost' ),
118                'input_schema'        => array(
119                    'type'                 => 'object',
120                    'required'             => array( 'slug', 'active' ),
121                    'properties'           => array(
122                        'slug'   => array(
123                            'type'        => 'string',
124                            'description' => __( 'Module slug to toggle (e.g. "critical_css", "page_cache").', 'jetpack-boost' ),
125                            'minLength'   => 1,
126                        ),
127                        'active' => array(
128                            'type'        => 'boolean',
129                            'description' => __( 'Desired state: true to enable, false to disable.', 'jetpack-boost' ),
130                        ),
131                    ),
132                    'additionalProperties' => false,
133                ),
134                'output_schema'       => array(
135                    'type'       => 'object',
136                    'properties' => array(
137                        'slug'    => array( 'type' => 'string' ),
138                        'active'  => array( 'type' => 'boolean' ),
139                        'changed' => array( 'type' => 'boolean' ),
140                    ),
141                ),
142                'execute_callback'    => array( __CLASS__, 'set_module_status' ),
143                'permission_callback' => array( __CLASS__, 'can_manage_modules' ),
144                'meta'                => array(
145                    'annotations'  => array(
146                        'readonly'    => false,
147                        'destructive' => false,
148                        'idempotent'  => true,
149                    ),
150                    'show_in_rest' => true,
151                    'mcp'          => array(
152                        'public' => true,
153                        'type'   => 'tool',
154                    ),
155                ),
156            ),
157
158            'jetpack-boost/get-speed-score'   => array(
159                'label'               => __( 'Get latest speed score', 'jetpack-boost' ),
160                'description'         => __( 'Return the most recent Jetpack Boost speed score for the home URL. Returns { mobile, desktop, timestamp, is_stale, has_history }. mobile/desktop are integers 0-100 (Google PageSpeed scale) or null when no score has been recorded. timestamp is a Unix epoch in seconds. is_stale=true means the latest score is older than 24 hours or invalidated by a site change; agents should request a refresh from the Boost UI before quoting it. has_history=false means no scores have been recorded yet.', 'jetpack-boost' ),
161                'input_schema'        => array(
162                    'type'                 => 'object',
163                    'default'              => array(),
164                    'properties'           => array(),
165                    'additionalProperties' => false,
166                ),
167                'output_schema'       => array(
168                    'type'       => 'object',
169                    'properties' => array(
170                        'mobile'      => array( 'type' => array( 'integer', 'null' ) ),
171                        'desktop'     => array( 'type' => array( 'integer', 'null' ) ),
172                        'timestamp'   => array( 'type' => array( 'integer', 'null' ) ),
173                        'is_stale'    => array( 'type' => 'boolean' ),
174                        'has_history' => array( 'type' => 'boolean' ),
175                    ),
176                ),
177                'execute_callback'    => array( __CLASS__, 'get_speed_score' ),
178                'permission_callback' => array( __CLASS__, 'can_view_modules' ),
179                'meta'                => array(
180                    'annotations'  => array(
181                        'readonly'    => true,
182                        'destructive' => false,
183                        'idempotent'  => true,
184                    ),
185                    'show_in_rest' => true,
186                    'mcp'          => array(
187                        'public' => true,
188                        'type'   => 'tool',
189                    ),
190                ),
191            ),
192
193            'jetpack-boost/clear-page-cache'  => array(
194                'label'               => __( 'Clear page cache', 'jetpack-boost' ),
195                'description'         => __( 'Clear every cached page under the site home URL. Idempotent: re-running on an empty cache is a no-op. Returns { cleared, message }. cleared=true means the clear request was dispatched against an active page_cache module; it does not promise that cached files actually existed (the underlying API does not surface a count). Requires the page_cache module to be active; if it is not, returns jetpack_boost_page_cache_inactive — enable it first via jetpack-boost/set-module-status with slug="page_cache".', 'jetpack-boost' ),
196                'input_schema'        => array(
197                    'type'                 => 'object',
198                    'default'              => array(),
199                    'properties'           => array(),
200                    'additionalProperties' => false,
201                ),
202                'output_schema'       => array(
203                    'type'       => 'object',
204                    'properties' => array(
205                        'cleared' => array( 'type' => 'boolean' ),
206                        'message' => array( 'type' => 'string' ),
207                    ),
208                ),
209                'execute_callback'    => array( __CLASS__, 'clear_page_cache' ),
210                'permission_callback' => array( __CLASS__, 'can_manage_modules' ),
211                'meta'                => array(
212                    'annotations'  => array(
213                        'readonly'    => false,
214                        'destructive' => false,
215                        'idempotent'  => true,
216                    ),
217                    'show_in_rest' => true,
218                    'mcp'          => array(
219                        'public' => true,
220                        'type'   => 'tool',
221                    ),
222                ),
223            ),
224        );
225    }
226
227    /**
228     * Permission check for read-only abilities.
229     *
230     * Boost has no domain-specific capability; every existing Boost surface
231     * gates on `manage_options`. We mirror that here so abilities are no more
232     * (or less) permissive than the REST and admin surfaces.
233     *
234     * @since $$next-version$$
235     */
236    public static function can_view_modules(): bool {
237        return is_user_logged_in() && current_user_can( 'manage_options' );
238    }
239
240    /**
241     * Permission check for write abilities.
242     *
243     * @since $$next-version$$
244     */
245    public static function can_manage_modules(): bool {
246        return is_user_logged_in() && current_user_can( 'manage_options' );
247    }
248
249    /**
250     * Cache for `build_module_index()`. Module construction touches options for
251     * every feature, so we memoise per-request — both `get_modules()` and
252     * `set_module_status()` consume the same map.
253     *
254     * @var array<string, Module>|null
255     */
256    private static $module_index_cache = null;
257
258    /**
259     * Build a `Module` instance for every feature + submodule, keyed by slug.
260     *
261     * @return array<string, Module>
262     */
263    private static function build_module_index(): array {
264        if ( null !== self::$module_index_cache ) {
265            return self::$module_index_cache;
266        }
267
268        $index = array();
269        foreach ( Features_Index::get_all_features() as $feature_class ) {
270            $module                       = new Module( new $feature_class() );
271            $index[ $module->get_slug() ] = $module;
272        }
273
274        self::$module_index_cache = $index;
275        return $index;
276    }
277
278    private static function render_module( Module $module ): array {
279        return array(
280            'slug'       => $module->get_slug(),
281            'active'     => $module->is_available() && $module->is_enabled(),
282            'available'  => $module->is_available(),
283            'optimizing' => $module->is_optimizing(),
284        );
285    }
286
287    /**
288     * Execute: filtered read of Boost modules.
289     *
290     * @since $$next-version$$
291     *
292     * @param array|null $input Input matching the ability's input_schema.
293     * @return array|\WP_Error
294     */
295    public static function get_modules( $input = null ) {
296        $input = is_array( $input ) ? $input : array();
297        $index = self::build_module_index();
298
299        // Single-slug short-circuit — return 0- or 1-element array, same shape as the list case.
300        // A non-string slug is invalid input (not "unknown"); rejecting it prevents the bad-shape
301        // fall-through where slug:123 would silently return every module.
302        if ( isset( $input['slug'] ) ) {
303            if ( ! is_string( $input['slug'] ) ) {
304                return new \WP_Error(
305                    'jetpack_boost_invalid_slug',
306                    __( 'The slug parameter must be a string.', 'jetpack-boost' )
307                );
308            }
309            if ( '' !== $input['slug'] ) {
310                return isset( $index[ $input['slug'] ] )
311                    ? array( self::render_module( $index[ $input['slug'] ] ) )
312                    : array();
313            }
314        }
315
316        $status_filter = isset( $input['status'] ) && is_string( $input['status'] ) && '' !== $input['status']
317            ? $input['status']
318            : null;
319        $search_filter = isset( $input['search'] ) && is_string( $input['search'] ) && '' !== $input['search']
320            ? $input['search']
321            : null;
322
323        $out = array();
324        foreach ( $index as $slug => $module ) {
325            $rendered = self::render_module( $module );
326
327            if ( null !== $status_filter ) {
328                $matches = false;
329                switch ( $status_filter ) {
330                    case 'active':
331                        $matches = $rendered['active'];
332                        break;
333                    case 'inactive':
334                        $matches = $rendered['available'] && ! $rendered['active'];
335                        break;
336                    case 'available':
337                        $matches = $rendered['available'];
338                        break;
339                    case 'optimizing':
340                        $matches = $rendered['optimizing'];
341                        break;
342                }
343                if ( ! $matches ) {
344                    continue;
345                }
346            }
347
348            // Boost slugs are ASCII snake_case; stripos is sufficient and avoids ext-mbstring.
349            if ( null !== $search_filter && false === stripos( $slug, $search_filter ) ) {
350                continue;
351            }
352
353            $out[] = $rendered;
354        }
355
356        // Deterministic ordering — agents diff results across calls.
357        usort(
358            $out,
359            static function ( $a, $b ) {
360                return strcmp( $a['slug'], $b['slug'] );
361            }
362        );
363
364        return $out;
365    }
366
367    /**
368     * Execute: declarative module toggle. Idempotent.
369     *
370     * @since $$next-version$$
371     *
372     * @param array|null $input Input matching the ability's input_schema.
373     * @return array|\WP_Error
374     */
375    public static function set_module_status( $input = null ) {
376        $input = is_array( $input ) ? $input : array();
377
378        // Required-id validation: not empty(), so "0" remains a legal slug if a future module ever uses it.
379        if ( ! isset( $input['slug'] ) || ! is_string( $input['slug'] ) || '' === $input['slug'] ) {
380            return new \WP_Error(
381                'jetpack_boost_missing_slug',
382                __( 'A module slug is required. Call jetpack-boost/get-modules to enumerate available slugs.', 'jetpack-boost' )
383            );
384        }
385        if ( ! array_key_exists( 'active', $input ) ) {
386            return new \WP_Error(
387                'jetpack_boost_missing_active',
388                __( 'A desired active state (boolean) is required.', 'jetpack-boost' )
389            );
390        }
391        if ( ! is_bool( $input['active'] ) ) {
392            return new \WP_Error(
393                'jetpack_boost_invalid_active',
394                __( 'The active parameter must be a boolean. Strings like "true" / "false" are not accepted.', 'jetpack-boost' )
395            );
396        }
397
398        $slug    = $input['slug'];
399        $desired = $input['active'];
400        $index   = self::build_module_index();
401
402        if ( ! isset( $index[ $slug ] ) ) {
403            return new \WP_Error(
404                'jetpack_boost_invalid_slug',
405                __( 'Unknown Boost module slug. Call jetpack-boost/get-modules to enumerate available slugs.', 'jetpack-boost' )
406            );
407        }
408
409        $module = $index[ $slug ];
410
411        if ( ! $module->is_available() ) {
412            return new \WP_Error(
413                'jetpack_boost_module_unavailable',
414                __( 'This module is not available on this site (e.g. requires a connection or a paid plan).', 'jetpack-boost' )
415            );
416        }
417
418        // Always-on modules ignore the persisted option at runtime. Writing here would leave
419        // on-disk state diverged from runtime state with no rollback, so refuse the write up front.
420        if ( $module->is_always_on() ) {
421            if ( $desired ) {
422                return array(
423                    'slug'    => $slug,
424                    'active'  => true,
425                    'changed' => false,
426                );
427            }
428            return new \WP_Error(
429                'jetpack_boost_module_always_on',
430                __( 'This module is always on and cannot be disabled.', 'jetpack-boost' )
431            );
432        }
433
434        $current = $module->is_enabled();
435        if ( $desired === $current ) {
436            return array(
437                'slug'    => $slug,
438                'active'  => $current,
439                'changed' => false,
440            );
441        }
442
443        if ( ! $module->update( $desired ) ) {
444            return new \WP_Error(
445                'jetpack_boost_module_update_failed',
446                __( 'Failed to persist the module status.', 'jetpack-boost' )
447            );
448        }
449
450        /**
451         * Fires when a module is enabled or disabled through the abilities surface.
452         *
453         * Mirrors the action emitted by `Modules_State_Entry::set()` so submodule
454         * lifecycle handlers fire identically regardless of caller.
455         *
456         * @param string $module_slug The module slug.
457         * @param bool   $is_active   The new state.
458         */
459        do_action( 'jetpack_boost_module_status_updated', $slug, $desired );
460
461        return array(
462            'slug'    => $slug,
463            'active'  => $desired,
464            'changed' => true,
465        );
466    }
467
468    /**
469     * Execute: latest speed score for the home URL.
470     *
471     * @since $$next-version$$
472     *
473     * @param array|null $input Unused; ability has no inputs.
474     * @return array
475     */
476    public static function get_speed_score( $input = null ) {
477        unset( $input );
478
479        $history = new Speed_Score_History( home_url() );
480        $latest  = $history->latest();
481
482        if ( null === $latest ) {
483            return array(
484                'mobile'      => null,
485                'desktop'     => null,
486                'timestamp'   => null,
487                'is_stale'    => false,
488                'has_history' => false,
489            );
490        }
491
492        // `scores` may be stored as either an associative array or stdClass depending on
493        // the API response payload. Normalise to array before reading.
494        $scores  = isset( $latest['scores'] ) ? (array) $latest['scores'] : array();
495        $mobile  = isset( $scores['mobile'] ) && is_numeric( $scores['mobile'] ) ? (int) $scores['mobile'] : null;
496        $desktop = isset( $scores['desktop'] ) && is_numeric( $scores['desktop'] ) ? (int) $scores['desktop'] : null;
497
498        return array(
499            'mobile'      => $mobile,
500            'desktop'     => $desktop,
501            'timestamp'   => isset( $latest['timestamp'] ) ? (int) $latest['timestamp'] : null,
502            'is_stale'    => $history->is_stale(),
503            'has_history' => true,
504        );
505    }
506
507    /**
508     * Execute: clear the Boost page cache for the home URL.
509     *
510     * @since $$next-version$$
511     *
512     * @param array|null $input Unused; ability has no inputs.
513     * @return array|\WP_Error
514     */
515    public static function clear_page_cache( $input = null ) {
516        unset( $input );
517
518        $page_cache = new Module( new Page_Cache() );
519        if ( ! $page_cache->is_available() || ! $page_cache->is_enabled() ) {
520            return new \WP_Error(
521                'jetpack_boost_page_cache_inactive',
522                __( 'The page_cache module is not active. Enable it first via jetpack-boost/set-module-status with slug="page_cache" and active=true.', 'jetpack-boost' )
523            );
524        }
525
526        $cache = new Boost_Cache();
527        // Boost_Cache::delete_recursive() returns void — no useful success/no-op signal
528        // to surface to the agent. Reporting `cleared: true` after a successful module-active
529        // gate is the most honest answer we can give.
530        $cache->delete_recursive( home_url() );
531
532        return array(
533            'cleared' => true,
534            'message' => __( 'Page cache cleared.', 'jetpack-boost' ),
535        );
536    }
537}