Skip to content

Commit 41faaff

Browse files
authored
Add a Site Health connection test and debug information section (#192)
1 parent eb01994 commit 41faaff

9 files changed

Lines changed: 672 additions & 74 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: minor
2+
Type: added
3+
4+
Site Health now shows your Bluesky connection status, explains why a reconnect is needed and how to prevent it, and adds an ATmosphere section to the debug information for easier troubleshooting.

includes/class-api.php

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414

1515
use Atmosphere\OAuth\Client;
1616
use Atmosphere\OAuth\DPoP;
17-
use Atmosphere\OAuth\Encryption;
1817

1918
/**
2019
* PDS API client.
@@ -60,9 +59,9 @@ public static function request( string $method, string $endpoint, array $args =
6059
*/
6160
$access_token_snapshot = (string) ( $conn['access_token'] ?? '' );
6261

63-
$dpop_jwk_json = Encryption::decrypt( $conn['dpop_jwk'] ?? '' );
64-
if ( false === $dpop_jwk_json ) {
65-
return Client::flag_decrypt_failure( $conn, 'dpop_jwk' );
62+
$dpop_jwk_json = Client::decrypt_field( $conn, 'dpop_jwk' );
63+
if ( \is_wp_error( $dpop_jwk_json ) ) {
64+
return $dpop_jwk_json;
6665
}
6766

6867
$dpop_jwk = \json_decode( $dpop_jwk_json, true );

includes/class-atmosphere.php

Lines changed: 38 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use Atmosphere\Rest\Client_Metadata_Controller;
2626
use Atmosphere\Rest\Reactions_Controller;
2727
use Atmosphere\WP_Admin\Admin;
28+
use Atmosphere\WP_Admin\Health_Check;
2829
use Atmosphere\WP_Admin\Settings_Fields;
2930

3031
/**
@@ -163,6 +164,17 @@ public function init(): void {
163164
\add_action( 'init', array( Options::class, 'init' ), 5 );
164165
\add_action( 'init', array( Settings_Fields::class, 'init' ), 5 );
165166

167+
/*
168+
* Site Health status test + debug information. Registered
169+
* directly on the pull filters (no context gate, no `init`
170+
* indirection): they only fire on Site Health surfaces — the
171+
* screen, the weekly scheduled check, WP-CLI — so the class is
172+
* autoloaded only there and every other request just stores two
173+
* callables.
174+
*/
175+
\add_filter( 'site_status_tests', array( Health_Check::class, 'add_tests' ) );
176+
\add_filter( 'debug_information', array( Health_Check::class, 'debug_information' ) );
177+
166178
/*
167179
* Display-side @handle.tld mention auto-linking. Self-registers on
168180
* init so the_content (priority 100) is wired for both front-end
@@ -1482,7 +1494,7 @@ public function register_share_status_field(): void {
14821494
return null;
14831495
}
14841496

1485-
$reconnect_class = \in_array( (string) $error['code'], self::RECONNECT_ERROR_CODES, true );
1497+
$reconnect_class = Client::is_reconnect_error( (string) $error['code'] );
14861498
$needs_reconnect = $reconnect_class && ! is_connected();
14871499

14881500
/*
@@ -2112,28 +2124,6 @@ private static function record_publish_error( int $post_id, \WP_Error $error, bo
21122124
);
21132125
}
21142126

2115-
/**
2116-
* Failure codes resolvable only by reconnecting the Bluesky account.
2117-
*
2118-
* Consumed by the `atmosphere_publish_error` REST field as the
2119-
* `needs_reconnect` flag (combined with the live connection state,
2120-
* so the flag drops once the operator reconnects) — the editor
2121-
* panel never keeps its own copy of this judgment. Folded into the
2122-
* permanent codes of
2123-
* {@see self::is_transient_publish_error()}, so each reconnect-class
2124-
* code is declared exactly once. Deliberately without
2125-
* `atmosphere_did_mismatch`, which reconnecting to the current
2126-
* account does not fix.
2127-
*
2128-
* @var string[]
2129-
*/
2130-
private const RECONNECT_ERROR_CODES = array(
2131-
'atmosphere_key_changed',
2132-
'atmosphere_decrypt',
2133-
'atmosphere_needs_reauth',
2134-
'atmosphere_not_connected',
2135-
);
2136-
21372127
/**
21382128
* Whether a publish failure is worth retrying.
21392129
*
@@ -2148,27 +2138,32 @@ private static function record_publish_error( int $post_id, \WP_Error $error, bo
21482138
* @return bool True when a retry has a chance of succeeding.
21492139
*/
21502140
private static function is_transient_publish_error( \WP_Error $error ): bool {
2151-
$permanent_codes = \array_merge(
2152-
// Reconnect-class failures are permanent by definition.
2153-
self::RECONNECT_ERROR_CODES,
2154-
array(
2155-
'atmosphere_post_not_publishable',
2156-
'atmosphere_missing_tid',
2157-
'atmosphere_invalid_pre_apply_writes_return',
2158-
'atmosphere_invalid_pre_apply_writes_response',
2159-
'atmosphere_invalid_pre_upload_blob_return',
2160-
'atmosphere_did_mismatch',
2141+
/*
2142+
* Reconnect-class failures are permanent by definition; the code
2143+
* list lives in {@see Client::is_reconnect_error()}, next to the
2144+
* paths that mint those codes, so each code is declared once.
2145+
*/
2146+
if ( Client::is_reconnect_error( (string) $error->get_error_code() ) ) {
2147+
return false;
2148+
}
21612149

2162-
/*
2163-
* Never retry a failed thread rollback: the orphan manifest
2164-
* records live partial records on the PDS, and a retried
2165-
* publish would mint fresh TIDs next to them — a duplicate,
2166-
* user-visible copy of the post. This state needs operator
2167-
* attention (see Post::META_ORPHAN_RECORDS), not another
2168-
* attempt.
2169-
*/
2170-
'atmosphere_thread_rollback_failed',
2171-
)
2150+
$permanent_codes = array(
2151+
'atmosphere_post_not_publishable',
2152+
'atmosphere_missing_tid',
2153+
'atmosphere_invalid_pre_apply_writes_return',
2154+
'atmosphere_invalid_pre_apply_writes_response',
2155+
'atmosphere_invalid_pre_upload_blob_return',
2156+
'atmosphere_did_mismatch',
2157+
2158+
/*
2159+
* Never retry a failed thread rollback: the orphan manifest
2160+
* records live partial records on the PDS, and a retried
2161+
* publish would mint fresh TIDs next to them — a duplicate,
2162+
* user-visible copy of the post. This state needs operator
2163+
* attention (see Post::META_ORPHAN_RECORDS), not another
2164+
* attempt.
2165+
*/
2166+
'atmosphere_thread_rollback_failed',
21722167
);
21732168

21742169
if ( \in_array( $error->get_error_code(), $permanent_codes, true ) ) {

includes/functions.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,30 @@ function get_reauth_reason(): string {
504504
return (string) ( get_connection()['reauth_reason'] ?? '' );
505505
}
506506

507+
/**
508+
* Lead sentence explaining why the connection needs a reconnect.
509+
*
510+
* Single source for the cause copy so every surface that reads the
511+
* `reauth_reason` marker — the admin reconnect notice and the Site
512+
* Health test — explains the same failure with the same words. Each
513+
* caller appends its own consequence/action tail; copy edits and
514+
* translations happen once, here.
515+
*
516+
* @since unreleased
517+
*
518+
* @return string Translated, unescaped sentence.
519+
*/
520+
function reauth_reason_lead(): string {
521+
switch ( get_reauth_reason() ) {
522+
case Client::REAUTH_REASON_KEY_CHANGED:
523+
return \__( 'Your site’s security keys have changed — this can happen after a migration, or when a security plugin rotates them on a schedule — so ATmosphere can no longer read its saved Bluesky login.', 'atmosphere' );
524+
case Client::REAUTH_REASON_DECRYPT_FAILED:
525+
return \__( 'ATmosphere can no longer read its saved Bluesky login.', 'atmosphere' );
526+
default:
527+
return \__( 'Your Bluesky session has expired.', 'atmosphere' );
528+
}
529+
}
530+
507531
/**
508532
* URL of the ATmosphere settings page.
509533
*

includes/oauth/class-client.php

Lines changed: 70 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,9 @@ class Client {
140140
* the stored tokens (salt rotation, regenerated wp-config.php).
141141
*
142142
* Written by {@see self::flag_decrypt_failure()}; read by the admin
143-
* reauth notice, whose copy explains the cause. A mistyped
144-
* comparison fails soft (falls through to generic expiry copy), so
145-
* consumers MUST use these constants.
143+
* reauth notice and Site Health, whose copy explains the cause. A
144+
* mistyped comparison fails soft (falls through to generic expiry
145+
* copy), so consumers MUST use these constants.
146146
*
147147
* @since unreleased
148148
*
@@ -794,14 +794,14 @@ public static function refresh(): true|\WP_Error {
794794
* @return true|\WP_Error
795795
*/
796796
private static function refresh_locked( array $conn ): true|\WP_Error {
797-
$refresh_token = Encryption::decrypt( $conn['refresh_token'] );
798-
if ( false === $refresh_token ) {
799-
return self::flag_decrypt_failure( $conn, 'refresh_token' );
797+
$refresh_token = self::decrypt_field( $conn, 'refresh_token' );
798+
if ( \is_wp_error( $refresh_token ) ) {
799+
return $refresh_token;
800800
}
801801

802-
$dpop_jwk_json = Encryption::decrypt( $conn['dpop_jwk'] );
803-
if ( false === $dpop_jwk_json ) {
804-
return self::flag_decrypt_failure( $conn, 'dpop_jwk' );
802+
$dpop_jwk_json = self::decrypt_field( $conn, 'dpop_jwk' );
803+
if ( \is_wp_error( $dpop_jwk_json ) ) {
804+
return $dpop_jwk_json;
805805
}
806806

807807
$dpop_jwk = \json_decode( $dpop_jwk_json, true );
@@ -973,6 +973,66 @@ private static function refresh_locked( array $conn ): true|\WP_Error {
973973
return true;
974974
}
975975

976+
/**
977+
* Decrypt a stored connection credential, flagging the row for
978+
* reauth on failure.
979+
*
980+
* The single chokepoint for reading connection ciphertext, so the
981+
* invariant "a failed credential decrypt always flags the row" is
982+
* enforced by the mechanism instead of repeated at every call site
983+
* (see {@see self::flag_decrypt_failure()} for why the failure is
984+
* permanent).
985+
*
986+
* @since unreleased
987+
*
988+
* @param array $conn Connection row the credential was read from.
989+
* @param string $field Connection field to decrypt (`access_token`,
990+
* `refresh_token`, `dpop_jwk`).
991+
* @return string|\WP_Error The plaintext, or the reauth error.
992+
*/
993+
public static function decrypt_field( array $conn, string $field ): string|\WP_Error {
994+
$plaintext = Encryption::decrypt( (string) ( $conn[ $field ] ?? '' ) );
995+
996+
if ( false === $plaintext ) {
997+
return self::flag_decrypt_failure( $conn, $field );
998+
}
999+
1000+
return $plaintext;
1001+
}
1002+
1003+
/**
1004+
* Failure codes resolvable only by reconnecting the Bluesky account.
1005+
*
1006+
* Consumed by the `atmosphere_publish_error` REST field as the
1007+
* `needs_reconnect` flag so the editor panel never keeps its own
1008+
* copy of this judgment. Lives here, next to the code paths that
1009+
* mint these codes, so a future reconnect-class error is added in
1010+
* one place. A curated subset of the permanent codes in
1011+
* {@see \Atmosphere\Atmosphere::is_transient_publish_error()} —
1012+
* deliberately without `atmosphere_did_mismatch`, which reconnecting
1013+
* to the current account does not fix.
1014+
*
1015+
* @var string[]
1016+
*/
1017+
private const RECONNECT_ERROR_CODES = array(
1018+
'atmosphere_key_changed',
1019+
'atmosphere_decrypt',
1020+
'atmosphere_needs_reauth',
1021+
'atmosphere_not_connected',
1022+
);
1023+
1024+
/**
1025+
* Whether a failure code can only be resolved by reconnecting.
1026+
*
1027+
* @since unreleased
1028+
*
1029+
* @param string $code Machine-readable failure code.
1030+
* @return bool
1031+
*/
1032+
public static function is_reconnect_error( string $code ): bool {
1033+
return \in_array( $code, self::RECONNECT_ERROR_CODES, true );
1034+
}
1035+
9761036
/**
9771037
* Build the error for an undecryptable connection credential and
9781038
* flag the connection for reauth.
@@ -1345,12 +1405,7 @@ public static function access_token(): string|\WP_Error {
13451405
$conn = \get_option( 'atmosphere_connection', array() );
13461406
}
13471407

1348-
$token = Encryption::decrypt( $conn['access_token'] );
1349-
if ( false === $token ) {
1350-
return self::flag_decrypt_failure( $conn, 'access_token' );
1351-
}
1352-
1353-
return $token;
1408+
return self::decrypt_field( $conn, 'access_token' );
13541409
}
13551410

13561411
/**

includes/oauth/class-encryption.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ private static function key(): string {
7070
/**
7171
* Whether a dedicated `ATMOSPHERE_ENCRYPTION_KEY` is in effect.
7272
*
73-
* Single owner of the defined-non-empty-string semantics used by
73+
* Single owner of the defined-non-empty-string semantics, so the
74+
* admin surfaces reporting the key source (the Site Health test)
75+
* cannot drift from the actual derivation in
7476
* {@see self::key_material()}. A constant misdefined as a non-string
7577
* (e.g. `false` or an array) is treated as unset rather than being
7678
* silently coerced into weak key material.
@@ -79,7 +81,7 @@ private static function key(): string {
7981
*
8082
* @return bool
8183
*/
82-
private static function has_dedicated_key(): bool {
84+
public static function has_dedicated_key(): bool {
8385
return \defined( 'ATMOSPHERE_ENCRYPTION_KEY' )
8486
&& \is_string( ATMOSPHERE_ENCRYPTION_KEY )
8587
&& '' !== ATMOSPHERE_ENCRYPTION_KEY;

includes/wp-admin/class-admin.php

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313
use Atmosphere\Handle;
1414
use Atmosphere\OAuth\Client;
1515
use Atmosphere\Publisher;
16-
use function Atmosphere\get_reauth_reason;
1716
use function Atmosphere\is_operator_disconnected;
17+
use function Atmosphere\reauth_reason_lead;
1818
use function Atmosphere\settings_url;
1919
use function Atmosphere\get_supported_post_types;
2020
use function Atmosphere\has_identity;
@@ -238,23 +238,18 @@ public static function maybe_render_reauth_notice(): void {
238238
}
239239

240240
$heading = \__( 'ATmosphere: reconnection required', 'atmosphere' );
241-
$reason = get_reauth_reason();
242241

243242
/*
244-
* Each branch supplies only its lead sentence; the shared tail
245-
* (what stops working + the reconnect link) is composed below so
246-
* copy edits and translations happen once. The disconnect gate's
243+
* The cause lead comes from `reauth_reason_lead()` (shared with
244+
* the Site Health test); only the shared tail (what stops working
245+
* + the reconnect link) is composed here. The disconnect gate's
247246
* stale-marker rationale lives in `is_operator_disconnected()`.
248247
*/
249248
if ( is_operator_disconnected() ) {
250249
$heading = \__( 'ATmosphere: disconnected', 'atmosphere' );
251250
$lead = \__( 'ATmosphere is disconnected from Bluesky.', 'atmosphere' );
252-
} elseif ( Client::REAUTH_REASON_KEY_CHANGED === $reason ) {
253-
$lead = \__( 'Your site’s security keys have changed — this can happen after a migration, or when a security plugin rotates them on a schedule — so ATmosphere can no longer read its saved Bluesky login.', 'atmosphere' );
254-
} elseif ( Client::REAUTH_REASON_DECRYPT_FAILED === $reason ) {
255-
$lead = \__( 'ATmosphere can no longer read its saved Bluesky login.', 'atmosphere' );
256251
} else {
257-
$lead = \__( 'Your Bluesky session has expired.', 'atmosphere' );
252+
$lead = reauth_reason_lead();
258253
}
259254

260255
/* translators: %s: URL to the ATmosphere settings page. */

0 commit comments

Comments
 (0)