Commit 5346e698975 for woocommerce

commit 5346e698975fac42df2d1043d06c273675cca43c
Author: Néstor Soriano <konamiman@konamiman.com>
Date:   Tue May 12 10:30:57 2026 +0200

    Add filters for GraphQL debug and introspection (#64729)

diff --git a/plugins/woocommerce/changelog/add-filters-for-graphql-debug-and-introspection b/plugins/woocommerce/changelog/add-filters-for-graphql-debug-and-introspection
new file mode 100644
index 00000000000..f439b73e9fd
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-filters-for-graphql-debug-and-introspection
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add filters for dual API debug mode and introspection, and surface status resolver failures in debug mode.
diff --git a/plugins/woocommerce/src/Api/Infrastructure/Principal.php b/plugins/woocommerce/src/Api/Infrastructure/Principal.php
index d16bd5d4208..448655271f5 100644
--- a/plugins/woocommerce/src/Api/Infrastructure/Principal.php
+++ b/plugins/woocommerce/src/Api/Infrastructure/Principal.php
@@ -57,11 +57,9 @@ class Principal {
 	 * model rather than inheriting a debug mode policy by accident.
 	 *
 	 * Note that this method's outcome is necessary but not sufficient for debug
-	 * mode to be active: the controller also requires either `WP_DEBUG` to be
-	 * defined and true, or the request to carry `_debug=1`. There's also a
-	 * developer escape hatch that bypasses this method entirely — when the
-	 * controller's `is_local_environment()` check is true, debug mode is
-	 * available without consulting the principal.
+	 * mode to be active: the controller also requires the request to carry
+	 * `_debug=1`. The decision can be overridden by the
+	 * `woocommerce_graphql_can_use_debug_mode` filter.
 	 */
 	public function can_use_debug_mode(): bool {
 		return user_can( $this->user, 'manage_options' );
diff --git a/plugins/woocommerce/src/Internal/Api/GraphQLController.php b/plugins/woocommerce/src/Internal/Api/GraphQLController.php
index 8782e0d71d1..3e108608a0b 100644
--- a/plugins/woocommerce/src/Internal/Api/GraphQLController.php
+++ b/plugins/woocommerce/src/Internal/Api/GraphQLController.php
@@ -242,7 +242,7 @@ abstract class GraphQLController {
 			// Resolver threw on one of the decision points inside
 			// process_request(). Produce a clean 500 without re-invoking
 			// the (broken) resolver.
-			return $this->build_resolver_failure_response();
+			return $this->build_resolver_failure_response( $e, $request, $principal );
 		} catch ( \Throwable $e ) {
 			$output = array(
 				'errors' => array(
@@ -257,7 +257,7 @@ abstract class GraphQLController {
 				// Resolver threw specifically when handed the synthetic
 				// errors shape from this catch block. Fall through to the
 				// fixed 500; do not loop back into the resolver.
-				return $this->build_resolver_failure_response();
+				return $this->build_resolver_failure_response( $e2, $request, $principal );
 			}

 			return new \WP_REST_Response( $output, $status );
@@ -266,21 +266,45 @@ abstract class GraphQLController {

 	/**
 	 * Build the canonical 500 response used when the HTTP status resolver
-	 * throws. Body shape matches an unhandled internal error so callers
-	 * don't need a separate path for "resolver blew up".
+	 * throws or returns an out-of-range value. Body shape matches an
+	 * unhandled internal error so callers don't need a separate path for
+	 * "resolver blew up".
+	 *
+	 * In debug mode, attaches `extensions.debug` (message, file, line, trace)
+	 * for the wrapper exception, plus an `extensions.previous` chain when the
+	 * resolver itself threw — mirroring the shape that {@see self::format_exception()}
+	 * produces for the generic-Throwable path. Outside debug mode the body
+	 * stays purely generic so resolver internals never leak to anonymous callers.
+	 *
+	 * @param StatusResolverFailedException $e         The wrapper exception thrown by {@see self::pick_status()}.
+	 * @param \WP_REST_Request              $request   The originating REST request.
+	 * @param ?object                       $principal The resolved principal, or null when resolution failed.
 	 */
-	private function build_resolver_failure_response(): \WP_REST_Response {
-		return new \WP_REST_Response(
-			array(
-				'errors' => array(
-					array(
-						'message'    => 'An unexpected error occurred.',
-						'extensions' => array( 'code' => 'INTERNAL_ERROR' ),
-					),
-				),
-			),
-			500
+	private function build_resolver_failure_response(
+		StatusResolverFailedException $e,
+		\WP_REST_Request $request,
+		?object $principal
+	): \WP_REST_Response {
+		$error = array(
+			'message'    => 'An unexpected error occurred.',
+			'extensions' => array( 'code' => 'INTERNAL_ERROR' ),
 		);
+
+		if ( $this->is_debug_mode( $principal, $request ) ) {
+			$error['extensions']['debug'] = array(
+				'message' => $e->getMessage(),
+				'file'    => $e->getFile(),
+				'line'    => $e->getLine(),
+				'trace'   => $e->getTraceAsString(),
+			);
+
+			$chain = $this->extract_previous_chain( $e );
+			if ( ! empty( $chain ) ) {
+				$error['extensions']['previous'] = $chain;
+			}
+		}
+
+		return new \WP_REST_Response( array( 'errors' => array( $error ) ), 500 );
 	}

 	/**
@@ -637,7 +661,7 @@ abstract class GraphQLController {
 	}

 	/**
-	 * Determine debug flags based on WP_DEBUG, principal, and query string.
+	 * Determine debug flags for the request, based on {@see self::is_debug_mode()}.
 	 *
 	 * @param \WP_REST_Request $request   The REST request.
 	 * @param ?object          $principal The resolved principal, or null if resolution itself failed.
@@ -652,52 +676,110 @@ abstract class GraphQLController {
 	/**
 	 * Check whether GraphQL introspection is allowed for this request.
 	 *
-	 * Introspection is permitted if either condition holds:
-	 * - The request is in debug mode ({@see self::is_debug_mode()}).
-	 * - The principal opts in via a `can_introspect(): bool` method.
+	 * The principal opts in via a `can_introspect(): bool` method; principals
+	 * that don't declare it are denied by default. The decision is then passed
+	 * through the {@see 'woocommerce_graphql_can_introspect'} filter so sites
+	 * can grant or revoke access without subclassing the principal — useful
+	 * for per-request rules (specific IPs, headers, query parameters, etc.).
 	 *
-	 * @param ?object          $principal The resolved principal, or null if resolution failed.
+	 * Fail-closed contract: the principal must be non-null (principal-resolution
+	 * failures deny outright, before the filter is consulted), the principal
+	 * method's return value is treated with `=== true`, and any throw from
+	 * either the principal method or the filter callback denies. The filter
+	 * must likewise return strictly `true` to allow; any other value denies.
+	 *
+	 * @param ?object          $principal The resolved principal, or null when principal resolution failed.
 	 * @param \WP_REST_Request $request   The REST request.
 	 */
 	private function is_introspection_allowed( ?object $principal, \WP_REST_Request $request ): bool {
-		if ( $this->is_debug_mode( $principal, $request ) ) {
-			return true;
+		if ( is_null( $principal ) ) {
+			return false;
 		}
-		return null !== $principal
-			&& method_exists( $principal, 'can_introspect' )
-			&& $principal->can_introspect();
+
+		try {
+			$can_introspect = method_exists( $principal, 'can_introspect' )
+				&& true === $principal->can_introspect();
+
+			/**
+			 * Filters whether the current principal may run GraphQL introspection.
+			 *
+			 * The filter receives the principal-derived decision (false when the
+			 * principal doesn't declare `can_introspect()` or its `can_introspect()`
+			 * doesn't return strictly `true`) and must return strictly `true` to
+			 * grant access; any other return value denies. The filter is not
+			 * invoked when principal resolution failed (i.e. when the controller
+			 * passes a null principal) — that case denies outright.
+			 *
+			 * @since 10.9.0
+			 *
+			 * @internal
+			 *
+			 * @param bool             $can_introspect Whether the principal can introspect, derived from `$principal->can_introspect()`.
+			 * @param object           $principal      The resolved principal.
+			 * @param \WP_REST_Request $request        The REST request being processed.
+			 */
+			$can_introspect = apply_filters( 'woocommerce_graphql_can_introspect', $can_introspect, $principal, $request );
+		} catch ( \Throwable $e ) {
+			return false;
+		}
+
+		return true === $can_introspect;
 	}

 	/**
 	 * Check if debug mode is active.
 	 *
-	 * Debug mode is active when both:
-	 * - The principal can use it (opted in via `can_use_debug_mode(): bool`,
-	 *   or a local environment is detected — the developer escape hatch).
-	 * - WP_DEBUG is enabled OR `_debug=1` is set on the request.
+	 * Debug mode is gated on `_debug=1` being set on the request: when absent,
+	 * debug mode is off regardless of any other signal. When present, the
+	 * principal opts in via a `can_use_debug_mode(): bool` method (principals
+	 * that don't declare it are denied by default), and the decision is then
+	 * passed through the {@see 'woocommerce_graphql_can_use_debug_mode'} filter.
 	 *
-	 * The principal-method check follows the same opt-in convention as
-	 * {@see self::is_introspection_allowed()} — plugin principals must
-	 * declare `can_use_debug_mode()` to grant access; absence denies.
+	 * Fail-closed contract: the principal must be non-null (principal-resolution
+	 * failures deny outright, before the filter is consulted), the principal
+	 * method's return value is treated with `=== true`, and any throw from
+	 * either the principal method or the filter callback denies. The filter
+	 * must likewise return strictly `true` to allow; any other value denies.
 	 *
-	 * @param ?object          $principal The resolved principal, or null if resolution failed.
+	 * @param ?object          $principal The resolved principal, or null when principal resolution failed.
 	 * @param \WP_REST_Request $request   The REST request.
 	 */
 	private function is_debug_mode( ?object $principal, \WP_REST_Request $request ): bool {
-		if ( ! $this->is_local_environment() ) {
-			$allowed = null !== $principal
-				&& method_exists( $principal, 'can_use_debug_mode' )
-				&& $principal->can_use_debug_mode();
-			if ( ! $allowed ) {
-				return false;
-			}
+		if ( '1' !== $request->get_param( '_debug' ) ) {
+			return false;
+		}
+		if ( is_null( $principal ) ) {
+			return false;
 		}

-		if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
-			return true;
+		try {
+			$can_debug = method_exists( $principal, 'can_use_debug_mode' )
+				&& true === $principal->can_use_debug_mode();
+
+			/**
+			 * Filters whether the current principal may activate GraphQL debug mode.
+			 *
+			 * Only invoked when the request carries `_debug=1` and a principal was
+			 * resolved successfully, so the filter is not called on every GraphQL
+			 * request. The filter receives the principal-derived decision (false
+			 * when the principal doesn't declare `can_use_debug_mode()` or its
+			 * `can_use_debug_mode()` doesn't return strictly `true`) and must
+			 * return strictly `true` to grant access; any other return value denies.
+			 *
+			 * @since 10.9.0
+			 *
+			 * @internal
+			 *
+			 * @param bool             $can_debug Whether the principal can use debug mode, derived from `$principal->can_use_debug_mode()`.
+			 * @param object           $principal The resolved principal.
+			 * @param \WP_REST_Request $request   The REST request being processed.
+			 */
+			$can_debug = apply_filters( 'woocommerce_graphql_can_use_debug_mode', $can_debug, $principal, $request );
+		} catch ( \Throwable $e ) {
+			return false;
 		}

-		return '1' === $request->get_param( '_debug' );
+		return true === $can_debug;
 	}

 	/**
@@ -954,26 +1036,4 @@ abstract class GraphQLController {

 		return false;
 	}
-
-	/**
-	 * Check if running in a local/development environment.
-	 *
-	 * Prefers {@see wp_get_environment_type()} when available. Otherwise
-	 * parses the site URL and performs a case-insensitive *exact* match
-	 * against the hostname — not a substring check, to avoid matching
-	 * impostor domains like `mylocalhost.com` or `127.0.0.1.attacker.example`.
-	 */
-	private function is_local_environment(): bool {
-		if ( function_exists( 'wp_get_environment_type' ) && 'local' === wp_get_environment_type() ) {
-			return true;
-		}
-
-		$host = wp_parse_url( get_site_url(), PHP_URL_HOST );
-		if ( ! is_string( $host ) ) {
-			return false;
-		}
-
-		$host = strtolower( $host );
-		return 'localhost' === $host || '127.0.0.1' === $host;
-	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerDebugModeTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerDebugModeTest.php
index f03689fe5d2..17ccb74c37d 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerDebugModeTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerDebugModeTest.php
@@ -102,21 +102,16 @@ class GraphQLControllerDebugModeTest extends WC_REST_Unit_Test_Case {
 	}

 	/**
-	 * @testdox debug mode is off without _debug=1 and WP_DEBUG, even for an admin.
+	 * @testdox debug mode is off without _debug=1, even for an admin.
 	 */
 	public function test_debug_mode_is_off_without_trigger(): void {
-		// Define WP_DEBUG only if it isn't already; we can't undefine constants.
-		if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
-			$this->markTestSkipped( 'Cannot exercise the "no debug trigger" branch when WP_DEBUG is on.' );
-		}
-
 		$response = $this->sut->handle_request(
 			$this->post_request( array( 'query' => '{ greeting { result } }' ), false )
 		);

 		$this->assertSame( 200, $response->get_status() );
 		$data = $response->get_data();
-		// No debug metrics should be attached when neither _debug=1 nor WP_DEBUG is set.
+		// No debug metrics should be attached when _debug=1 is missing.
 		$this->assertArrayNotHasKey( 'debug', $data['extensions'] ?? array() );
 	}

@@ -197,20 +192,8 @@ class GraphQLControllerDebugModeTest extends WC_REST_Unit_Test_Case {

 	/**
 	 * @testdox debug mode stays off for an authenticated low-privilege user even with `_debug=1`.
-	 *
-	 * Skipped when the test environment is local (`wp_get_environment_type()`
-	 * returns `'local'` or the site URL host is `localhost` / `127.0.0.1`),
-	 * because in that case `is_local_environment()` short-circuits the
-	 * admin/_debug gate and the test can't tell the two paths apart.
 	 */
 	public function test_debug_mode_is_off_for_low_privilege_user_with_debug_param(): void {
-		$reflection = new \ReflectionClass( $this->sut );
-		$is_local   = $reflection->getMethod( 'is_local_environment' );
-		$is_local->setAccessible( true );
-		if ( $is_local->invoke( $this->sut ) ) {
-			$this->markTestSkipped( 'is_local_environment() returns true in this environment; the non-admin debug gate is not exercisable.' );
-		}
-
 		$editor = self::factory()->user->create( array( 'role' => 'editor' ) );
 		wp_set_current_user( $editor );

@@ -222,4 +205,198 @@ class GraphQLControllerDebugModeTest extends WC_REST_Unit_Test_Case {
 		$data = $response->get_data();
 		$this->assertArrayNotHasKey( 'debug', $data['extensions'] ?? array() );
 	}
+
+	/**
+	 * @testdox the woocommerce_graphql_can_use_debug_mode filter can grant debug mode to a user the principal would deny.
+	 */
+	public function test_filter_can_grant_debug_mode_to_low_privilege_user(): void {
+		$editor = self::factory()->user->create( array( 'role' => 'editor' ) );
+		wp_set_current_user( $editor );
+
+		$received_principal = null;
+		$received_request   = null;
+		$filter             = function ( bool $can_debug, ?object $principal, \WP_REST_Request $request ) use ( &$received_principal, &$received_request ): bool {
+			$received_principal = $principal;
+			$received_request   = $request;
+			return true;
+		};
+		add_filter( 'woocommerce_graphql_can_use_debug_mode', $filter, 10, 3 );
+
+		try {
+			$response = $this->sut->handle_request(
+				$this->post_request( array( 'query' => '{ greeting { result } }' ), true )
+			);
+		} finally {
+			remove_filter( 'woocommerce_graphql_can_use_debug_mode', $filter, 10 );
+		}
+
+		$this->assertSame( 200, $response->get_status() );
+		$data = $response->get_data();
+		$this->assertArrayHasKey( 'debug', $data['extensions'] ?? array() );
+
+		$this->assertNotNull( $received_principal, 'The filter should receive the resolved principal.' );
+		$this->assertInstanceOf( \WP_REST_Request::class, $received_request );
+	}
+
+	/**
+	 * @testdox the woocommerce_graphql_can_use_debug_mode filter can revoke debug mode from an admin.
+	 */
+	public function test_filter_can_revoke_debug_mode_from_admin(): void {
+		$filter = function (): bool {
+			return false;
+		};
+		add_filter( 'woocommerce_graphql_can_use_debug_mode', $filter );
+
+		try {
+			$response = $this->sut->handle_request(
+				$this->post_request( array( 'query' => '{ greeting { result } }' ), true )
+			);
+		} finally {
+			remove_filter( 'woocommerce_graphql_can_use_debug_mode', $filter );
+		}
+
+		$this->assertSame( 200, $response->get_status() );
+		$data = $response->get_data();
+		$this->assertArrayNotHasKey( 'debug', $data['extensions'] ?? array() );
+	}
+
+	/**
+	 * @testdox the woocommerce_graphql_can_use_debug_mode filter must return strictly true; truthy non-bool denies.
+	 */
+	public function test_filter_requires_strict_true_to_grant_debug_mode(): void {
+		$editor = self::factory()->user->create( array( 'role' => 'editor' ) );
+		wp_set_current_user( $editor );
+
+		$filter = function () {
+			return 1;
+		};
+		add_filter( 'woocommerce_graphql_can_use_debug_mode', $filter );
+
+		try {
+			$response = $this->sut->handle_request(
+				$this->post_request( array( 'query' => '{ greeting { result } }' ), true )
+			);
+		} finally {
+			remove_filter( 'woocommerce_graphql_can_use_debug_mode', $filter );
+		}
+
+		$this->assertSame( 200, $response->get_status() );
+		$data = $response->get_data();
+		$this->assertArrayNotHasKey( 'debug', $data['extensions'] ?? array() );
+	}
+
+	/**
+	 * @testdox the woocommerce_graphql_can_use_debug_mode filter is not invoked when _debug=1 is absent.
+	 */
+	public function test_filter_is_not_invoked_without_debug_param(): void {
+		$invoked = false;
+		$filter  = function ( bool $can_debug ) use ( &$invoked ): bool {
+			$invoked = true;
+			return $can_debug;
+		};
+		add_filter( 'woocommerce_graphql_can_use_debug_mode', $filter );
+
+		try {
+			$this->sut->handle_request(
+				$this->post_request( array( 'query' => '{ greeting { result } }' ), false )
+			);
+		} finally {
+			remove_filter( 'woocommerce_graphql_can_use_debug_mode', $filter );
+		}
+
+		$this->assertFalse( $invoked );
+	}
+
+	/**
+	 * @testdox a throw from the woocommerce_graphql_can_use_debug_mode filter callback denies debug mode (fail-closed).
+	 *
+	 * Defensive: is_debug_mode() is invoked from format_exception() and
+	 * build_resolver_failure_response(), both of which run while the controller
+	 * is already handling another exception. A throw escaping is_debug_mode()
+	 * there would corrupt the error pipeline, so any throw must be caught.
+	 */
+	public function test_filter_callback_throw_denies_debug_mode(): void {
+		$filter = function () {
+			throw new \RuntimeException( 'broken-filter-callback' );
+		};
+		add_filter( 'woocommerce_graphql_can_use_debug_mode', $filter );
+
+		try {
+			$response = $this->sut->handle_request(
+				$this->post_request( array( 'query' => '{ greeting { result } }' ), true )
+			);
+		} finally {
+			remove_filter( 'woocommerce_graphql_can_use_debug_mode', $filter );
+		}
+
+		// Response is well-formed (the throw didn't escape the controller),
+		// and debug mode stayed off (the throw was treated as a deny).
+		$this->assertSame( 200, $response->get_status() );
+		$data = $response->get_data();
+		$this->assertArrayNotHasKey( 'debug', $data['extensions'] ?? array() );
+
+		// The filter's exception message must not appear anywhere on the wire.
+		$wire = wp_json_encode( $data );
+		$this->assertIsString( $wire );
+		$this->assertStringNotContainsString( 'broken-filter-callback', $wire );
+	}
+
+	/**
+	 * @testdox a null principal denies debug mode even when a permissive filter is installed.
+	 *
+	 * Honours the contract documented on handle_request(): when principal
+	 * resolution itself fails ($principal stays null), no debug info is
+	 * surfaced — the filter is not consulted in that case.
+	 */
+	public function test_null_principal_denies_debug_mode_even_with_permissive_filter(): void {
+		$filter_invoked = false;
+		$filter         = function ( bool $can_debug ) use ( &$filter_invoked ): bool {
+			// Avoid parameter not used PHPCS errors.
+			unset( $can_debug );
+			$filter_invoked = true;
+			return true;
+		};
+		add_filter( 'woocommerce_graphql_can_use_debug_mode', $filter );
+
+		try {
+			$reflection = new \ReflectionClass( GraphQLController::class );
+			$method     = $reflection->getMethod( 'is_debug_mode' );
+			$method->setAccessible( true );
+
+			$request = new \WP_REST_Request( 'POST', '/wc/graphql' );
+			$request->set_query_params( array( '_debug' => '1' ) );
+
+			$result = $method->invoke( $this->sut, null, $request );
+		} finally {
+			remove_filter( 'woocommerce_graphql_can_use_debug_mode', $filter );
+		}
+
+		$this->assertFalse( $result );
+		$this->assertFalse( $filter_invoked, 'Filter must not be consulted for null principals.' );
+	}
+
+	/**
+	 * @testdox a throw from the principal's can_use_debug_mode() method denies debug mode (fail-closed).
+	 */
+	public function test_principal_method_throw_denies_debug_mode(): void {
+		$throwing_principal = new class() {
+			/**
+			 * Always throw to simulate a buggy plugin principal.
+			 *
+			 * @throws \RuntimeException Always.
+			 */
+			public function can_use_debug_mode(): bool {
+				throw new \RuntimeException( 'broken-principal-method' );
+			}
+		};
+
+		$reflection = new \ReflectionClass( GraphQLController::class );
+		$method     = $reflection->getMethod( 'is_debug_mode' );
+		$method->setAccessible( true );
+
+		$request = new \WP_REST_Request( 'POST', '/wc/graphql' );
+		$request->set_query_params( array( '_debug' => '1' ) );
+
+		$this->assertFalse( $method->invoke( $this->sut, $throwing_principal, $request ) );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerExecutionTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerExecutionTest.php
index 2879774ec8e..57a53e248d2 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerExecutionTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerExecutionTest.php
@@ -256,6 +256,83 @@ class GraphQLControllerExecutionTest extends WC_REST_Unit_Test_Case {
 		$this->assertSame( 'Query', $response->get_data()['data']['__schema']['queryType']['name'] ?? null );
 	}

+	/**
+	 * @testdox the woocommerce_graphql_can_introspect filter can grant introspection to an anonymous request.
+	 */
+	public function test_filter_can_grant_introspection_to_anonymous_user(): void {
+		wp_set_current_user( 0 );
+
+		$received_principal = null;
+		$received_request   = null;
+		$filter             = function ( bool $can_introspect, ?object $principal, \WP_REST_Request $request ) use ( &$received_principal, &$received_request ): bool {
+			$received_principal = $principal;
+			$received_request   = $request;
+			return true;
+		};
+		add_filter( 'woocommerce_graphql_can_introspect', $filter, 10, 3 );
+
+		try {
+			$response = $this->sut->handle_request(
+				$this->post_request( array( 'query' => '{ __schema { queryType { name } } }' ) )
+			);
+		} finally {
+			remove_filter( 'woocommerce_graphql_can_introspect', $filter, 10 );
+		}
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( 'Query', $response->get_data()['data']['__schema']['queryType']['name'] ?? null );
+
+		$this->assertNotNull( $received_principal, 'The filter should receive the resolved principal even for anonymous requests.' );
+		$this->assertInstanceOf( \WP_REST_Request::class, $received_request );
+	}
+
+	/**
+	 * @testdox the woocommerce_graphql_can_introspect filter can revoke introspection from an admin.
+	 */
+	public function test_filter_can_revoke_introspection_from_admin(): void {
+		$admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $admin );
+
+		$filter = function (): bool {
+			return false;
+		};
+		add_filter( 'woocommerce_graphql_can_introspect', $filter );
+
+		try {
+			$response = $this->sut->handle_request(
+				$this->post_request( array( 'query' => '{ __schema { queryType { name } } }' ) )
+			);
+		} finally {
+			remove_filter( 'woocommerce_graphql_can_introspect', $filter );
+		}
+
+		$data = $response->get_data();
+		$this->assertNotEmpty( $data['errors'] ?? array() );
+	}
+
+	/**
+	 * @testdox the woocommerce_graphql_can_introspect filter must return strictly true; truthy non-bool denies.
+	 */
+	public function test_filter_requires_strict_true_to_grant_introspection(): void {
+		wp_set_current_user( 0 );
+
+		$filter = function () {
+			return 1;
+		};
+		add_filter( 'woocommerce_graphql_can_introspect', $filter );
+
+		try {
+			$response = $this->sut->handle_request(
+				$this->post_request( array( 'query' => '{ __schema { queryType { name } } }' ) )
+			);
+		} finally {
+			remove_filter( 'woocommerce_graphql_can_introspect', $filter );
+		}
+
+		$data = $response->get_data();
+		$this->assertNotEmpty( $data['errors'] ?? array() );
+	}
+
 	/**
 	 * @testdox handle_request returns a syntax error as HTTP 400 GRAPHQL_PARSE_ERROR.
 	 */
@@ -473,4 +550,85 @@ class GraphQLControllerExecutionTest extends WC_REST_Unit_Test_Case {
 		$this->assertSame( 200, $response->get_status() );
 		$this->assertSame( 'anonymous', $response->get_data()['data']['principalAware']['result'] ?? null );
 	}
+
+	/**
+	 * @testdox a throw from the woocommerce_graphql_can_introspect filter callback denies introspection (fail-closed).
+	 */
+	public function test_filter_callback_throw_denies_introspection(): void {
+		$admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $admin );
+
+		$filter = function () {
+			throw new \RuntimeException( 'broken-filter-callback' );
+		};
+		add_filter( 'woocommerce_graphql_can_introspect', $filter );
+
+		try {
+			$response = $this->sut->handle_request(
+				$this->post_request( array( 'query' => '{ __schema { queryType { name } } }' ) )
+			);
+		} finally {
+			remove_filter( 'woocommerce_graphql_can_introspect', $filter );
+		}
+
+		// Validation rejects the introspection query (fail-closed), and the
+		// filter's exception message must not appear anywhere on the wire.
+		$data = $response->get_data();
+		$this->assertNotEmpty( $data['errors'] ?? array() );
+
+		$wire = wp_json_encode( $data );
+		$this->assertIsString( $wire );
+		$this->assertStringNotContainsString( 'broken-filter-callback', $wire );
+	}
+
+	/**
+	 * @testdox a null principal denies introspection even when a permissive filter is installed.
+	 */
+	public function test_null_principal_denies_introspection_even_with_permissive_filter(): void {
+		$filter_invoked = false;
+		$filter         = function ( bool $can_introspect ) use ( &$filter_invoked ): bool {
+			// Avoid parameter not used PHPCS errors.
+			unset( $can_introspect );
+			$filter_invoked = true;
+			return true;
+		};
+		add_filter( 'woocommerce_graphql_can_introspect', $filter );
+
+		try {
+			$reflection = new \ReflectionClass( GraphQLController::class );
+			$method     = $reflection->getMethod( 'is_introspection_allowed' );
+			$method->setAccessible( true );
+
+			$request = new \WP_REST_Request( 'POST', '/wc/graphql' );
+			$result  = $method->invoke( $this->sut, null, $request );
+		} finally {
+			remove_filter( 'woocommerce_graphql_can_introspect', $filter );
+		}
+
+		$this->assertFalse( $result );
+		$this->assertFalse( $filter_invoked, 'Filter must not be consulted for null principals.' );
+	}
+
+	/**
+	 * @testdox a throw from the principal's can_introspect() method denies introspection (fail-closed).
+	 */
+	public function test_principal_method_throw_denies_introspection(): void {
+		$throwing_principal = new class() {
+			/**
+			 * Always throw to simulate a buggy plugin principal.
+			 *
+			 * @throws \RuntimeException Always.
+			 */
+			public function can_introspect(): bool {
+				throw new \RuntimeException( 'broken-principal-method' );
+			}
+		};
+
+		$reflection = new \ReflectionClass( GraphQLController::class );
+		$method     = $reflection->getMethod( 'is_introspection_allowed' );
+		$method->setAccessible( true );
+
+		$request = new \WP_REST_Request( 'POST', '/wc/graphql' );
+		$this->assertFalse( $method->invoke( $this->sut, $throwing_principal, $request ) );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerStatusResolverTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerStatusResolverTest.php
index 8e5729810f1..421bbf9a372 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerStatusResolverTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerStatusResolverTest.php
@@ -483,4 +483,98 @@ class GraphQLControllerStatusResolverTest extends WC_REST_Unit_Test_Case {
 			)->get_status()
 		);
 	}
+
+	// ------------------------------------------------------------------
+	// Debug-mode enrichment for the canonical 500: extensions.debug
+	// surfaces the wrapper exception, and extensions.previous chains
+	// through to the resolver's own throw when there was one.
+	// ------------------------------------------------------------------
+
+	/**
+	 * @testdox A throwing resolver in debug mode surfaces the wrapper message and the resolver's previous chain.
+	 */
+	public function test_throwing_resolver_in_debug_mode_surfaces_previous_chain(): void {
+		$admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $admin );
+
+		$sut     = $this->controller_with_resolver( new AlwaysThrowingResolver() );
+		$request = $this->post_request( array( 'query' => '{ greeting { result } }' ) );
+		$request->set_query_params( array( '_debug' => '1' ) );
+
+		$response = $sut->handle_request( $request );
+		$this->assertSame( 500, $response->get_status() );
+
+		$data = $response->get_data();
+		$this->assertSame( 'INTERNAL_ERROR', $data['errors'][0]['extensions']['code'] ?? null );
+		$this->assertSame( 'An unexpected error occurred.', $data['errors'][0]['message'] ?? null );
+
+		$debug = $data['errors'][0]['extensions']['debug'] ?? null;
+		$this->assertIsArray( $debug );
+		$this->assertSame( 'HTTP status resolver threw.', $debug['message'] ?? null );
+		$this->assertArrayHasKey( 'file', $debug );
+		$this->assertArrayHasKey( 'line', $debug );
+		$this->assertArrayHasKey( 'trace', $debug );
+
+		// The previous chain must contain the resolver's own RuntimeException
+		// with its distinctive message — that's the actual cause a developer
+		// needs to see.
+		$previous = $data['errors'][0]['extensions']['previous'] ?? null;
+		$this->assertIsArray( $previous );
+		$this->assertContains( \RuntimeException::class, array_column( $previous, 'class' ) );
+		$this->assertContains( AlwaysThrowingResolver::THROW_MESSAGE, array_column( $previous, 'message' ) );
+	}
+
+	/**
+	 * @testdox An out-of-range resolver return in debug mode surfaces the wrapper message; no previous chain (the wrapper had no cause).
+	 */
+	public function test_out_of_range_resolver_return_in_debug_mode_surfaces_wrapper_message(): void {
+		$admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $admin );
+
+		$sut     = $this->controller_with_resolver( new FixedReturnResolver( 0 ) );
+		$request = $this->post_request( array( 'query' => '{ greeting { result } }' ) );
+		$request->set_query_params( array( '_debug' => '1' ) );
+
+		$response = $sut->handle_request( $request );
+		$this->assertSame( 500, $response->get_status() );
+
+		$data = $response->get_data();
+		$this->assertSame( 'INTERNAL_ERROR', $data['errors'][0]['extensions']['code'] ?? null );
+
+		$debug = $data['errors'][0]['extensions']['debug'] ?? null;
+		$this->assertIsArray( $debug );
+		// The wrapper message is what surfaces here — and it's the message a
+		// developer needs to identify the bug ("the resolver returned 0").
+		$this->assertStringContainsString( 'out-of-range status code', $debug['message'] ?? '' );
+		$this->assertStringContainsString( '0', $debug['message'] ?? '' );
+
+		// Out-of-range path constructs the wrapper without a $previous, so
+		// no previous chain should be attached.
+		$this->assertArrayNotHasKey( 'previous', $data['errors'][0]['extensions'] );
+	}
+
+	/**
+	 * @testdox A throwing resolver without _debug=1 produces the canonical body — no debug or previous keys leak.
+	 */
+	public function test_throwing_resolver_without_debug_param_produces_canonical_body(): void {
+		// Even an admin: without _debug=1 the resolver-failure response stays
+		// purely generic so no resolver internals leak.
+		$admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $admin );
+
+		$sut      = $this->controller_with_resolver( new AlwaysThrowingResolver() );
+		$response = $sut->handle_request( $this->post_request( array( 'query' => '{ greeting { result } }' ) ) );
+
+		$this->assertSame( 500, $response->get_status() );
+		$data = $response->get_data();
+		$this->assertSame( 'INTERNAL_ERROR', $data['errors'][0]['extensions']['code'] ?? null );
+		$this->assertArrayNotHasKey( 'debug', $data['errors'][0]['extensions'] );
+		$this->assertArrayNotHasKey( 'previous', $data['errors'][0]['extensions'] );
+
+		// Belt and braces: the resolver's exception message must NOT appear
+		// anywhere in the serialized response.
+		$wire = wp_json_encode( $data );
+		$this->assertIsString( $wire );
+		$this->assertStringNotContainsString( AlwaysThrowingResolver::THROW_MESSAGE, $wire );
+	}
 }