Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 56 additions & 65 deletions lib/experimental/connectors/default-connectors.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,49 @@
* @package gutenberg
*/

/**
* Determines the source of an API key for a given provider.
*
* Checks in order: environment variable, PHP constant, database.
* Uses the same naming convention as the WP AI Client ProviderRegistry.
*
* @access private
*
* @param string $provider_id The provider ID (e.g., 'openai', 'anthropic', 'google').
* @return string The key source: 'env', 'constant', 'database', or 'none'.
*/
function _gutenberg_get_api_key_source( string $provider_id ): string {
// Convert provider ID to CONSTANT_CASE for env var name.
// e.g., 'openai' -> 'OPENAI', 'anthropic' -> 'ANTHROPIC'.
$constant_case_id = strtoupper(
preg_replace( '/([a-z])([A-Z])/', '$1_$2', str_replace( '-', '_', $provider_id ) )
);
$env_var_name = "{$constant_case_id}_API_KEY";

// Check environment variable first.
$env_value = getenv( $env_var_name );
if ( false !== $env_value && '' !== $env_value ) {
return 'env';
}

// Check PHP constant.
if ( defined( $env_var_name ) ) {
$const_value = constant( $env_var_name );
if ( is_string( $const_value ) && '' !== $const_value ) {
return 'constant';
}
}

// Check database.
$setting_name = "connectors_ai_{$provider_id}_api_key";
$db_value = get_option( $setting_name, '' );
if ( '' !== $db_value ) {
return 'database';
}

return 'none';
}

/**
* Masks an API key, showing only the last 4 characters.
*
Expand Down Expand Up @@ -280,72 +323,7 @@ function _gutenberg_get_connector_settings(): array {
return $connectors;
}

/**
* Validates connector API keys in the REST response when explicitly requested.
*
* Runs on `rest_post_dispatch` for `/wp/v2/settings` requests that include connector
* fields via `_fields`. For each requested connector field, it validates the unmasked
* key against the provider and replaces the response value with `invalid_key` if
* validation fails.
*
* @access private
*
* @param WP_REST_Response $response The response object.
* @param WP_REST_Server $server The server instance.
* @param WP_REST_Request $request The request object.
* @return WP_REST_Response The potentially modified response.
*/
function _gutenberg_validate_connector_keys_in_rest( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response {
if ( '/wp/v2/settings' !== $request->get_route() ) {
return $response;
}

if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
return $response;
}

$fields = $request->get_param( '_fields' );
if ( ! $fields ) {
return $response;
}

if ( is_array( $fields ) ) {
$requested = $fields;
} else {
$requested = array_map( 'trim', explode( ',', $fields ) );
}

$data = $response->get_data();
if ( ! is_array( $data ) ) {
return $response;
}

foreach ( _gutenberg_get_connector_settings() as $connector_id => $connector_data ) {
$auth = $connector_data['authentication'];
if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
continue;
}

$setting_name = $auth['setting_name'];
if ( ! in_array( $setting_name, $requested, true ) ) {
continue;
}

$real_key = _gutenberg_get_real_api_key( $setting_name, '_gutenberg_mask_api_key' );
if ( '' === $real_key ) {
continue;
}

if ( true !== _gutenberg_is_ai_api_key_valid( $real_key, $connector_id ) ) {
$data[ $setting_name ] = 'invalid_key';
}
}

$response->set_data( $data );
return $response;
}
remove_filter( 'rest_post_dispatch', '_wp_connectors_validate_keys_in_rest', 10 );
add_filter( 'rest_post_dispatch', '_gutenberg_validate_connector_keys_in_rest', 10, 3 );

/**
* Registers default connector settings and mask/sanitize filters.
Expand Down Expand Up @@ -420,6 +398,12 @@ function _gutenberg_pass_default_connector_keys_to_ai_client(): void {
continue;
}

// Skip if the key is already provided via env var or constant.
$key_source = _gutenberg_get_api_key_source( $connector_id );
if ( 'env' === $key_source || 'constant' === $key_source ) {
continue;
}

$api_key = _gutenberg_get_real_api_key( $auth['setting_name'], '_gutenberg_mask_api_key' );
if ( '' === $api_key || ! $registry->hasProvider( $connector_id ) ) {
continue;
Expand Down Expand Up @@ -450,6 +434,7 @@ function _gutenberg_get_connector_script_module_data( array $data ): array {
return $data;
}

$registry = \WordPress\AiClient\AiClient::defaultRegistry();
$connectors = array();
foreach ( _gutenberg_get_connector_settings() as $connector_id => $connector_data ) {
$auth = $connector_data['authentication'];
Expand All @@ -458,6 +443,12 @@ function _gutenberg_get_connector_script_module_data( array $data ): array {
if ( 'api_key' === $auth['method'] ) {
$auth_out['settingName'] = $auth['setting_name'] ?? '';
$auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null;
$auth_out['keySource'] = _gutenberg_get_api_key_source( $connector_id );
try {
$auth_out['isConnected'] = $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id );
} catch ( Exception $e ) {
$auth_out['isConnected'] = false;
}
}

$connector_out = array(
Expand Down
26 changes: 23 additions & 3 deletions packages/connectors/src/connector-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,16 @@ export function ConnectorItem( {
);
}

export type ApiKeySource = 'env' | 'constant' | 'database' | 'none';

export interface DefaultConnectorSettingsProps {
onSave?: ( apiKey: string ) => void | Promise< void >;
onRemove?: () => void;
initialValue?: string;
helpUrl?: string;
helpLabel?: string;
readOnly?: boolean;
keySource?: ApiKeySource;
}

/**
Expand All @@ -78,6 +81,7 @@ export interface DefaultConnectorSettingsProps {
* @param props.helpUrl - URL to documentation for obtaining an API key.
* @param props.helpLabel - Custom label for the help link. Defaults to the URL without protocol.
* @param props.readOnly - Whether the form is in read-only mode.
* @param props.keySource - The source of the API key: 'env', 'constant', 'database', or 'none'.
*/
export function DefaultConnectorSettings( {
onSave,
Expand All @@ -86,6 +90,7 @@ export function DefaultConnectorSettings( {
helpUrl,
helpLabel,
readOnly = false,
keySource,
}: DefaultConnectorSettingsProps ) {
const [ apiKey, setApiKey ] = useState( initialValue );
const [ isSaving, setIsSaving ] = useState( false );
Expand All @@ -110,7 +115,20 @@ export function DefaultConnectorSettings( {
)
: undefined;

const isExternallyConfigured =
keySource === 'env' || keySource === 'constant';

const getHelp = () => {
if ( isExternallyConfigured ) {
if ( keySource === 'env' ) {
return __(
'This API key is configured using an environment variable.'
);
}
if ( keySource === 'constant' ) {
return __( 'This API key is configured as a constant.' );
}
}
if ( readOnly ) {
return helpUrl
? createInterpolateElement(
Expand Down Expand Up @@ -182,9 +200,11 @@ export function DefaultConnectorSettings( {
help={ getHelp() }
/>
{ readOnly ? (
<Button variant="link" isDestructive onClick={ onRemove }>
{ __( 'Remove and replace' ) }
</Button>
onRemove && (
<Button variant="link" isDestructive onClick={ onRemove }>
{ __( 'Remove and replace' ) }
</Button>
)
) : (
<HStack justify="flex-start">
<Button
Expand Down
1 change: 1 addition & 0 deletions packages/connectors/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ export {
ConnectorItem as __experimentalConnectorItem,
DefaultConnectorSettings as __experimentalDefaultConnectorSettings,
} from './connector-item';
export type { ApiKeySource as __experimentalApiKeySource } from './connector-item';
export type { ConnectorConfig, ConnectorRenderProps } from './types';
export { privateApis } from './private-apis';
33 changes: 29 additions & 4 deletions routes/connectors-home/default-connectors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
__experimentalRegisterConnector as registerConnector,
__experimentalConnectorItem as ConnectorItem,
__experimentalDefaultConnectorSettings as DefaultConnectorSettings,
type __experimentalApiKeySource as ApiKeySource,
type ConnectorRenderProps,
} from '@wordpress/connectors';
import { __ } from '@wordpress/i18n';
Expand All @@ -23,7 +24,13 @@ import {
} from './logos';

type ConnectorAuthentication =
| { method: 'api_key'; settingName: string; credentialsUrl: string | null }
| {
method: 'api_key';
settingName: string;
credentialsUrl: string | null;
keySource?: ApiKeySource;
isConnected?: boolean;
}
| { method: 'none' };

interface ConnectorData {
Expand Down Expand Up @@ -101,6 +108,8 @@ interface ApiKeyConnectorConfig {
icon?: React.ReactNode;
isInstalled?: boolean;
isActivated?: boolean;
keySource?: ApiKeySource;
initialIsConnected?: boolean;
}

function ApiKeyConnector( {
Expand All @@ -112,6 +121,8 @@ function ApiKeyConnector( {
icon,
isInstalled,
isActivated,
keySource: initialKeySource,
initialIsConnected,
}: ConnectorRenderProps & ApiKeyConnectorConfig ) {
let helpLabel: string | undefined;
try {
Expand All @@ -131,6 +142,7 @@ function ApiKeyConnector( {
isBusy,
isConnected,
currentApiKey,
keySource,
handleButtonClick,
getButtonLabel,
saveApiKey,
Expand All @@ -140,7 +152,11 @@ function ApiKeyConnector( {
settingName,
isInstalled,
isActivated,
keySource: initialKeySource,
initialIsConnected,
} );
const isExternallyConfigured =
keySource === 'env' || keySource === 'constant';
const showUnavailableBadge =
( pluginStatus === 'not-installed' && canInstallPlugins === false ) ||
( pluginStatus === 'inactive' && canActivatePlugins === false );
Expand Down Expand Up @@ -184,11 +200,18 @@ function ApiKeyConnector( {
{ isExpanded && pluginStatus === 'active' && (
<DefaultConnectorSettings
key={ isConnected ? 'connected' : 'setup' }
initialValue={ currentApiKey }
initialValue={
isExternallyConfigured
? '••••••••••••••••'
: currentApiKey
}
helpUrl={ helpUrl }
helpLabel={ helpLabel }
readOnly={ isConnected }
onRemove={ removeApiKey }
readOnly={ isConnected || isExternallyConfigured }
keySource={ keySource }
onRemove={
isExternallyConfigured ? undefined : removeApiKey
}
onSave={ async ( apiKey: string ) => {
await saveApiKey( apiKey );
setIsExpanded( false );
Expand Down Expand Up @@ -234,6 +257,8 @@ export function registerDefaultConnectors() {
) }
isInstalled={ data.plugin?.isInstalled }
isActivated={ data.plugin?.isActivated }
keySource={ authentication.keySource }
initialIsConnected={ authentication.isConnected }
/>
),
} );
Expand Down
Loading
Loading