Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.09% covered (success)
95.09%
213 / 224
72.22% covered (warning)
72.22%
13 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
Wpcom_Feature_Flags
95.09% covered (success)
95.09%
213 / 224
72.22% covered (warning)
72.22%
13 / 18
78
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 is_a11n
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 current_user_can_manage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 get_overrides
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 save_overrides
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 reset_overrides_cache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 overrides_from_states
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
7.23
 filter_enabled
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 register_admin_page
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 handle_admin_post
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 maybe_handle_submission
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 handle_save
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
7
 render_admin_page
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 print_screen
99.06% covered (success)
99.06%
105 / 106
0.00% covered (danger)
0.00%
0 / 1
16
 is_support_session
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 sanitize_overrides
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
6.22
 is_valid_flag_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_rows
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
9
1<?php
2/**
3 * AUTOMATTICIANS ONLY. Internal Automattic tooling for flipping Jetpack feature
4 * flags on WordPress.com Simple and Atomic sites.
5 *
6 * This is not a WordPress.com feature and is not supported. Site owners never
7 * see it: the screen, its menu entry, and its save path are each gated on the
8 * Automattician check below, which fails closed. Nothing here should ever be
9 * documented for, linked to, or demonstrated to anyone outside Automattic.
10 *
11 * The jetpack-feature-flags package is a registry: it resolves every flag
12 * through the `jetpack_feature_flag_enabled` filter and deliberately stores no
13 * state of its own. Nothing on WordPress.com answered that filter, so a flag
14 * could only be flipped by shipping a code change or a sandbox patch.
15 *
16 * This feature adds the missing control surface, on Simple and Atomic both:
17 * a Tools -> Feature Flags screen listing the registered flags with a
18 * three-state control each.
19 *
20 * Two properties are load-bearing:
21 *
22 * - The *screen* is Automattician-only and fails closed. Without the wpcom
23 *   platform primitives that identify an Automattician, nobody sees it.
24 * - The *overrides* are site-wide and are NOT re-gated on the Automattician
25 *   check. That is the point: an override has to change what the site actually
26 *   does, logged-out visitors included, or it cannot be used to test a flag
27 *   end to end. It also means an override changes what the site's owner sees,
28 *   which is why the screen says so in as many words.
29 *
30 * Strings here are intentionally not translated. This is internal Automattician
31 * tooling, and putting its debug copy in front of GlotPress volunteers would
32 * waste their time.
33 *
34 * @package automattic/jetpack-mu-wpcom
35 */
36
37namespace Automattic\Jetpack\Jetpack_Mu_Wpcom;
38
39use Automattic\Jetpack\Constants;
40use Automattic\Jetpack\Feature_Flags\Feature_Flags;
41use Automattic\Jetpack\Status\Host;
42use WPCOMSH_Support_Session_Detect;
43
44/**
45 * Site-wide feature flag overrides, and the Automattician-only screen that sets them.
46 *
47 * AUTOMATTICIANS ONLY — internal Automattic tooling, not a supported
48 * user-facing feature. Every entry point that exposes the screen or writes an
49 * override goes through current_user_can_manage(); do not add one that skips it.
50 *
51 * (Deliberately not tagged `@internal`: Phan reads that as "callable only from
52 * this namespace" and rejects the loader in \Automattic\Jetpack calling init().)
53 */
54class Wpcom_Feature_Flags {
55
56    /**
57     * Option holding the site's flag overrides, as a `flag name => bool` map.
58     *
59     * Stored non-autoloaded and deleted outright when the last override is
60     * removed, so the overwhelming majority of sites — which will never open
61     * this screen — carry no row and pay nothing for it.
62     */
63    const OVERRIDES_OPTION = 'wpcom_feature_flag_overrides';
64
65    /**
66     * Nonce action guarding the override form.
67     */
68    const NONCE_ACTION = 'wpcom-feature-flags-save';
69
70    /**
71     * Admin page slug.
72     */
73    const PAGE_SLUG = 'wpcom-feature-flags';
74
75    /**
76     * Capability required on top of the Automattician gate.
77     *
78     * Overrides change site behaviour, so hold the screen to the same bar as any
79     * other site-wide setting rather than leaning on the gate alone.
80     */
81    const CAPABILITY = 'manage_options';
82
83    /**
84     * Request-scoped cache of the sanitized override map.
85     *
86     * Null means "not read from the option yet", which is distinct from the empty
87     * array a site with no overrides legitimately has.
88     *
89     * @var array<array-key, bool>|null
90     */
91    private static $overrides = null;
92
93    /**
94     * Register the hooks this feature needs.
95     *
96     * @return void
97     */
98    public static function init() {
99        add_filter( 'jetpack_feature_flag_enabled', array( self::class, 'filter_enabled' ), 10, 2 );
100        add_action( 'admin_menu', array( self::class, 'register_admin_page' ) );
101    }
102
103    /**
104     * Whether the current visitor is an Automattician.
105     *
106     * Mirrors the platform split already used by do_not_track_a11ns() in
107     * wpcom-wpadmin-page-view.php: on Simple the platform's own
108     * is_automattician() is authoritative, and on Atomic the a8c proxy is what
109     * identifies us. Both branches fail closed when their primitive is missing.
110     *
111     * A support session reaches an Atomic site through the same proxy, but it is
112     * a Happiness Engineer acting on the site owner's behalf rather than an
113     * Automattician testing unreleased work, so it is excluded. That exclusion
114     * fails closed too — see is_support_session() for why it cannot just ask
115     * wpcomsh and believe the answer.
116     *
117     * AT_PROXIED_REQUEST is read through Constants rather than defined() — which
118     * is what the neighbouring code uses — so the Atomic branch is reachable from
119     * tests at all. That is safe here because Constants::set_constant() is only
120     * callable by code already executing in this process, which by then can do
121     * anything this gate protects, and because manage_options is required on top.
122     * Do not lean on this gate alone for anything stronger.
123     *
124     * @return bool Whether the current visitor is an Automattician.
125     */
126    public static function is_a11n() {
127        if ( ( new Host() )->is_wpcom_simple() ) {
128            return function_exists( 'is_automattician' ) && (bool) is_automattician();
129        }
130
131        if ( ! Constants::is_true( 'AT_PROXIED_REQUEST' ) ) {
132            return false;
133        }
134
135        return ! self::is_support_session();
136    }
137
138    /**
139     * Whether the current user may read and change this site's flag overrides.
140     *
141     * @return bool Whether the current user may manage flag overrides.
142     */
143    public static function current_user_can_manage() {
144        return self::is_a11n() && current_user_can( self::CAPABILITY );
145    }
146
147    /**
148     * Return this site's flag overrides.
149     *
150     * Memoized for the request. filter_enabled() runs once per flag resolution,
151     * and the screen resolves every listed flag to fill its Effective column, so
152     * without this the option read, the per-entry preg_match(), and the ksort()
153     * in sanitize_overrides() all repeat for every flag checked. The option
154     * cannot change mid-request except through save_overrides(), which clears
155     * this. Code that writes the option behind our back — wp-cli, a direct
156     * update_option() — is picked up on the next request.
157     *
158     * @return array<array-key, bool> Map of flag name to forced value.
159     */
160    public static function get_overrides() {
161        if ( null !== self::$overrides ) {
162            return self::$overrides;
163        }
164
165        $stored = get_option( self::OVERRIDES_OPTION );
166
167        self::$overrides = is_array( $stored ) ? self::sanitize_overrides( $stored ) : array();
168
169        return self::$overrides;
170    }
171
172    /**
173     * Persist this site's flag overrides, replacing whatever was stored.
174     *
175     * @param array<array-key, bool> $overrides Map of flag name to forced value.
176     * @return void
177     */
178    public static function save_overrides( array $overrides ) {
179        $overrides = self::sanitize_overrides( $overrides );
180
181        self::$overrides = null;
182
183        if ( empty( $overrides ) ) {
184            delete_option( self::OVERRIDES_OPTION );
185
186            return;
187        }
188
189        update_option( self::OVERRIDES_OPTION, $overrides, false );
190    }
191
192    /**
193     * Forget the memoized override map.
194     *
195     * Intended for tests, which write the option directly rather than through
196     * save_overrides(). Mirrors Feature_Flags::reset() in the registry package.
197     *
198     * @return void
199     */
200    public static function reset_overrides_cache() {
201        self::$overrides = null;
202    }
203
204    /**
205     * Turn the form's three-state controls into an override map.
206     *
207     * "default" means the absence of an override rather than an override to the
208     * flag's current default, so a flag left alone keeps following whatever the
209     * code that registered it decides later.
210     *
211     * @param array $states Map of flag name to 'on', 'off', or 'default'.
212     * @return array<array-key, bool> Override map.
213     */
214    public static function overrides_from_states( array $states ) {
215        $overrides = array();
216
217        foreach ( $states as $name => $state ) {
218            /*
219             * PHP casts a decimal-integer array key to int, so an all-digit flag
220             * name — which the documented ^[a-z0-9][a-z0-9_-]*$ pattern allows —
221             * arrives from $_POST as an int. Rejecting non-strings here would drop
222             * it silently after the screen had already offered a working-looking
223             * control for it. save_overrides() is what validates the name.
224             */
225            if ( ! is_string( $name ) && ! is_int( $name ) ) {
226                continue;
227            }
228
229            $name = (string) $name;
230
231            if ( ! is_string( $state ) ) {
232                continue;
233            }
234
235            if ( 'on' === $state ) {
236                $overrides[ $name ] = true;
237            } elseif ( 'off' === $state ) {
238                $overrides[ $name ] = false;
239            }
240        }
241
242        return $overrides;
243    }
244
245    /**
246     * Answer the feature flag package's resolution filter with this site's overrides.
247     *
248     * Deliberately not gated on is_a11n(): overrides are site-wide, so they have
249     * to apply to every visitor, including logged-out ones.
250     *
251     * @param bool   $enabled Whether the flag is enabled.
252     * @param string $name    Flag name.
253     * @return bool Whether the flag is enabled.
254     */
255    public static function filter_enabled( $enabled, $name ) {
256        $overrides = self::get_overrides();
257
258        if ( ! is_string( $name ) || ! array_key_exists( $name, $overrides ) ) {
259            return $enabled;
260        }
261
262        return $overrides[ $name ];
263    }
264
265    /**
266     * Register the Tools -> Feature Flags screen for Automatticians.
267     *
268     * @return string|false The resulting page's hook suffix, or false when the
269     *                      current user may not manage flag overrides.
270     */
271    public static function register_admin_page() {
272        if ( ! self::current_user_can_manage() ) {
273            return false;
274        }
275
276        $hook_suffix = add_submenu_page(
277            'tools.php',
278            'Feature Flags (a8c)',
279            'Feature Flags (a8c)',
280            self::CAPABILITY,
281            self::PAGE_SLUG,
282            array( self::class, 'render_admin_page' )
283        );
284
285        if ( $hook_suffix ) {
286            // Submissions are handled before any output, so the save can redirect.
287            add_action( 'load-' . $hook_suffix, array( self::class, 'handle_admin_post' ) );
288        }
289
290        return $hook_suffix;
291    }
292
293    /**
294     * Handle a submission and redirect back to the screen.
295     *
296     * Post/Redirect/Get: without the redirect, reloading the screen after a save
297     * re-submits the form, and back/forward navigation replays it.
298     *
299     * @return void
300     */
301    public static function handle_admin_post() {
302        $redirect = self::maybe_handle_submission();
303
304        if ( null === $redirect ) {
305            return;
306        }
307
308        wp_safe_redirect( $redirect );
309        exit;
310    }
311
312    /**
313     * Apply a submission, if this request is one, and say where to go next.
314     *
315     * Split from handle_admin_post() so everything except the redirect-and-exit
316     * is reachable from tests.
317     *
318     * @return string|null URL to redirect to, or null when this is not a submission.
319     */
320    public static function maybe_handle_submission() {
321        if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || 'POST' !== strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) ) {
322            return null;
323        }
324
325        /*
326         * Three outcomes, not two: a rejected submission has to be
327         * distinguishable from a plain page load. handle_save() returns false for
328         * a stale nonce — which this screen invites, being one you leave open —
329         * and re-rendering the unchanged page silently would look exactly like a
330         * successful save of the values already stored.
331         */
332        // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- handle_save() verifies the nonce and sanitizes every value it stores.
333        $result = self::handle_save( wp_unslash( $_POST ) ) ? 'saved' : 'rejected';
334
335        return add_query_arg( 'flags-notice', $result, admin_url( 'tools.php?page=' . self::PAGE_SLUG ) );
336    }
337
338    /**
339     * Apply a submitted override form.
340     *
341     * Re-checks the Automattician gate, the capability, and the nonce: the
342     * screen's absence from the menu is not authorization on its own.
343     *
344     * The submitted map replaces the stored one wholesale, so two Automatticians
345     * saving the same site concurrently is last-write-wins. Every form carries
346     * every flag the screen listed, so in practice they overwrite each other with
347     * the same values; this is not an atomic read-modify-write and is not meant
348     * to be one.
349     *
350     * @param array $request Unslashed request data.
351     * @return bool Whether the overrides were saved.
352     */
353    public static function handle_save( array $request ) {
354        if ( ! self::current_user_can_manage() ) {
355            return false;
356        }
357
358        $nonce = isset( $request['_wpnonce'] ) && is_string( $request['_wpnonce'] ) ? $request['_wpnonce'] : '';
359
360        if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
361            return false;
362        }
363
364        $states = isset( $request['flag_state'] ) && is_array( $request['flag_state'] ) ? $request['flag_state'] : array();
365
366        self::save_overrides( self::overrides_from_states( $states ) );
367
368        return true;
369    }
370
371    /**
372     * Render the Tools -> Feature Flags screen.
373     *
374     * @return void
375     */
376    public static function render_admin_page() {
377        if ( ! self::current_user_can_manage() ) {
378            wp_die(
379                'Jetpack feature flag controls are Automatticians only. This is internal Automattic tooling, not a site feature.',
380                'Feature Flags',
381                array( 'response' => 403 )
382            );
383        }
384
385        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display-only flag set by our own redirect; nothing is written here.
386        $notice = isset( $_GET['flags-notice'] ) && is_string( $_GET['flags-notice'] )
387            // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- As above.
388            ? sanitize_key( wp_unslash( $_GET['flags-notice'] ) )
389            : '';
390
391        self::print_screen( self::get_rows(), self::get_overrides(), $notice );
392    }
393
394    /**
395     * Print the screen's markup.
396     *
397     * @param array<string, array>   $rows      Flags to list, keyed by flag name.
398     * @param array<array-key, bool> $overrides The overrides currently in force.
399     * @param string                 $notice    'saved', 'rejected', or '' for a plain load.
400     * @return void
401     */
402    private static function print_screen( array $rows, array $overrides, $notice = '' ) {
403        $states = array(
404            'default' => 'Default',
405            'on'      => 'Force on',
406            'off'     => 'Force off',
407        );
408
409        ?>
410        <div class="wrap">
411            <h1>
412                Feature Flags
413                <span class="dashicons dashicons-lock" style="vertical-align: middle;" aria-hidden="true"></span>
414                <span style="font-size: 0.6em; font-weight: normal; vertical-align: middle;">Automatticians only</span>
415            </h1>
416
417            <?php if ( 'saved' === $notice ) : ?>
418                <div class="notice notice-success is-dismissible"><p>Overrides saved.</p></div>
419            <?php elseif ( 'rejected' === $notice ) : ?>
420                <div class="notice notice-error is-dismissible">
421                    <p>
422                        <strong>Your changes were not saved.</strong> The form&#8217;s security token had
423                        expired, or this session no longer passes the Automattician check. Reload the
424                        screen and try again — the states below are what is actually stored.
425                    </p>
426                </div>
427            <?php endif; ?>
428
429            <div class="notice notice-warning">
430                <p>
431                    <strong>Automatticians only — internal Automattic tooling.</strong> This screen is
432                    not part of WordPress.com, is not visible to the site&#8217;s owner, and is not
433                    supported. Do not point anyone outside Automattic at it.
434                </p>
435                <p>
436                    <strong>Overrides here are site-wide.</strong> They change what this site does for
437                    everyone — the site&#8217;s owner and logged-out visitors included — not just for
438                    you. Set a flag back to <em>Default</em> when you are done with it.
439                </p>
440            </div>
441
442            <p>
443                Flags are resolved as: the default they were registered with, then this screen, then
444                any <code>jetpack_feature_flag_enabled_{flag}</code> filter. That per-flag filter runs
445                last, so a sandbox patch or mu-plugin using it beats whatever you set here.
446            </p>
447
448            <form method="post" action="<?php echo esc_url( admin_url( 'tools.php?page=' . self::PAGE_SLUG ) ); ?>">
449                <?php wp_nonce_field( self::NONCE_ACTION ); ?>
450
451                <table class="widefat striped">
452                    <caption class="screen-reader-text">
453                        Feature flags registered on this site, with the override forced from this screen.
454                    </caption>
455                    <thead>
456                        <tr>
457                            <th scope="col">Flag</th>
458                            <th scope="col">Owner</th>
459                            <th scope="col">Default</th>
460                            <th scope="col">Effective</th>
461                            <th scope="col">State</th>
462                        </tr>
463                    </thead>
464                    <tbody>
465                        <?php if ( empty( $rows ) ) : ?>
466                            <tr>
467                                <td colspan="5">
468                                    No feature flags are registered on this site, and nothing is
469                                    overridden. Flags appear here once code running on this site calls
470                                    <code>Feature_Flags::register()</code>.
471                                </td>
472                            </tr>
473                        <?php endif; ?>
474
475                        <?php foreach ( $rows as $flag_name => $row ) : ?>
476                            <?php
477                            $current = 'default';
478                            if ( array_key_exists( $flag_name, $overrides ) ) {
479                                $current = $overrides[ $flag_name ] ? 'on' : 'off';
480                            }
481                            ?>
482                            <tr>
483                                <th scope="row">
484                                    <code><?php echo esc_html( $flag_name ); ?></code>
485                                    <?php if ( ! $row['registered'] ) : ?>
486                                        <p class="description">Not registered on this site.</p>
487                                    <?php elseif ( '' !== $row['description'] ) : ?>
488                                        <p class="description"><?php echo esc_html( $row['description'] ); ?></p>
489                                    <?php endif; ?>
490                                </th>
491                                <td><?php echo '' === $row['owner'] ? '&#8212;' : esc_html( $row['owner'] ); ?></td>
492                                <td>
493                                    <?php
494                                    if ( ! $row['registered'] ) {
495                                        echo '&#8212;';
496                                    } else {
497                                        echo $row['default'] ? 'On' : 'Off';
498                                    }
499                                    ?>
500                                </td>
501                                <td>
502                                    <?php
503                                    if ( null === $row['effective'] ) {
504                                        echo '&#8212;';
505                                    } else {
506                                        echo $row['effective'] ? 'On' : 'Off';
507                                    }
508                                    ?>
509                                </td>
510                                <td>
511                                    <?php if ( ! $row['overridable'] ) : ?>
512                                        <p class="description">
513                                            This flag&#8217;s name does not match
514                                            <code>^[a-z0-9][a-z0-9_-]*$</code>, so an override for it cannot
515                                            be stored. Fix the name where the flag is registered.
516                                        </p>
517                                    <?php else : ?>
518                                        <fieldset>
519                                            <legend class="screen-reader-text">Override state for <?php echo esc_html( $flag_name ); ?></legend>
520                                            <?php foreach ( $states as $value => $label ) : ?>
521                                                <label style="margin-inline-end: 1em;">
522                                                    <input
523                                                        type="radio"
524                                                        name="flag_state[<?php echo esc_attr( $flag_name ); ?>]"
525                                                        value="<?php echo esc_attr( $value ); ?>"
526                                                        <?php checked( $current, $value ); ?>
527                                                    />
528                                                    <?php echo esc_html( $label ); ?>
529                                                </label>
530                                            <?php endforeach; ?>
531                                        </fieldset>
532                                    <?php endif; ?>
533                                </td>
534                            </tr>
535                        <?php endforeach; ?>
536                    </tbody>
537                </table>
538
539                <?php submit_button( 'Save overrides' ); ?>
540            </form>
541        </div>
542        <?php
543    }
544
545    /**
546     * Whether this request has to be treated as a wpcomsh support session.
547     *
548     * Fails closed: anything short of a positive "not a support session" answer
549     * counts as one.
550     *
551     * The detector keeps its verdict in a client-side cookie and reports a
552     * missing cookie as "not a support session"
553     * (WPCOMSH_Support_Session_Detect::is_probably_support_session()). That
554     * default is right for the thing it was written for — suppressing a Tracks
555     * event — and wrong for an authorization gate, because the cookie is absent
556     * in cases nobody intended: it carries whatever lifetime wpcom passed as
557     * `expires`, so it can lapse while the login session lives on, and it is set
558     * SameSite=Strict, so a cross-site navigation into wp-admin does not send it
559     * on the first request. It can also simply be deleted; httponly stops page
560     * script from touching it, not a person with devtools open.
561     *
562     * So require has_detection_result() before trusting the verdict. The cost is
563     * that an Automattician whose browser holds no detection result does not see
564     * the screen until they log in through WordPress.com SSO again, which is what
565     * sets the cookie. Losing the screen for one navigation is the cheaper
566     * failure.
567     *
568     * Guarded with class_exists because the detector ships in wpcomsh, so it only
569     * exists on Atomic. Its absence counts as a support session for the same
570     * reason: with no detector there is no way to rule one out.
571     *
572     * @return bool Whether this request has to be treated as a support session.
573     */
574    private static function is_support_session() {
575        if ( ! class_exists( 'WPCOMSH_Support_Session_Detect' ) ) {
576            return true;
577        }
578
579        if ( ! WPCOMSH_Support_Session_Detect::has_detection_result() ) {
580            return true;
581        }
582
583        return WPCOMSH_Support_Session_Detect::is_probably_support_session();
584    }
585
586    /**
587     * Drop anything from an override map that could not have come from the form.
588     *
589     * The option is read on requests that have nothing to do with the screen, and
590     * flag names arrive as submitted form keys, so both directions are sanitized.
591     *
592     * @param array $overrides Untrusted override map.
593     * @return array<array-key, bool> Sanitized override map, sorted by flag name.
594     */
595    private static function sanitize_overrides( array $overrides ) {
596        $sanitized = array();
597
598        foreach ( $overrides as $name => $enabled ) {
599            // An all-digit flag name reaches this as an int key. See overrides_from_states().
600            if ( ! is_string( $name ) && ! is_int( $name ) ) {
601                continue;
602            }
603
604            if ( ! self::is_valid_flag_name( (string) $name ) ) {
605                continue;
606            }
607
608            if ( ! is_scalar( $enabled ) ) {
609                continue;
610            }
611
612            $sanitized[ $name ] = (bool) $enabled;
613        }
614
615        ksort( $sanitized );
616
617        return $sanitized;
618    }
619
620    /**
621     * Whether a string is shaped like a feature flag name.
622     *
623     * The same pattern the jetpack-feature-flags package documents and enforces
624     * at lint time with the Jetpack.FeatureFlags.FeatureFlagName sniff.
625     * Registration does not check it at runtime, so this screen has to.
626     *
627     * @param string $name Candidate flag name.
628     * @return bool Whether the name is valid.
629     */
630    private static function is_valid_flag_name( $name ) {
631        return (bool) preg_match( '/^[a-z0-9][a-z0-9_-]*$/', $name );
632    }
633
634    /**
635     * Build the rows the screen lists.
636     *
637     * Registered flags come from the jetpack-feature-flags registry. Overrides
638     * for names the registry no longer knows are listed too — flags get retired,
639     * and an override that outlives its registration must stay visible on the
640     * screen that set it rather than becoming an invisible stuck value.
641     *
642     * @return array<string, array> Map of flag name to row data.
643     */
644    private static function get_rows() {
645        $has_registry = class_exists( Feature_Flags::class );
646        $registered   = $has_registry ? Feature_Flags::all() : array();
647        $rows         = array();
648
649        foreach ( $registered as $name => $definition ) {
650            $rows[ $name ] = array(
651                'registered'  => true,
652                'default'     => ! empty( $definition['default'] ),
653                'description' => isset( $definition['description'] ) ? (string) $definition['description'] : '',
654                'owner'       => isset( $definition['owner'] ) ? (string) $definition['owner'] : '',
655            );
656        }
657
658        foreach ( array_keys( self::get_overrides() ) as $name ) {
659            if ( isset( $rows[ $name ] ) ) {
660                continue;
661            }
662
663            $rows[ $name ] = array(
664                'registered'  => false,
665                'default'     => false,
666                'description' => '',
667                'owner'       => '',
668            );
669        }
670
671        foreach ( $rows as $name => $row ) {
672            /*
673             * Registration does not validate names at runtime — the package
674             * enforces the pattern with a PHPCS sniff, which never runs against
675             * the wpcom Simple codebase. sanitize_overrides() would silently drop
676             * an override for a name that fails it, so flag those rows here and
677             * render them without a control rather than offering one that does
678             * nothing.
679             */
680            $rows[ $name ]['overridable'] = self::is_valid_flag_name( $name );
681
682            /*
683             * What the flag actually resolves to right now, which is not always
684             * what this screen set: the per-flag
685             * `jetpack_feature_flag_enabled_{$name}` filter runs after ours and
686             * wins. Showing it turns the screen from a settings form into
687             * something that answers "I forced it on, so why is it still off?".
688             */
689            $rows[ $name ]['effective'] = $has_registry ? Feature_Flags::is_enabled( $name ) : null;
690        }
691
692        ksort( $rows );
693
694        return $rows;
695    }
696}