Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.74% covered (warning)
87.74%
186 / 212
63.16% covered (warning)
63.16%
12 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Reader_Chat
88.57% covered (warning)
88.57%
186 / 210
63.16% covered (warning)
63.16%
12 / 19
108.47
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 register_settings
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 sync_query_filter_option
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 add_sync_options_whitelist
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 enqueue_scripts
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
9
 get_load_strategy
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 add_resource_hints
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_deferred_loader_script
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 is_site_coming_soon_or_unlaunched
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 render_mount_div
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 get_reader_chat_config
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 get_current_post_context
87.50% covered (warning)
87.50%
21 / 24
0.00% covered (danger)
0.00%
0 / 1
8.12
 get_asset_version
83.33% covered (warning)
83.33%
20 / 24
0.00% covered (danger)
0.00%
0 / 1
9.37
 read_local_asset_json
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 fetch_remote_asset_json
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 decode_asset_json
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 has_ai_features
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 has_search_plan_access
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
14.09
 is_dev_mode
50.00% covered (danger)
50.00%
10 / 20
0.00% covered (danger)
0.00%
0 / 1
64.12
1<?php
2/**
3 * Jetpack Reader Chat — Agents Manager CDN loader for blog readers.
4 *
5 * Loads a self-contained reader-chat bundle from the widgets.wp.com CDN
6 * and renders a floating chat UI on singular posts for logged-out visitors.
7 *
8 * The reader-chat bundle inlines all WP dependencies (built without
9 * DependencyExtractionWebpackPlugin) so it works on the frontend
10 * without WordPress's script loader.
11 *
12 * Enable via filter:
13 *   add_filter( 'jetpack_reader_chat_enabled', '__return_true' );
14 *
15 * The bundle is deferred until the visitor interacts with the page; see
16 * the `jetpack_reader_chat_load_strategy` filter.
17 *
18 * @package automattic/jetpack
19 */
20
21namespace Automattic\Jetpack\Extensions\AiAssistantPlugin;
22
23use Automattic\Jetpack\Connection\Manager as Connection_Manager;
24use Automattic\Jetpack\Search\Helper as Search_Helper;
25use Automattic\Jetpack\Search\Plan;
26use Automattic\Jetpack\Status;
27use Automattic\Jetpack\Status\Host;
28use Jetpack_Options;
29
30if ( ! defined( 'ABSPATH' ) ) {
31    exit( 0 );
32}
33
34const READER_CHAT_JS_URL                  = 'https://widgets.wp.com/agents-manager/reader-chat.min.js';
35const READER_CHAT_CSS_URL                 = 'https://widgets.wp.com/agents-manager/reader-chat.css';
36const READER_CHAT_ASSET_TRANSIENT         = 'jetpack_reader_chat_asset';
37const READER_CHAT_ASSET_FAILURE_CACHE_TTL = 5 * MINUTE_IN_SECONDS;
38const READER_CHAT_QUERY_FILTER_OPTION     = 'reader_chat_query_filter';
39const READER_CHAT_QUERY_FILTER_MAX_BYTES  = 20000;
40
41/**
42 * Handles loading the reader chat UI on the frontend.
43 */
44class Jetpack_Reader_Chat {
45
46    /**
47     * Initialize hooks.
48     *
49     * @return void
50     */
51    public static function init(): void {
52        // Register the setting unconditionally so the Search REST endpoint can
53        // flip it even when the feature is currently disabled.
54        add_action( 'init', array( __CLASS__, 'register_settings' ) );
55        add_filter( 'jetpack_sync_options_whitelist', array( __CLASS__, 'add_sync_options_whitelist' ) );
56
57        /**
58         * Filter to enable or disable the Jetpack Reader Chat feature.
59         *
60         * Defaults to the value of the reader_chat site option (false when
61         * unset). Override programmatically with:
62         *   add_filter( 'jetpack_reader_chat_enabled', '__return_true' );
63         *
64         * @since 15.9
65         *
66         * @param bool $enabled Whether the reader chat is enabled.
67         */
68        if ( ! apply_filters( 'jetpack_reader_chat_enabled', (bool) get_option( 'reader_chat', false ) ) ) {
69            return;
70        }
71
72        /**
73         * Filter whether Reader Chat should hook its public frontend loader.
74         *
75         * @since 15.9
76         *
77         * @param bool $enabled Whether the reader chat frontend loader should be hooked.
78         */
79        if ( ! apply_filters( 'jetpack_reader_chat_enqueue_enabled', true ) ) {
80            return;
81        }
82
83        add_action( 'admin_init', array( __CLASS__, 'sync_query_filter_option' ) );
84        add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_scripts' ) );
85        add_action( 'wp_footer', array( __CLASS__, 'render_mount_div' ) );
86    }
87
88    /**
89     * Register the reader_chat option so Search settings can read and write it.
90     *
91     * @since 15.9
92     *
93     * @return void
94     */
95    public static function register_settings(): void {
96        register_setting(
97            'jetpack_search',
98            'reader_chat',
99            array(
100                'type'              => 'boolean',
101                'description'       => __( 'Whether Site Chat is enabled on this site.', 'jetpack' ),
102                'sanitize_callback' => 'rest_sanitize_boolean',
103                'default'           => false,
104            )
105        );
106    }
107
108    /**
109     * Evaluate the Site Chat content filter and store it where wpcom can read it.
110     *
111     * The filter is PHP on this site, so wpcom, which answers the chat requests,
112     * can only enforce it through the synced `reader_chat_query_filter` option.
113     *
114     * @since $$next-version$$
115     *
116     * @return void
117     */
118    public static function sync_query_filter_option(): void {
119        /**
120         * Filter the Elasticsearch filter clause that limits which content Site Chat can use.
121         *
122         * Same shape as the `adminQueryFilter` Instant Search option, but applied to
123         * Site Chat only. Return an empty array to apply no restriction. For example,
124         * to hide posts older than a date:
125         *
126         *   array( 'range' => array( 'date' => array( 'gte' => '2024-01-01' ) ) )
127         *
128         * @since $$next-version$$
129         *
130         * @param array $query_filter Elasticsearch filter clause. Default empty.
131         */
132        $query_filter = apply_filters( 'jetpack_site_chat_query_filter', array() );
133
134        // An empty array, not a deleted option, so the autoloaded value never misses the cache.
135        if ( empty( $query_filter ) ) {
136            $query_filter = array();
137        }
138
139        $encoded = is_array( $query_filter ) ? wp_json_encode( $query_filter, JSON_UNESCAPED_SLASHES ) : false;
140        if ( false === $encoded || strlen( $encoded ) > READER_CHAT_QUERY_FILTER_MAX_BYTES ) {
141            // Keep the last good filter: clearing it would expose the content the owner hid.
142            $message = __( 'The Site Chat query filter must be an array under 20 KB. The previous filter stays in effect.', 'jetpack' );
143            _doing_it_wrong( 'jetpack_site_chat_query_filter', esc_html( $message ), '$$next-version$$' );
144            return;
145        }
146
147        update_option( READER_CHAT_QUERY_FILTER_OPTION, $query_filter, true );
148    }
149
150    /**
151     * Add Reader Chat's setting to Jetpack Sync's option whitelist.
152     *
153     * Atomic and Jurassic Ninja sites write `reader_chat` locally via
154     * Search settings, while the wpcom-hosted agent reads the wpcom-side option
155     * before serving public chat requests. Syncing the option keeps
156     * the local toggle and agent permission gate aligned.
157     *
158     * @since 15.9
159     *
160     * @param array $options Option names allowed to sync.
161     * @return array Updated option names.
162     */
163    public static function add_sync_options_whitelist( array $options ): array {
164        $options[] = 'reader_chat';
165        return array_values( array_unique( $options ) );
166    }
167
168    /**
169     * Enqueue the reader chat script on the frontend.
170     *
171     * Loads on every public-facing page (home, archives, pages, singular
172     * posts). Skips admin, feeds, and AJAX to keep the bundle off contexts
173     * where the chat UI doesn't belong. currentPost in the config is only
174     * populated on singular views — stream views get general suggestions.
175     *
176     * @return void
177     */
178    public static function enqueue_scripts(): void {
179        if ( is_admin() || is_feed() || wp_doing_ajax() ) {
180            return;
181        }
182
183        if ( self::is_site_coming_soon_or_unlaunched() ) {
184            return;
185        }
186
187        /**
188         * Filter to override the AI features check.
189         *
190         * Set to true to load reader chat regardless of Jetpack connection
191         * status, or false to force-disable. Defaults to null, meaning use
192         * the built-in check. Useful for testing on dev sites.
193         *
194         * @param bool|null $override null = use default check, true/false = override.
195         */
196        $has_features = apply_filters( 'jetpack_reader_chat_has_ai_features', null );
197        if ( ! ( $has_features ?? self::has_ai_features() ) ) {
198            return;
199        }
200
201        if ( ! self::has_search_plan_access() ) {
202            return;
203        }
204
205        $version  = self::get_asset_version();
206        $strategy = self::get_load_strategy();
207        $eager    = 'eager' === $strategy;
208
209        // The reader-chat bundle is self-contained — no WP script dependencies. A src-less
210        // handle prints only its inline scripts, so deferred modes leave the fetch to the loader.
211        wp_register_script( 'jetpack-reader-chat', $eager ? READER_CHAT_JS_URL : false, array(), $version, true );
212        wp_enqueue_script( 'jetpack-reader-chat' );
213
214        if ( $eager ) {
215            wp_enqueue_style( 'jetpack-reader-chat', READER_CHAT_CSS_URL, array(), $version );
216        } else {
217            wp_add_inline_script(
218                'jetpack-reader-chat',
219                self::get_deferred_loader_script( 'idle' === $strategy, $version ),
220                'after'
221            );
222            add_filter( 'wp_resource_hints', array( __CLASS__, 'add_resource_hints' ), 10, 2 );
223        }
224
225        // Inject config for the JS bundle (before the script tag).
226        wp_add_inline_script(
227            'jetpack-reader-chat',
228            'window.JetpackReaderChatConfig = ' . wp_json_encode(
229                self::get_reader_chat_config(),
230                JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
231            ) . ';',
232            'before'
233        );
234    }
235
236    /**
237     * Get when the frontend should download and run the reader chat bundle.
238     *
239     * @return string 'interaction', 'idle', or 'eager'.
240     */
241    private static function get_load_strategy(): string {
242        /**
243         * Filter when the Site Chat bundle loads on the frontend.
244         *
245         * - 'interaction' (default): on the visitor's first scroll, pointer, touch, key, or focus event.
246         * - 'idle': once the page has finished loading, or on first interaction if that comes sooner.
247         * - 'eager': with the page, as a regular script and stylesheet.
248         *
249         * @since $$next-version$$
250         *
251         * @param string $strategy The load strategy.
252         */
253        $strategy = apply_filters( 'jetpack_reader_chat_load_strategy', 'interaction' );
254
255        return in_array( $strategy, array( 'idle', 'eager' ), true ) ? $strategy : 'interaction';
256    }
257
258    /**
259     * Restore the DNS prefetch WordPress adds for enqueued CDN assets, which a src-less handle loses.
260     *
261     * @param array  $urls          URLs to print for the relation type.
262     * @param string $relation_type The relation type the URLs are printed for.
263     * @return array Updated URLs.
264     */
265    public static function add_resource_hints( $urls, $relation_type ) {
266        if ( 'dns-prefetch' === $relation_type ) {
267            $urls[] = '//widgets.wp.com';
268        }
269        return $urls;
270    }
271
272    /**
273     * Build the inline script that injects the reader chat stylesheet and bundle on demand.
274     *
275     * @param bool             $idle    Whether to also load once the page is idle.
276     * @param string|null|bool $version Asset version, or null to omit the query param.
277     * @return string Inline JavaScript.
278     */
279    private static function get_deferred_loader_script( bool $idle, $version ): string {
280        $ver      = $version ? rawurlencode( $version ) : null;
281        $settings = array(
282            'js'   => add_query_arg( 'ver', $ver, READER_CHAT_JS_URL ),
283            'css'  => add_query_arg( 'ver', $ver, READER_CHAT_CSS_URL ),
284            'idle' => $idle,
285        );
286
287        $loader = <<<'JS'
288( function ( s ) {
289    var events = [ 'pointerdown', 'pointermove', 'keydown', 'touchstart', 'scroll', 'wheel', 'focusin' ];
290    var started = false;
291    function load() {
292        if ( started ) {
293            return;
294        }
295        started = true;
296        events.forEach( function ( e ) {
297            window.removeEventListener( e, load, true );
298        } );
299        // Fetch the bundle alongside the styles, then run it once the styles are in.
300        var preload = document.createElement( 'link' );
301        preload.rel = 'preload';
302        preload.as = 'script';
303        preload.href = s.js;
304        document.head.appendChild( preload );
305        var link = document.createElement( 'link' );
306        link.rel = 'stylesheet';
307        link.href = s.css;
308        link.onload = link.onerror = function () {
309            var script = document.createElement( 'script' );
310            script.src = s.js;
311            document.body.appendChild( script );
312        };
313        document.head.appendChild( link );
314    }
315    // The bundle sets this key while the chat is open, so keep an open chat open across page views.
316    try {
317        var agentId = ( window.JetpackReaderChatConfig || {} ).agentId;
318        if ( '1' === window.sessionStorage.getItem( 'jetpack-reader-chat-open-' + agentId ) ) {
319            return load();
320        }
321    } catch ( e ) {}
322    events.forEach( function ( e ) {
323        window.addEventListener( e, load, { capture: true, passive: true } );
324    } );
325    if ( s.idle ) {
326        var whenIdle = function () {
327            if ( window.requestIdleCallback ) {
328                window.requestIdleCallback( load, { timeout: 2000 } );
329            } else {
330                setTimeout( load, 1 );
331            }
332        };
333        if ( 'complete' === document.readyState ) {
334            whenIdle();
335        } else {
336            window.addEventListener( 'load', whenIdle );
337        }
338    }
339} )(
340JS;
341
342        return $loader . wp_json_encode( $settings, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ' );';
343    }
344
345    /**
346     * Check whether the current site is Coming Soon or unlaunched.
347     *
348     * Reader Chat is a public frontend widget, so it should not mount on sites
349     * that are still hidden behind launch or Coming Soon visibility.
350     *
351     * @return bool Whether the site is hidden by launch or Coming Soon visibility.
352     */
353    private static function is_site_coming_soon_or_unlaunched(): bool {
354        $status = new Status();
355        if ( $status->is_coming_soon() ) {
356            return true;
357        }
358
359        return 'unlaunched' === get_option( 'launch-status' );
360    }
361
362    /**
363     * Render the mount div in the footer.
364     *
365     * Only outputs the div when the script was successfully enqueued.
366     *
367     * @return void
368     */
369    public static function render_mount_div(): void {
370        if ( ! wp_script_is( 'jetpack-reader-chat' ) ) {
371            return;
372        }
373
374        echo '<div id="jetpack-reader-chat"></div>';
375    }
376
377    /**
378     * Build the config object for the reader chat JS bundle.
379     *
380     * @return array The config array for JSON encoding.
381     */
382    private static function get_reader_chat_config(): array {
383        $host = new Host();
384        if ( $host->is_wpcom_simple() ) {
385            $site_id = get_current_blog_id();
386        } else {
387            $site_id = (int) Jetpack_Options::get_option( 'id' );
388        }
389
390        $config = array(
391            'siteId'    => $site_id,
392            'siteUrl'   => home_url(),
393            'siteName'  => get_bloginfo( 'name' ),
394            'isDevMode' => self::is_dev_mode(),
395            'agentId'   => 'reader-chat',
396        );
397
398        $current_post = self::get_current_post_context();
399        if ( null !== $current_post ) {
400            $config['currentPost'] = $current_post;
401        }
402
403        return $config;
404    }
405
406    /**
407     * Build the current post context for the reader chat config.
408     *
409     * Returns null on non-singular views or when no post is available.
410     *
411     * @return array|null Post context, or null when not on a singular view.
412     */
413    private static function get_current_post_context(): ?array {
414        if ( ! is_singular() ) {
415            return null;
416        }
417
418        $post = get_post();
419        if ( ! $post ) {
420            return null;
421        }
422
423        // Only expose current post context for content that is publicly
424        // viewable. Draft/private/future/trash posts can be visible to
425        // editors through previews, but reader chat is public-facing and
426        // should not receive non-public post content in its inline config.
427        if ( is_preview() || ! is_post_publicly_viewable( $post ) ) {
428            return null;
429        }
430
431        // Respect password-protected posts: do not leak body content to
432        // visitors who have not entered the password. Omit the whole
433        // currentPost envelope so the chat doesn't imply it "knows" the
434        // post's content either.
435        if ( post_password_required( $post ) ) {
436            return null;
437        }
438
439        $context = array(
440            'id'      => $post->ID,
441            'title'   => get_the_title( $post ),
442            'url'     => get_permalink( $post ),
443            'excerpt' => wp_trim_words( wp_strip_all_tags( $post->post_content ), 120 ),
444            'author'  => get_the_author_meta( 'display_name', (int) $post->post_author ),
445            'date'    => get_the_date( 'F j, Y', $post ),
446        );
447
448        $categories = get_the_category( $post->ID );
449        if ( $categories ) {
450            $context['categories'] = wp_list_pluck( $categories, 'name' );
451        }
452
453        $tags = get_the_tags( $post->ID );
454        if ( $tags ) {
455            $context['tags'] = wp_list_pluck( $tags, 'name' );
456        }
457
458        return $context;
459    }
460
461    /**
462     * Get the version string for the CDN bundle.
463     *
464     * Attempts to read the version from the remote asset manifest.
465     * Falls back to a timestamp in dev mode, or null in production.
466     *
467     * @return string|false|null The version string, or null to omit the query param.
468     */
469    private static function get_asset_version() {
470        $skip_cache = defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG;
471
472        if ( ! $skip_cache ) {
473            $cached = get_transient( READER_CHAT_ASSET_TRANSIENT );
474            if ( false !== $cached ) {
475                return $cached['version'] ?? null;
476            }
477        }
478
479        $json_path = 'widgets.wp.com/agents-manager/reader-chat.asset.json';
480
481        // Try local filesystem first (available on WordPress.com).
482        $data = self::read_local_asset_json( ABSPATH . $json_path );
483
484        // Fallback to HTTP fetch.
485        if ( false === $data ) {
486            $data = self::fetch_remote_asset_json( 'https://' . $json_path );
487        }
488
489        if ( false === $data ) {
490            // Dev mode: return a cache-busting version so the sandbox bundle loads.
491            if ( self::is_dev_mode() ) {
492                return 'dev-' . time();
493            }
494            if ( ! $skip_cache ) {
495                set_transient(
496                    READER_CHAT_ASSET_TRANSIENT,
497                    array(
498                        'version' => null,
499                    ),
500                    READER_CHAT_ASSET_FAILURE_CACHE_TTL
501                );
502            }
503            return null;
504        }
505
506        if ( ! $skip_cache ) {
507            set_transient( READER_CHAT_ASSET_TRANSIENT, $data, HOUR_IN_SECONDS );
508        }
509
510        return $data['version'] ?? null;
511    }
512
513    /**
514     * Read and decode a local asset manifest JSON file.
515     *
516     * @param string $path Absolute filesystem path to the JSON file.
517     * @return array|false Decoded data or false on failure.
518     */
519    private static function read_local_asset_json( string $path ) {
520        if ( ! file_exists( $path ) ) {
521            return false;
522        }
523
524        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file, not remote URL.
525        $contents = file_get_contents( $path );
526        if ( false === $contents ) {
527            return false;
528        }
529
530        return self::decode_asset_json( $contents );
531    }
532
533    /**
534     * Fetch and decode a remote asset manifest JSON file.
535     *
536     * @param string $url URL to fetch.
537     * @return array|false Decoded data or false on failure.
538     */
539    private static function fetch_remote_asset_json( string $url ) {
540        $response = wp_safe_remote_get( $url );
541        if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
542            return false;
543        }
544
545        return self::decode_asset_json( wp_remote_retrieve_body( $response ) );
546    }
547
548    /**
549     * Decode a JSON asset manifest string and validate the result is an array.
550     *
551     * @param string $contents Raw JSON string.
552     * @return array|false Decoded array or false on decode failure / non-array.
553     */
554    private static function decode_asset_json( string $contents ) {
555        $data = json_decode( $contents, true );
556        if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $data ) ) {
557            return false;
558        }
559        return $data;
560    }
561
562    /**
563     * Check whether AI features are available for this site.
564     *
565     * @return bool
566     */
567    private static function has_ai_features(): bool {
568        $host = new Host();
569
570        if ( $host->is_wpcom_simple() ) {
571            return true;
572        }
573
574        return ( new Connection_Manager( 'jetpack' ) )->has_connected_owner()
575            && ! ( new Status() )->is_offline_mode()
576            && \Jetpack_AI_Settings::is_ai_enabled();
577    }
578
579    /**
580     * Check whether the current site can serve Reader Chat under its Search plan.
581     *
582     * Uses WordPress.com's local Search plan source on Simple sites. Elsewhere,
583     * uses the cached Jetpack Search plan option instead of forcing a remote
584     * refresh on public frontend requests.
585     *
586     * @return bool
587     */
588    private static function has_search_plan_access(): bool {
589        $plan_access = apply_filters( 'jetpack_reader_chat_has_search_plan_access', null );
590        if ( null !== $plan_access ) {
591            return (bool) $plan_access;
592        }
593
594        $host = new Host();
595        if ( $host->is_wpcom_simple() ) {
596            $blog_id = get_current_blog_id();
597            if ( $blog_id <= 0 ) {
598                return false;
599            }
600
601            if ( function_exists( 'require_lib' ) ) {
602                require_lib( 'jetpack-search' );
603            }
604
605            $wpcom_plan_info_class = '\Jetpack\Search\Plan_Info';
606            if ( class_exists( $wpcom_plan_info_class ) ) {
607                $plan_info = new $wpcom_plan_info_class( $blog_id );
608                return $plan_info->supports_search()
609                    && method_exists( $plan_info, 'is_free_search_plan' )
610                    && ! $plan_info->is_free_search_plan()
611                    && ! $plan_info->is_disabled_due_to_overage();
612            }
613        }
614
615        if ( ! class_exists( Plan::class ) ) {
616            return false;
617        }
618
619        $plan_info = get_option( Plan::JETPACK_SEARCH_PLAN_INFO_OPTION_KEY );
620        if ( ! is_array( $plan_info ) ) {
621            return false;
622        }
623
624        return ! Search_Helper::is_forced_free_plan()
625            && ! empty( $plan_info['supports_search'] )
626            && ( $plan_info['effective_subscription']['product_slug'] ?? null ) !== Plan::JETPACK_SEARCH_FREE_PRODUCT_SLUG
627            && empty( $plan_info['plan_usage']['must_upgrade'] );
628    }
629
630    /**
631     * Check if the current request is from a development environment.
632     *
633     * Matches the pattern used in Jetpack_AI_Sidebar::is_dev_mode().
634     * IMPORTANT: Only use for feature gating, not authorization.
635     *
636     * @return bool
637     */
638    private static function is_dev_mode(): bool {
639        $domain = wp_parse_url( get_site_url(), PHP_URL_HOST );
640        if ( ! is_string( $domain ) ) {
641            return false;
642        }
643
644        if (
645            'localhost' === $domain ||
646            '.jurassic.tube' === stristr( $domain, '.jurassic.tube' ) ||
647            '.jurassic.ninja' === stristr( $domain, '.jurassic.ninja' )
648        ) {
649            return true;
650        }
651
652        if ( function_exists( 'wpcom_is_proxied_request' ) && wpcom_is_proxied_request() ) {
653            return true;
654        }
655
656        if (
657            ( isset( $_SERVER['A8C_PROXIED_REQUEST'] ) && (bool) sanitize_text_field( wp_unslash( $_SERVER['A8C_PROXIED_REQUEST'] ) ) ) ||
658            ( defined( 'A8C_PROXIED_REQUEST' ) && A8C_PROXIED_REQUEST )
659        ) {
660            return true;
661        }
662
663        if ( defined( 'AT_PROXIED_REQUEST' ) && AT_PROXIED_REQUEST && defined( 'ATOMIC_CLIENT_ID' ) ) {
664            switch ( ATOMIC_CLIENT_ID ) {
665                case 1:
666                case 2:
667                case 3: // Pressable
668                case 32:
669                case 118: // Commerce garden client (ciab)
670                    return true;
671            }
672        }
673
674        return false;
675    }
676}