Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.74% covered (success)
90.74%
98 / 108
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
94.23% covered (success)
94.23%
98 / 104
60.00% covered (warning)
60.00%
6 / 10
31.18
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
85.00% covered (warning)
85.00%
17 / 20
0.00% covered (danger)
0.00%
0 / 1
8.22
 build_settings_response
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
8
 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                __( '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                'ai_seo'            => array(
223                    'enabled'   => $stored['ai_seo'],
224                    'available' => $this->is_ai_seo_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 row is available, so the settings page can hide it.
236     * The row governs user-initiated suggestions as well as automatic
237     * generation, so it follows the package's shared AI SEO gate.
238     *
239     * Guarded with class_exists: the autoloader can pick an older jetpack-seo
240     * copy from another plugin, predating this class. Without the gate's verdict
241     * the row is hidden rather than offered.
242     *
243     * @return bool
244     */
245    private function is_ai_seo_available() {
246        if ( ! class_exists( Ai_Seo::class ) ) {
247            return false;
248        }
249
250        return Ai_Seo::is_available();
251    }
252
253    /**
254     * Whether Feature Clip can operate on this site, so the settings page can
255     * grey out its nested row where the feature can't run.
256     *
257     * Feature Clip is nested under the image editor: it reports available only
258     * when Image Studio is enabled — the shared environment (host and master
259     * gates plus platform checks) AND the `image_editor` toggle. With the image
260     * editor off the clip row greys out rather than hides, so the settings page
261     * keys that greyed state off this field.
262     *
263     * The extension file that defines the predicate isn't loaded in every
264     * context this endpoint is (on WordPress.com the endpoint loads from the
265     * synced jetpack-endpoints directory), so a partial load defaults to
266     * available rather than greying a row that works.
267     *
268     * @return bool
269     */
270    private function is_feature_clip_available() {
271        if ( ! function_exists( '\Automattic\Jetpack\Extensions\ImageStudio\is_image_studio_enabled' ) ) {
272            return true;
273        }
274
275        return (bool) \Automattic\Jetpack\Extensions\ImageStudio\is_image_studio_enabled();
276    }
277
278    /**
279     * Whether the site can know its plan: Simple sites always can; elsewhere a
280     * connected owner outside offline mode is required. Mirrors the connection
281     * predicate the AI feature load points use — offline mode included, since a
282     * site can hold connection tokens while offline mode keeps every AI surface
283     * from loading.
284     *
285     * @return bool
286     */
287    private function is_connected() {
288        return ( new Host() )->is_wpcom_simple()
289            || ( ( new Manager( 'jetpack' ) )->has_connected_owner()
290                && ! ( new Status() )->is_offline_mode() );
291    }
292
293    /**
294     * Whether the current user's own account is connected. is_connected() above
295     * is the site-level gate the feature load points share, but the editor chat
296     * keys its variant off the requesting user: the agents-manager loader
297     * downgrades to its disconnected variant when the current user holds no
298     * token, so the settings page needs this bit to tell an admin whose account
299     * is not connected that the chat will not run for them. Simple
300     * short-circuits true, matching is_connected().
301     *
302     * @return bool
303     */
304    private function is_user_connected() {
305        return ( new Host() )->is_wpcom_simple()
306            || ( new Manager( 'jetpack' ) )->is_user_connected();
307    }
308}
309
310wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_AI_Feature_Settings' );