Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.40% covered (success)
93.40%
99 / 106
70.00% covered (warning)
70.00%
7 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_REST_API_V2_Endpoint_AI_Feature_Settings
97.06% covered (success)
97.06%
99 / 102
70.00% covered (warning)
70.00%
7 / 10
30
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
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%
36 / 36
100.00% covered (success)
100.00%
1 / 1
8
 is_seo_enhancer_available
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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_Enhancer;
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                __( 'AI has been turned off for this site.', 'jetpack' ),
144                array( 'status' => 403 )
145            );
146        }
147
148        if ( $request->has_param( 'master_enabled' ) ) {
149            // Routes through the setter so the write lands on whichever store backs
150            // the master on this platform: the option on Simple, the `ai` module
151            // off-Simple.
152            Jetpack_AI_Settings::set_master_enabled( (bool) $request->get_param( 'master_enabled' ) );
153        }
154
155        $features = $request->get_param( 'features' );
156        if ( is_array( $features ) ) {
157            foreach ( Jetpack_AI_Settings::FEATURE_OPTIONS as $key => $option ) {
158                if ( ! array_key_exists( $key, $features ) ) {
159                    continue;
160                }
161
162                // A feature value may be a bare boolean or an object carrying an enabled key.
163                $value = $features[ $key ];
164                if ( is_array( $value ) ) {
165                    if ( ! array_key_exists( 'enabled', $value ) ) {
166                        continue;
167                    }
168                    $value = $value['enabled'];
169                }
170
171                update_option( $option, rest_sanitize_boolean( $value ) );
172            }
173        }
174
175        return rest_ensure_response( $this->build_settings_response() );
176    }
177
178    /**
179     * Assemble the full settings + gate-state payload.
180     *
181     * @return array
182     */
183    private function build_settings_response() {
184        $search_plan = class_exists( Search_Plan::class ) ? new Search_Plan() : null;
185
186        // Entitlement: the plan includes some Search product (Classic or Instant).
187        $supports_search = $search_plan && $search_plan->supports_search();
188
189        // AI Answers only runs with the paid Search product provisioned. Mirror
190        // the gate the Search dashboard's AI Answers tab uses for its upsell:
191        // gated when the plan is free or lacks Instant Search.
192        $ai_search_requires_upgrade = ! ( $search_plan && $search_plan->supports_instant_search() && ! $search_plan->is_free_plan() );
193
194        $stored = array();
195        foreach ( Jetpack_AI_Settings::FEATURE_OPTIONS as $key => $option ) {
196            $stored[ $key ] = (bool) get_option(
197                $option,
198                Jetpack_AI_Settings::FEATURE_DEFAULTS[ $key ]
199            );
200        }
201
202        return array(
203            'host_allows_ai'    => Jetpack_AI_Settings::host_allows_ai(),
204            'is_connected'      => $this->is_connected(),
205            'is_user_connected' => $this->is_user_connected(),
206            'plan'              => array(
207                'supports_ai'         => class_exists( Current_Plan::class ) && Current_Plan::supports( 'ai-assistant' ),
208                'supports_search'     => $supports_search,
209                // The free Search tier reports supports_search too, but its
210                // remedy for the gated AI Search row is still an upgrade — the
211                // settings page needs this flag to pick the right badge copy.
212                'is_free_search_plan' => $supports_search && $search_plan->is_free_plan(),
213            ),
214            'master_enabled'    => Jetpack_AI_Settings::is_master_enabled(),
215            'features'          => array(
216                'writing_assistant' => array( 'enabled' => $stored['writing_assistant'] ),
217                'image_editor'      => array( 'enabled' => $stored['image_editor'] ),
218                'feature_clip'      => array(
219                    'enabled'   => $stored['feature_clip'],
220                    'available' => $this->is_feature_clip_available(),
221                ),
222                'seo_enhancer'      => array(
223                    'enabled'   => $stored['seo_enhancer'],
224                    'available' => $this->is_seo_enhancer_available(),
225                ),
226                'ai_search'         => array(
227                    'enabled'          => $stored['ai_search'],
228                    'requires_upgrade' => $ai_search_requires_upgrade,
229                ),
230            ),
231        );
232    }
233
234    /**
235     * Whether the AI SEO enhancer is available on this site, so the settings
236     * page can hide its row. Defers to the SEO package's shared gate; the
237     * terms are unchanged.
238     *
239     * @return bool
240     */
241    private function is_seo_enhancer_available() {
242        return AI_SEO_Enhancer::is_available();
243    }
244
245    /**
246     * Whether Feature Clip can operate on this site, so the settings page can
247     * grey out its nested row where the feature can't run.
248     *
249     * Feature Clip is nested under the image editor: it reports available only
250     * when Image Studio is enabled — the shared environment (host and master
251     * gates plus platform checks) AND the `image_editor` toggle. With the image
252     * editor off the clip row greys out rather than hides, so the settings page
253     * keys that greyed state off this field.
254     *
255     * The extension file that defines the predicate isn't loaded in every
256     * context this endpoint is (on WordPress.com the endpoint loads from the
257     * synced jetpack-endpoints directory), so a partial load defaults to
258     * available rather than greying a row that works.
259     *
260     * @return bool
261     */
262    private function is_feature_clip_available() {
263        if ( ! function_exists( '\Automattic\Jetpack\Extensions\ImageStudio\is_image_studio_enabled' ) ) {
264            return true;
265        }
266
267        return (bool) \Automattic\Jetpack\Extensions\ImageStudio\is_image_studio_enabled();
268    }
269
270    /**
271     * Whether the site can know its plan: Simple sites always can; elsewhere a
272     * connected owner outside offline mode is required. Mirrors the connection
273     * predicate the AI feature load points use — offline mode included, since a
274     * site can hold connection tokens while offline mode keeps every AI surface
275     * from loading.
276     *
277     * @return bool
278     */
279    private function is_connected() {
280        return ( new Host() )->is_wpcom_simple()
281            || ( ( new Manager( 'jetpack' ) )->has_connected_owner()
282                && ! ( new Status() )->is_offline_mode() );
283    }
284
285    /**
286     * Whether the current user's own account is connected. is_connected() above
287     * is the site-level gate the feature load points share, but the editor chat
288     * keys its variant off the requesting user: the agents-manager loader
289     * downgrades to its disconnected variant when the current user holds no
290     * token, so the settings page needs this bit to tell an admin whose account
291     * is not connected that the chat will not run for them. Simple
292     * short-circuits true, matching is_connected().
293     *
294     * @return bool
295     */
296    private function is_user_connected() {
297        return ( new Host() )->is_wpcom_simple()
298            || ( new Manager( 'jetpack' ) )->is_user_connected();
299    }
300}
301
302wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_AI_Feature_Settings' );