array( 'GET' ), '#^status$#' => array( 'GET' ), '#^onboarding$#' => array( 'PUT' ), '#^test-mode$#' => array( 'PUT' ), '#^consent-posture$#' => array( 'PUT' ), '#^events$#' => array( 'GET' ), '#^credentials$#' => array( 'PUT' ), '#^ads-credentials$#' => array( 'PUT' ), '#^ads/configure$#' => array( 'POST' ), '#^feed-config$#' => array( 'GET', 'PUT' ), '#^feed/provision$#' => array( 'POST' ), '#^feed/generate$#' => array( 'POST' ), '#^feed/runs$#' => array( 'GET' ), '#^feed/openai$#' => array( 'GET' ), '#^catalog/sync$#' => array( 'POST' ), '#^dashboard$#' => array( 'GET' ), '#^alerts$#' => array( 'GET' ), '#^notification-settings$#' => array( 'GET', 'PUT' ), '#^optimize/scores$#' => array( 'GET' ), '#^optimize/preview$#' => array( 'GET' ), '#^optimize/rewrite$#' => array( 'POST' ), '#^optimize/context-hints$#' => array( 'POST' ), '#^copilot/products$#' => array( 'GET' ), '#^copilot/ads$#' => array( 'GET' ), '#^copilot/ads/product-set$#' => array( 'GET' ), '#^copilot/ads/apply$#' => array( 'POST' ), '#^copilot/ads/history$#' => array( 'GET' ), '#^copilot/rules$#' => array( 'GET', 'PUT' ), '#^visibility$#' => array( 'GET' ), '#^visibility/suggestions$#' => array( 'GET' ), '#^visibility/prompts$#' => array( 'POST' ), // Arret du suivi: POST et non DELETE. La passerelle interdit le verbe destructeur, et cette // route ne detruit rien, elle desactive une question et garde ses mesures. '#^visibility/prompts/stop$#' => array( 'POST' ), '#^billing$#' => array( 'GET' ), '#^billing/checkout$#' => array( 'POST' ), '#^billing/cancel$#' => array( 'POST' ), // Groupe de boutiques d'une offre Scale: lecture, puis quatre ecritures en POST. '#^store-group$#' => array( 'GET' ), '#^store-group/code$#' => array( 'POST' ), '#^store-group/join$#' => array( 'POST' ), '#^store-group/remove$#' => array( 'POST' ), '#^store-group/leave$#' => array( 'POST' ), ); /** * Route de la passerelle, sous convrail/v1. Le chemin est FACULTATIF, barre oblique comprise. * * WordPress retire la barre oblique finale de toute route REST avant de la comparer * (`untrailingslashit` dans `rest_api_loaded`). La lecture de la boutique appelle * `convrail/v1/api/`, qui arrivait donc en `convrail/v1/api`: l'ancien motif exigeait la barre * et repondait « No route was found », ce qui vidait tout l'onglet Reglages (constate le * 2026-09-23 dans les deux langues). */ const PROXY_ROUTE = '/api(?:/(?P[a-zA-Z0-9\-_/.]*))?'; /** * Ecrire chez OpenAI ou reconstruire un catalogue prend plus que le delai courant. La * reecriture IA aussi : jusqu'a 60 s par Claude Code, plus un essai sans image. Avec 20 s, * WordPress abandonnait avant la reponse et affichait « Convrail did not answer ». */ const SLOW_PATHS = array( 'ads-credentials' => 90, 'ads/configure' => 90, 'feed-config' => 60, 'catalog/sync' => 180, 'optimize/rewrite' => 90, 'optimize/context-hints' => 90, ); public static function register_routes() { /* * Resynchronisation complete, declenchee LOCALEMENT. La plateforme ne peut pas aller * chercher le catalogue d'une boutique WooCommerce: c'est le plugin qui le pousse. Le * bouton appelait la route Shopify et echouait a tous les coups. */ register_rest_route( 'convrail/v1', '/sync', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'sync' ), 'permission_callback' => array( __CLASS__, 'can_manage' ), ) ); register_rest_route( 'convrail/v1', self::PROXY_ROUTE, array( 'methods' => array( 'GET', 'POST', 'PUT' ), 'callback' => array( __CLASS__, 'proxy' ), 'permission_callback' => array( __CLASS__, 'can_manage' ), 'args' => array( 'path' => array( 'required' => false, 'type' => 'string', 'validate_callback' => function ( $value ) { // Pas de remontee de repertoire, quoi qu'il arrive. return is_string( $value ) && false === strpos( $value, '..' ); }, ), ), ) ); } public static function sync() { if ( ! Convrail_Client::is_connected() ) { return new WP_Error( 'convrail_not_connected', __( 'Connect your store first.', 'convrail' ), array( 'status' => 409 ) ); } Convrail_Products::schedule_full_sync(); return rest_ensure_response( array( 'scheduled' => true ) ); } public static function can_manage() { return current_user_can( 'manage_woocommerce' ); } public static function proxy( WP_REST_Request $request ) { if ( ! Convrail_Client::is_connected() ) { return new WP_Error( 'convrail_not_connected', __( 'Connect your store first.', 'convrail' ), array( 'status' => 409 ) ); } $path = trim( (string) $request->get_param( 'path' ), '/' ); $method = $request->get_method(); $allowed = self::allowed_methods( $path ); if ( null === $allowed ) { return new WP_Error( 'convrail_unknown_path', __( 'Unknown endpoint.', 'convrail' ), array( 'status' => 404 ) ); } if ( ! in_array( $method, $allowed, true ) ) { return new WP_Error( 'convrail_bad_method', __( 'Method not allowed here.', 'convrail' ), array( 'status' => 405 ) ); } $target = '/internal/shops/' . rawurlencode( Convrail_Verify::site_domain() ); if ( '' !== $path ) { $target .= '/' . $path; } $query = $request->get_query_params(); unset( $query['path'], $query['_locale'], $query['_wpnonce'] ); if ( ! empty( $query ) ) { $target .= '?' . http_build_query( $query ); } $timeout = isset( self::SLOW_PATHS[ $path ] ) ? self::SLOW_PATHS[ $path ] : 20; $body = 'GET' === $method ? null : (array) $request->get_json_params(); $result = Convrail_Client::request( $method, $target, $body, true, $timeout ); if ( is_wp_error( $result ) ) { return self::api_error( $result ); } // L'ecran d'administration lit l'etat de la boutique: c'est le bon moment pour tenir a jour // les options que la vitrine utilise, sans ajouter un appel reseau sur chaque page publique. if ( '' === $path || 'status' === $path ) { Convrail_Client::remember_shop_state( $result ); } return rest_ensure_response( $result ); } /** * Traduit l'echec d'un appel a l'API en un message qui dit ce qui s'est REELLEMENT passe. * * Jusqu'au 2026-09-23, tout echec devenait « Convrail did not answer. », y compris une API qui * avait bel et bien repondu pour refuser le jeton de la boutique: le marchand relancait en * boucle un ecran qu'aucune relance ne pouvait reparer. Le code (`convrail_*`) est ce que lit * l'ecran React; le statut reste celui de l'API quand elle a repondu, pour le diagnostic. * * Un refus du jeton de la boutique (401 ou 403 de l'API) ne porte JAMAIS le code d'un refus * WordPress: « vous n'avez pas la permission » serait faux, l'administrateur a tous les droits, * c'est la connexion de la boutique qu'il faut renouveler. * * @param WP_Error $error Retour de Convrail_Client::request(). * @return WP_Error */ public static function api_error( $error ) { if ( Convrail_Client::is_transport_error( $error ) ) { return new WP_Error( 'convrail_unreachable', __( 'Convrail did not answer.', 'convrail' ), array( 'status' => 502 ) ); } $status = Convrail_Client::error_status( $error ); if ( 401 === $status || 403 === $status ) { return new WP_Error( 'convrail_reconnect', __( 'Convrail no longer accepts this store’s connection key. Reconnect the store on the Connection page: your settings and your history are kept.', 'convrail' ), array( 'status' => $status ) ); } if ( 429 === $status ) { return new WP_Error( 'convrail_busy', __( 'Too many requests at once; try again in a moment.', 'convrail' ), array( 'status' => 429 ) ); } if ( 503 === $status && self::api_error_code( $error ) === 'ai_unavailable' ) { return new WP_Error( 'convrail_ai_unavailable', __( 'The AI draft could not be generated right now. Nothing was changed. Try again in a few minutes.', 'convrail' ), array( 'status' => 503 ) ); } if ( $status >= 500 ) { return new WP_Error( 'convrail_server_error', __( 'Convrail had a problem; try again in a moment.', 'convrail' ), array( 'status' => $status ) ); } if ( $status >= 400 ) { return new WP_Error( 'convrail_rejected', __( 'Convrail could not process this request.', 'convrail' ), array( 'status' => $status ) ); } // Aucun statut et pas un echec de transport: la boutique n'a pas (ou plus) de jeton. return new WP_Error( 'convrail_not_connected', __( 'Connect your store first.', 'convrail' ), array( 'status' => 409 ) ); } /** * Code d'erreur renvoye par l'API (`{"error":"ai_unavailable"}`), vide s'il n'y en a pas. * * @param WP_Error $error Retour de Convrail_Client::request(). * @return string */ private static function api_error_code( $error ) { $data = $error->get_error_data(); $body = is_array( $data ) && isset( $data['body'] ) && is_string( $data['body'] ) ? json_decode( $data['body'], true ) : null; return is_array( $body ) && isset( $body['error'] ) && is_string( $body['error'] ) ? $body['error'] : ''; } private static function allowed_methods( $path ) { foreach ( self::ALLOWED as $pattern => $methods ) { if ( preg_match( $pattern, $path ) ) { return $methods; } } return null; } }