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