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