@@ -266,6 +266,50 @@ function _wp_connectors_mask_api_key( string $key ): string {
266266 return str_repeat ( "\u{2022}" , min ( strlen ( $ key ) - 4 , 16 ) ) . substr ( $ key , -4 );
267267}
268268
269+ /**
270+ * Determines the source of an API key for a given provider.
271+ *
272+ * Checks in order: environment variable, PHP constant, database.
273+ * Uses the same naming convention as the WP AI Client ProviderRegistry.
274+ *
275+ * @since 7.0.0
276+ * @access private
277+ *
278+ * @param string $provider_id The provider ID (e.g., 'openai', 'anthropic', 'google').
279+ * @return string The key source: 'env', 'constant', 'database', or 'none'.
280+ */
281+ function _wp_connectors_get_api_key_source ( string $ provider_id ): string {
282+ // Convert provider ID to CONSTANT_CASE for env var name.
283+ // e.g., 'openai' -> 'OPENAI', 'anthropic' -> 'ANTHROPIC'.
284+ $ constant_case_id = strtoupper (
285+ preg_replace ( '/([a-z])([A-Z])/ ' , '$1_$2 ' , str_replace ( '- ' , '_ ' , $ provider_id ) )
286+ );
287+ $ env_var_name = "{$ constant_case_id }_API_KEY " ;
288+
289+ // Check environment variable first.
290+ $ env_value = getenv ( $ env_var_name );
291+ if ( false !== $ env_value && '' !== $ env_value ) {
292+ return 'env ' ;
293+ }
294+
295+ // Check PHP constant.
296+ if ( defined ( $ env_var_name ) ) {
297+ $ const_value = constant ( $ env_var_name );
298+ if ( is_string ( $ const_value ) && '' !== $ const_value ) {
299+ return 'constant ' ;
300+ }
301+ }
302+
303+ // Check database.
304+ $ setting_name = "connectors_ai_ {$ provider_id }_api_key " ;
305+ $ db_value = get_option ( $ setting_name , '' );
306+ if ( '' !== $ db_value ) {
307+ return 'database ' ;
308+ }
309+
310+ return 'none ' ;
311+ }
312+
269313/**
270314 * Checks whether an API key is valid for a given provider.
271315 *
@@ -305,25 +349,6 @@ function _wp_connectors_is_ai_api_key_valid( string $key, string $provider_id ):
305349 }
306350}
307351
308- /**
309- * Retrieves the real (unmasked) value of a connector API key.
310- *
311- * Temporarily removes the masking filter, reads the option, then re-adds it.
312- *
313- * @since 7.0.0
314- * @access private
315- *
316- * @param string $option_name The option name for the API key.
317- * @param callable $mask_callback The mask filter function.
318- * @return string The real API key value.
319- */
320- function _wp_connectors_get_real_api_key ( string $ option_name , callable $ mask_callback ): string {
321- remove_filter ( "option_ {$ option_name }" , $ mask_callback );
322- $ value = get_option ( $ option_name , '' );
323- add_filter ( "option_ {$ option_name }" , $ mask_callback );
324- return (string ) $ value ;
325- }
326-
327352/**
328353 * Gets the registered connector settings.
329354 *
@@ -360,70 +385,69 @@ function _wp_connectors_get_connector_settings(): array {
360385}
361386
362387/**
363- * Validates connector API keys in the REST response when explicitly requested.
388+ * Masks and validates connector API keys in REST responses.
389+ *
390+ * On every `/wp/v2/settings` response, masks connector API key values so raw
391+ * keys are never exposed via the REST API.
364392 *
365- * Runs on `rest_post_dispatch` for `/wp/v2/settings` requests that include connector
366- * fields via `_fields`. For each requested connector field, it validates the unmasked
367- * key against the provider and replaces the response value with `invalid_key` if
368- * validation fails.
393+ * On POST or PUT requests, validates each updated key against the provider
394+ * before masking. If validation fails, the key is reverted to an empty string.
369395 *
370396 * @since 7.0.0
371397 * @access private
372398 *
373399 * @param WP_REST_Response $response The response object.
374400 * @param WP_REST_Server $server The server instance.
375401 * @param WP_REST_Request $request The request object.
376- * @return WP_REST_Response The potentially modified response.
402+ * @return WP_REST_Response The modified response with masked/validated keys .
377403 */
378- function _wp_connectors_validate_keys_in_rest ( WP_REST_Response $ response , WP_REST_Server $ server , WP_REST_Request $ request ): WP_REST_Response {
404+ function _wp_connectors_rest_settings_dispatch ( WP_REST_Response $ response , WP_REST_Server $ server , WP_REST_Request $ request ): WP_REST_Response {
379405 if ( '/wp/v2/settings ' !== $ request ->get_route () ) {
380406 return $ response ;
381407 }
382408
383- $ fields = $ request ->get_param ( '_fields ' );
384- if ( ! $ fields ) {
385- return $ response ;
386- }
387-
388- if ( is_array ( $ fields ) ) {
389- $ requested = $ fields ;
390- } else {
391- $ requested = array_map ( 'trim ' , explode ( ', ' , $ fields ) );
392- }
393-
394409 $ data = $ response ->get_data ();
395410 if ( ! is_array ( $ data ) ) {
396411 return $ response ;
397412 }
398413
414+ $ is_update = 'POST ' === $ request ->get_method () || 'PUT ' === $ request ->get_method ();
415+
399416 foreach ( _wp_connectors_get_connector_settings () as $ connector_id => $ connector_data ) {
400417 $ auth = $ connector_data ['authentication ' ];
401- if ( 'ai_provider ' !== $ connector_data [ ' type ' ] || ' api_key ' !== $ auth ['method ' ] || empty ( $ auth ['setting_name ' ] ) ) {
418+ if ( 'api_key ' !== $ auth ['method ' ] || empty ( $ auth ['setting_name ' ] ) ) {
402419 continue ;
403420 }
404421
405422 $ setting_name = $ auth ['setting_name ' ];
406- if ( ! in_array ( $ setting_name , $ requested , true ) ) {
423+ if ( ! array_key_exists ( $ setting_name , $ data ) ) {
407424 continue ;
408425 }
409426
410- $ real_key = _wp_connectors_get_real_api_key ( $ setting_name , '_wp_connectors_mask_api_key ' );
411- if ( '' === $ real_key ) {
412- continue ;
427+ $ value = $ data [ $ setting_name ];
428+
429+ // On update, validate the key before masking.
430+ if ( $ is_update && is_string ( $ value ) && '' !== $ value ) {
431+ if ( true !== _wp_connectors_is_ai_api_key_valid ( $ value , $ connector_id ) ) {
432+ update_option ( $ setting_name , '' );
433+ $ data [ $ setting_name ] = '' ;
434+ continue ;
435+ }
413436 }
414437
415- if ( true !== _wp_connectors_is_ai_api_key_valid ( $ real_key , $ connector_id ) ) {
416- $ data [ $ setting_name ] = 'invalid_key ' ;
438+ // Mask the key in the response.
439+ if ( is_string ( $ value ) && '' !== $ value ) {
440+ $ data [ $ setting_name ] = _wp_connectors_mask_api_key ( $ value );
417441 }
418442 }
419443
420444 $ response ->set_data ( $ data );
421445 return $ response ;
422446}
423- add_filter ( 'rest_post_dispatch ' , '_wp_connectors_validate_keys_in_rest ' , 10 , 3 );
447+ add_filter ( 'rest_post_dispatch ' , '_wp_connectors_rest_settings_dispatch ' , 10 , 3 );
424448
425449/**
426- * Registers default connector settings and mask/sanitize filters .
450+ * Registers default connector settings.
427451 *
428452 * @since 7.0.0
429453 * @access private
@@ -442,10 +466,9 @@ function _wp_register_default_connector_settings(): void {
442466 continue ;
443467 }
444468
445- $ setting_name = $ auth ['setting_name ' ];
446469 register_setting (
447470 'connectors ' ,
448- $ setting_name ,
471+ $ auth [ ' setting_name ' ] ,
449472 array (
450473 'type ' => 'string ' ,
451474 'label ' => sprintf (
@@ -460,18 +483,9 @@ function _wp_register_default_connector_settings(): void {
460483 ),
461484 'default ' => '' ,
462485 'show_in_rest ' => true ,
463- 'sanitize_callback ' => static function ( string $ value ) use ( $ connector_id ): string {
464- $ value = sanitize_text_field ( $ value );
465- if ( '' === $ value ) {
466- return $ value ;
467- }
468-
469- $ valid = _wp_connectors_is_ai_api_key_valid ( $ value , $ connector_id );
470- return true === $ valid ? $ value : '' ;
471- },
486+ 'sanitize_callback ' => 'sanitize_text_field ' ,
472487 )
473488 );
474- add_filter ( "option_ {$ setting_name }" , '_wp_connectors_mask_api_key ' );
475489 }
476490}
477491add_action ( 'init ' , '_wp_register_default_connector_settings ' , 20 );
@@ -499,7 +513,13 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void {
499513 continue ;
500514 }
501515
502- $ api_key = _wp_connectors_get_real_api_key ( $ auth ['setting_name ' ], '_wp_connectors_mask_api_key ' );
516+ // Skip if the key is already provided via env var or constant.
517+ $ key_source = _wp_connectors_get_api_key_source ( $ connector_id );
518+ if ( 'env ' === $ key_source || 'constant ' === $ key_source ) {
519+ continue ;
520+ }
521+
522+ $ api_key = get_option ( $ auth ['setting_name ' ], '' );
503523 if ( '' === $ api_key ) {
504524 continue ;
505525 }
@@ -525,6 +545,18 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void {
525545 * @return array Script module data with connectors added.
526546 */
527547function _wp_connectors_get_connector_script_module_data ( array $ data ): array {
548+ $ registry = AiClient::defaultRegistry ();
549+
550+ // Build a slug-to-file map for plugin installation status.
551+ if ( ! function_exists ( 'get_plugins ' ) ) {
552+ require_once ABSPATH . 'wp-admin/includes/plugin.php ' ;
553+ }
554+ $ plugin_files_by_slug = array ();
555+ foreach ( array_keys ( get_plugins () ) as $ plugin_file ) {
556+ $ slug = str_contains ( $ plugin_file , '/ ' ) ? dirname ( $ plugin_file ) : str_replace ( '.php ' , '' , $ plugin_file );
557+ $ plugin_files_by_slug [ $ slug ] = $ plugin_file ;
558+ }
559+
528560 $ connectors = array ();
529561 foreach ( _wp_connectors_get_connector_settings () as $ connector_id => $ connector_data ) {
530562 $ auth = $ connector_data ['authentication ' ];
@@ -533,17 +565,34 @@ function _wp_connectors_get_connector_script_module_data( array $data ): array {
533565 if ( 'api_key ' === $ auth ['method ' ] ) {
534566 $ auth_out ['settingName ' ] = $ auth ['setting_name ' ] ?? '' ;
535567 $ auth_out ['credentialsUrl ' ] = $ auth ['credentials_url ' ] ?? null ;
568+ $ auth_out ['keySource ' ] = _wp_connectors_get_api_key_source ( $ connector_id );
569+ try {
570+ $ auth_out ['isConnected ' ] = $ registry ->hasProvider ( $ connector_id ) && $ registry ->isProviderConfigured ( $ connector_id );
571+ } catch ( Exception $ e ) {
572+ $ auth_out ['isConnected ' ] = false ;
573+ }
536574 }
537575
538576 $ connector_out = array (
539577 'name ' => $ connector_data ['name ' ],
540578 'description ' => $ connector_data ['description ' ],
579+ 'logoUrl ' => ! empty ( $ connector_data ['logo_url ' ] ) ? $ connector_data ['logo_url ' ] : null ,
541580 'type ' => $ connector_data ['type ' ],
542581 'authentication ' => $ auth_out ,
543582 );
544583
545- if ( ! empty ( $ connector_data ['plugin ' ] ) ) {
546- $ connector_out ['plugin ' ] = $ connector_data ['plugin ' ];
584+ if ( ! empty ( $ connector_data ['plugin ' ]['slug ' ] ) ) {
585+ $ plugin_slug = $ connector_data ['plugin ' ]['slug ' ];
586+ $ plugin_file = $ plugin_files_by_slug [ $ plugin_slug ] ?? null ;
587+
588+ $ is_installed = null !== $ plugin_file ;
589+ $ is_activated = $ is_installed && is_plugin_active ( $ plugin_file );
590+
591+ $ connector_out ['plugin ' ] = array (
592+ 'slug ' => $ plugin_slug ,
593+ 'isInstalled ' => $ is_installed ,
594+ 'isActivated ' => $ is_activated ,
595+ );
547596 }
548597
549598 $ connectors [ $ connector_id ] = $ connector_out ;
0 commit comments