Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.52% covered (warning)
89.52%
111 / 124
54.55% covered (warning)
54.55%
6 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_REST_API_V2_Endpoint_AI_Feature_Settings
92.50% covered (success)
92.50%
111 / 120
54.55% covered (warning)
54.55%
6 / 11
37.58
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%
28 / 28
100.00% covered (success)
100.00%
1 / 1
2
 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
79.17% covered (warning)
79.17%
19 / 24
0.00% covered (danger)
0.00%
0 / 1
9.73
 extract_feature_value
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 ai_search_requires_upgrade
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
4
 build_settings_response
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
7
 can_manage_seo
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 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
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 * Toggles use site-local options; SEO settings access uses the cached WordPress.com site record.
14 * WordPress.com Simple keeps its existing settings endpoint.
15 *
16 * @package automattic/jetpack
17 */
18
19use Automattic\Jetpack\Connection\Manager;
20use Automattic\Jetpack\Current_Plan;
21use Automattic\Jetpack\Search\Plan as Search_Plan;
22use Automattic\Jetpack\SEO\Ai_Seo;
23use Automattic\Jetpack\Status\Host;
24
25if ( ! defined( 'ABSPATH' ) ) {
26    exit( 0 );
27}
28
29// On WordPress.com the endpoint files load from the synced jetpack-endpoints
30// directory, outside the plugin tree, so pull the settings class in via the
31// plugin dir constant (same pattern as the jetpack-ai endpoint's AI helper).
32require_once JETPACK__PLUGIN_DIR . '_inc/lib/class-jetpack-ai-settings.php';
33
34/**
35 * Class WPCOM_REST_API_V2_Endpoint_AI_Feature_Settings
36 */
37class WPCOM_REST_API_V2_Endpoint_AI_Feature_Settings extends WP_REST_Controller {
38    /**
39     * Namespace prefix.
40     *
41     * @var string
42     */
43    public $namespace = 'wpcom/v2';
44
45    /**
46     * Endpoint base route.
47     *
48     * @var string
49     */
50    public $rest_base = 'jetpack-ai/feature-settings';
51
52    /**
53     * Constructor.
54     */
55    public function __construct() {
56        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
57    }
58
59    /**
60     * Register routes.
61     *
62     * Not on WordPress.com Simple: the per-feature toggles and their write
63     * endpoint apply to Atomic and self-hosted sites only, while Simple keeps
64     * the existing wp.com settings contract.
65     */
66    public function register_routes() {
67        if ( ( new Host() )->is_wpcom_simple() ) {
68            return;
69        }
70
71        register_rest_route(
72            $this->namespace,
73            '/' . $this->rest_base,
74            array(
75                array(
76                    'methods'             => WP_REST_Server::READABLE,
77                    'callback'            => array( $this, 'get_settings' ),
78                    'permission_callback' => array( $this, 'permissions_check' ),
79                ),
80                array(
81                    'methods'             => WP_REST_Server::EDITABLE,
82                    'callback'            => array( $this, 'update_settings' ),
83                    'permission_callback' => array( $this, 'permissions_check' ),
84                    'args'                => array(
85                        'master_enabled' => array(
86                            'type'     => 'boolean',
87                            'required' => false,
88                        ),
89                        'features'       => array(
90                            'type'     => 'object',
91                            'required' => false,
92                        ),
93                    ),
94                ),
95            )
96        );
97    }
98
99    /**
100     * Check permissions.
101     *
102     * @return bool|WP_Error
103     */
104    public function permissions_check() {
105        if ( ! current_user_can( 'manage_options' ) ) {
106            return new WP_Error(
107                'rest_forbidden',
108                __( 'You do not have permission to manage Jetpack AI settings.', 'jetpack' ),
109                array( 'status' => rest_authorization_required_code() )
110            );
111        }
112
113        return true;
114    }
115
116    /**
117     * GET handler.
118     *
119     * @return WP_REST_Response
120     */
121    public function get_settings() {
122        return rest_ensure_response( $this->build_settings_response() );
123    }
124
125    /**
126     * POST handler. Accepts a partial payload and writes only the keys present.
127     *
128     * @param WP_REST_Request $request The request.
129     * @return WP_REST_Response|WP_Error
130     */
131    public function update_settings( $request ) {
132        // The host gate is a server-owner decision: while it is off there is
133        // nothing to configure, so refuse writes outright.
134        if ( ! Jetpack_AI_Settings::host_allows_ai() ) {
135            return new WP_Error(
136                'ai_disabled_by_host',
137                __( 'Jetpack AI is not available for this site.', 'jetpack' ),
138                array( 'status' => 403 )
139            );
140        }
141
142        $features = $request->get_param( 'features' );
143
144        // AI Answers requires a paid Search plan. Checked up front, before any
145        // option changes, so a payload combining `ai_search` with other
146        // features doesn't partially apply.
147        if ( is_array( $features ) ) {
148            $ai_search_value = self::extract_feature_value( $features, 'ai_search' );
149            if ( $ai_search_value && $this->ai_search_requires_upgrade() ) {
150                return new WP_Error(
151                    'ai_search_requires_upgrade',
152                    __( 'AI-generated search answers require a paid Jetpack Search plan.', 'jetpack' ),
153                    array( 'status' => 403 )
154                );
155            }
156        }
157
158        if ( $request->has_param( 'master_enabled' ) ) {
159            // Routes through the setter so the write lands on whichever store backs
160            // the master on this platform: the option on Simple, the `ai` module
161            // off-Simple.
162            Jetpack_AI_Settings::set_master_enabled( (bool) $request->get_param( 'master_enabled' ) );
163        }
164
165        if ( is_array( $features ) ) {
166            foreach ( Jetpack_AI_Settings::FEATURE_OPTIONS as $key => $option ) {
167                $value = self::extract_feature_value( $features, $key );
168                if ( null === $value ) {
169                    continue;
170                }
171
172                update_option( $option, $value );
173            }
174        }
175
176        return rest_ensure_response( $this->build_settings_response() );
177    }
178
179    /**
180     * Pull one feature's value out of the `features` request param, sanitized
181     * to a bool. A feature value may be a bare boolean or an object carrying
182     * an `enabled` key. Returns null only when the key (or `enabled` sub-key)
183     * is absent — a present-but-null value still sanitizes to false, it
184     * isn't treated as absent.
185     *
186     * @param array  $features The `features` request param.
187     * @param string $key      Feature key.
188     * @return bool|null Sanitized value, or null if absent.
189     */
190    private static function extract_feature_value( array $features, string $key ) {
191        if ( ! array_key_exists( $key, $features ) ) {
192            return null;
193        }
194
195        $value = $features[ $key ];
196        if ( is_array( $value ) ) {
197            if ( ! array_key_exists( 'enabled', $value ) ) {
198                return null;
199            }
200            $value = $value['enabled'];
201        }
202
203        return rest_sanitize_boolean( $value );
204    }
205
206    /**
207     * Whether enabling AI-generated search answers requires a plan upgrade.
208     * Computed fresh from `Search_Plan`, deliberately not via the shared,
209     * memoized `Search_Blocks::supports_paid_search()` — this endpoint's own
210     * tests change plan fixtures across dispatches within one PHPUnit
211     * process, and that memo doesn't reset, which breaks them.
212     *
213     * @param Search_Plan|null $search_plan Plan instance to reuse, or null to create one.
214     * @return bool
215     */
216    private function ai_search_requires_upgrade( ?Search_Plan $search_plan = null ) {
217        $search_plan ??= ( class_exists( Search_Plan::class ) ? new Search_Plan() : null );
218        return ! ( $search_plan && $search_plan->supports_instant_search() && ! $search_plan->is_free_plan() );
219    }
220
221    /**
222     * Assemble the full settings + gate-state payload.
223     *
224     * @return array
225     */
226    private function build_settings_response() {
227        $search_plan  = class_exists( Search_Plan::class ) ? new Search_Plan() : null;
228        $is_connected = Jetpack_AI_Settings::site_is_connected();
229
230        // Entitlement: the plan includes some Search product (Classic or Instant).
231        $supports_search = $search_plan && $search_plan->supports_search();
232
233        // AI Answers only runs with the paid Search product provisioned. Mirror
234        // the gate the Search dashboard's AI Answers tab uses for its upsell:
235        // gated when the plan is free or lacks Instant Search.
236        $ai_search_requires_upgrade = $this->ai_search_requires_upgrade( $search_plan );
237
238        $stored = array();
239        foreach ( Jetpack_AI_Settings::FEATURE_OPTIONS as $key => $option ) {
240            $stored[ $key ] = (bool) get_option(
241                $option,
242                Jetpack_AI_Settings::FEATURE_DEFAULTS[ $key ]
243            );
244        }
245
246        return array(
247            'host_allows_ai'    => Jetpack_AI_Settings::host_allows_ai(),
248            'is_connected'      => $is_connected,
249            'is_user_connected' => Jetpack_AI_Settings::user_is_connected(),
250            'plan'              => array(
251                'supports_ai'         => class_exists( Current_Plan::class ) && Current_Plan::supports( 'ai-assistant' ),
252                'supports_search'     => $supports_search,
253                // The free Search tier reports supports_search too, but its
254                // remedy for the gated AI Search row is still an upgrade — the
255                // settings page needs this flag to pick the right badge copy.
256                'is_free_search_plan' => $supports_search && $search_plan->is_free_plan(),
257            ),
258            'master_enabled'    => Jetpack_AI_Settings::is_master_enabled(),
259            'features'          => array(
260                'writing_assistant' => array( 'enabled' => $stored['writing_assistant'] ),
261                'image_editor'      => array( 'enabled' => $stored['image_editor'] ),
262                'feature_clip'      => array(
263                    'enabled'   => $stored['feature_clip'],
264                    'available' => $this->is_feature_clip_available(),
265                ),
266                'ai_seo'            => array(
267                    'enabled'    => $stored['ai_seo'],
268                    'available'  => $this->is_ai_seo_available(),
269                    'can_manage' => $is_connected && $this->can_manage_seo(),
270                ),
271                'ai_search'         => array(
272                    'enabled'          => $stored['ai_search'],
273                    'requires_upgrade' => $ai_search_requires_upgrade,
274                ),
275            ),
276        );
277    }
278
279    /**
280     * Whether the site's active features include SEO settings.
281     *
282     * The is_ai_seo_available() check accepts plan defaults, which can report SEO
283     * support even when site-specific restrictions, such as VIP's, block settings.
284     *
285     * @return bool
286     */
287    private function can_manage_seo() {
288        $site_data = ( new Manager( 'jetpack' ) )->get_connected_site_data();
289        if ( is_wp_error( $site_data ) ) {
290            return false;
291        }
292
293        $active_features = $site_data->plan->features->active ?? null;
294        return is_array( $active_features ) && in_array( 'advanced-seo', $active_features, true );
295    }
296
297    /**
298     * Whether the AI SEO row is available, so the settings page can hide it. The
299     * row is offered only where a surface it governs can run: the sidebar's
300     * suggestions or the editor's generation.
301     *
302     * Guarded with is_callable: the autoloader can pick an older jetpack-seo copy
303     * from another plugin, predating this gate. Without its verdict the row is
304     * hidden rather than offered.
305     *
306     * @return bool
307     */
308    private function is_ai_seo_available() {
309        if ( ! is_callable( array( Ai_Seo::class, 'has_reachable_surface' ) ) ) {
310            return false;
311        }
312
313        return Ai_Seo::has_reachable_surface();
314    }
315
316    /**
317     * Whether Feature Clip can operate on this site, so the settings page can
318     * grey out its nested row where the feature can't run.
319     *
320     * Feature Clip is nested under the image editor: it reports available only
321     * when Image Studio is enabled — the shared environment (host and master
322     * gates plus platform checks) AND the `image_editor` toggle. With the image
323     * editor off the clip row greys out rather than hides, so the settings page
324     * keys that greyed state off this field.
325     *
326     * The extension file that defines the predicate isn't loaded in every
327     * context this endpoint is (on WordPress.com the endpoint loads from the
328     * synced jetpack-endpoints directory), so a partial load defaults to
329     * available rather than greying a row that works.
330     *
331     * @return bool
332     */
333    private function is_feature_clip_available() {
334        if ( ! function_exists( '\Automattic\Jetpack\Extensions\ImageStudio\is_image_studio_enabled' ) ) {
335            return true;
336        }
337
338        return (bool) \Automattic\Jetpack\Extensions\ImageStudio\is_image_studio_enabled();
339    }
340}
341
342wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_AI_Feature_Settings' );