@@ -338,6 +338,50 @@ function _wp_connectors_mask_api_key( string $key ): string {
338338 return str_repeat ( "\u{2022}" , min ( strlen ( $ key ) - 4 , 16 ) ) . substr ( $ key , -4 );
339339}
340340
341+ /**
342+ * Determines the source of an API key for a given provider.
343+ *
344+ * Checks in order: environment variable, PHP constant, database.
345+ * Uses the same naming convention as the WP AI Client ProviderRegistry.
346+ *
347+ * @since 7.0.0
348+ * @access private
349+ *
350+ * @param string $provider_id The provider ID (e.g., 'openai', 'anthropic', 'google').
351+ * @param string $setting_name The option name for the API key (e.g., 'connectors_ai_openai_api_key').
352+ * @return string The key source: 'env', 'constant', 'database', or 'none'.
353+ */
354+ function _wp_connectors_get_api_key_source ( string $ provider_id , string $ setting_name ): string {
355+ // Convert provider ID to CONSTANT_CASE for env var name.
356+ // e.g., 'openai' -> 'OPENAI', 'anthropic' -> 'ANTHROPIC'.
357+ $ constant_case_id = strtoupper (
358+ preg_replace ( '/([a-z])([A-Z])/ ' , '$1_$2 ' , str_replace ( '- ' , '_ ' , $ provider_id ) )
359+ );
360+ $ env_var_name = "{$ constant_case_id }_API_KEY " ;
361+
362+ // Check environment variable first.
363+ $ env_value = getenv ( $ env_var_name );
364+ if ( false !== $ env_value && '' !== $ env_value ) {
365+ return 'env ' ;
366+ }
367+
368+ // Check PHP constant.
369+ if ( defined ( $ env_var_name ) ) {
370+ $ const_value = constant ( $ env_var_name );
371+ if ( is_string ( $ const_value ) && '' !== $ const_value ) {
372+ return 'constant ' ;
373+ }
374+ }
375+
376+ // Check database.
377+ $ db_value = get_option ( $ setting_name , '' );
378+ if ( '' !== $ db_value ) {
379+ return 'database ' ;
380+ }
381+
382+ return 'none ' ;
383+ }
384+
341385/**
342386 * Checks whether an API key is valid for a given provider.
343387 *
@@ -378,89 +422,69 @@ function _wp_connectors_is_ai_api_key_valid( string $key, string $provider_id ):
378422}
379423
380424/**
381- * Retrieves the real (unmasked) value of a connector API key .
425+ * Masks and validates connector API keys in REST responses .
382426 *
383- * Temporarily removes the masking filter, reads the option, then re-adds it.
384- *
385- * @since 7.0.0
386- * @access private
387- *
388- * @param string $option_name The option name for the API key.
389- * @param callable $mask_callback The mask filter function.
390- * @return string The real API key value.
391- */
392- function _wp_connectors_get_real_api_key ( string $ option_name , callable $ mask_callback ): string {
393- remove_filter ( "option_ {$ option_name }" , $ mask_callback );
394- $ value = get_option ( $ option_name , '' );
395- add_filter ( "option_ {$ option_name }" , $ mask_callback );
396- return (string ) $ value ;
397- }
398-
399- /**
400- * Validates connector API keys in the REST response when explicitly requested.
427+ * On every `/wp/v2/settings` response, masks connector API key values so raw
428+ * keys are never exposed via the REST API.
401429 *
402- * Runs on `rest_post_dispatch` for `/wp/v2/settings` requests that include connector
403- * fields via `_fields`. For each requested connector field, it validates the unmasked
404- * key against the provider and replaces the response value with `invalid_key` if
405- * validation fails.
430+ * On POST or PUT requests, validates each updated key against the provider
431+ * before masking. If validation fails, the key is reverted to an empty string.
406432 *
407433 * @since 7.0.0
408434 * @access private
409435 *
410436 * @param WP_REST_Response $response The response object.
411437 * @param WP_REST_Server $server The server instance.
412438 * @param WP_REST_Request $request The request object.
413- * @return WP_REST_Response The potentially modified response.
439+ * @return WP_REST_Response The modified response with masked/validated keys .
414440 */
415- function _wp_connectors_validate_keys_in_rest ( WP_REST_Response $ response , WP_REST_Server $ server , WP_REST_Request $ request ): WP_REST_Response {
441+ function _wp_connectors_rest_settings_dispatch ( WP_REST_Response $ response , WP_REST_Server $ server , WP_REST_Request $ request ): WP_REST_Response {
416442 if ( '/wp/v2/settings ' !== $ request ->get_route () ) {
417443 return $ response ;
418444 }
419445
420- $ fields = $ request ->get_param ( '_fields ' );
421- if ( ! $ fields ) {
422- return $ response ;
423- }
424-
425- if ( is_array ( $ fields ) ) {
426- $ requested = $ fields ;
427- } else {
428- $ requested = array_map ( 'trim ' , explode ( ', ' , $ fields ) );
429- }
430-
431446 $ data = $ response ->get_data ();
432447 if ( ! is_array ( $ data ) ) {
433448 return $ response ;
434449 }
435450
451+ $ is_update = 'POST ' === $ request ->get_method () || 'PUT ' === $ request ->get_method ();
452+
436453 foreach ( wp_get_connectors () as $ connector_id => $ connector_data ) {
437454 $ auth = $ connector_data ['authentication ' ];
438455 if ( 'ai_provider ' !== $ connector_data ['type ' ] || 'api_key ' !== $ auth ['method ' ] || empty ( $ auth ['setting_name ' ] ) ) {
439456 continue ;
440457 }
441458
442459 $ setting_name = $ auth ['setting_name ' ];
443- if ( ! in_array ( $ setting_name , $ requested , true ) ) {
460+ if ( ! array_key_exists ( $ setting_name , $ data ) ) {
444461 continue ;
445462 }
446463
447- $ real_key = _wp_connectors_get_real_api_key ( $ setting_name , '_wp_connectors_mask_api_key ' );
448- if ( '' === $ real_key ) {
449- continue ;
464+ $ value = $ data [ $ setting_name ];
465+
466+ // On update, validate the key before masking.
467+ if ( $ is_update && is_string ( $ value ) && '' !== $ value ) {
468+ if ( true !== _wp_connectors_is_ai_api_key_valid ( $ value , $ connector_id ) ) {
469+ update_option ( $ setting_name , '' );
470+ $ data [ $ setting_name ] = '' ;
471+ continue ;
472+ }
450473 }
451474
452- if ( true !== _wp_connectors_is_ai_api_key_valid ( $ real_key , $ connector_id ) ) {
453- $ data [ $ setting_name ] = 'invalid_key ' ;
475+ // Mask the key in the response.
476+ if ( is_string ( $ value ) && '' !== $ value ) {
477+ $ data [ $ setting_name ] = _wp_connectors_mask_api_key ( $ value );
454478 }
455479 }
456480
457481 $ response ->set_data ( $ data );
458482 return $ response ;
459483}
460- add_filter ( 'rest_post_dispatch ' , '_wp_connectors_validate_keys_in_rest ' , 10 , 3 );
484+ add_filter ( 'rest_post_dispatch ' , '_wp_connectors_rest_settings_dispatch ' , 10 , 3 );
461485
462486/**
463- * Registers default connector settings and mask/sanitize filters .
487+ * Registers default connector settings.
464488 *
465489 * @since 7.0.0
466490 * @access private
@@ -479,10 +503,9 @@ function _wp_register_default_connector_settings(): void {
479503 continue ;
480504 }
481505
482- $ setting_name = $ auth ['setting_name ' ];
483506 register_setting (
484507 'connectors ' ,
485- $ setting_name ,
508+ $ auth [ ' setting_name ' ] ,
486509 array (
487510 'type ' => 'string ' ,
488511 'label ' => sprintf (
@@ -497,18 +520,9 @@ function _wp_register_default_connector_settings(): void {
497520 ),
498521 'default ' => '' ,
499522 'show_in_rest ' => true ,
500- 'sanitize_callback ' => static function ( string $ value ) use ( $ connector_id ): string {
501- $ value = sanitize_text_field ( $ value );
502- if ( '' === $ value ) {
503- return $ value ;
504- }
505-
506- $ valid = _wp_connectors_is_ai_api_key_valid ( $ value , $ connector_id );
507- return true === $ valid ? $ value : '' ;
508- },
523+ 'sanitize_callback ' => 'sanitize_text_field ' ,
509524 )
510525 );
511- add_filter ( "option_ {$ setting_name }" , '_wp_connectors_mask_api_key ' );
512526 }
513527}
514528add_action ( 'init ' , '_wp_register_default_connector_settings ' , 20 );
@@ -536,7 +550,13 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void {
536550 continue ;
537551 }
538552
539- $ api_key = _wp_connectors_get_real_api_key ( $ auth ['setting_name ' ], '_wp_connectors_mask_api_key ' );
553+ // Skip if the key is already provided via env var or constant.
554+ $ key_source = _wp_connectors_get_api_key_source ( $ connector_id , $ auth ['setting_name ' ] );
555+ if ( 'env ' === $ key_source || 'constant ' === $ key_source ) {
556+ continue ;
557+ }
558+
559+ $ api_key = get_option ( $ auth ['setting_name ' ], '' );
540560 if ( '' === $ api_key ) {
541561 continue ;
542562 }
@@ -562,6 +582,18 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void {
562582 * @return array<string, mixed> Script module data with connectors added.
563583 */
564584function _wp_connectors_get_connector_script_module_data ( array $ data ): array {
585+ $ registry = AiClient::defaultRegistry ();
586+
587+ // Build a slug-to-file map for plugin installation status.
588+ if ( ! function_exists ( 'get_plugins ' ) ) {
589+ require_once ABSPATH . 'wp-admin/includes/plugin.php ' ;
590+ }
591+ $ plugin_files_by_slug = array ();
592+ foreach ( array_keys ( get_plugins () ) as $ plugin_file ) {
593+ $ slug = str_contains ( $ plugin_file , '/ ' ) ? dirname ( $ plugin_file ) : str_replace ( '.php ' , '' , $ plugin_file );
594+ $ plugin_files_by_slug [ $ slug ] = $ plugin_file ;
595+ }
596+
565597 $ connectors = array ();
566598 foreach ( wp_get_connectors () as $ connector_id => $ connector_data ) {
567599 $ auth = $ connector_data ['authentication ' ];
@@ -570,17 +602,34 @@ function _wp_connectors_get_connector_script_module_data( array $data ): array {
570602 if ( 'api_key ' === $ auth ['method ' ] ) {
571603 $ auth_out ['settingName ' ] = $ auth ['setting_name ' ] ?? '' ;
572604 $ auth_out ['credentialsUrl ' ] = $ auth ['credentials_url ' ] ?? null ;
605+ $ auth_out ['keySource ' ] = _wp_connectors_get_api_key_source ( $ connector_id , $ auth ['setting_name ' ] ?? '' );
606+ try {
607+ $ auth_out ['isConnected ' ] = $ registry ->hasProvider ( $ connector_id ) && $ registry ->isProviderConfigured ( $ connector_id );
608+ } catch ( Exception $ e ) {
609+ $ auth_out ['isConnected ' ] = false ;
610+ }
573611 }
574612
575613 $ connector_out = array (
576614 'name ' => $ connector_data ['name ' ],
577615 'description ' => $ connector_data ['description ' ],
616+ 'logoUrl ' => ! empty ( $ connector_data ['logo_url ' ] ) ? $ connector_data ['logo_url ' ] : null ,
578617 'type ' => $ connector_data ['type ' ],
579618 'authentication ' => $ auth_out ,
580619 );
581620
582- if ( ! empty ( $ connector_data ['plugin ' ] ) ) {
583- $ connector_out ['plugin ' ] = $ connector_data ['plugin ' ];
621+ if ( ! empty ( $ connector_data ['plugin ' ]['slug ' ] ) ) {
622+ $ plugin_slug = $ connector_data ['plugin ' ]['slug ' ];
623+ $ plugin_file = $ plugin_files_by_slug [ $ plugin_slug ] ?? null ;
624+
625+ $ is_installed = null !== $ plugin_file ;
626+ $ is_activated = $ is_installed && is_plugin_active ( $ plugin_file );
627+
628+ $ connector_out ['plugin ' ] = array (
629+ 'slug ' => $ plugin_slug ,
630+ 'isInstalled ' => $ is_installed ,
631+ 'isActivated ' => $ is_activated ,
632+ );
584633 }
585634
586635 $ connectors [ $ connector_id ] = $ connector_out ;
0 commit comments