Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.92% covered (success)
92.92%
105 / 113
60.00% covered (warning)
60.00%
6 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_REST_API_V2_Endpoint_AI_Feature_Settings
96.33% covered (success)
96.33%
105 / 109
60.00% covered (warning)
60.00%
6 / 10
34
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 register_routes
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
1
 permissions_check
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 get_settings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 update_settings
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
8
 build_settings_response
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
12
 is_ai_seo_available
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 is_feature_clip_available
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 is_connected
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 is_user_connected
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * REST API endpoint for the Jetpack AI feature settings page.
4 *
5 * GET  — returns the AI gate state (host support, connection, plan) together
6 *        with the master switch and per-feature toggle values, in one round
7 *        trip, so the settings page can render every state without extra
8 *        requests.
9 * POST — accepts a partial update ({ master_enabled, features }) and writes
10 *        the site-local options backing the toggles. Returns the fresh GET
11 *        shape.
12 *
13 * Unlike the MCP settings endpoint, nothing here proxies to WPCOM: the
14 * settings are site-local wp_options, so the endpoint works the same on
15 * every host. On WordPress.com Simple it is the only writable surface for
16 * these options — core settings REST refuses them there.
17 *
18 * @package automattic/jetpack
19 */
20
21use Automattic\Jetpack\Connection\Manager;
22use Automattic\Jetpack\Current_Plan;
23use Automattic\Jetpack\Search\Plan as Search_Plan;
24use Automattic\Jetpack\SEO\Ai_Seo;
25use Automattic\Jetpack\Status;
26use Automattic\Jetpack\Status\Host;
27
28if ( ! defined( 'ABSPATH' ) ) {
29    exit( 0 );
30}
31
32// On WordPress.com the endpoint files load from the synced jetpack-endpoints
33// directory, outside the plugin tree, so pull the settings class in via the
34// plugin dir constant (same pattern as the jetpack-ai endpoint's AI helper).
35require_once JETPACK__PLUGIN_DIR . '_inc/lib/class-jetpack-ai-settings.php';
36
37/**
38 * Class WPCOM_REST_API_V2_Endpoint_AI_Feature_Settings
39 */
40class WPCOM_REST_API_V2_Endpoint_AI_Feature_Settings extends WP_REST_Controller {
41    /**
42     * Namespace prefix.
43     *
44     * @var string
45     */
46    public $namespace = 'wpcom/v2';
47
48    /**
49     * Endpoint base route.
50     *
51     * @var string
52     */
53    public $rest_base = 'jetpack-ai/feature-settings';
54
55    /**
56     * Constructor.
57     */
58    public function __construct() {
59        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
60    }
61
62    /**
63     * Register routes.
64     *
65     * Registered on every host, WordPress.com Simple included: the Hub's
66     * WordPress Agent view reads and writes through this route wherever the
67     * page loads (see the file header for Simple).
68     */
69    public function register_routes() {
70        register_rest_route(
71            $this->namespace,
72            '/' . $this->rest_base,
73            array(
74                array(
75                    'methods'             => WP_REST_Server::READABLE,
76                    'callback'            => array( $this, 'get_settings' ),
77                    'permission_callback' => array( $this, 'permissions_check' ),
78                ),
79                array(
80                    'methods'             => WP_REST_Server::EDITABLE,
81                    'callback'            => array( $this, 'update_settings' ),
82                    'permission_callback' => array( $this, 'permissions_check' ),
83                    'args'                => array(
84                        'master_enabled' => array(
85                            'type'     => 'boolean',
86                            'required' => false,
87                        ),
88                        'features'       => array(
89                            'type'     => 'object',
90                            'required' => false,
91                        ),
92                    ),
93                ),
94            )
95        );
96    }
97
98    /**
99     * Check permissions.
100     *
101     * @return bool|WP_Error
102     */
103    public function permissions_check() {
104        if ( ! current_user_can( 'manage_options' ) ) {
105            return new WP_Error(
106                'rest_forbidden',
107                __( 'You do not have permission to manage Jetpack AI settings.', 'jetpack' ),
108                array( 'status' => rest_authorization_required_code() )
109            );
110        }
111
112        return true;
113    }
114
115    /**
116     * GET handler.
117     *
118     * @return WP_REST_Response
119     */
120    public function get_settings() {
121        return rest_ensure_response( $this->build_settings_response() );
122    }
123
124    /**
125     * POST handler. Accepts a partial payload and writes only the keys present.
126     *
127     * @param WP_REST_Request $request The request.
128     * @return WP_REST_Response|WP_Error
129     */
130    public function update_settings( $request ) {
131        // The host gate is a server-owner decision: while it is off there is
132        // nothing to configure, so refuse writes outright.
133        if ( ! Jetpack_AI_Settings::host_allows_ai() ) {
134            return new WP_Error(
135                'ai_disabled_by_host',
136                __( 'AI has been turned off for this site.', 'jetpack' ),
137                array( 'status' => 403 )
138            );
139        }
140
141        if ( $request->has_param( 'master_enabled' ) ) {
142            // Routes through the setter so the write lands on whichever store backs
143            // the master on this platform: the option on Simple, the `ai` module
144            // off-Simple.
145            Jetpack_AI_Settings::set_master_enabled( (bool) $request->get_param( 'master_enabled' ) );
146        }
147
148        $features = $request->get_param( 'features' );
149        if ( is_array( $features ) ) {
150            foreach ( Jetpack_AI_Settings::FEATURE_OPTIONS as $key => $option ) {
151                if ( ! array_key_exists( $key, $features ) ) {
152                    continue;
153                }
154
155                // A feature value may be a bare boolean or an object carrying an enabled key.
156                $value = $features[ $key ];
157                if ( is_array( $value ) ) {
158                    if ( ! array_key_exists( 'enabled', $value ) ) {
159                        continue;
160                    }
161                    $value = $value['enabled'];
162                }
163
164                update_option( $option, rest_sanitize_boolean( $value ) );
165            }
166        }
167
168        return rest_ensure_response( $this->build_settings_response() );
169    }
170
171    /**
172     * Assemble the full settings + gate-state payload.
173     *
174     * @return array
175     */
176    private function build_settings_response() {
177        if ( ( new Host() )->is_wpcom_simple() && function_exists( 'wpcom_site_has_feature' ) ) {
178            // Search\Plan's cache never populates on Simple (its refresh signs a
179            // blog-token request), so read the WordPress.com feature gates instead.
180            // AI Answers needs Instant Search: classic-search-only plans
181            // (Business and higher) still need the Search purchase.
182            $has_instant = (bool) wpcom_site_has_feature( 'instant-search' );
183            // The free Search tier grants instant-search but cannot run AI
184            // Answers; free and paid Search do not coexist on Simple.
185            $is_free_search_plan = function_exists( 'wpcom_get_site_purchases' )
186                && (bool) wp_list_filter( wpcom_get_site_purchases(), array( 'product_slug' => 'jetpack_search_free' ) );
187
188            $supports_search            = $has_instant && ! $is_free_search_plan;
189            $ai_search_requires_upgrade = ! $supports_search;
190        } else {
191            $search_plan = class_exists( Search_Plan::class ) ? new Search_Plan() : null;
192
193            // Entitlement: the plan includes some Search product (Classic or Instant).
194            $supports_search = $search_plan && $search_plan->supports_search();
195
196            // AI Answers only runs with the paid Search product provisioned. Mirror
197            // the gate the Search dashboard's AI Answers tab uses for its upsell:
198            // gated when the plan is free or lacks Instant Search.
199            $ai_search_requires_upgrade = ! ( $search_plan && $search_plan->supports_instant_search() && ! $search_plan->is_free_plan() );
200
201            // The free Search tier reports supports_search too, but its remedy for
202            // the gated AI Search row is still an upgrade — the settings page needs
203            // this flag to pick the right badge copy.
204            $is_free_search_plan = $supports_search && $search_plan->is_free_plan();
205        }
206
207        $stored = array();
208        foreach ( Jetpack_AI_Settings::FEATURE_OPTIONS as $key => $option ) {
209            $stored[ $key ] = (bool) get_option(
210                $option,
211                Jetpack_AI_Settings::FEATURE_DEFAULTS[ $key ]
212            );
213        }
214
215        return array(
216            'host_allows_ai'    => Jetpack_AI_Settings::host_allows_ai(),
217            'is_connected'      => $this->is_connected(),
218            'is_user_connected' => $this->is_user_connected(),
219            'plan'              => array(
220                'supports_ai'         => class_exists( Current_Plan::class ) && Current_Plan::supports( 'ai-assistant' ),
221                'supports_search'     => $supports_search,
222                'is_free_search_plan' => $is_free_search_plan,
223            ),
224            'master_enabled'    => Jetpack_AI_Settings::is_master_enabled(),
225            'features'          => array(
226                'writing_assistant' => array( 'enabled' => $stored['writing_assistant'] ),
227                'image_editor'      => array( 'enabled' => $stored['image_editor'] ),
228                'feature_clip'      => array(
229                    'enabled'   => $stored['feature_clip'],
230                    'available' => $this->is_feature_clip_available(),
231                ),
232                'ai_seo'            => array(
233                    'enabled'   => $stored['ai_seo'],
234                    'available' => $this->is_ai_seo_available(),
235                ),
236                'ai_search'         => array(
237                    'enabled'          => $stored['ai_search'],
238                    'requires_upgrade' => $ai_search_requires_upgrade,
239                ),
240            ),
241        );
242    }
243
244    /**
245     * Whether the AI SEO row is available, so the settings page can hide it.
246     * The row governs user-initiated suggestions as well as automatic
247     * generation, so it follows the package's shared AI SEO gate.
248     *
249     * Guarded with class_exists: the autoloader can pick an older jetpack-seo
250     * copy from another plugin, predating this class. Without the gate's verdict
251     * the row is hidden rather than offered.
252     *
253     * @return bool
254     */
255    private function is_ai_seo_available() {
256        if ( ! class_exists( Ai_Seo::class ) ) {
257            return false;
258        }
259
260        return Ai_Seo::is_available();
261    }
262
263    /**
264     * Whether Feature Clip can operate on this site, so the settings page can
265     * grey out its nested row where the feature can't run.
266     *
267     * Feature Clip is nested under the image editor: it reports available only
268     * when Image Studio is enabled — the shared environment (host and master
269     * gates plus platform checks) AND the `image_editor` toggle. With the image
270     * editor off the clip row greys out rather than hides, so the settings page
271     * keys that greyed state off this field.
272     *
273     * The extension file that defines the predicate isn't loaded in every
274     * context this endpoint is (on WordPress.com the endpoint loads from the
275     * synced jetpack-endpoints directory), so a partial load defaults to
276     * available rather than greying a row that works.
277     *
278     * @return bool
279     */
280    private function is_feature_clip_available() {
281        if ( ! function_exists( '\Automattic\Jetpack\Extensions\ImageStudio\is_image_studio_enabled' ) ) {
282            return true;
283        }
284
285        return (bool) \Automattic\Jetpack\Extensions\ImageStudio\is_image_studio_enabled();
286    }
287
288    /**
289     * Whether the site can know its plan: Simple sites always can; elsewhere a
290     * connected owner outside offline mode is required. Mirrors the connection
291     * predicate the AI feature load points use — offline mode included, since a
292     * site can hold connection tokens while offline mode keeps every AI surface
293     * from loading.
294     *
295     * @return bool
296     */
297    private function is_connected() {
298        return ( new Host() )->is_wpcom_simple()
299            || ( ( new Manager( 'jetpack' ) )->has_connected_owner()
300                && ! ( new Status() )->is_offline_mode() );
301    }
302
303    /**
304     * Whether the current user's own account is connected. is_connected() above
305     * is the site-level gate the feature load points share, but the editor chat
306     * keys its variant off the requesting user: the agents-manager loader
307     * downgrades to its disconnected variant when the current user holds no
308     * token, so the settings page needs this bit to tell an admin whose account
309     * is not connected that the chat will not run for them. Simple
310     * short-circuits true, matching is_connected().
311     *
312     * @return bool
313     */
314    private function is_user_connected() {
315        return ( new Host() )->is_wpcom_simple()
316            || ( new Manager( 'jetpack' ) )->is_user_connected();
317    }
318}
319
320wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_AI_Feature_Settings' );