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