Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.61% covered (success)
97.61%
530 / 543
81.82% covered (warning)
81.82%
36 / 44
CRAP
0.00% covered (danger)
0.00%
0 / 1
Error_Handler
97.61% covered (success)
97.61%
530 / 543
81.82% covered (warning)
81.82%
36 / 44
228
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 get_displayable_errors
100.00% covered (success)
100.00%
92 / 92
100.00% covered (success)
100.00%
1 / 1
49
 get_error_display_config
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_error_display_configs
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
2
 get_invalid_connection_owner_message
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_blocked_request_message
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 classify_error_audience
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 is_owner_scoped_error
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 promote_owner_errors
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
8
 handle_verified_errors
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 should_allow_error_filtering
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 jetpack_react_dashboard_error
89.29% covered (warning)
89.29%
25 / 28
0.00% covered (danger)
0.00%
0 / 1
12.18
 get_instance
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 report_error
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
6
 should_report_error
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 error_reporting_gate_transient
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 store_error
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
7.10
 build_action_error_data
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
5
 build_error_array
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
5.01
 build_connection_error_data
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 build_connection_wp_error
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 wp_error_to_array
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
13
 send_error_to_wpcom
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 encrypt_data_to_wpcom
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
2.50
 get_user_id_from_token
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 get_stored_errors
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 get_verified_errors
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 garbage_collector
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 delete_all_errors
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 delete_all_api_errors
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
12
 delete_all_errors_and_return_unfiltered_value
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 delete_stored_errors
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 delete_verified_errors
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 delete_error_by_code
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
6
 get_error_by_nonce
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 verify_error
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 register_verify_error_endpoint
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 verify_xml_rpc_error
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 generic_admin_notice_error
94.74% covered (success)
94.74%
36 / 38
0.00% covered (danger)
0.00%
0 / 1
15.03
 check_api_response_for_errors
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
14
 check_signed_request_for_errors
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 check_xmlrpc_fault_for_errors
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 has_external_filters
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 invalidate_displayable_errors_cache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * The Jetpack Connection error class file.
4 *
5 * @package automattic/jetpack-connection
6 */
7
8namespace Automattic\Jetpack\Connection;
9
10/**
11 * The Jetpack Connection error handler.
12 *
13 * This class stores and surfaces connection (authentication/signature) errors for requests
14 * in both directions: incoming (WP.com to this site) and outgoing (this site to WP.com).
15 *
16 * Flow 1 — incoming request errors. Entry point: `report_error()`.
17 *
18 * 1. An incoming XML-RPC or REST API request with an invalid signature triggers an error in
19 *    `Manager::verify_xml_rpc_signature()`, which reports it here. (Signed incoming REST
20 *    requests are funneled into the same verification path by `REST_Authentication`.)
21 * 2. Applies a gate to only process each error code once an hour to avoid overflow
22 * 3. It stores the error in the database, but we don't know yet if this is a valid error, because
23 *    we can't confirm it came from WP.com.
24 * 4. It encrypts the error details and sends it to the wp.com server
25 * 5. wp.com checks it and, if valid, sends a new request back to this site using the verify_xml_rpc_error REST endpoint
26 * 6. This endpoint adds this error to the Verified errors in the database
27 * 7. Triggers a workflow depending on the error (display user an error message, do some self healing, etc.)
28 *
29 * Flow 2 — outgoing request errors. Entry points: `check_api_response_for_errors()`,
30 * `check_signed_request_for_errors()`, and `check_xmlrpc_fault_for_errors()`.
31 *
32 * 1. Every signed request made through `Client::remote_request()` has its response checked
33 *    by `check_api_response_for_errors()`. A request that could not be signed at all never
34 *    gets a response, so `Client::remote_request()` passes the signing failure to
35 *    `check_signed_request_for_errors()` instead. An XML-RPC fault arrives as an HTTP 200
36 *    response with an XML body, so it never reaches `check_api_response_for_errors()` either
37 *    (which returns immediately on a 200 and decodes the body as JSON); `Jetpack_IXR_Client::query()`
38 *    calls `check_xmlrpc_fault_for_errors()` directly from its fault branch instead.
39 * 2. When the response (or the signing failure, or the fault) carries a known error code, the
40 *    error is stored and immediately marked verified (the same hourly gate applies). The
41 *    WP.com verification round-trip of flow 1 is skipped because the error arrived in a
42 *    response to a request this site itself initiated and signed — the failed response is its
43 *    own evidence — or, for signing failures, because the evidence is the site's own state.
44 *
45 * Stored errors carry two orthogonal classification fields:
46 *
47 * - `error_type` — the transport/source of the failed request: 'xmlrpc', 'rest',
48 *   'local_state' (connection-state errors that a successful outgoing request cannot
49 *   disprove — e.g. `invalid_connection_owner`, or WP.com being blocked from reaching
50 *   this site; stored as 'connection' by package versions <= 8.8), or '' for entries
51 *   stored by older package versions.
52 * - `error_direction` — 'incoming', 'outgoing', or '' (legacy entries and
53 *   'local_state'-type errors, which have no direction).
54 *
55 * Note on naming: both option names below contain "xmlrpc" because they predate REST
56 * support. They are intentionally kept as-is to avoid a data migration and breaking
57 * consumers that read the options directly — despite the names, they store errors of
58 * every type and direction.
59 *
60 * Errors are stored in the database as options in the following format:
61 *
62 * [
63 *   $error_code => [
64 *     $user_id => [
65 *       $error_details
66 *     ]
67 *   ]
68 * ]
69 *
70 * For each error code we store a maximum of 5 errors for 5 different user ids.
71 *
72 * A user ID can be:
73 * * 0 for blog tokens
74 * * positive integer for user tokens
75 * * 'invalid' for malformed tokens
76 *
77 * Example error structure:
78 * [
79 *   'invalid_token' => [
80 *     '123' => [
81 *       'error_code' => 'invalid_token',
82 *       'user_id' => '123',
83 *       'error_message' => 'The token is invalid',
84 *       'error_data' => ['action' => 'reconnect'],
85 *       'timestamp' => 1234567890,
86 *       'nonce' => 'abc123def',
87 *       'error_type' => 'xmlrpc',
88 *       'error_direction' => 'incoming'
89 *     ]
90 *   ]
91 * ]
92 *
93 * @since 1.14.2
94 */
95class Error_Handler {
96
97    /**
98     * The name of the option that stores the errors
99     *
100     * @since 1.14.2
101     *
102     * @var string
103     */
104    const STORED_ERRORS_OPTION = 'jetpack_connection_xmlrpc_errors';
105
106    /**
107     * The name of the option that stores the errors
108     *
109     * @since 1.14.2
110     *
111     * @var string
112     */
113    const STORED_VERIFIED_ERRORS_OPTION = 'jetpack_connection_xmlrpc_verified_errors';
114
115    /**
116     * The prefix of the transient that controls the gate for each error code
117     *
118     * @since 1.14.2
119     *
120     * @var string
121     */
122    const ERROR_REPORTING_GATE = 'jetpack_connection_error_reporting_gate_';
123
124    /**
125     * `error_type` value for errors from XML-RPC requests.
126     *
127     * @since 8.9.0
128     *
129     * @var string
130     */
131    const ERROR_TYPE_XMLRPC = 'xmlrpc';
132
133    /**
134     * `error_type` value for errors from REST requests.
135     *
136     * @since 8.9.0
137     *
138     * @var string
139     */
140    const ERROR_TYPE_REST = 'rest';
141
142    /**
143     * `error_type` value for local connection-state errors that involve no request,
144     * e.g. `invalid_connection_owner`. The evidence for these errors is the site's own
145     * database, which is also why they carry no `error_direction`.
146     *
147     * Note: package versions <= 8.8 stored these errors with the type 'connection'.
148     *
149     * @since 8.9.0
150     *
151     * @var string
152     */
153    const ERROR_TYPE_LOCAL_STATE = 'local_state';
154
155    /**
156     * `error_direction` value for errors triggered by incoming requests (WP.com to this site).
157     *
158     * @since 8.9.0
159     *
160     * @var string
161     */
162    const DIRECTION_INCOMING = 'incoming';
163
164    /**
165     * `error_direction` value for errors triggered by outgoing requests (this site to WP.com).
166     *
167     * @since 8.9.0
168     *
169     * @var string
170     */
171    const DIRECTION_OUTGOING = 'outgoing';
172
173    /**
174     * Time in seconds a test should live in the database before being discarded
175     *
176     * @since 1.14.2
177     */
178    const ERROR_LIFE_TIME = DAY_IN_SECONDS;
179
180    /**
181     * List of known errors. Only error codes in this list will be handled
182     *
183     * @since 1.14.2
184     *
185     * @var array
186     */
187    public $known_errors = array(
188        // Incoming request token problems (Manager::internal_verify_xml_rpc_signature).
189        'malformed_user_id',         // The user_id segment of the request token is not numeric.
190        'unknown_user',              // The request token's user does not exist on this site.
191        // Incoming and outgoing token problems (Manager::internal_verify_xml_rpc_signature; Client::build_signed_request).
192        'malformed_token',           // Request token is empty/garbled or version-mismatched (incoming); or the local token has no secret half (outgoing).
193        // Locally stored token problems (Tokens::get_access_token).
194        'no_user_tokens',            // The user_tokens option is empty; no user tokens exist at all.
195        'empty_master_user_option',  // The owner's token was requested but the master_user option is empty.
196        'no_token_for_user',         // No stored token for the requested user.
197        'token_malformed',           // The stored token for the requested user is corrupt (missing chunks).
198        'user_id_mismatch',          // The requested user ID doesn't match the user_id segment of their stored token.
199        'no_possible_tokens',        // No stored blog token.
200        'no_valid_user_token',       // The stored user token doesn't match the key the request was signed with.
201        'no_valid_blog_token',       // The stored blog token doesn't match the key the request was signed with.
202        'unknown_token',             // No stored token matches the request token's key.
203        // Signature verification problems (Jetpack_Signature), or errors WPCOM returned
204        // for an outbound request (Error_Handler::check_api_response_for_errors,
205        // Error_Handler::check_xmlrpc_fault_for_errors).
206        'could_not_sign',            // Signing the request failed for an unknown reason.
207        'invalid_scheme',            // Invalid URL scheme when signing.
208        'unknown_scheme_port',       // The URL scheme has no known port, so the signature cannot be built.
209        'invalid_secret',            // The stored token secret is invalid.
210        'invalid_token',             // No token available when signing; from WPCOM: the token used was rejected.
211        'token_mismatch',            // The request token doesn't match the token we hold.
212        'invalid_body',              // The request body is malformed.
213        'invalid_signature',         // A signature parameter is malformed, or the timestamp is off (clock skew).
214        'invalid_body_hash',         // The body hash doesn't match the request body.
215        'invalid_nonce',             // The request nonce could not be added (likely a reuse/replay).
216        'signature_mismatch',        // Computed signature differs: wrong secret, or URL/body drift (domain change, proxy).
217        // Connection state problems (Manager::get_connection_owner, Connection_Health_Tests).
218        'invalid_connection_owner',  // The connection owner cannot be resolved: token missing or WP user deleted.
219        'xmlrpc_request_blocked',    // WP.com reached the site but the request was rejected (firewall, WAF, or server rule).
220    );
221
222    /**
223     * Holds the instance of this singleton class
224     *
225     * @since 1.14.2
226     *
227     * @var Error_Handler $instance
228     */
229    public static $instance = null;
230
231    /**
232     * Cached displayable errors to avoid duplicate processing
233     *
234     * @since 6.13.10
235     *
236     * @var array|null
237     */
238    private $cached_displayable_errors = null;
239
240    /**
241     * Initialize instance, hooks and load verified errors handlers
242     *
243     * @since 1.14.2
244     */
245    private function __construct() {
246        defined( 'JETPACK__ERRORS_PUBLIC_KEY' ) || define( 'JETPACK__ERRORS_PUBLIC_KEY', 'KdZY80axKX+nWzfrOcizf0jqiFHnrWCl9X8yuaClKgM=' );
247
248        add_action( 'rest_api_init', array( $this, 'register_verify_error_endpoint' ) );
249
250        // Handle verified errors on admin pages.
251        add_action( 'admin_init', array( $this, 'handle_verified_errors' ) );
252
253        // If the site gets reconnected, clear errors.
254        add_action( 'jetpack_site_registered', array( $this, 'delete_all_errors' ) );
255        add_action( 'jetpack_get_site_data_success', array( $this, 'delete_all_api_errors' ) );
256        add_filter( 'jetpack_connection_disconnect_site_wpcom', array( $this, 'delete_all_errors_and_return_unfiltered_value' ) );
257        add_filter( 'jetpack_connection_delete_all_tokens', array( $this, 'delete_all_errors_and_return_unfiltered_value' ) );
258        add_action( 'jetpack_unlinked_user', array( $this, 'delete_all_errors' ) );
259        add_action( 'jetpack_updated_user_token', array( $this, 'delete_all_errors' ) );
260    }
261
262    /**
263     * Gets displayable errors with predefined structure and optional filtering.
264     *
265     * This method returns a hierarchical array of errors (error_code => user_id => error_details)
266     * that can be safely displayed in My Jetpack and other UI components. It includes
267     * predefined error messages and actions, with optional filtering for specific sites.
268     * Only processes a limited set of error codes that are meant to be displayed to users.
269     *
270     * The result is specific to the current viewer: an error is omitted entirely when
271     * they lack the capability to resolve it, so viewer-facing surfaces need not gate
272     * it again. Two exceptions: a context with no current user gets the unfiltered set,
273     * and consumer-injected errors are appended after the gate. See docs/error-handling.md.
274     *
275     * error_data.action is only set when it deviates from the default behavior
276     * (e.g. 'none' to suppress the reconnect CTA); when absent, readers fall back
277     * to offering the reconnect CTA.
278     *
279     * @since 6.13.10
280     *
281     * @return array Array of displayable errors with hierarchical structure.
282     *               Example:
283     *               [
284     *                 'invalid_token' => [
285     *                   '123' => [
286     *                     'error_code' => 'invalid_token',
287     *                     'user_id' => '123',
288     *                     'error_message' => 'Your connection with WordPress.com seems to be broken...',
289     *                     'audience' => 'user',
290     *                     'error_data' => [...],
291     *                     'timestamp' => 1234567890,
292     *                     'nonce' => 'abc123def',
293     *                     'error_type' => 'xmlrpc'
294     *                   ]
295     *                 ]
296     *               ]
297     */
298    public function get_displayable_errors() {
299        $viewer_id = get_current_user_id();
300
301        // Check if we have a cached result for this viewer AND no filters are applied.
302        // The output is viewer-dependent (see audience classification below), so the
303        // cache is keyed by the current user.
304        if ( is_array( $this->cached_displayable_errors )
305            && array_key_exists( $viewer_id, $this->cached_displayable_errors )
306            && ! $this->has_external_filters() ) {
307            return $this->cached_displayable_errors[ $viewer_id ];
308        }
309
310        $verified_errors    = $this->get_verified_errors();
311        $displayable_errors = array();
312
313        // The common case is zero verified errors: skip the owner/transferability
314        // lookups entirely then. The external filter below still runs so consumers
315        // (e.g. wpcomsh) can inject errors into an empty set.
316        if ( ! empty( $verified_errors ) ) {
317            $generic_message = __( "Your connection with WordPress.com seems to be broken. If you're experiencing issues, please try reconnecting.", 'jetpack-connection' );
318
319            $owner_id        = (int) \Jetpack_Options::get_option( 'master_user' );
320            $viewer_is_owner = $owner_id > 0 && $viewer_id === $owner_id;
321            $is_transferable = ( new Manager() )->is_ownership_transferable();
322
323            // Viewer-wide, so resolved once rather than per error.
324            $viewer_can_connect      = current_user_can( 'jetpack_connect' );
325            $viewer_can_connect_user = current_user_can( 'jetpack_connect_user' );
326
327            foreach ( $verified_errors as $error_code => $users ) {
328                // Only process error codes that are meant to be displayed to users.
329                // A raw verified error whose code is marked non-displayable in
330                // get_error_display_configs() is never surfaced.
331                $display_config = $this->get_error_display_config( $error_code );
332                if ( null === $display_config ) {
333                    continue;
334                }
335
336                foreach ( $users as $user_id => $error ) {
337                    // An error that cannot be attributed to the blog token or to any user's
338                    // token belongs to no audience and is not actionable by any viewer.
339                    if ( 'invalid' === $user_id ) {
340                        continue;
341                    }
342
343                    // An owner error attributed to someone who is no longer the connection
344                    // owner describes a previous owner's token. Nobody can act on it.
345                    // Only skip when there is a current owner to compare against.
346                    if ( 'invalid_connection_owner' === $error_code
347                        && $owner_id > 0
348                        && (int) $user_id !== $owner_id ) {
349                        continue;
350                    }
351
352                    $audience = $this->classify_error_audience( $user_id, $owner_id );
353
354                    // A viewer is only ever shown errors for: their own user connection, the
355                    // site connection, or the connection owner. Another (non-owner) user's
356                    // broken token is invisible to everyone else, not just non-actionable.
357                    // `invalid_connection_owner` is exempt: when there's no current owner to
358                    // compare against, it falls back to 'user' audience by ID alone.
359                    if ( 'user' === $audience
360                        && (int) $user_id !== $viewer_id
361                        && 'invalid_connection_owner' !== $error_code ) {
362                        continue;
363                    }
364
365                    // An error a viewer cannot act on is withheld entirely.
366                    $viewer_owns_error = 'user' === $audience && ! $this->is_owner_scoped_error( $error_code, $audience );
367
368                    if ( $viewer_id > 0 ) {
369                        $can_view_error = $viewer_owns_error ? $viewer_can_connect_user : $viewer_can_connect;
370
371                        if ( ! $can_view_error ) {
372                            continue;
373                        }
374                    }
375
376                    $message = $generic_message;
377                    $action  = null;
378
379                    if ( isset( $display_config['message_callback'] ) ) {
380                        $message = call_user_func( $display_config['message_callback'], $error );
381                    }
382
383                    // The owner reading their own missing-token error. The message callback
384                    // has no viewer context, so it describes the owner in the third person —
385                    // correct for every other reader, but stilted for the owner themselves.
386                    // Only the missing-token flavor needs this: the deleted-WP-user flavor
387                    // cannot be viewed by an owner who no longer exists.
388                    if ( 'owner' === $audience
389                        && $viewer_is_owner
390                        && 'invalid_connection_owner' === $error_code
391                        && ! ( $error['error_data']['has_user_token'] ?? true ) ) {
392                        $message = __( 'You need to reconnect your WordPress.com account to restore the connection.', 'jetpack-connection' );
393                    } elseif ( 'owner' === $audience && ! $viewer_is_owner ) {
394                        // A secondary admin looking at the connection owner's token error. What
395                        // they can usefully be told depends on whether ownership is transferable.
396                        // Only name the owner, or describe what reconnecting would do, for
397                        // viewers who can act on connection issues.
398                        $owner_name = '';
399                        if ( $viewer_can_connect ) {
400                            $owner      = get_userdata( $owner_id );
401                            $owner_name = $owner instanceof \WP_User ? $owner->display_name : '';
402                        }
403
404                        if ( ! $is_transferable ) {
405                            // Ownership is locked (a consumer declared it non-transferable).
406                            // This admin cannot resolve the error themselves, so surface an
407                            // informational notice naming the owner and offer no reconnect CTA.
408                            $message = $owner_name
409                                ? sprintf(
410                                    /* translators: %s is the display name of the Jetpack connection owner. */
411                                    __( 'The connection owner (%s) needs to reconnect their WordPress.com account to restore the connection.', 'jetpack-connection' ),
412                                    $owner_name
413                                )
414                                : __( 'The connection owner needs to reconnect their WordPress.com account to restore the connection.', 'jetpack-connection' );
415                            $action = 'none';
416                        } elseif ( $viewer_can_connect ) {
417                            // Ownership is transferable, so the reconnect CTA stays available
418                            // to this admin — but it is destructive in a way the generic copy
419                            // doesn't convey. Manager::restore() branches on the *clicking*
420                            // user's tokens, not on whose token the error describes.
421                            $message = $owner_name
422                                ? sprintf(
423                                    /* translators: %s is the display name of the Jetpack connection owner. */
424                                    __( 'The connection owner (%s) needs to reconnect their WordPress.com account to restore the connection. If you reconnect instead, you will become the new connection owner and every other user will be disconnected from WordPress.com.', 'jetpack-connection' ),
425                                    $owner_name
426                                )
427                                : __( 'The connection owner needs to reconnect their WordPress.com account to restore the connection. If you reconnect instead, you will become the new connection owner and every other user will be disconnected from WordPress.com.', 'jetpack-connection' );
428                        }
429                    }
430
431                    // Relinking your own account and restoring the site are different actions
432                    // with different capabilities, and this notice only offers the second one.
433                    // A reporter-declared action is something else, so it is left alone.
434                    if ( $viewer_owns_error && ! $viewer_can_connect && empty( $error['error_data']['action'] ) ) {
435                        $action = 'none';
436                    }
437
438                    $error['audience']      = $audience;
439                    $error['error_message'] = $message;
440
441                    // Only emit error_data.action when it deviates from the default. Readers
442                    // already fall back to the reconnect CTA when no action is set, and
443                    // injecting an explicit 'reconnect' could trip consumer code paths
444                    // reserved for custom actions.
445                    $notice_link = $display_config['notice_link'] ?? null;
446                    $has_link    = ! empty( $notice_link['url'] ) && ! empty( $notice_link['label'] );
447
448                    if ( null !== $action || ! empty( $display_config['support_link'] ) || $has_link ) {
449                        $error_data = ( isset( $error['error_data'] ) && is_array( $error['error_data'] ) ) ? $error['error_data'] : array();
450
451                        if ( null !== $action ) {
452                            $error_data['action'] = $action;
453                        }
454
455                        // Flags a reconnect-may-not-fix-it error so the notice offers a
456                        // support link alongside the reconnect CTA. See `support_link` in
457                        // get_error_display_configs().
458                        if ( ! empty( $display_config['support_link'] ) ) {
459                            $error_data['support_link'] = true;
460                        }
461
462                        // Where the resolution lives somewhere else (Site Health for a
463                        // blocked request), carry the link on the error so every notice can
464                        // offer it — not just the wp-admin one. Errors like this suppress
465                        // the reconnect CTA, so without it the notice names a problem and
466                        // offers nothing to do about it. See `notice_link` in
467                        // get_error_display_configs().
468                        if ( $has_link ) {
469                            $error_data['notice_link'] = array(
470                                'label' => $notice_link['label'],
471                                'url'   => $notice_link['url'],
472                            );
473                        }
474
475                        $error['error_data'] = $error_data;
476                    }
477
478                    if ( ! isset( $displayable_errors[ $error_code ] ) ) {
479                        $displayable_errors[ $error_code ] = array();
480                    }
481                    $displayable_errors[ $error_code ][ $user_id ] = $error;
482                }
483            }
484
485            // A broken connection owner outranks everything else in the set. Run this
486            // before the external filter below so consumer-injected errors are never
487            // dropped by it — they are the consumer's own state, not ours to rank.
488            $displayable_errors = $this->promote_owner_errors( $displayable_errors );
489        }
490
491        /**
492         * Filter displayable connection errors to allow customization of error messages and actions.
493         *
494         * This filter allows sites to customize how connection errors are displayed,
495         * including modifying error messages, actions, and data. Access to this filter
496         * is controlled by should_allow_error_filtering().
497         *
498         * Consumer-injected errors take precedence over the default state. They are not
499         * required to carry the newer `audience` field: it is optional metadata used
500         * only for our own audience-aware messaging, and any reader must treat a missing
501         * value as site-wide (`$error['audience'] ?? 'site'`).
502         *
503         * @since 6.12.0
504         *
505         * @param array $displayable_errors Array of displayable errors with hierarchical structure.
506         * @param array $verified_errors    Array of raw verified errors from the database.
507         */
508        if ( $this->should_allow_error_filtering() ) {
509            $displayable_errors = apply_filters( 'jetpack_connection_get_verified_errors', $displayable_errors, $verified_errors );
510        }
511
512        // Only cache if no external filters are applied
513        if ( ! $this->has_external_filters() ) {
514            if ( ! is_array( $this->cached_displayable_errors ) ) {
515                $this->cached_displayable_errors = array();
516            }
517            $this->cached_displayable_errors[ $viewer_id ] = $displayable_errors;
518        }
519
520        return $displayable_errors;
521    }
522
523    /**
524     * Returns the display configuration for error codes that are meant to be
525     * displayed to users, keyed by error code.
526     *
527     * This is the whitelist consulted by get_displayable_errors(): a raw verified
528     * error whose code is not displayable here is never surfaced. Every code in
529     * `$known_errors` appears in get_error_display_configs(), non-displayable ones
530     * as `false` with the reason recorded alongside them.
531     *
532     * Copy is resolved at display time rather than stored with the error, so messages
533     * follow the viewer's locale and stay current across package updates. Adding a new
534     * error code means adding one entry to get_error_display_configs() — no branching
535     * in the display or notice paths.
536     *
537     * Everything here is display-time state that cannot be stored with the error:
538     * copy must resolve in each viewer's locale and follow current code, and the
539     * notice flags describe how this package renders, not the error itself. The
540     * error's *action* is deliberately NOT configured here — reporters declare it
541     * at creation time in `error_data['action']` (see `wp_error_to_array()`), since
542     * it is a stable machine token.
543     *
544     * Recognized keys, all optional:
545     * - `message_callback` (callable): receives the stored error array, returns the
546     *   displayable message. Omit to keep the generic reconnect copy.
547     * - `default_admin_notice` (bool): when true, generic_admin_notice_error() shows
548     *   this error's message even when no consumer supplies one via the
549     *   `jetpack_connection_error_notice_message` filter (which still overrides).
550     *   This is the only key that reaches beyond My Jetpack's own display: it opts
551     *   the code into a site-wide wp-admin notice. Leave it unset unless the error
552     *   genuinely needs that broader reach (see `xmlrpc_request_blocked` below for why).
553     * - `notice_link` (array): presentational `label` and `url` for a link the notice
554     *   offers alongside (or instead of) the CTA. It reaches two surfaces, gated
555     *   differently on purpose:
556     *   - The default wp-admin notice appends it only when showing this error's own
557     *     default message. There, `jetpack_connection_error_notice_message` hands the
558     *     consumer a bare string with no way to drop the link, so a filtered message
559     *     that kept it could end up pointing somewhere its copy never mentions.
560     *   - The displayable error carries it as `error_data['notice_link']`
561     *     unconditionally, for the connection JS package to render in its own notices.
562     *     No equivalent gate is possible or needed: `error_message` on this path is
563     *     not filtered through anything, and the one filter that can rewrite it —
564     *     `jetpack_connection_displayable_errors` below — receives the whole error
565     *     array, link included, so a consumer changing the copy can unset the link in
566     *     the same pass.
567     * - `survives_owner_promotion` (bool): when true, this code is not dropped by
568     *   promote_owner_errors() while the connection owner's own connection is broken.
569     *   Set it only for a code that is not a token problem, and so is not waiting on
570     *   the owner's reconnect to become actionable. Setting it does not make the code
571     *   trigger that reduction — it only exempts it from one.
572     * - `support_link` (bool): when true, `error_data['support_link']` is set on the
573     *   displayable error, and My Jetpack's notice appends a "Contact Jetpack
574     *   Support" link next to the reconnect CTA. Set it only where reconnecting is
575     *   not reliably the fix, so the viewer has somewhere else to go.
576     *
577     * @since 8.10.0
578     * @since 8.11.0 Merged with the former hardcoded list in get_displayable_errors():
579     *        this method is now also the whitelist, not just the source of overrides.
580     *
581     * @param string $error_code The error code.
582     * @return array|null Display configuration, or null if this code is not displayable.
583     */
584    private function get_error_display_config( $error_code ) {
585        $config = $this->get_error_display_configs()[ $error_code ] ?? false;
586
587        return false === $config ? null : $config;
588    }
589
590    /**
591     * Returns the display disposition of every code in `$known_errors`, keyed by
592     * error code: an array of display configuration for a displayable code, or
593     * `false` for one that is never surfaced to users.
594     *
595     * Kept in the same order as `$known_errors` so the two read side by side, and
596     * covering every code rather than only the displayable ones.
597     *
598     * Split out from get_error_display_config() so the full set can be enumerated
599     * without invoking that method once per known error code.
600     *
601     * @since 8.11.0
602     *
603     * @return array Display configuration (array) or `false`, keyed by error code.
604     */
605    private function get_error_display_configs() {
606        static $configs = null;
607
608        if ( null !== $configs ) {
609            return $configs;
610        }
611
612        // What each code means is documented once, on `$known_errors`. The comments
613        // here record only the display decision, and only where it isn't obvious: an
614        // uncommented `array()` is a broken token that reconnecting fixes, which is
615        // what the generic copy already says.
616        $configs = array(
617            // Attacker-controllable garbage in an incoming request. Nothing about this
618            // site's own connection is wrong.
619            'malformed_user_id'        => false,
620            // Expected after a user is deleted, and the owner flavor is covered by
621            // invalid_connection_owner. Incoming reports also drive WP.com-side
622            // self-healing, so a notice would surface a problem already resolving itself.
623            'unknown_user'             => false,
624            'malformed_token'          => array(),
625            // Never connecting a WordPress.com account is expected, not broken. The owner
626            // flavor is covered by invalid_connection_owner.
627            'no_user_tokens'           => false,
628            // Same, for a site that has never had an owner. invalid_connection_owner
629            // covers the case where there was one and it broke.
630            'empty_master_user_option' => false,
631            // As no_user_tokens, for a single requested user.
632            'no_token_for_user'        => false,
633            'token_malformed'          => array(),
634            // Corrupt local token data, but for one user only, and the
635            // no_valid_user_token/token_malformed pair surfaces it when it actually
636            // blocks a request.
637            'user_id_mismatch'         => false,
638            'no_possible_tokens'       => array(),
639            'no_valid_user_token'      => array(),
640            'no_valid_blog_token'      => array(),
641            'unknown_token'            => array(),
642            'could_not_sign'           => array(),
643            // Both are about the URL being signed, not the connection: a code bug or an
644            // exotic site URL, which reconnecting does not change.
645            'invalid_scheme'           => false,
646            'unknown_scheme_port'      => false,
647            // Corrupt local token data like token_malformed above, caught at signing time
648            // rather than lookup time. Reconnect fixes it the same way.
649            'invalid_secret'           => array(),
650            'invalid_token'            => array(),
651            'token_mismatch'           => array(),
652            // Per-request and transport-level, so unaffected by the state of the connection.
653            'invalid_body'             => false,
654            // Environmental in both directions — a malformed parameter or clock skew,
655            // neither of which a reconnect fixes.
656            'invalid_signature'        => false,
657            // Something altered the request in transit. Not a token problem, and
658            // signature_mismatch carries the same diagnosis with usable copy.
659            'invalid_body_hash'        => false,
660            // A replay, or object-cache trouble. Self-resolving per request.
661            'invalid_nonce'            => false,
662            // Ambiguous cause: could be a genuine secret desync (reconnect fixes it) or a
663            // proxy/CDN/WAF/security plugin altering the request in transit (reconnect
664            // doesn't help). Uses the generic message — support_link offers an
665            // alternative either way.
666            'signature_mismatch'       => array(
667                'support_link' => true,
668            ),
669            // Two flavors with different remedies — see
670            // get_invalid_connection_owner_message().
671            'invalid_connection_owner' => array(
672                'message_callback' => array( $this, 'get_invalid_connection_owner_message' ),
673            ),
674            // The token can be perfectly valid here: the site is rejecting WordPress.com's
675            // requests, so a reconnect would be rejected the same way. The callback
676            // suppresses the reconnect CTA and names the real cause, staying brief because
677            // Site Health holds the full diagnosis. Ships a default admin notice because
678            // no other detection path can see this — WP.com's requests never arrive. And
679            // it outlives a broken owner, whose reconnect the same rule would block.
680            'xmlrpc_request_blocked'   => array(
681                'message_callback'         => array( $this, 'get_blocked_request_message' ),
682                'default_admin_notice'     => true,
683                'survives_owner_promotion' => true,
684                'notice_link'              => array(
685                    'label' => __( 'Visit Site Health', 'jetpack-connection' ),
686                    'url'   => admin_url( 'site-health.php' ),
687                ),
688            ),
689        );
690
691        return $configs;
692    }
693
694    /**
695     * Builds the displayable message for the invalid-connection-owner error.
696     *
697     * `has_user_token` (set in Manager::get_connection_owner(), carried through into
698     * `error_data` by wp_error_to_array()) distinguishes the two flavors:
699     * - false: the owner's user token is simply missing — they still exist as a
700     *   WP user, so reconnecting as them restores the connection.
701     * - true: the token is there, but the WP user it points at was deleted from
702     *   this site. Nobody can reconnect as a user who no longer exists —
703     *   reconnecting here means a different admin becoming the new owner, not
704     *   the original owner logging back in.
705     *
706     * @since 8.11.0
707     *
708     * @param array $error The stored error array.
709     * @return string The message.
710     */
711    private function get_invalid_connection_owner_message( $error ) {
712        if ( ! ( $error['error_data']['has_user_token'] ?? true ) ) {
713            return __( 'The connection owner needs to reconnect their WordPress.com account to restore the connection.', 'jetpack-connection' );
714        }
715
716        return __( 'The WordPress.com account for this connection no longer exists on this site. An administrator needs to reconnect to become the new connection owner.', 'jetpack-connection' );
717    }
718
719    /**
720     * Builds the displayable message for the blocked-request error.
721     *
722     * Deliberately brief: Site Health holds the detailed diagnosis (including the
723     * HTTP status the site returned) and the resolution steps, so the message only
724     * names the condition and points there.
725     *
726     * @since 8.10.0
727     *
728     * @param array $error The stored error array (unused; part of the message_callback contract).
729     * @return string The message.
730     */
731    private function get_blocked_request_message( $error ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
732        return __( 'WordPress.com requests to your site are being blocked, usually by a firewall or security rule. See Site Health for details and next steps.', 'jetpack-connection' );
733    }
734
735    /**
736     * Classifies the audience of a stored connection error based on its user ID.
737     *
738     * The audience determines who a connection error is relevant to and, in turn,
739     * how it should be surfaced:
740     * - `site`  : blog-token / site-wide errors (user ID `0`).
741     * - `owner` : errors tied to the connection owner's user token.
742     * - `user`  : errors tied to a specific (non-owner) user's token.
743     *
744     * Unattributable errors (user ID 'invalid') are skipped by the display pipeline
745     * before classification, so this method only receives numeric user IDs.
746     *
747     * @since 8.8.0
748     *
749     * @param string|int $user_id  The user ID associated with the error (`0` or a positive integer).
750     * @param int        $owner_id The local user ID of the connection owner, or 0 if there is none.
751     * @return string One of 'site', 'owner', or 'user'.
752     */
753    private function classify_error_audience( $user_id, $owner_id ) {
754        $user_id = (int) $user_id;
755
756        if ( 0 === $user_id ) {
757            return 'site';
758        }
759
760        if ( $owner_id > 0 && $user_id === $owner_id ) {
761            return 'owner';
762        }
763
764        return 'user';
765    }
766
767    /**
768     * Whether an error describes the connection owner's own connection.
769     *
770     * @since $$next-version$$
771     *
772     * @param string $error_code The error code.
773     * @param string $audience   The classified audience.
774     * @return bool
775     */
776    private function is_owner_scoped_error( $error_code, $audience ) {
777        return 'owner' === $audience || 'invalid_connection_owner' === $error_code;
778    }
779
780    /**
781     * Reduces a set of displayable errors to the connection-owner ones when the
782     * owner's own connection is broken.
783     *
784     * The connection owner is the account every other connection on the site hangs
785     * off. While it is broken, no other error in the set is independently
786     * actionable.
787     *
788     * See is_owner_scoped_error() for which errors count as a broken owner.
789     *
790     * A code whose display config sets `survives_owner_promotion` is kept regardless.
791     * The premise above holds for token errors, whose one remedy is a reconnect the
792     * owner has to perform first — see that key's documentation on
793     * get_error_display_config() for when it doesn't.
794     *
795     * @since 8.11.0
796     *
797     * @param array $displayable_errors Displayable errors, keyed by error code then user ID.
798     * @return array The owner-only subset when the owner is broken, otherwise the input unchanged.
799     */
800    private function promote_owner_errors( array $displayable_errors ) {
801        $owner_errors    = array();
802        $has_owner_error = false;
803
804        foreach ( $displayable_errors as $error_code => $users ) {
805            // Errors injected by a consumer through the filter that runs after this
806            // reduction have no config of ours; anything reaching here without one is
807            // treated as ordinary.
808            $display_config = $this->get_error_display_config( $error_code );
809            $survives       = null !== $display_config && ! empty( $display_config['survives_owner_promotion'] );
810
811            foreach ( $users as $user_id => $error ) {
812                $is_owner_error = $this->is_owner_scoped_error( $error_code, $error['audience'] ?? '' );
813
814                if ( ! $is_owner_error && ! $survives ) {
815                    continue;
816                }
817
818                $owner_errors[ $error_code ][ $user_id ] = $error;
819
820                // An exempt error is not itself a broken owner, so it must not trigger the
821                // reduction on its own — only survive one triggered by something else.
822                $has_owner_error = $has_owner_error || $is_owner_error;
823            }
824        }
825
826        return $has_owner_error ? $owner_errors : $displayable_errors;
827    }
828
829    /**
830     * Sets up hooks for displaying verified errors on admin pages.
831     *
832     * This method is hooked into 'admin_init'. It retrieves displayable errors
833     * and, if any exist, sets up the necessary action and filter hooks to display
834     * them in admin notices and the React dashboard.
835     *
836     * @since 1.14.2
837     */
838    public function handle_verified_errors() {
839        $displayable_errors = $this->get_displayable_errors();
840
841        // If there are any displayable errors, set up the hooks for displaying them in React dashboard and admin notices.
842        if ( ! empty( $displayable_errors ) ) {
843            add_action( 'admin_notices', array( $this, 'generic_admin_notice_error' ) );
844            add_filter( 'react_connection_errors_initial_state', array( $this, 'jetpack_react_dashboard_error' ), 10, 1 );
845        }
846    }
847
848    /**
849     * Determines whether error filtering should be allowed.
850     *
851     * This method controls access to the jetpack_connection_displayable_errors filter.
852     * Currently, only WoA sites are allowed to use this filter.
853     *
854     * @since 6.13.10
855     *
856     * @return bool True if error filtering should be allowed, false otherwise.
857     */
858    protected function should_allow_error_filtering() {
859        $host = new \Automattic\Jetpack\Status\Host();
860        if ( $host->is_woa_site() || $host->is_vip_site() || $host->is_newspack_site() ) {
861            return true;
862        }
863
864        return false;
865    }
866
867    /**
868     * Provides displayable connection errors for the React dashboard in a flat array format.
869     *
870     * This method transforms the hierarchical displayable_errors structure into the flat format
871     * expected by the React dashboard. It's used as a filter for 'react_connection_errors_initial_state'.
872     * Returns only the first error to avoid overwhelming the user with multiple error messages.
873     *
874     * @since 8.9.0
875     *
876     * @param array $errors Existing errors from other filters (unused but required for filter signature).
877     * @return array Array containing only the first displayable error for the React dashboard.
878     *               Example:
879     *               [
880     *                 [
881     *                   'code' => 'connection_error',
882     *                   'message' => 'Your connection with WordPress.com seems to be broken...',
883     *                   'action' => 'reconnect',
884     *                   'data' => [
885     *                     'api_error_code' => 'invalid_token',
886     *                     'action' => 'reconnect',
887     *                     'audience' => 'site' // Who the error is relevant to: 'site', 'owner', or 'user'.
888     *                   ]
889     *                 ]
890     *               ]
891     */
892    public function jetpack_react_dashboard_error( $errors ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
893        $displayable_errors = $this->get_displayable_errors();
894
895        // Get the first error only
896        $first_error_code = array_key_first( $displayable_errors );
897        if ( ! $first_error_code ) {
898            return array(); // No errors
899        }
900
901        $first_user_errors = $displayable_errors[ $first_error_code ];
902        if ( ! is_array( $first_user_errors ) || empty( $first_user_errors ) ) {
903            return array(); // Invalid error structure
904        }
905
906        $first_error = reset( $first_user_errors );
907
908        // Validate error structure
909        if ( ! is_array( $first_error ) || ! isset( $first_error['error_message'] ) ) {
910            return array(); // Invalid error structure
911        }
912
913        // Determine the action - use the one from error_data if available, otherwise default to 'reconnect'
914        $action = 'reconnect'; // Default action for connection errors
915        if ( isset( $first_error['error_data']['action'] ) && is_string( $first_error['error_data']['action'] ) ) {
916            $action = $first_error['error_data']['action'];
917        }
918
919        // Safely merge error data, ensuring we don't overwrite critical fields
920        $error_data = isset( $first_error['error_data'] ) && is_array( $first_error['error_data'] ) ? $first_error['error_data'] : array();
921
922        // Build the data array with safe merging
923        $dashboard_data = array( 'api_error_code' => $first_error_code );
924
925        // Add error_data fields, but be careful not to overwrite api_error_code
926        foreach ( $error_data as $key => $value ) {
927            if ( 'api_error_code' !== $key ) {
928                $dashboard_data[ $key ] = $value;
929            }
930        }
931
932        // Expose the error audience (site/owner/user) so the dashboard can render
933        // audience-aware copy. Falls back to site-wide for consumer-injected errors
934        // that predate the audience field.
935        $dashboard_data['audience'] = $first_error['audience'] ?? 'site';
936
937        $dashboard_error = array(
938            array(
939                'code'    => 'connection_error',
940                'message' => $first_error['error_message'],
941                'action'  => $action,
942                'data'    => $dashboard_data,
943            ),
944        );
945
946        return $dashboard_error;
947    }
948
949    /**
950     * Gets the instance of this singleton class
951     *
952     * @since 1.14.2
953     *
954     * @return Error_Handler $instance
955     */
956    public static function get_instance() {
957        if ( self::$instance === null ) {
958            self::$instance = new self();
959        }
960        return self::$instance;
961    }
962
963    /**
964     * Keep track of a connection error that was encountered
965     *
966     * This is the entry point of the incoming-request error flow (flow 1 in the class
967     * docblock) when called with `$skip_wpcom_verification = false` (the default).
968     *
969     * Only error codes present in `$known_errors` are handled; anything else is
970     * silently discarded. The `WP_Error` must carry the data shape produced by
971     * `build_connection_error_data()`, or it is discarded as well.
972     *
973     * @param \WP_Error $error  The error object.
974     * @param boolean   $force  Force the report, even if should_report_error is false.
975     * @param boolean   $skip_wpcom_verification Set to 'true' to verify the error locally and skip the WP.com
976     *                  verification round-trip. Only do this when the error is self-evidencing — e.g. it came
977     *                  from a response WP.com sent to a request this site initiated (the outgoing flow), or
978     *                  from local connection state. Skipping verification for an incoming request error would
979     *                  let any unauthenticated requester plant a verified error and trigger its workflows
980     *                  (admin notices, self-healing), so leave it 'false' for anything derived from an
981     *                  incoming request.
982     *
983     * @return void
984     * @since 1.14.2
985     */
986    public function report_error( \WP_Error $error, $force = false, $skip_wpcom_verification = false ) {
987        if ( in_array( $error->get_error_code(), $this->known_errors, true ) && ( $this->should_report_error( $error ) || $force ) ) {
988            $stored_error = $this->store_error( $error );
989            if ( $stored_error ) {
990                $skip_wpcom_verification ? $this->verify_error( $stored_error ) : $this->send_error_to_wpcom( $stored_error );
991            }
992        }
993    }
994
995    /**
996     * Checks the status of the gate
997     *
998     * This protects the site (and WPCOM) against over loads.
999     *
1000     * @since 1.14.2
1001     *
1002     * @param \WP_Error $error the error object.
1003     * @return boolean $should_report True if gate is open and the error should be reported.
1004     */
1005    public function should_report_error( \WP_Error $error ) {
1006        if ( defined( '\\JETPACK_DEV_DEBUG' ) && constant( '\\JETPACK_DEV_DEBUG' ) ) {
1007            return true;
1008        }
1009
1010        /**
1011         * Whether to bypass the gate for the error handling
1012         *
1013         * By default, we only process errors once an hour for each error code.
1014         * This is done to avoid overflows. If you need to disable this gate, you can set this variable to true.
1015         *
1016         * This filter is useful for unit testing
1017         *
1018         * @since 1.14.2
1019         *
1020         * @param boolean $bypass_gate whether to bypass the gate. Default is false, do not bypass.
1021         */
1022        $bypass_gate = apply_filters( 'jetpack_connection_bypass_error_reporting_gate', false );
1023        if ( true === $bypass_gate ) {
1024            return true;
1025        }
1026
1027        $transient = self::error_reporting_gate_transient( $error );
1028
1029        if ( get_transient( $transient ) ) {
1030            return false;
1031        }
1032
1033        set_transient( $transient, true, HOUR_IN_SECONDS );
1034        return true;
1035    }
1036
1037    /**
1038     * Builds the reporting-gate transient name for an error.
1039     *
1040     * Keyed by code and direction, not code alone: an outgoing error is reported
1041     * immediately and verified locally (no WP.com round trip), while an incoming error of the
1042     * same code still needs to clear the gate to reach the `verify_xml_rpc_error` round trip
1043     * that triggers WP.com-side self-healing (flow 1 in the class docblock).
1044     *
1045     * @param \WP_Error $error the error object.
1046     * @return string
1047     */
1048    private static function error_reporting_gate_transient( \WP_Error $error ) {
1049        $error_data      = $error->get_error_data();
1050        $error_direction = is_array( $error_data ) && ! empty( $error_data['error_direction'] ) ? $error_data['error_direction'] : '';
1051
1052        return self::ERROR_REPORTING_GATE . $error->get_error_code() . '_' . $error_direction;
1053    }
1054
1055    /**
1056     * Stores the error in the database so we know there is an issue and can inform the user
1057     *
1058     * @since 1.14.2
1059     *
1060     * @param \WP_Error $error the error object.
1061     * @return boolean|array False if stored errors were not updated and the error array if it was successfully stored.
1062     */
1063    public function store_error( \WP_Error $error ) {
1064
1065        $stored_errors = $this->get_stored_errors();
1066        $error_array   = $this->wp_error_to_array( $error );
1067
1068        if ( ! $error_array ) {
1069            return false;
1070        }
1071
1072        $error_code = $error->get_error_code();
1073        $user_id    = $error_array['user_id'];
1074
1075        if ( ! isset( $stored_errors[ $error_code ] ) || ! is_array( $stored_errors[ $error_code ] ) ) {
1076            $stored_errors[ $error_code ] = array();
1077        }
1078
1079        $stored_errors[ $error_code ][ $user_id ] = $error_array;
1080
1081        // Let's store a maximum of 5 different user ids for each error code.
1082        $error_code_count = is_countable( $stored_errors[ $error_code ] ) ? count( $stored_errors[ $error_code ] ) : 0;
1083        if ( $error_code_count > 5 ) {
1084            // array_shift will destroy keys here because they are numeric, so manually remove first item.
1085            $keys = array_keys( $stored_errors[ $error_code ] );
1086            unset( $stored_errors[ $error_code ][ $keys[0] ] );
1087        }
1088
1089        if ( update_option( self::STORED_ERRORS_OPTION, $stored_errors ) ) {
1090            return $error_array;
1091        }
1092
1093        return false;
1094    }
1095
1096    /**
1097     * Builds action error data for generic JavaScript components.
1098     *
1099     * This helper method creates standardized error_data arrays that work with the generic
1100     * JavaScript error handling components. External plugins (like wpcomsh) can use this
1101     * to ensure their error structures are compatible.
1102     *
1103     * @since 6.16.0
1104     *
1105     * @param array $args Action configuration arguments - only non-empty values will be included.
1106     * @return array Standardized error_data array for JavaScript components.
1107     */
1108    public function build_action_error_data( array $args = array() ) {
1109        // Set default values for variants
1110        $args = wp_parse_args(
1111            $args,
1112            array(
1113                'action_variant'           => 'primary',
1114                'secondary_action_variant' => 'secondary',
1115            )
1116        );
1117
1118        // Start with core data
1119        $error_data = array(
1120            'blog_id' => \Jetpack_Options::get_option( 'id' ),
1121        );
1122
1123        // Validate variant values
1124        $valid_variants = array( 'primary', 'secondary' );
1125        if ( ! in_array( $args['action_variant'], $valid_variants, true ) ) {
1126            $args['action_variant'] = 'primary';
1127        }
1128        if ( ! in_array( $args['secondary_action_variant'], $valid_variants, true ) ) {
1129            $args['secondary_action_variant'] = 'secondary';
1130        }
1131
1132        // Merge extra_data first, then regular args (so args take precedence)
1133        if ( ! empty( $args['extra_data'] ) && is_array( $args['extra_data'] ) ) {
1134            $error_data = array_merge( $error_data, $args['extra_data'] );
1135            unset( $args['extra_data'] ); // Remove from args to avoid duplication
1136        }
1137
1138        // Filter out empty values and merge with error_data
1139        $filtered_args = array_filter(
1140            $args,
1141            function ( $value ) {
1142                return ! empty( $value );
1143            }
1144        );
1145
1146        return array_merge( $error_data, $filtered_args );
1147    }
1148
1149    /**
1150     * Builds a standardized error array for the connection error system.
1151     *
1152     * This method creates a consistent error array structure that can be used
1153     * by both internal error handling and external plugins/customizations.
1154     *
1155     * @since 1.14.2
1156     * @since 8.9.0 Added the `$error_direction` parameter and output field.
1157     *
1158     * @param string $error_code      The error code identifier.
1159     * @param string $error_message   The human-readable error message.
1160     * @param array  $error_data      Additional error data (optional).
1161     * @param string $user_id         The user ID associated with the error (optional).
1162     * @param string $error_type      The type of error (optional). One of the `ERROR_TYPE_*` constants or ''.
1163     * @param string $error_direction The direction of the request that triggered the error (optional).
1164     *                                One of the `DIRECTION_*` constants or ''.
1165     * @return array|false The standardized error array or false on failure.
1166     *                     Example successful return:
1167     *                     [
1168     *                       'error_code' => 'invalid_token',
1169     *                       'user_id' => '123',
1170     *                       'error_message' => 'The token is invalid',
1171     *                       'error_data' => ['action' => 'reconnect'],
1172     *                       'timestamp' => 1234567890,
1173     *                       'nonce' => 'abc123def',
1174     *                       'error_type' => 'xmlrpc',
1175     *                       'error_direction' => 'incoming'
1176     *                     ]
1177     */
1178    public function build_error_array( string $error_code, string $error_message, array $error_data = array(), $user_id = '0', string $error_type = '', string $error_direction = '' ) {
1179        // Validate required parameters
1180        if ( empty( $error_code ) || empty( $error_message ) ) {
1181            return false;
1182        }
1183
1184        // Validate user_id is a string or integer
1185        if ( ! is_string( $user_id ) && ! is_int( $user_id ) ) {
1186            return false;
1187        }
1188
1189        return array(
1190            'error_code'      => $error_code,
1191            'user_id'         => $user_id,
1192            'error_message'   => $error_message,
1193            'error_data'      => $error_data,
1194            'timestamp'       => time(),
1195            'nonce'           => wp_generate_password( 10, false ),
1196            'error_type'      => $error_type,
1197            'error_direction' => $error_direction,
1198        );
1199    }
1200
1201    /**
1202     * Builds the standardized `WP_Error` data payload for a connection error.
1203     *
1204     * This is the single place the error-data contract consumed by `wp_error_to_array()`
1205     * is defined. Use it (or `build_connection_wp_error()`) instead of assembling the
1206     * data array by hand, so every reporter produces the same shape:
1207     *
1208     * - `signature_details` is guaranteed to contain a `token` key (empty string when
1209     *   the error is not tied to a specific token), which `wp_error_to_array()` requires.
1210     *   The token is also what WP.com checks when verifying incoming-flow errors, so its
1211     *   key must not be renamed.
1212     * - `error_type` and `error_direction` are validated against the class constants and
1213     *   stored as '' when the given value is not recognized. For 'local_state' errors the
1214     *   direction is always forced to '' — they describe the site's own database, not a
1215     *   request, so a direction would be meaningless and is ignored if passed.
1216     * - `$extra` cannot override the reserved keys: `signature_details`, `error_type`,
1217     *   and `error_direction` always win the merge.
1218     *
1219     * @since 8.9.0
1220     *
1221     * @param array  $signature_details Details of the signed request that failed: `token`,
1222     *                                  and typically `timestamp`, `nonce`, `body_hash`,
1223     *                                  `method`, `url`.
1224     * @param string $error_type        One of the `ERROR_TYPE_*` constants.
1225     * @param string $error_direction   One of the `DIRECTION_*` constants. Ignored for
1226     *                                  'local_state' errors, which have no direction.
1227     * @param array  $extra             Optional additional data, e.g. a `user_id` fallback for
1228     *                                  errors whose token cannot be attributed to a user, or
1229     *                                  `has_user_token` for `invalid_connection_owner`.
1230     * @return array The error data array to pass as the third argument of `WP_Error`.
1231     */
1232    public static function build_connection_error_data( array $signature_details, string $error_type, string $error_direction, array $extra = array() ) {
1233        $valid_types      = array( self::ERROR_TYPE_XMLRPC, self::ERROR_TYPE_REST, self::ERROR_TYPE_LOCAL_STATE );
1234        $valid_directions = array( self::DIRECTION_INCOMING, self::DIRECTION_OUTGOING );
1235
1236        $error_type = in_array( $error_type, $valid_types, true ) ? $error_type : '';
1237
1238        if ( self::ERROR_TYPE_LOCAL_STATE === $error_type ) {
1239            $error_direction = '';
1240        } else {
1241            $error_direction = in_array( $error_direction, $valid_directions, true ) ? $error_direction : '';
1242        }
1243
1244        return array_merge(
1245            $extra,
1246            array(
1247                'signature_details' => array_merge( array( 'token' => '' ), $signature_details ),
1248                'error_type'        => $error_type,
1249                'error_direction'   => $error_direction,
1250            )
1251        );
1252    }
1253
1254    /**
1255     * Builds a `WP_Error` carrying the standardized connection error data.
1256     *
1257     * Convenience wrapper around `build_connection_error_data()` — see it for the
1258     * data contract. All connection error reporters should create their `WP_Error`
1259     * objects through this factory.
1260     *
1261     * @since 8.9.0
1262     *
1263     * @param string $error_code        The error code, ideally one of `$known_errors`.
1264     * @param string $error_message     The human-readable error message. `build_error_array()` rejects an
1265     *                                  empty message, so a generic fallback is substituted when this is ''.
1266     * @param array  $signature_details Details of the signed request that failed. See `build_connection_error_data()`.
1267     * @param string $error_type        One of the `ERROR_TYPE_*` constants.
1268     * @param string $error_direction   One of the `DIRECTION_*` constants, or '' for errors with no direction.
1269     * @param array  $extra             Optional additional data. See `build_connection_error_data()`.
1270     * @return \WP_Error
1271     */
1272    public static function build_connection_wp_error( string $error_code, string $error_message, array $signature_details, string $error_type, string $error_direction, array $extra = array() ) {
1273        return new \WP_Error(
1274            $error_code,
1275            '' === $error_message ? __( 'An error occurred with the connection.', 'jetpack-connection' ) : $error_message,
1276            self::build_connection_error_data( $signature_details, $error_type, $error_direction, $extra )
1277        );
1278    }
1279
1280    /**
1281     * Converts a WP_Error object in the array representation we store in the database
1282     *
1283     * The `WP_Error` data must follow the contract defined by `build_connection_error_data()`:
1284     * a `signature_details` array containing at least a `token` key is required, and this
1285     * method returns false without storing anything when it is absent. `error_type` and
1286     * `error_direction` are read from the data and stored as '' when missing.
1287     *
1288     * The user attribution comes from the token in `signature_details`, which identifies
1289     * the exact credential that failed. An explicit `user_id` in the error data is only
1290     * consulted as a fallback when the token yields no user (e.g. non-signature errors
1291     * such as `invalid_connection_owner`, which are reported with an empty token).
1292     *
1293     * @since 1.14.2
1294     *
1295     * @param \WP_Error $error the error object.
1296     * @return boolean|array False if error is invalid or the error array
1297     */
1298    public function wp_error_to_array( \WP_Error $error ) {
1299
1300        $data = $error->get_error_data();
1301
1302        if ( ! isset( $data['signature_details'] ) || ! is_array( $data['signature_details'] ) ) {
1303            return false;
1304        }
1305
1306        $signature_details = $data['signature_details'];
1307
1308        if ( ! isset( $signature_details['token'] ) ) {
1309            return false;
1310        }
1311
1312        $user_id = $this->get_user_id_from_token( $signature_details['token'] );
1313
1314        if ( 'invalid' === $user_id && isset( $data['user_id'] ) && is_numeric( $data['user_id'] ) ) {
1315            $user_id = (string) (int) $data['user_id'];
1316        }
1317
1318        $error_data = $signature_details;
1319
1320        // For invalid_connection_owner, has_user_token distinguishes a missing owner
1321        // token from a deleted owner WP user. Keep it so display code can tell the
1322        // two flavors apart.
1323        if ( isset( $data['has_user_token'] ) ) {
1324            $error_data['has_user_token'] = (bool) $data['has_user_token'];
1325        }
1326
1327        // For xmlrpc_request_blocked, the HTTP status the site returned to WP.com
1328        // (e.g. 403). Keep it so display code can include it in the message.
1329        if ( isset( $data['site_http_status'] ) ) {
1330            $error_data['site_http_status'] = (int) $data['site_http_status'];
1331        }
1332
1333        // The display action declared by the reporter at creation time, e.g. 'none'
1334        // to suppress the reconnect CTA. Only our own reporters set this (it is never
1335        // derived from request data); readers treat a missing action as 'reconnect'.
1336        if ( isset( $data['action'] ) && is_string( $data['action'] ) ) {
1337            $error_data['action'] = $data['action'];
1338        }
1339
1340        return $this->build_error_array(
1341            $error->get_error_code(),
1342            $error->get_error_message(),
1343            $error_data,
1344            $user_id,
1345            empty( $data['error_type'] ) ? '' : $data['error_type'],
1346            empty( $data['error_direction'] ) ? '' : $data['error_direction']
1347        );
1348    }
1349
1350    /**
1351     * Sends the error to WP.com to be verified
1352     *
1353     * @since 1.14.2
1354     *
1355     * @param array $error_array The array representation of the error as it is stored in the database.
1356     * @return bool
1357     */
1358    public function send_error_to_wpcom( $error_array ) {
1359
1360        $blog_id = \Jetpack_Options::get_option( 'id' );
1361
1362        $encrypted_data = $this->encrypt_data_to_wpcom( $error_array );
1363
1364        if ( false === $encrypted_data ) {
1365            return false;
1366        }
1367
1368        $args = array(
1369            'body' => array(
1370                'error_data' => $encrypted_data,
1371            ),
1372        );
1373
1374        // send encrypted data to WP.com Public-API v2.
1375        wp_remote_post( "https://public-api.wordpress.com/wpcom/v2/sites/{$blog_id}/jetpack-report-error/", $args );
1376        return true;
1377    }
1378
1379    /**
1380     * Encrypt data to be sent over to WP.com
1381     *
1382     * @since 1.14.2
1383     *
1384     * @param array|string $data the data to be encoded.
1385     * @return boolean|string The encoded string on success, false on failure
1386     */
1387    public function encrypt_data_to_wpcom( $data ) {
1388
1389        try {
1390            // phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
1391            // phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
1392            $encrypted_data = base64_encode( sodium_crypto_box_seal( wp_json_encode( $data, JSON_UNESCAPED_SLASHES ), base64_decode( JETPACK__ERRORS_PUBLIC_KEY ) ) );
1393            // phpcs:enable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
1394            // phpcs:enable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
1395        } catch ( \SodiumException $e ) {
1396            // error encrypting data.
1397            return false;
1398        }
1399
1400        return $encrypted_data;
1401    }
1402
1403    /**
1404     * Extracts the user ID from a token
1405     *
1406     * @since 1.14.2
1407     *
1408     * @param string $token the token used to make the request.
1409     * @return string $the user id or `invalid` if user id not present.
1410     */
1411    public function get_user_id_from_token( $token ) {
1412        $user_id = 'invalid';
1413
1414        if ( $token ) {
1415            $parsed_token = explode( ':', wp_unslash( $token ) );
1416
1417            if ( isset( $parsed_token[2] ) && ctype_digit( $parsed_token[2] ) ) {
1418                $user_id = $parsed_token[2];
1419            }
1420        }
1421
1422        return $user_id;
1423    }
1424
1425    /**
1426     * Gets the reported errors stored in the database
1427     *
1428     * @since 1.14.2
1429     *
1430     * @return array $errors
1431     */
1432    public function get_stored_errors() {
1433
1434        $stored_errors = get_option( self::STORED_ERRORS_OPTION );
1435
1436        if ( ! is_array( $stored_errors ) ) {
1437            $stored_errors = array();
1438        }
1439
1440        $stored_errors = $this->garbage_collector( $stored_errors );
1441
1442        return $stored_errors;
1443    }
1444
1445    /**
1446     * Gets the verified errors stored in the database.
1447     *
1448     * This method retrieves only the errors that are actually stored in the database,
1449     * without applying any filters that might inject additional errors. This is used
1450     * internally by methods that need to modify and store the verified errors back
1451     * to the database to prevent accidentally persisting filtered/injected errors.
1452     *
1453     * @since 1.14.2
1454     *
1455     * @return array $errors
1456     */
1457    public function get_verified_errors() {
1458        $verified_errors = get_option( self::STORED_VERIFIED_ERRORS_OPTION );
1459
1460        if ( ! is_array( $verified_errors ) ) {
1461            $verified_errors = array();
1462        }
1463
1464        $verified_errors = $this->garbage_collector( $verified_errors );
1465
1466        return $verified_errors;
1467    }
1468
1469    /**
1470     * Removes expired errors from the array
1471     *
1472     * This method is called by get_stored_errors and get_verified errors and filters their result
1473     * Whenever a new error is stored to the database or verified, this will be triggered and the
1474     * expired error will be permanently removed from the database
1475     *
1476     * @since 1.14.2
1477     *
1478     * @param array $errors array of errors as stored in the database.
1479     * @return array
1480     */
1481    private function garbage_collector( $errors ) {
1482        foreach ( $errors as $error_code => $users ) {
1483            foreach ( $users as $user_id => $error ) {
1484                if ( empty( $error['timestamp'] ) || self::ERROR_LIFE_TIME < time() - (int) $error['timestamp'] ) {
1485                    unset( $errors[ $error_code ][ $user_id ] );
1486                }
1487            }
1488        }
1489        // Clear empty error codes.
1490        $errors = array_filter(
1491            $errors,
1492            function ( $user_errors ) {
1493                return ! empty( $user_errors );
1494            }
1495        );
1496        return $errors;
1497    }
1498
1499    /**
1500     * Delete all stored and verified errors from the database
1501     *
1502     * @since 1.14.2
1503     *
1504     * @return void
1505     */
1506    public function delete_all_errors() {
1507        $this->delete_stored_errors();
1508        $this->delete_verified_errors();
1509
1510        // Invalidate cache since we deleted all errors
1511        $this->invalidate_displayable_errors_cache();
1512    }
1513
1514    /**
1515     * Delete all stored and verified API errors from the database, leave the non-API errors intact.
1516     *
1517     * Only 'xmlrpc' and 'rest' type errors are deleted. 'local_state' type errors are
1518     * deliberately kept: they describe local connection state (e.g. a missing owner token),
1519     * which a successful API request does not disprove.
1520     *
1521     * @since 1.54.0
1522     *
1523     * @return void
1524     */
1525    public function delete_all_api_errors() {
1526        $type_filter = function ( $errors ) {
1527            if ( is_array( $errors ) ) {
1528                foreach ( $errors as $key => $error ) {
1529                    if ( ! empty( $error['error_type'] ) && in_array( $error['error_type'], array( self::ERROR_TYPE_XMLRPC, self::ERROR_TYPE_REST ), true ) ) {
1530                        unset( $errors[ $key ] );
1531                    }
1532                }
1533            }
1534
1535            return count( $errors ) ? $errors : null;
1536        };
1537
1538        $stored_errors = $this->get_stored_errors();
1539        if ( is_array( $stored_errors ) && count( $stored_errors ) ) {
1540            $stored_errors = array_filter( array_map( $type_filter, $stored_errors ) );
1541            if ( count( $stored_errors ) ) {
1542                update_option( static::STORED_ERRORS_OPTION, $stored_errors );
1543            } else {
1544                delete_option( static::STORED_ERRORS_OPTION );
1545            }
1546        }
1547
1548        $verified_errors = $this->get_verified_errors();
1549        if ( is_array( $verified_errors ) && count( $verified_errors ) ) {
1550            $verified_errors = array_filter( array_map( $type_filter, $verified_errors ) );
1551            if ( count( $verified_errors ) ) {
1552                update_option( static::STORED_VERIFIED_ERRORS_OPTION, $verified_errors );
1553            } else {
1554                delete_option( static::STORED_VERIFIED_ERRORS_OPTION );
1555            }
1556        }
1557
1558        // Invalidate cache since we may have deleted verified errors
1559        $this->invalidate_displayable_errors_cache();
1560    }
1561
1562    /**
1563     * Delete all stored and verified errors from the database and returns unfiltered value
1564     *
1565     * This is used to hook into a couple of filters that expect true to not short circuit the disconnection flow
1566     *
1567     * @since 8.9.0
1568     *
1569     * @param mixed $check The input sent by the filter.
1570     * @return boolean
1571     */
1572    public function delete_all_errors_and_return_unfiltered_value( $check ) {
1573        $this->delete_all_errors();
1574        return $check;
1575    }
1576
1577    /**
1578     * Delete the reported errors stored in the database
1579     *
1580     * @since 1.14.2
1581     *
1582     * @return boolean True, if option is successfully deleted. False on failure.
1583     */
1584    public function delete_stored_errors() {
1585        return delete_option( self::STORED_ERRORS_OPTION );
1586    }
1587
1588    /**
1589     * Delete the verified errors stored in the database
1590     *
1591     * @since 1.14.2
1592     *
1593     * @return boolean True, if option is successfully deleted. False on failure.
1594     */
1595    public function delete_verified_errors() {
1596        return delete_option( self::STORED_VERIFIED_ERRORS_OPTION );
1597    }
1598
1599    /**
1600     * Deletes all stored and verified errors for a single error code.
1601     *
1602     * Used by self-healing flows that can positively confirm one specific error
1603     * condition is gone (e.g. a passing connection test clearing
1604     * `xmlrpc_request_blocked`) without touching unrelated errors.
1605     *
1606     * @since 8.10.0
1607     *
1608     * @param string $error_code The error code to delete.
1609     * @return bool True if any stored or verified error was deleted.
1610     */
1611    public function delete_error_by_code( $error_code ) {
1612        $deleted = false;
1613
1614        // Reopen the reporting gate for this code: deletion means the condition was
1615        // positively confirmed cleared, so a recurrence must be reportable immediately
1616        // rather than suppressed for up to an hour. The gate is keyed by code + direction
1617        // (see error_reporting_gate_transient()), so every direction variant is cleared.
1618        delete_transient( self::ERROR_REPORTING_GATE . $error_code . '_' . self::DIRECTION_INCOMING );
1619        delete_transient( self::ERROR_REPORTING_GATE . $error_code . '_' . self::DIRECTION_OUTGOING );
1620        delete_transient( self::ERROR_REPORTING_GATE . $error_code . '_' );
1621
1622        $stored_errors = $this->get_stored_errors();
1623        if ( isset( $stored_errors[ $error_code ] ) ) {
1624            unset( $stored_errors[ $error_code ] );
1625            $deleted = true;
1626            if ( count( $stored_errors ) ) {
1627                update_option( self::STORED_ERRORS_OPTION, $stored_errors );
1628            } else {
1629                delete_option( self::STORED_ERRORS_OPTION );
1630            }
1631        }
1632
1633        $verified_errors = $this->get_verified_errors();
1634        if ( isset( $verified_errors[ $error_code ] ) ) {
1635            unset( $verified_errors[ $error_code ] );
1636            $deleted = true;
1637            if ( count( $verified_errors ) ) {
1638                update_option( self::STORED_VERIFIED_ERRORS_OPTION, $verified_errors );
1639            } else {
1640                delete_option( self::STORED_VERIFIED_ERRORS_OPTION );
1641            }
1642        }
1643
1644        if ( $deleted ) {
1645            $this->invalidate_displayable_errors_cache();
1646        }
1647
1648        return $deleted;
1649    }
1650
1651    /**
1652     * Gets an error based on the nonce
1653     *
1654     * Receives a nonce and finds the related error.
1655     *
1656     * @since 1.14.2
1657     *
1658     * @param string $nonce The nonce created for the error we want to get.
1659     * @return null|array Returns the error array representation or null if error not found.
1660     */
1661    public function get_error_by_nonce( $nonce ) {
1662        $errors = $this->get_stored_errors();
1663        foreach ( $errors as $user_group ) {
1664            foreach ( $user_group as $error ) {
1665                if ( $error['nonce'] === $nonce ) {
1666                    return $error;
1667                }
1668            }
1669        }
1670        return null;
1671    }
1672
1673    /**
1674     * Adds an error to the verified error list
1675     *
1676     * @since 1.14.2
1677     *
1678     * @param array $error The error array, as it was saved in the unverified errors list.
1679     * @return void
1680     */
1681    public function verify_error( $error ) {
1682
1683        $verified_errors = $this->get_verified_errors();
1684        $error_code      = $error['error_code'];
1685        $user_id         = $error['user_id'];
1686
1687        if ( ! isset( $verified_errors[ $error_code ] ) ) {
1688            $verified_errors[ $error_code ] = array();
1689        }
1690
1691        $verified_errors[ $error_code ][ $user_id ] = $error;
1692
1693        update_option( self::STORED_VERIFIED_ERRORS_OPTION, $verified_errors );
1694
1695        // Invalidate cache since we added a new verified error
1696        $this->invalidate_displayable_errors_cache();
1697    }
1698
1699    /**
1700     * Register REST API end point for error handling.
1701     *
1702     * @since 1.14.2
1703     *
1704     * @return void
1705     */
1706    public function register_verify_error_endpoint() {
1707        register_rest_route(
1708            'jetpack/v4',
1709            '/verify_xmlrpc_error',
1710            array(
1711                'methods'             => \WP_REST_Server::CREATABLE,
1712                'callback'            => array( $this, 'verify_xml_rpc_error' ),
1713                'permission_callback' => '__return_true',
1714                'args'                => array(
1715                    'nonce' => array(
1716                        'required' => true,
1717                        'type'     => 'string',
1718                    ),
1719                ),
1720            )
1721        );
1722    }
1723
1724    /**
1725     * Handles verification that a xml rpc error is legit and came from WordPres.com
1726     *
1727     * @since 1.14.2
1728     *
1729     * @param \WP_REST_Request $request The request sent to the WP REST API.
1730     *
1731     * @return boolean
1732     */
1733    public function verify_xml_rpc_error( \WP_REST_Request $request ) {
1734        $error = $this->get_error_by_nonce( $request['nonce'] );
1735
1736        if ( $error ) {
1737            $this->verify_error( $error );
1738            return new \WP_REST_Response( true, 200 );
1739        }
1740
1741        return new \WP_REST_Response( false, 200 );
1742    }
1743
1744    /**
1745     * Prints a generic error notice for all connection errors
1746     *
1747     * @since 8.9.0
1748     *
1749     * @return void
1750     */
1751    public function generic_admin_notice_error() {
1752        // do not add admin notice to the jetpack dashboard.
1753        global $pagenow;
1754        if ( 'admin.php' === $pagenow || isset( $_GET['page'] ) && 'jetpack' === $_GET['page'] ) { // phpcs:ignore
1755            return;
1756        }
1757
1758        if ( ! current_user_can( 'jetpack_connect' ) ) {
1759            return;
1760        }
1761
1762        $displayable_errors = $this->get_displayable_errors();
1763
1764        // Most errors default to no admin notice — consumers opt in via the filter
1765        // below, and the React dashboard is the primary surface. Error codes whose
1766        // display config sets `default_admin_notice` provide their own message and
1767        // do not depend on a consumer supplying one.
1768        $default_message = '';
1769        $notice_link     = null;
1770        foreach ( $displayable_errors as $error_code => $user_errors ) {
1771            $display_config = $this->get_error_display_config( $error_code );
1772            if ( empty( $display_config['default_admin_notice'] ) ) {
1773                continue;
1774            }
1775            // On selected hosting platforms the displayable errors pass through a
1776            // consumer filter, so the shape is not guaranteed.
1777            if ( ! is_array( $user_errors ) ) {
1778                continue;
1779            }
1780            $first_error = reset( $user_errors );
1781            if ( is_array( $first_error ) && ! empty( $first_error['error_message'] ) ) {
1782                $default_message = $first_error['error_message'];
1783                $notice_link     = $display_config['notice_link'] ?? null;
1784                break;
1785            }
1786        }
1787
1788        /**
1789         * Filters the message to be displayed in the admin notices area when there's a connection error.
1790         *
1791         * By default we don't display any errors, except for the blocked-request error
1792         * (`xmlrpc_request_blocked`), which provides its own default message.
1793         *
1794         * Return an empty value to disable the message.
1795         *
1796         * @since 8.9.0
1797         * @since 8.10.0 The default message is no longer always empty.
1798         *
1799         * @param string $message The error message.
1800         * @param array  $errors The array of errors. See Automattic\Jetpack\Connection\Error_Handler for details on the array structure.
1801         */
1802        $message = apply_filters( 'jetpack_connection_error_notice_message', $default_message, $displayable_errors );
1803
1804        /**
1805         * Fires inside the admin_notices hook just before displaying the error message for a broken connection.
1806         *
1807         * If you want to disable the default message from being displayed, return an empty value in the jetpack_connection_error_notice_message filter.
1808         *
1809         * @since 8.9.0
1810         *
1811         * @param array $errors The array of errors. See Automattic\Jetpack\Connection\Error_Handler for details on the array structure.
1812         */
1813        do_action( 'jetpack_connection_error_notice', $displayable_errors );
1814
1815        if ( empty( $message ) ) {
1816            return;
1817        }
1818
1819        $notice_content = esc_html( $message );
1820
1821        // Append the link only when the notice is showing the unmodified default
1822        // message — a filtered message keeps full control of the copy.
1823        if ( $notice_link && $message === $default_message && ! empty( $notice_link['url'] ) && ! empty( $notice_link['label'] ) ) {
1824            $notice_content .= sprintf(
1825                ' <a href="%1$s">%2$s</a>',
1826                esc_url( $notice_link['url'] ),
1827                esc_html( $notice_link['label'] )
1828            );
1829        }
1830
1831        wp_admin_notice(
1832            $notice_content,
1833            array(
1834                'type'               => 'error',
1835                'dismissible'        => true,
1836                'additional_classes' => array( 'jetpack-message', 'jp-connect' ),
1837                'attributes'         => array( 'style' => 'display:block !important;' ),
1838            )
1839        );
1840    }
1841
1842    /**
1843     * Check an outgoing signed request's response for errors, and store them if needed.
1844     *
1845     * This is the entry point of the outgoing-request error flow (flow 2 in the class
1846     * docblock). `Client::remote_request()` calls it after every outgoing signed request.
1847     * Errors captured here are stored directly as verified — the WP.com verification
1848     * round-trip used for incoming errors is unnecessary, because the error arrived in a
1849     * response to a request this site itself initiated and signed.
1850     *
1851     * Note: XML-RPC faults arrive as HTTP 200 responses with an XML body, so they are
1852     * invisible to this method — only errors surfaced at the HTTP level with a JSON error
1853     * envelope are captured. `Jetpack_IXR_Client::query()` reports faults itself, via
1854     * check_xmlrpc_fault_for_errors().
1855     *
1856     * @see wp_remote_request() For more information on the $http_response array format.
1857     * @param array|\WP_Error $http_response The response or WP_Error on failure.
1858     * @param array           $auth_data Auth data, allowed keys: `token`, `timestamp`, `nonce`, `body-hash`.
1859     * @param string          $url Request URL.
1860     * @param string          $method Request method.
1861     * @param string          $error_type The transport of the outgoing request: `ERROR_TYPE_XMLRPC` or `ERROR_TYPE_REST`.
1862     *
1863     * @return void
1864     */
1865    public function check_api_response_for_errors( $http_response, $auth_data, $url, $method, $error_type ) {
1866        if ( 200 === wp_remote_retrieve_response_code( $http_response ) || ! is_array( $auth_data ) || ! $url || ! $method ) {
1867            return;
1868        }
1869
1870        $body_raw = wp_remote_retrieve_body( $http_response );
1871        if ( ! $body_raw ) {
1872            return;
1873        }
1874
1875        $body = json_decode( $body_raw, true );
1876
1877        // Support both error envelopes: the legacy v1 JSON-API shape (`error`) and the
1878        // WP-API v2 shape (`code`), the latter used by `wpcom/v2` endpoints such as
1879        // `jetpack-wpcom-user-data`. Prefer `error` for backwards compatibility.
1880        $error_code = is_array( $body ) ? ( $body['error'] ?? $body['code'] ?? null ) : null;
1881
1882        if ( empty( $error_code ) || ( ! is_string( $error_code ) && ! is_int( $error_code ) ) ) {
1883            return;
1884        }
1885
1886        $error = self::build_connection_wp_error(
1887            (string) $error_code,
1888            empty( $body['message'] ) ? '' : $body['message'],
1889            array(
1890                'token'     => empty( $auth_data['token'] ) ? '' : $auth_data['token'],
1891                'timestamp' => empty( $auth_data['timestamp'] ) ? '' : $auth_data['timestamp'],
1892                'nonce'     => empty( $auth_data['nonce'] ) ? '' : $auth_data['nonce'],
1893                // `Client::build_signed_request()` builds this key as `body-hash` (it is sent as an
1894                // `Authorization` header parameter). The snake_case fallback keeps callers that pass
1895                // the stored `signature_details` shape working.
1896                'body_hash' => $auth_data['body-hash'] ?? $auth_data['body_hash'] ?? '',
1897                'method'    => $method,
1898                'url'       => $url,
1899            ),
1900            $error_type,
1901            self::DIRECTION_OUTGOING
1902        );
1903
1904        $this->report_error( $error, false, true );
1905    }
1906
1907    /**
1908     * Check the result of signing an outgoing request for errors, and store them if needed.
1909     *
1910     * This is the second entry point of the outgoing-request error flow (flow 2 in the class
1911     * docblock). It handles failures from `Client::build_signed_request()`, which
1912     * occur before a request is sent and therefore have no response to inspect.
1913     *
1914     * Like response errors in flow 2, these are stored as verified without a WP.com
1915     * round-trip because the site's own token and URL state provides the evidence.
1916     * The hourly reporting gate in `report_error()` still applies.
1917     *
1918     * Codes reaching this method include `malformed_token` and `invalid_body` (from
1919     * `Client::build_signed_request()`), plus the signing errors returned by
1920     * `Jetpack_Signature::sign_request()` (e.g. `invalid_scheme`, `unknown_scheme_port`),
1921     * plus the token-lookup errors raised by `Tokens::get_access_token()` (e.g.
1922     * `no_user_tokens`, `no_token_for_user`). `tokens_locked` also reaches here but is not
1923     * in `known_errors`, so `report_error()` silently discards it — see the comment on
1924     * `Client::build_signed_request()`'s `tokens_locked` branch for why.
1925     *
1926     * This includes token lookup, request validation, and request signing errors.
1927     *
1928     * @since 8.10.1
1929     *
1930     * @param mixed  $signing_result The return value of `Client::build_signed_request()`. Ignored unless it is a `WP_Error`.
1931     * @param string $url            Request URL.
1932     * @param string $method         Request method.
1933     * @param string $error_type     The transport of the outgoing request: `ERROR_TYPE_XMLRPC` or `ERROR_TYPE_REST`.
1934     *
1935     * @return void
1936     */
1937    public function check_signed_request_for_errors( $signing_result, $url, $method, $error_type ) {
1938        if ( ! is_wp_error( $signing_result ) ) {
1939            return;
1940        }
1941
1942        $data = $signing_result->get_error_data();
1943
1944        // The signing errors raised by `Jetpack_Signature` already carry the details of the
1945        // request they failed to sign; the ones raised by `Client` itself carry nothing.
1946        $signature_details = isset( $data['signature_details'] ) && is_array( $data['signature_details'] )
1947            ? $data['signature_details']
1948            : array();
1949
1950        $signature_details += array(
1951            'method' => $method,
1952            'url'    => $url,
1953        );
1954
1955        $error = self::build_connection_wp_error(
1956            (string) $signing_result->get_error_code(),
1957            $signing_result->get_error_message(),
1958            $signature_details,
1959            $error_type,
1960            self::DIRECTION_OUTGOING,
1961            // `Tokens::get_access_token()` attaches `user_id` to the WP_Errors it raises when it
1962            // has already resolved one (see its docblock); pass it through as the attribution
1963            // fallback consulted by `wp_error_to_array()`. Errors with no token to look up at all
1964            // (e.g. `tokens_locked`, `malformed_token` from `Client` itself) carry no such data,
1965            // and fall back to unattributed there.
1966            array( 'user_id' => isset( $data['user_id'] ) ? (int) $data['user_id'] : 0 )
1967        );
1968
1969        $this->report_error( $error, false, true );
1970    }
1971
1972    /**
1973     * Check an outgoing XML-RPC request's fault response for errors, and store them if needed.
1974     *
1975     * This is the third entry point of the outgoing-request error flow (flow 2 in the class
1976     * docblock). XML-RPC faults arrive as HTTP 200 responses with an XML body, so
1977     * check_api_response_for_errors() never sees them — it returns immediately on a 200,
1978     * and decodes the body as JSON rather than XML anyway. `Jetpack_IXR_Client::query()`
1979     * calls this method directly from its fault branch instead.
1980     *
1981     * The code/message pair is recovered from the fault string by the caller, via
1982     * `Jetpack_IXR_Client::parse_jetpack_fault_string()` — the class that owns the
1983     * `Jetpack: [code] message` convention. An unparseable fault string is the caller's
1984     * concern, not this method's; a fault code reaching here is untrusted input, and it's
1985     * `report_error()`'s `$known_errors` allowlist, not this method, that keeps an
1986     * unrecognized code from being stored. In practice every `jetpack.*` XML-RPC handler
1987     * on WP.com emits fixed string literals here, never attacker- or request-composed
1988     * ones, and the handful that are also `$known_errors` (`unknown_token`,
1989     * `signature_mismatch`, `invalid_token`, `token_mismatch`, `invalid_signature`) are
1990     * the same codes this site itself raises for the same failure — WP.com is just
1991     * verifying signatures with the same scheme.
1992     *
1993     * @since 8.10.4
1994     *
1995     * @param string $error_code    The Jetpack error code parsed from the fault string.
1996     * @param string $error_message The error message parsed from the fault string.
1997     * @param string $url           Request URL.
1998     * @param string $method        Request method.
1999     * @param int    $user_id       The local user ID the request was signed for, or `0` for the blog token.
2000     *
2001     * @return void
2002     */
2003    public function check_xmlrpc_fault_for_errors( string $error_code, string $error_message, string $url, string $method, int $user_id = 0 ) {
2004        $error = self::build_connection_wp_error(
2005            $error_code,
2006            $error_message,
2007            array(
2008                'method' => $method,
2009                'url'    => $url,
2010            ),
2011            self::ERROR_TYPE_XMLRPC,
2012            self::DIRECTION_OUTGOING,
2013            array( 'user_id' => $user_id )
2014        );
2015
2016        $this->report_error( $error, false, true );
2017    }
2018
2019    /**
2020     * Determines whether external filters are applied to the get_displayable_errors method.
2021     *
2022     * @since 6.13.10
2023     *
2024     * @return bool True if external filters are applied, false otherwise.
2025     */
2026    private function has_external_filters() {
2027        return has_filter( 'jetpack_connection_get_verified_errors' ) &&
2028            $this->should_allow_error_filtering();
2029    }
2030
2031    /**
2032     * Invalidates the cached displayable errors
2033     *
2034     * @since 6.13.10
2035     *
2036     * @return void
2037     */
2038    private function invalidate_displayable_errors_cache() {
2039        $this->cached_displayable_errors = null;
2040    }
2041}