| @@ -52,8 +52,15 @@ | ||
| 52 | 52 | * none of them is a browser, which is exactly what "block bad bots" rules |
| 53 | 53 | * key on. A site that answers WordPress's own UA but 403s these is |
| 54 | 54 | * unreachable for every AI client while looking perfectly healthy from |
| 55 | 55 | * inside. |
| 56 | + * | |
| 57 | + * DO NOT replace these with a descriptive agent such as | |
| 58 | + * `ThinkRank-SelfTest/1.0`. An affected host allowlists a named agent and | |
| 59 | + * keeps refusing `python-requests/…`, so the check would go green while | |
| 60 | + * ChatGPT stays blocked — the exact false pass this test exists to catch. | |
| 61 | + * SiteGround support has recommended that change; declining it is | |
| 62 | + * deliberate. See #379. | |
| 56 | 63 | */ |
| 57 | 64 | private const CLIENT_USER_AGENTS = [ |
| 58 | 65 | 'python-requests/2.32.3', |
| 59 | 66 | 'node-fetch/3.3.2', |
| @@ -59,8 +66,15 @@ | ||
| 59 | 66 | 'node-fetch/3.3.2', |
| 60 | 67 | ]; |
| 61 | 68 | |
| 62 | 69 | /** |
| 70 | + * Where an affected site owner is sent for the workaround list. The plugin | |
| 71 | + * cannot fix an edge block, so the failing check hands over the diagnostic | |
| 72 | + * and the host-side options instead. | |
| 73 | + */ | |
| 74 | + private const HOSTING_DOC_URL = 'https://thinkrank.ai/docs/mcp/hosting-compatibility/'; | |
| 75 | + | |
| 76 | + /** | |
| 63 | 77 | * Run the round-trip self-test. |
| 64 | 78 | * |
| 65 | 79 | * @return array<string, mixed> |
| 66 | 80 | */ |
| @@ -96,8 +110,18 @@ | ||
| 96 | 110 | $result['message'] = __( 'No connection token exists yet. Click Connect to mint one, then run the test again.', 'thinkrank' ); |
| 97 | 111 | return $result; |
| 98 | 112 | } |
| 99 | 113 | |
| 114 | + if ( Mcp_Pairing::state()['token_sealed'] ) { | |
| 115 | + // A token exists and still authenticates the clients holding it, | |
| 116 | + // but this site can no longer decrypt it, so there is nothing to | |
| 117 | + // present. Probing with '' would report an authentication failure | |
| 118 | + // and point support at entirely the wrong thing. | |
| 119 | + $result['stage'] = 'token_sealed'; | |
| 120 | + $result['message'] = __( 'A connection token exists but can no longer be read on this site — the security keys in wp-config.php changed after it was minted. Clients already set up with it keep working. Use Reset token to mint one this site can show, then run the test again.', 'thinkrank' ); | |
| 121 | + return $result; | |
| 122 | + } | |
| 123 | + | |
| 100 | 124 | $token = Mcp_Pairing::site_token(); |
| 101 | 125 | |
| 102 | 126 | $pretty = self::probe_jsonrpc( $endpoint, $token ); |
| 103 | 127 | $rest = self::probe_jsonrpc( $fallback, $token ); |
| @@ -125,9 +149,9 @@ | ||
| 125 | 149 | // Only reported when it could actually run — claiming a pass we did |
| 126 | 150 | // not measure is the failure mode this whole test exists to avoid. |
| 127 | 151 | $user_agent = self::probe_user_agent( $endpoint ); |
| 128 | 152 | if ( null !== $user_agent ) { |
| 129 | - $result['checks'][] = self::check( 'user_agent', __( 'Client access', 'thinkrank' ), $user_agent['stage'], $user_agent['detail'] ); | |
| 153 | + $result['checks'][] = self::check( 'user_agent', __( 'Client access', 'thinkrank' ), $user_agent['stage'], $user_agent['detail'], $user_agent['doc_url'] ?? '' ); | |
| 130 | 154 | } |
| 131 | 155 | |
| 132 | 156 | // Locked-out clients. The loopback below can pass while a REMOTE client |
| 133 | 157 | // is walled off by the failed-auth limiter — the exact state a connector |
| @@ -330,13 +354,81 @@ | ||
| 330 | 354 | * |
| 331 | 355 | * @return array{stage:string,detail:string} |
| 332 | 356 | */ |
| 333 | 357 | private static function probe_discovery(): array { |
| 334 | - // Each document must carry an identifier EXACTLY equal to the one we | |
| 335 | - // compute locally. A mere "the key exists" check passes on another | |
| 336 | - // plugin's metadata served from the same /.well-known/ path, which is | |
| 337 | - // the hijack case the rewrite rules already warn about. | |
| 338 | - $docs = [ | |
| 358 | + $documents = []; | |
| 359 | + | |
| 360 | + // --- Published files vs. the identity this site has NOW ----------- | |
| 361 | + // The static /.well-known/ documents embed absolute home_url()-derived | |
| 362 | + // identifiers, and the whole reason they exist is that the host serves | |
| 363 | + // them before WordPress. After a domain change, an http->https switch | |
| 364 | + // or a staging clone, the stale copy therefore wins over the correct | |
| 365 | + // dynamic route and the site advertises an issuer it no longer owns, | |
| 366 | + // which a spec-compliant client must refuse (#486). | |
| 367 | + // | |
| 368 | + // Checked on disk, ahead of the HTTP probes below, because loopback | |
| 369 | + // does not always take the path an external client does — a site can | |
| 370 | + // serve a stale document to the internet while our own request never | |
| 371 | + // sees it, and every probe below then passes. | |
| 372 | + $stale = Mcp_Static_Discovery::stale_document(); | |
| 373 | + if ( null !== $stale ) { | |
| 374 | + Mcp_Static_Discovery::refresh(); | |
| 375 | + $still_stale = Mcp_Static_Discovery::stale_document(); | |
| 376 | + | |
| 377 | + if ( null !== $still_stale ) { | |
| 378 | + return [ | |
| 379 | + 'stage' => 'stale_static_discovery', | |
| 380 | + 'documents' => $documents, | |
| 381 | + 'detail' => sprintf( | |
| 382 | + /* translators: 1: file path relative to the site root, 2: identifier name, 3: value found in the file, 4: value it should carry. */ | |
| 383 | + __( 'The static discovery file %1$s advertises %2$s as %3$s, but this site is %4$s. It was written before the site URL changed, the host serves it ahead of WordPress, and it could not be rewritten or removed — so clients read the old identity and refuse to connect. Delete that file from the site root, or restore write access there and run this test again.', 'thinkrank' ), | |
| 384 | + $still_stale['file'], | |
| 385 | + $still_stale['key'], | |
| 386 | + '' === $still_stale['found'] ? __( 'nothing', 'thinkrank' ) : $still_stale['found'], | |
| 387 | + $still_stale['expected'] | |
| 388 | + ), | |
| 389 | + ]; | |
| 390 | + } | |
| 391 | + } | |
| 392 | + | |
| 393 | + // --- The documents clients are POINTED at (must work) ------------- | |
| 394 | + // The 401 challenge advertises the REST-served resource metadata, and | |
| 395 | + // spec-compliant clients derive the OIDC-suffix form of the AS | |
| 396 | + // metadata from our path-based issuer. Neither lives under the site | |
| 397 | + // root's /.well-known/ directory, so both survive hosts that | |
| 398 | + // intercept that directory at the proxy edge (SiteGround). Each must | |
| 399 | + // carry an identifier EXACTLY equal to the one we compute locally — a | |
| 400 | + // mere "the key exists" check passes on another plugin's metadata, | |
| 401 | + // which is the hijack case the rewrite rules already warn about. | |
| 402 | + $primary = [ | |
| 403 | + Mcp_OAuth::resource_metadata_url() => [ | |
| 404 | + 'key' => 'resource', | |
| 405 | + 'expected' => Mcp_Pairing::site_endpoint(), | |
| 406 | + ], | |
| 407 | + rest_url( 'thinkrank/v1/mcp/oauth/authorization-server' ) => [ | |
| 408 | + 'key' => 'issuer', | |
| 409 | + 'expected' => Mcp_OAuth::issuer(), | |
| 410 | + ], | |
| 411 | + Mcp_OAuth::issuer() . '/.well-known/openid-configuration' => [ | |
| 412 | + 'key' => 'issuer', | |
| 413 | + 'expected' => Mcp_OAuth::issuer(), | |
| 414 | + ], | |
| 415 | + ]; | |
| 416 | + | |
| 417 | + foreach ( $primary as $url => $spec ) { | |
| 418 | + $issue = self::probe_document( $url, $spec, $documents ); | |
| 419 | + if ( null !== $issue ) { | |
| 420 | + return $issue; | |
| 421 | + } | |
| 422 | + } | |
| 423 | + | |
| 424 | + // --- The spec-derived /.well-known/ forms (should work) ----------- | |
| 425 | + // A client that ignores the challenge pointer derives these itself | |
| 426 | + // (RFC 9728 / RFC 8414 path-insert). Some hosts resolve the root | |
| 427 | + // /.well-known/ directory at their proxy as physical files, 404ing | |
| 428 | + // before WordPress runs — measurably different from broken rewrites, | |
| 429 | + // and fixable by publishing the documents AS physical files. | |
| 430 | + $derived = [ | |
| 339 | 431 | home_url( '/.well-known/oauth-protected-resource/' . Mcp_Pairing::SITE_ENDPOINT_PATH ) => [ |
| 340 | 432 | 'key' => 'resource', |
| 341 | 433 | 'expected' => Mcp_Pairing::site_endpoint(), |
| 342 | 434 | ], |
| @@ -345,66 +437,65 @@ | ||
| 345 | 437 | 'expected' => Mcp_OAuth::issuer(), |
| 346 | 438 | ], |
| 347 | 439 | ]; |
| 348 | 440 | |
| 349 | - $documents = []; | |
| 441 | + $root_issue = null; | |
| 442 | + foreach ( $derived as $url => $spec ) { | |
| 443 | + $root_issue = self::probe_document( $url, $spec, $documents ); | |
| 444 | + if ( null !== $root_issue ) { | |
| 445 | + break; | |
| 446 | + } | |
| 447 | + } | |
| 350 | 448 | |
| 351 | - foreach ( $docs as $url => $spec ) { | |
| 352 | - $response = wp_remote_get( | |
| 353 | - $url, | |
| 354 | - [ | |
| 355 | - 'timeout' => 10, | |
| 356 | - 'redirection' => 0, | |
| 357 | - ] | |
| 358 | - ); | |
| 359 | - if ( is_wp_error( $response ) ) { | |
| 360 | - return [ | |
| 361 | - 'stage' => 'discovery', | |
| 362 | - 'documents' => $documents, | |
| 363 | - 'detail' => sprintf( | |
| 364 | - /* translators: 1: discovery document URL, 2: transport error. */ | |
| 365 | - __( 'The OAuth discovery document %1$s could not be fetched: %2$s. Clients that connect by URL alone cannot authenticate without it.', 'thinkrank' ), | |
| 366 | - $url, | |
| 367 | - $response->get_error_message() | |
| 368 | - ), | |
| 369 | - ]; | |
| 449 | + if ( null !== $root_issue ) { | |
| 450 | + // A document that answers with SOMEONE ELSE'S identity is a plugin | |
| 451 | + // conflict poisoning derive-only clients — that stays a hard fail. | |
| 452 | + // Only the intercepted/unreachable shapes are softened below. | |
| 453 | + if ( 'mismatch' === ( $root_issue['kind'] ?? '' ) ) { | |
| 454 | + return $root_issue; | |
| 370 | 455 | } |
| 371 | - $status = (int) wp_remote_retrieve_response_code( $response ); | |
| 372 | - $raw = (string) wp_remote_retrieve_body( $response ); | |
| 373 | - $body = json_decode( $raw, true ); | |
| 374 | - $documents[ $url ] = is_array( $body ) ? $body : $raw; | |
| 375 | 456 | |
| 376 | - if ( 200 !== $status || ! is_array( $body ) || ! isset( $body[ $spec['key'] ] ) ) { | |
| 377 | - return [ | |
| 378 | - 'stage' => 'discovery', | |
| 379 | - 'documents' => $documents, | |
| 380 | - 'detail' => sprintf( | |
| 381 | - /* translators: 1: discovery document URL, 2: HTTP status code. */ | |
| 382 | - __( 'The OAuth discovery document %1$s returned %2$d instead of valid metadata. Re-save Settings → Permalinks; if it persists, another plugin may be claiming the /.well-known/ URLs.', 'thinkrank' ), | |
| 383 | - $url, | |
| 384 | - $status | |
| 385 | - ), | |
| 386 | - ]; | |
| 457 | + // The host's own trick becomes the fix: if the proxy insists on | |
| 458 | + // serving /.well-known/ as physical files, give it physical files. | |
| 459 | + /** | |
| 460 | + * Filter whether the self-test may publish static /.well-known/ | |
| 461 | + * discovery files when the dynamic route is unreachable. | |
| 462 | + * | |
| 463 | + * @since 1.32.0 | |
| 464 | + * | |
| 465 | + * @param bool $allowed Defaults to whether the install can host them. | |
| 466 | + */ | |
| 467 | + $may_publish = apply_filters( 'thinkrank_mcp_static_discovery_publish', Mcp_Static_Discovery::applicable() ); | |
| 468 | + | |
| 469 | + $healed = false; | |
| 470 | + if ( $may_publish && Mcp_Static_Discovery::publish() ) { | |
| 471 | + $healed = true; | |
| 472 | + foreach ( $derived as $url => $spec ) { | |
| 473 | + if ( null !== self::probe_document( $url, $spec, $documents ) ) { | |
| 474 | + $healed = false; | |
| 475 | + break; | |
| 476 | + } | |
| 477 | + } | |
| 387 | 478 | } |
| 388 | 479 | |
| 389 | - $advertised = (string) $body[ $spec['key'] ]; | |
| 390 | - if ( $advertised !== $spec['expected'] ) { | |
| 480 | + if ( ! $healed ) { | |
| 481 | + // Not fatal on its own any more: the challenge points clients | |
| 482 | + // at the REST document (verified above), so the flow survives. | |
| 483 | + // Say what is degraded instead of failing the whole check. | |
| 484 | + $scheme_issue = self::probe_scheme(); | |
| 485 | + if ( null !== $scheme_issue ) { | |
| 486 | + $scheme_issue['documents'] = $documents; | |
| 487 | + return $scheme_issue; | |
| 488 | + } | |
| 391 | 489 | return [ |
| 392 | - 'stage' => 'discovery', | |
| 490 | + 'stage' => 'ok', | |
| 393 | 491 | 'documents' => $documents, |
| 394 | - 'detail' => sprintf( | |
| 395 | - /* translators: 1: metadata field name, 2: value found in the document, 3: value it should be, 4: discovery document URL. */ | |
| 396 | - __( 'The discovery document %4$s advertises %1$s "%2$s" but this site\'s MCP endpoint is "%3$s". RFC 9728 requires an exact match, so clients reject the metadata and report that the server does not implement OAuth. If the two differ only by scheme, a reverse proxy is terminating TLS without passing X-Forwarded-Proto; otherwise another plugin is serving this URL.', 'thinkrank' ), | |
| 397 | - $spec['key'], | |
| 398 | - $advertised, | |
| 399 | - $spec['expected'], | |
| 400 | - $url | |
| 401 | - ), | |
| 492 | + 'detail' => __( 'The primary discovery documents are served and correct, but the host intercepts the site root\'s /.well-known/ directory before WordPress runs (common on SiteGround shared hosting), and static files could not be published there. Clients that follow the challenge — ChatGPT, Claude — still connect; a client that only derives the root /.well-known/ URL itself may not. If write access to the site root is possible, granting it lets ThinkRank publish static discovery files that fix this completely.', 'thinkrank' ), | |
| 402 | 493 | ]; |
| 403 | 494 | } |
| 404 | 495 | } |
| 405 | 496 | |
| 406 | - // Both documents agree with us — but they agree on whatever home_url() | |
| 497 | + // Documents agree with us — but they agree on whatever home_url() | |
| 407 | 498 | // says, so a site whose stored URL is http:// while it actually serves |
| 408 | 499 | // https:// is self-consistently wrong. Clients connect over https and |
| 409 | 500 | // then reject the http identifier. |
| 410 | 501 | $scheme_issue = self::probe_scheme(); |
| @@ -415,13 +506,83 @@ | ||
| 415 | 506 | |
| 416 | 507 | return [ |
| 417 | 508 | 'stage' => 'ok', |
| 418 | 509 | 'documents' => $documents, |
| 419 | - 'detail' => __( 'Both OAuth discovery documents are served and advertise this site\'s MCP endpoint exactly.', 'thinkrank' ), | |
| 510 | + 'detail' => __( 'All OAuth discovery documents are served and advertise this site\'s MCP endpoint exactly.', 'thinkrank' ), | |
| 420 | 511 | ]; |
| 421 | 512 | } |
| 422 | 513 | |
| 423 | 514 | /** |
| 515 | + * Fetch and validate one discovery document. Appends what was actually | |
| 516 | + * served to $documents either way, so support can read the site's real | |
| 517 | + * responses instead of asking the customer for screenshots. | |
| 518 | + * | |
| 519 | + * @param string $url Document URL. | |
| 520 | + * @param array{key:string,expected:string} $spec Identity field + required value. | |
| 521 | + * @param array<string,mixed> $documents Accumulator (by reference). | |
| 522 | + * @return array{stage:string,documents:array<string,mixed>,detail:string}|null Null when the document is valid. | |
| 523 | + */ | |
| 524 | + private static function probe_document( string $url, array $spec, array &$documents ): ?array { | |
| 525 | + $response = wp_remote_get( | |
| 526 | + $url, | |
| 527 | + [ | |
| 528 | + 'timeout' => 10, | |
| 529 | + 'redirection' => 0, | |
| 530 | + ] | |
| 531 | + ); | |
| 532 | + if ( is_wp_error( $response ) ) { | |
| 533 | + return [ | |
| 534 | + 'stage' => 'discovery', | |
| 535 | + 'kind' => 'unreachable', | |
| 536 | + 'documents' => $documents, | |
| 537 | + 'detail' => sprintf( | |
| 538 | + /* translators: 1: discovery document URL, 2: transport error. */ | |
| 539 | + __( 'The OAuth discovery document %1$s could not be fetched: %2$s. Clients that connect by URL alone cannot authenticate without it.', 'thinkrank' ), | |
| 540 | + $url, | |
| 541 | + $response->get_error_message() | |
| 542 | + ), | |
| 543 | + ]; | |
| 544 | + } | |
| 545 | + $status = (int) wp_remote_retrieve_response_code( $response ); | |
| 546 | + $raw = (string) wp_remote_retrieve_body( $response ); | |
| 547 | + $body = json_decode( $raw, true ); | |
| 548 | + $documents[ $url ] = is_array( $body ) ? $body : $raw; | |
| 549 | + | |
| 550 | + if ( 200 !== $status || ! is_array( $body ) || ! isset( $body[ $spec['key'] ] ) ) { | |
| 551 | + return [ | |
| 552 | + 'stage' => 'discovery', | |
| 553 | + 'kind' => 'invalid', | |
| 554 | + 'documents' => $documents, | |
| 555 | + 'detail' => sprintf( | |
| 556 | + /* translators: 1: discovery document URL, 2: HTTP status code. */ | |
| 557 | + __( 'The OAuth discovery document %1$s returned %2$d instead of valid metadata. Re-save Settings → Permalinks; if it persists, the host may be intercepting the URL before WordPress runs, or another plugin may be claiming it.', 'thinkrank' ), | |
| 558 | + $url, | |
| 559 | + $status | |
| 560 | + ), | |
| 561 | + ]; | |
| 562 | + } | |
| 563 | + | |
| 564 | + $advertised = (string) $body[ $spec['key'] ]; | |
| 565 | + if ( $advertised !== $spec['expected'] ) { | |
| 566 | + return [ | |
| 567 | + 'stage' => 'discovery', | |
| 568 | + 'kind' => 'mismatch', | |
| 569 | + 'documents' => $documents, | |
| 570 | + 'detail' => sprintf( | |
| 571 | + /* translators: 1: metadata field name, 2: value found in the document, 3: value it should be, 4: discovery document URL. */ | |
| 572 | + __( 'The discovery document %4$s advertises %1$s "%2$s" but this site\'s MCP endpoint is "%3$s". RFC 9728 requires an exact match, so clients reject the metadata and report that the server does not implement OAuth. If the two differ only by scheme, a reverse proxy is terminating TLS without passing X-Forwarded-Proto; otherwise another plugin is serving this URL.', 'thinkrank' ), | |
| 573 | + $spec['key'], | |
| 574 | + $advertised, | |
| 575 | + $spec['expected'], | |
| 576 | + $url | |
| 577 | + ), | |
| 578 | + ]; | |
| 579 | + } | |
| 580 | + | |
| 581 | + return null; | |
| 582 | + } | |
| 583 | + | |
| 584 | + /** | |
| 424 | 585 | * Catch the reverse-proxy scheme trap: WordPress stores an http:// home |
| 425 | 586 | * URL, so every advertised OAuth identifier is http://, while the site is |
| 426 | 587 | * really served over https://. Everything is internally consistent, so no |
| 427 | 588 | * comparison against our own values can see it — the only tell is that the |
| @@ -546,9 +707,9 @@ | ||
| 546 | 707 | return [ |
| 547 | 708 | 'stage' => 'challenge', |
| 548 | 709 | 'detail' => sprintf( |
| 549 | 710 | /* translators: 1: resource_metadata URL from the challenge header, 2: endpoint URL. */ |
| 550 | - __( 'The challenge from %2$s points at %1$s, but that URL does not return OAuth metadata. This is the first thing a client fetches, so the connection fails there. On a subdirectory install the spec-derived URL sits at the domain root, which WordPress cannot serve — a root redirect to this URL is needed.', 'thinkrank' ), | |
| 711 | + __( 'The challenge from %2$s points at %1$s, but that URL does not return OAuth metadata. This is the first thing a client fetches, so the connection fails there. A security plugin or edge rule blocking the REST API for visitors is the usual cause.', 'thinkrank' ), | |
| 551 | 712 | $metadata_url, |
| 552 | 713 | $url |
| 553 | 714 | ), |
| 554 | 715 | ]; |
| @@ -602,12 +763,13 @@ | ||
| 602 | 763 | if ( in_array( $status, [ 200, 202, 401 ], true ) ) { |
| 603 | 764 | continue; |
| 604 | 765 | } |
| 605 | 766 | return [ |
| 606 | - 'stage' => 'ua_filter', | |
| 607 | - 'detail' => sprintf( | |
| 767 | + 'stage' => 'ua_filter', | |
| 768 | + 'doc_url' => self::HOSTING_DOC_URL, | |
| 769 | + 'detail' => sprintf( | |
| 608 | 770 | /* translators: 1: user agent string, 2: HTTP status returned for it, 3: HTTP status returned for WordPress's own user agent. */ |
| 609 | - __( 'The endpoint answered %3$d for WordPress but %2$d for an AI client\'s User-Agent (%1$s). A security plugin, firewall or "block bad bots" rule is refusing non-browser clients — exempt the MCP and /.well-known/ paths, or no AI client will ever reach this site.', 'thinkrank' ), | |
| 771 | + __( 'The endpoint answered %3$d for WordPress but %2$d for an AI client\'s User-Agent (%1$s). ThinkRank deliberately tests with the generic agents real MCP backends send; this refusal means a security plugin, firewall or host-level "block bad bots" rule (SiteGround\'s edge protection does this) will also refuse the real AI client. Ask the host to exempt the MCP and /.well-known/ paths, or allowlist these User-Agents.', 'thinkrank' ), | |
| 610 | 772 | $agent, |
| 611 | 773 | $status, |
| 612 | 774 | $baseline |
| 613 | 775 | ), |
| @@ -659,19 +821,27 @@ | ||
| 659 | 821 | |
| 660 | 822 | /** |
| 661 | 823 | * Shape one check for the UI list. |
| 662 | 824 | * |
| 663 | - * @param string $id Check id. | |
| 664 | - * @param string $label Human label. | |
| 665 | - * @param string $stage Resulting stage ('ok' when it passed). | |
| 666 | - * @param string $detail Explanatory line. | |
| 667 | - * @return array{id:string,label:string,ok:bool,detail:string} | |
| 825 | + * @param string $id Check id. | |
| 826 | + * @param string $label Human label. | |
| 827 | + * @param string $stage Resulting stage ('ok' when it passed). | |
| 828 | + * @param string $detail Explanatory line. | |
| 829 | + * @param string $doc_url Optional docs page for a failure the user has to | |
| 830 | + * fix outside WordPress. Omitted when empty. | |
| 831 | + * @return array{id:string,label:string,ok:bool,detail:string,doc_url?:string} | |
| 668 | 832 | */ |
| 669 | - private static function check( string $id, string $label, string $stage, string $detail ): array { | |
| 670 | - return [ | |
| 833 | + private static function check( string $id, string $label, string $stage, string $detail, string $doc_url = '' ): array { | |
| 834 | + $check = [ | |
| 671 | 835 | 'id' => $id, |
| 672 | 836 | 'label' => $label, |
| 673 | 837 | 'ok' => 'ok' === $stage, |
| 674 | 838 | 'detail' => $detail, |
| 675 | 839 | ]; |
| 840 | + | |
| 841 | + if ( '' !== $doc_url ) { | |
| 842 | + $check['doc_url'] = $doc_url; | |
| 843 | + } | |
| 844 | + | |
| 845 | + return $check; | |
| 676 | 846 | } |
| 677 | 847 | } |