PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.7
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.7
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 +220 -47 16.2 → 16.3-a.7 View file →
@@ -216,8 +216,9 @@
216 216 'signature_mismatch', // Computed signature differs: wrong secret, or URL/body drift (domain change, proxy).
217 217 // Connection state problems (Manager::get_connection_owner, Connection_Health_Tests).
218 218 'invalid_connection_owner', // The connection owner cannot be resolved: token missing or WP user deleted.
219 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).
220 221 );
221 222
222 223 /**
223 224 * Holds the instance of this singleton class
@@ -266,13 +267,19 @@
266 267 * that can be safely displayed in My Jetpack and other UI components. It includes
267 268 * predefined error messages and actions, with optional filtering for specific sites.
268 269 * Only processes a limited set of error codes that are meant to be displayed to users.
269 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 + *
270 276 * error_data.action is only set when it deviates from the default behavior
271 277 * (e.g. 'none' to suppress the reconnect CTA); when absent, readers fall back
272 278 * to offering the reconnect CTA.
273 279 *
274 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.
275 282 *
276 283 * @return array Array of displayable errors with hierarchical structure.
277 284 * Example:
278 285 * [
@@ -314,8 +321,13 @@
314 321 $owner_id = (int) \Jetpack_Options::get_option( 'master_user' );
315 322 $viewer_is_owner = $owner_id > 0 && $viewer_id === $owner_id;
316 323 $is_transferable = ( new Manager() )->is_ownership_transferable();
317 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 +
318 330 foreach ( $verified_errors as $error_code => $users ) {
319 331 // Only process error codes that are meant to be displayed to users.
320 332 // A raw verified error whose code is marked non-displayable in
321 333 // get_error_display_configs() is never surfaced.
@@ -352,8 +364,19 @@
352 364 && 'invalid_connection_owner' !== $error_code ) {
353 365 continue;
354 366 }
355 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 +
356 379 $message = $generic_message;
357 380 $action = null;
358 381
359 382 if ( isset( $display_config['message_callback'] ) ) {
@@ -374,10 +397,9 @@
374 397 // A secondary admin looking at the connection owner's token error. What
375 398 // they can usefully be told depends on whether ownership is transferable.
376 399 // Only name the owner, or describe what reconnecting would do, for
377 400 // viewers who can act on connection issues.
378 - $viewer_can_connect = current_user_can( 'jetpack_connect' );
379 - $owner_name = '';
401 + $owner_name = '';
380 402 if ( $viewer_can_connect ) {
381 403 $owner = get_userdata( $owner_id );
382 404 $owner_name = $owner instanceof \WP_User ? $owner->display_name : '';
383 405 }
@@ -408,8 +430,15 @@
408 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' );
409 431 }
410 432 }
411 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 +
412 441 $error['audience'] = $audience;
413 442 $error['error_message'] = $message;
414 443
415 444 // Only emit error_data.action when it deviates from the default. Readers
@@ -415,9 +444,12 @@
415 444 // Only emit error_data.action when it deviates from the default. Readers
416 445 // already fall back to the reconnect CTA when no action is set, and
417 446 // injecting an explicit 'reconnect' could trip consumer code paths
418 447 // reserved for custom actions.
419 - if ( null !== $action || ! empty( $display_config['support_link'] ) ) {
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 ) {
420 452 $error_data = ( isset( $error['error_data'] ) && is_array( $error['error_data'] ) ) ? $error['error_data'] : array();
421 453
422 454 if ( null !== $action ) {
423 455 $error_data['action'] = $action;
@@ -429,8 +461,21 @@
429 461 if ( ! empty( $display_config['support_link'] ) ) {
430 462 $error_data['support_link'] = true;
431 463 }
432 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 +
433 478 $error['error_data'] = $error_data;
434 479 }
435 480
436 481 if ( ! isset( $displayable_errors[ $error_code ] ) ) {
@@ -507,11 +552,22 @@
507 552 * `jetpack_connection_error_notice_message` filter (which still overrides).
508 553 * This is the only key that reaches beyond My Jetpack's own display: it opts
509 554 * the code into a site-wide wp-admin notice. Leave it unset unless the error
510 555 * genuinely needs that broader reach (see `xmlrpc_request_blocked` below for why).
511 - * - `notice_link` (array): presentational `label` and `url` for a link appended to
512 - * the default admin notice only. Only used when the notice shows this error's
513 - * default message (a filtered message keeps full control of the copy).
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.
514 570 * - `survives_owner_promotion` (bool): when true, this code is not dropped by
515 571 * promote_owner_errors() while the connection owner's own connection is broken.
516 572 * Set it only for a code that is not a token problem, and so is not waiting on
517 573 * the owner's reconnect to become actionable. Setting it does not make the code
@@ -562,61 +618,61 @@
562 618 // what the generic copy already says.
563 619 $configs = array(
564 620 // Attacker-controllable garbage in an incoming request. Nothing about this
565 621 // site's own connection is wrong.
566 - 'malformed_user_id' => false,
622 + 'malformed_user_id' => false,
567 623 // Expected after a user is deleted, and the owner flavor is covered by
568 624 // invalid_connection_owner. Incoming reports also drive WP.com-side
569 625 // self-healing, so a notice would surface a problem already resolving itself.
570 - 'unknown_user' => false,
571 - 'malformed_token' => array(),
626 + 'unknown_user' => false,
627 + 'malformed_token' => array(),
572 628 // Never connecting a WordPress.com account is expected, not broken. The owner
573 629 // flavor is covered by invalid_connection_owner.
574 - 'no_user_tokens' => false,
630 + 'no_user_tokens' => false,
575 631 // Same, for a site that has never had an owner. invalid_connection_owner
576 632 // covers the case where there was one and it broke.
577 - 'empty_master_user_option' => false,
633 + 'empty_master_user_option' => false,
578 634 // As no_user_tokens, for a single requested user.
579 - 'no_token_for_user' => false,
580 - 'token_malformed' => array(),
635 + 'no_token_for_user' => false,
636 + 'token_malformed' => array(),
581 637 // Corrupt local token data, but for one user only, and the
582 638 // no_valid_user_token/token_malformed pair surfaces it when it actually
583 639 // blocks a request.
584 - 'user_id_mismatch' => false,
585 - 'no_possible_tokens' => array(),
586 - 'no_valid_user_token' => array(),
587 - 'no_valid_blog_token' => array(),
588 - 'unknown_token' => array(),
589 - 'could_not_sign' => array(),
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(),
590 646 // Both are about the URL being signed, not the connection: a code bug or an
591 647 // exotic site URL, which reconnecting does not change.
592 - 'invalid_scheme' => false,
593 - 'unknown_scheme_port' => false,
648 + 'invalid_scheme' => false,
649 + 'unknown_scheme_port' => false,
594 650 // Corrupt local token data like token_malformed above, caught at signing time
595 651 // rather than lookup time. Reconnect fixes it the same way.
596 - 'invalid_secret' => array(),
597 - 'invalid_token' => array(),
598 - 'token_mismatch' => array(),
652 + 'invalid_secret' => array(),
653 + 'invalid_token' => array(),
654 + 'token_mismatch' => array(),
599 655 // Per-request and transport-level, so unaffected by the state of the connection.
600 - 'invalid_body' => false,
656 + 'invalid_body' => false,
601 657 // Environmental in both directions — a malformed parameter or clock skew,
602 658 // neither of which a reconnect fixes.
603 - 'invalid_signature' => false,
659 + 'invalid_signature' => false,
604 660 // Something altered the request in transit. Not a token problem, and
605 661 // signature_mismatch carries the same diagnosis with usable copy.
606 - 'invalid_body_hash' => false,
662 + 'invalid_body_hash' => false,
607 663 // A replay, or object-cache trouble. Self-resolving per request.
608 - 'invalid_nonce' => false,
664 + 'invalid_nonce' => false,
609 665 // Ambiguous cause: could be a genuine secret desync (reconnect fixes it) or a
610 666 // proxy/CDN/WAF/security plugin altering the request in transit (reconnect
611 667 // doesn't help). Uses the generic message — support_link offers an
612 668 // alternative either way.
613 - 'signature_mismatch' => array(
669 + 'signature_mismatch' => array(
614 670 'support_link' => true,
615 671 ),
616 672 // Two flavors with different remedies — see
617 673 // get_invalid_connection_owner_message().
618 - 'invalid_connection_owner' => array(
674 + 'invalid_connection_owner' => array(
619 675 'message_callback' => array( $this, 'get_invalid_connection_owner_message' ),
620 676 ),
621 677 // The token can be perfectly valid here: the site is rejecting WordPress.com's
622 678 // requests, so a reconnect would be rejected the same way. The callback
@@ -623,9 +679,9 @@
623 679 // suppresses the reconnect CTA and names the real cause, staying brief because
624 680 // Site Health holds the full diagnosis. Ships a default admin notice because
625 681 // no other detection path can see this — WP.com's requests never arrive. And
626 682 // it outlives a broken owner, whose reconnect the same rule would block.
627 - 'xmlrpc_request_blocked' => array(
683 + 'xmlrpc_request_blocked' => array(
628 684 'message_callback' => array( $this, 'get_blocked_request_message' ),
629 685 'default_admin_notice' => true,
630 686 'survives_owner_promotion' => true,
631 687 'notice_link' => array(
@@ -632,8 +688,22 @@
632 688 'label' => __( 'Visit Site Health', 'jetpack-connection' ),
633 689 'url' => admin_url( 'site-health.php' ),
634 690 ),
635 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 + ),
636 706 );
637 707
638 708 return $configs;
639 709 }
@@ -679,8 +749,23 @@
679 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' );
680 750 }
681 751
682 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 + /**
683 768 * Classifies the audience of a stored connection error based on its user ID.
684 769 *
685 770 * The audience determines who a connection error is relevant to and, in turn,
686 771 * how it should be surfaced:
@@ -711,8 +796,56 @@
711 796 return 'user';
712 797 }
713 798
714 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 + /**
715 848 * Reduces a set of displayable errors to the connection-owner ones when the
716 849 * owner's own connection is broken.
717 850 *
718 851 * The connection owner is the account every other connection on the site hangs
@@ -718,14 +851,9 @@
718 851 * The connection owner is the account every other connection on the site hangs
719 852 * off. While it is broken, no other error in the set is independently
720 853 * actionable.
721 854 *
722 - * Two shapes count as a broken owner:
723 - * - any error classified with the `owner` audience, i.e. attributed to the
724 - * current owner's user ID; and
725 - * - `invalid_connection_owner` at any audience — when there is no current owner
726 - * to compare a user ID against, classify_error_audience() falls back to
727 - * `user`, but the code itself already says the owner cannot be resolved.
855 + * See is_owner_scoped_error() for which errors count as a broken owner.
728 856 *
729 857 * A code whose display config sets `survives_owner_promotion` is kept regardless.
730 858 * The premise above holds for token errors, whose one remedy is a reconnect the
731 859 * owner has to perform first — see that key's documentation on
@@ -747,10 +875,9 @@
747 875 $display_config = $this->get_error_display_config( $error_code );
748 876 $survives = null !== $display_config && ! empty( $display_config['survives_owner_promotion'] );
749 877
750 878 foreach ( $users as $user_id => $error ) {
751 - $is_owner_error = 'owner' === ( $error['audience'] ?? '' )
752 - || 'invalid_connection_owner' === $error_code;
879 + $is_owner_error = $this->is_owner_scoped_error( $error_code, $error['audience'] ?? '' );
753 880
754 881 if ( ! $is_owner_error && ! $survives ) {
755 882 continue;
756 883 }
@@ -1025,9 +1152,11 @@
1025 1152 $keys = array_keys( $stored_errors[ $error_code ] );
1026 1153 unset( $stored_errors[ $error_code ][ $keys[0] ] );
1027 1154 }
1028 1155
1029 - 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 ) ) {
1030 1159 return $error_array;
1031 1160 }
1032 1161
1033 1162 return false;
@@ -1478,9 +1607,9 @@
1478 1607 $stored_errors = $this->get_stored_errors();
1479 1608 if ( is_array( $stored_errors ) && count( $stored_errors ) ) {
1480 1609 $stored_errors = array_filter( array_map( $type_filter, $stored_errors ) );
1481 1610 if ( count( $stored_errors ) ) {
1482 - update_option( static::STORED_ERRORS_OPTION, $stored_errors );
1611 + update_option( static::STORED_ERRORS_OPTION, $stored_errors, false );
1483 1612 } else {
1484 1613 delete_option( static::STORED_ERRORS_OPTION );
1485 1614 }
1486 1615 }
@@ -1488,14 +1617,19 @@
1488 1617 $verified_errors = $this->get_verified_errors();
1489 1618 if ( is_array( $verified_errors ) && count( $verified_errors ) ) {
1490 1619 $verified_errors = array_filter( array_map( $type_filter, $verified_errors ) );
1491 1620 if ( count( $verified_errors ) ) {
1492 - update_option( static::STORED_VERIFIED_ERRORS_OPTION, $verified_errors );
1621 + update_option( static::STORED_VERIFIED_ERRORS_OPTION, $verified_errors, false );
1493 1622 } else {
1494 1623 delete_option( static::STORED_VERIFIED_ERRORS_OPTION );
1495 1624 }
1496 1625 }
1497 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 +
1498 1632 // Invalidate cache since we may have deleted verified errors
1499 1633 $this->invalidate_displayable_errors_cache();
1500 1634 }
1501 1635
@@ -1521,9 +1655,11 @@
1521 1655 *
1522 1656 * @return boolean True, if option is successfully deleted. False on failure.
1523 1657 */
1524 1658 public function delete_stored_errors() {
1525 - 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;
1526 1662 }
1527 1663
1528 1664 /**
1529 1665 * Delete the verified errors stored in the database
@@ -1532,12 +1668,38 @@
1532 1668 *
1533 1669 * @return boolean True, if option is successfully deleted. False on failure.
1534 1670 */
1535 1671 public function delete_verified_errors() {
1536 - 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;
1537 1675 }
1538 1676
1539 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 + /**
1540 1702 * Deletes all stored and verified errors for a single error code.
1541 1703 *
1542 1704 * Used by self-healing flows that can positively confirm one specific error
1543 1705 * condition is gone (e.g. a passing connection test clearing
@@ -1563,9 +1725,9 @@
1563 1725 if ( isset( $stored_errors[ $error_code ] ) ) {
1564 1726 unset( $stored_errors[ $error_code ] );
1565 1727 $deleted = true;
1566 1728 if ( count( $stored_errors ) ) {
1567 - update_option( self::STORED_ERRORS_OPTION, $stored_errors );
1729 + update_option( self::STORED_ERRORS_OPTION, $stored_errors, false );
1568 1730 } else {
1569 1731 delete_option( self::STORED_ERRORS_OPTION );
1570 1732 }
1571 1733 }
@@ -1574,9 +1736,9 @@
1574 1736 if ( isset( $verified_errors[ $error_code ] ) ) {
1575 1737 unset( $verified_errors[ $error_code ] );
1576 1738 $deleted = true;
1577 1739 if ( count( $verified_errors ) ) {
1578 - update_option( self::STORED_VERIFIED_ERRORS_OPTION, $verified_errors );
1740 + update_option( self::STORED_VERIFIED_ERRORS_OPTION, $verified_errors, false );
1579 1741 } else {
1580 1742 delete_option( self::STORED_VERIFIED_ERRORS_OPTION );
1581 1743 }
1582 1744 }
@@ -1581,8 +1743,12 @@
1581 1743 }
1582 1744 }
1583 1745
1584 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' );
1585 1751 $this->invalidate_displayable_errors_cache();
1586 1752 }
1587 1753
1588 1754 return $deleted;
@@ -1629,9 +1795,9 @@
1629 1795 }
1630 1796
1631 1797 $verified_errors[ $error_code ][ $user_id ] = $error;
1632 1798
1633 - update_option( self::STORED_VERIFIED_ERRORS_OPTION, $verified_errors );
1799 + update_option( self::STORED_VERIFIED_ERRORS_OPTION, $verified_errors, false );
1634 1800
1635 1801 // Invalidate cache since we added a new verified error
1636 1802 $this->invalidate_displayable_errors_cache();
1637 1803 }
@@ -1875,8 +2041,15 @@
1875 2041 * @return void
1876 2042 */
1877 2043 public function check_signed_request_for_errors( $signing_result, $url, $method, $error_type ) {
1878 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' ) ) {
1879 2052 return;
1880 2053 }
1881 2054
1882 2055 $data = $signing_result->get_error_data();