PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
← All changes | includes/mcp/class-mcp-pairing.php +53 -7 2.0.2 → 2.10.0 View file →
@@ -93,9 +93,9 @@
93 93
94 94 /**
95 95 * Current pairing state, defaults merged.
96 96 *
97 - * @return array{site_token:string,token_hash:string,connected:bool,connected_at:int,scopes:string[],user_id:int,last_used:int}
97 + * @return array{site_token:string,token_hash:string,token_sealed:bool,connected:bool,connected_at:int,scopes:string[],user_id:int,last_used:int}
98 98 */
99 99 public static function state(): array {
100 100 $stored = get_option( self::OPTION, [] );
101 101 if ( ! is_array( $stored ) ) {
@@ -100,15 +100,27 @@
100 100 $stored = get_option( self::OPTION, [] );
101 101 if ( ! is_array( $stored ) ) {
102 102 $stored = [];
103 103 }
104 - $raw = isset( $stored['site_token'] ) ? (string) $stored['site_token'] : '';
104 + $raw = isset( $stored['site_token'] ) ? (string) $stored['site_token'] : '';
105 + $plain = '' === $raw ? '' : Secret_At_Rest::decrypt( $raw );
105 106
107 + // Sealed: something IS stored, but this site can no longer open it —
108 + // the auth salt rotated, or sodium went away under us (decrypt() hands
109 + // the envelope back unchanged in that case). Either way there is no
110 + // displayable credential, and the envelope must never be passed off as
111 + // one: it would be copied into a client and 401 forever.
112 + $sealed = '' !== $raw && ( '' === $plain || Secret_At_Rest::is_encrypted( $plain ) );
113 +
106 114 return [
107 115 // Decrypted for display and for the self-test's own probe. Stored
108 116 // encrypted (#396) — a database read on its own no longer yields a
109 117 // usable admin-equivalent credential.
110 - 'site_token' => '' === $raw ? '' : Secret_At_Rest::decrypt( $raw ),
118 + 'site_token' => $sealed ? '' : $plain,
119 + // Whether a stored token exists that cannot be shown here. Callers
120 + // use this to tell "never connected" apart from "connected, but
121 + // this site cannot display the token any more".
122 + 'token_sealed' => $sealed,
111 123 // What authorize() compares against. Held separately so a token
112 124 // whose ciphertext can no longer be opened — the auth salt was
113 125 // rotated, the site was migrated without wp-config — keeps
114 126 // authenticating the clients already configured with it, instead of
@@ -235,13 +247,20 @@
235 247
236 248 /**
237 249 * Whether an MCP connection token is currently active for this site.
238 250 *
251 + * Deliberately reads the hash, not the decrypted token. Those are not the
252 + * same question: after an auth salt rotation the ciphertext will not open,
253 + * so `site_token` is '' — but `token_hash` still verifies the credential
254 + * every configured client is holding, and verify_token() still accepts it.
255 + * Answering "not connected" there made ensure_connected() mint a fresh
256 + * token over the hash, which was the only surviving copy of the live one.
257 + *
239 258 * @return bool
240 259 */
241 260 public static function is_connected(): bool {
242 261 $state = self::state();
243 - return $state['connected'] && '' !== $state['site_token'];
262 + return $state['connected'] && ( '' !== $state['token_hash'] || '' !== $state['site_token'] );
244 263 }
245 264
246 265 /**
247 266 * Whether the active connection is limited to read-only tools.
@@ -262,8 +281,11 @@
262 281 $state = self::state();
263 282 return [
264 283 'connected' => self::is_connected(),
265 284 'connection_token' => $state['site_token'],
285 + // Connected, but the token cannot be displayed on this site any
286 + // more. The screen offers a rotate instead of a blank recipe.
287 + 'token_sealed' => $state['token_sealed'],
266 288 'connect_url' => self::connect_url(),
267 289 'mcp_endpoint' => self::site_endpoint(),
268 290 'mcp_endpoint_rest' => self::site_endpoint_fallback(),
269 291 'connected_at' => $state['connected_at'],
@@ -376,14 +398,38 @@
376 398 * @param bool $read_only Grant only the `read` scope on a NEW token.
377 399 * @return array<string,mixed> Public status.
378 400 */
379 401 public static function connect( bool $read_only = false ): array {
380 - $state = self::state();
381 - $existing = '' !== $state['site_token'];
382 - $token = $existing ? $state['site_token'] : self::mint_token();
402 + $state = self::state();
403 + // The hash is what decides "is there a pairing", not the decrypted
404 + // token: after an auth salt rotation the ciphertext will not open, but
405 + // the credential every configured client holds still authenticates
406 + // against the hash.
407 + $existing = '' !== $state['token_hash'] || '' !== $state['site_token'];
383 408 $scopes = $existing && ! empty( $state['scopes'] )
384 409 ? $state['scopes']
385 410 : self::scopes_for( $read_only );
411 +
412 + if ( $existing && $state['token_sealed'] ) {
413 + // Keeping a pairing this site can no longer read. Falling through
414 + // would re-encrypt $state['site_token'] — which is '' here — and
415 + // write hash('') over token_hash, destroying the last copy of a
416 + // live credential and silently resetting its scopes and owner.
417 + // Touch only the metadata; rotate() is the deliberate re-mint.
418 + $stored = get_option( self::OPTION, [] );
419 + $stored = is_array( $stored ) ? $stored : [];
420 + $stored['connected'] = true;
421 + $stored['scopes'] = $scopes;
422 + if ( empty( $stored['user_id'] ) ) {
423 + $stored['user_id'] = get_current_user_id();
424 + }
425 +
426 + update_option( self::OPTION, $stored, false );
427 +
428 + return self::public_status();
429 + }
430 +
431 + $token = $existing ? $state['site_token'] : self::mint_token();
386 432
387 433 update_option(
388 434 self::OPTION,
389 435 [