Plugin Directory

Changeset 3771056


Ignore:
Timestamp:
10/28/2026 04:17:40 PM (61 minutes ago)
Author:
cinghaman
Message:

Release 0.7.5: order ownership, refunds and session hardening

Location:
agentmatica-mcp-connector
Files:
14 edited
1 copied

Legend:

Unmodified
Added
Removed
  • agentmatica-mcp-connector/tags/0.7.5/agentmatica.php

    r3766293 r3771056  
    44 * Plugin URI: https://agentmatica.com/docs/integrations/wordpress
    55 * Description: Free MCP connector for Agentmatica.com. Expose WordPress content and optional WooCommerce tools to Agentmatica visitor chat.
    6  * Version: 0.7.3
     6 * Version: 0.7.5
    77 * Requires at least: 6.9
    88 * Requires PHP: 8.0
    … …  
    2323}
    2424
    25 define( 'AGENTMATICA_VERSION', '0.7.3' );
     25define( 'AGENTMATICA_VERSION', '0.7.5' );
    2626define( 'AGENTMATICA_PLUGIN_FILE', __FILE__ );
    2727define( 'AGENTMATICA_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
  • agentmatica-mcp-connector/tags/0.7.5/includes/class-customer-token.php

    r3766293 r3771056  
    1919                add_action( 'rest_api_init', array( __CLASS__, 'register_routes' ) );
    2020                add_action( 'wp_head', array( __CLASS__, 'print_rest_nonce' ), 1 );
     21                add_action( 'wp_head', array( __CLASS__, 'print_account_url' ), 1 );
    2122                add_filter( 'rest_authentication_errors', array( __CLASS__, 'allow_cookie_auth_without_nonce' ) );
    2223        }
    … …  
    3334                $nonce = wp_create_nonce( 'wp_rest' );
    3435                echo '<meta name="agentmatica-wp-rest-nonce" content="' . esc_attr( $nonce ) . '" />' . "\n";
    35                 $json  = wp_json_encode( $nonce );
     36                $json = wp_json_encode( $nonce );
    3637                if ( is_string( $json ) ) {
    3738                        wp_print_inline_script_tag( 'window.agentmaticaWpRestNonce=' . $json . ';' );
    3839                }
     40        }
     41
     42        /**
     43         * Where the widget sends shoppers to sign in. The My Account page can be
     44         * renamed, translated or under a subdirectory, so never guess /my-account/.
     45         * Same URL for every visitor, so this is safe on cached pages.
     46         */
     47        public static function print_account_url(): void {
     48                if ( is_admin() || ! function_exists( 'wc_get_page_permalink' ) ) {
     49                        return;
     50                }
     51
     52                $url = wc_get_page_permalink( 'myaccount' );
     53                if ( ! is_string( $url ) || $url === '' ) {
     54                        return;
     55                }
     56                echo '<meta name="agentmatica-woo-account-url" content="' . esc_url( $url ) . '" />' . "\n";
    3957        }
    4058
    … …  
    5775                        return $result;
    5876                }
     77                // A nonce was sent and core rejected it: keep the error, never fall back.
     78                if ( self::request_header( 'HTTP_X_WP_NONCE' ) !== '' ) {
     79                        return $result;
     80                }
    5981                if ( ! self::request_is_same_origin_fetch() ) {
    6082                        return $result;
    … …  
    87109         * Top-level clicks send Sec-Fetch-Mode: navigate and must be refused.
    88110         */
    89         private static function request_is_same_origin_fetch(): bool {
     111        public static function request_is_same_origin_fetch(): bool {
     112                // Only fetch()/XHR: refuse navigations and subresources such as
     113                // <img src="/wp-json/.../customer-token?session_key=..."> (mode no-cors,
     114                // dest image), which also carry Sec-Fetch-Site: same-origin.
    90115                $mode = strtolower( self::request_header( 'HTTP_SEC_FETCH_MODE' ) );
    91                 if ( $mode === 'navigate' ) {
     116                if ( $mode !== '' && ! in_array( $mode, array( 'cors', 'same-origin' ), true ) ) {
     117                        return false;
     118                }
     119                $dest = strtolower( self::request_header( 'HTTP_SEC_FETCH_DEST' ) );
     120                if ( $dest !== '' && $dest !== 'empty' ) {
    92121                        return false;
    93122                }
    … …  
    215244                }
    216245
    217                 Agentmatica_WooCommerce_Orders::bind_logged_in_customer(
    218                         array(
    219                                 'session_key' => $session_key,
    220                                 'wp_user_id'  => $user_id,
    221                         )
    222                 );
     246                // Binding happens only when the Agentmatica server verifies this token
     247                // (verify_customer_token), never just because a token was minted.
    223248
    224249                $exp   = time() + self::TOKEN_TTL;
  • agentmatica-mcp-connector/tags/0.7.5/includes/modules/woocommerce/class-cart.php

    r3766293 r3771056  
    4646        }
    4747
     48        /**
     49         * Storefront cart routes act on the shopper's Woo session cookie, so only the
     50         * store's own pages (the widget's fetch) may call them, not other sites.
     51         */
     52        public static function storefront_same_origin_permission(): bool {
     53                return Agentmatica_Customer_Token::request_is_same_origin_fetch();
     54        }
     55
    4856        public static function register_read_storefront_cart_route(): void {
    4957                register_rest_route(
    … …  
    5361                                'methods'             => 'GET',
    5462                                'callback'            => array( __CLASS__, 'handle_read_storefront_cart_rest' ),
    55                                 'permission_callback' => '__return_true',
     63                                'permission_callback' => array( __CLASS__, 'storefront_same_origin_permission' ),
    5664                        )
    5765                );
    … …  
    6573                                'methods'             => 'POST',
    6674                                'callback'            => array( __CLASS__, 'handle_sync_storefront_cart_rest' ),
    67                                 'permission_callback' => '__return_true',
     75                                'permission_callback' => array( __CLASS__, 'storefront_same_origin_permission' ),
    6876                        )
    6977                );
    … …  
    7785                                'methods'             => 'POST',
    7886                                'callback'            => array( __CLASS__, 'handle_add_storefront_cart_item_rest' ),
    79                                 'permission_callback' => '__return_true',
     87                                'permission_callback' => array( __CLASS__, 'storefront_same_origin_permission' ),
    8088                        )
    8189                );
    … …  
    102110                $product_id   = isset( $params['product_id'] ) ? (int) $params['product_id'] : 0;
    103111                $variation_id = isset( $params['variation_id'] ) ? (int) $params['variation_id'] : 0;
    104                 $quantity     = isset( $params['quantity'] ) ? max( 1, (int) $params['quantity'] ) : 1;
     112                $quantity     = self::clamp_line_quantity( $params['quantity'] ?? 1 );
    105113
    106114                if ( $product_id <= 0 ) {
    … …  
    319327                $backup_cart = WC()->cart->get_cart();
    320328
    321                 remove_all_actions( 'woocommerce_cart_emptied' );
    322                 WC()->cart->empty_cart( false );
     329                self::empty_cart_keeping_session();
    323330
    324331                $added_any    = false;
    … …  
    330337                        $product_id   = isset( $line['product_id'] ) ? (int) $line['product_id'] : 0;
    331338                        $variation_id = isset( $line['variation_id'] ) ? (int) $line['variation_id'] : 0;
    332                         $quantity     = isset( $line['quantity'] ) ? max( 1, (int) $line['quantity'] ) : 1;
     339                        $quantity     = self::clamp_line_quantity( $line['quantity'] ?? 1 );
    333340                        if ( $variation_id > 0 ) {
    334341                                $variation_product = wc_get_product( $variation_id );
    … …  
    345352                                continue;
    346353                        }
    347                         $added = self::add_line_to_wc_cart( $product_id, $quantity, $variation_id );
     354                        $added          = self::add_line_to_wc_cart( $product_id, $quantity, $variation_id );
    348355                        $sync_attempt[] = array(
    349356                                'product_id'   => $product_id,
    … …  
    394401         * @return string|false Cart item key.
    395402         */
     403        /** Per-line cap for public cart routes; stock limits still apply in Woo. */
     404        private const MAX_LINE_QUANTITY = 99;
     405
     406        /**
     407         * @param mixed $raw Quantity from a request.
     408         */
     409        private static function clamp_line_quantity( $raw ): int {
     410                $quantity = is_numeric( $raw ) ? (int) $raw : 1;
     411                return min( self::MAX_LINE_QUANTITY, max( 1, $quantity ) );
     412        }
     413
    396414        private static function add_line_to_wc_cart( int $product_id, int $quantity, int $variation_id ) {
    397415                if ( ! function_exists( 'WC' ) || ! WC()->cart instanceof WC_Cart ) {
    … …  
    517535                $product_id   = isset( $input['product_id'] ) ? (int) $input['product_id'] : 0;
    518536                $variation_id = isset( $input['variation_id'] ) ? (int) $input['variation_id'] : 0;
    519                 $quantity     = isset( $input['quantity'] ) ? max( 1, (int) $input['quantity'] ) : 1;
     537                $quantity     = self::clamp_line_quantity( $input['quantity'] ?? 1 );
    520538
    521539                if ( $session_key === '' ) {
    … …  
    10141032                }
    10151033
    1016                 remove_all_actions( 'woocommerce_cart_emptied' );
    1017                 WC()->cart->empty_cart( false );
     1034                self::empty_cart_keeping_session();
    10181035
    10191036                foreach ( $snapshot as $cart_item ) {
    … …  
    10271044                                continue;
    10281045                        }
    1029                         self::add_line_to_wc_cart( $product_id, $quantity, $variation_id );
     1046                        // Re-add the line exactly as it was: the saved attribute choices
     1047                        // ("Any size" variations have no fixed attributes) and any add-on
     1048                        // data. Rebuilding from get_variation_attributes() drops both.
     1049                        $variation = isset( $cart_item['variation'] ) && is_array( $cart_item['variation'] )
     1050                                ? $cart_item['variation']
     1051                                : array();
     1052                        $item_data = array_diff_key(
     1053                                $cart_item,
     1054                                array_flip( self::CORE_CART_ITEM_KEYS )
     1055                        );
     1056                        try {
     1057                                WC()->cart->add_to_cart( $product_id, $quantity, $variation_id, $variation, $item_data );
     1058                        } catch ( \Exception $exception ) {
     1059                                // Leave this line out; restoring the rest beats an empty cart.
     1060                                unset( $exception );
     1061                        }
     1062                }
     1063        }
     1064
     1065        /** Keys Woo sets on every cart item; everything else is extension data. */
     1066        private const CORE_CART_ITEM_KEYS = array(
     1067                'key',
     1068                'product_id',
     1069                'variation_id',
     1070                'variation',
     1071                'quantity',
     1072                'data',
     1073                'data_hash',
     1074                'line_tax_data',
     1075                'line_subtotal',
     1076                'line_subtotal_tax',
     1077                'line_total',
     1078                'line_tax',
     1079        );
     1080
     1081        /**
     1082         * empty_cart() fires woocommerce_cart_emptied, whose Woo callback destroys
     1083         * the cart session. Detach only that callback for this call; other
     1084         * plugins' listeners keep running.
     1085         */
     1086        private static function empty_cart_keeping_session(): void {
     1087                global $wp_filter;
     1088
     1089                $detached = array();
     1090                $hook     = $wp_filter['woocommerce_cart_emptied'] ?? null;
     1091                if ( $hook instanceof WP_Hook ) {
     1092                        foreach ( $hook->callbacks as $priority => $callbacks ) {
     1093                                foreach ( $callbacks as $callback ) {
     1094                                        $function = $callback['function'] ?? null;
     1095                                        if (
     1096                                                is_array( $function )
     1097                                                && isset( $function[0], $function[1] )
     1098                                                && $function[0] instanceof WC_Cart_Session
     1099                                                && $function[1] === 'destroy_cart_session'
     1100                                        ) {
     1101                                                remove_action( 'woocommerce_cart_emptied', $function, (int) $priority );
     1102                                                $detached[] = array( $function, (int) $priority, (int) ( $callback['accepted_args'] ?? 1 ) );
     1103                                        }
     1104                                }
     1105                        }
     1106                }
     1107
     1108                WC()->cart->empty_cart( false );
     1109
     1110                foreach ( $detached as $entry ) {
     1111                        add_action( 'woocommerce_cart_emptied', $entry[0], $entry[1], $entry[2] );
    10301112                }
    10311113        }
  • agentmatica-mcp-connector/tags/0.7.5/includes/modules/woocommerce/class-order-guards.php

    r3766293 r3771056  
    9090
    9191        public static function order_owned_by_session( WC_Order $order, array $verified ): bool {
     92                $wp_user_id = isset( $verified['wp_user_id'] ) ? (int) $verified['wp_user_id'] : 0;
     93                if ( $wp_user_id > 0 ) {
     94                        // Logged-in sessions own orders through the account only. WooCommerce
     95                        // does not verify account emails, so matching the billing email would
     96                        // let anyone register with a guest shopper's email and act on their orders.
     97                        $customer_id = (int) $order->get_customer_id();
     98                        return $customer_id > 0 && $customer_id === $wp_user_id;
     99                }
     100
    92101                $email = strtolower( trim( (string) ( $verified['email'] ?? '' ) ) );
    93102                if ( $email !== '' && Agentmatica_WooCommerce_Orders::email_matches_order( $order, $email ) ) {
    94103                        return true;
    95                 }
    96 
    97                 $wp_user_id = isset( $verified['wp_user_id'] ) ? (int) $verified['wp_user_id'] : 0;
    98                 if ( $wp_user_id > 0 ) {
    99                         $customer_id = (int) $order->get_customer_id();
    100                         if ( $customer_id > 0 && $customer_id === $wp_user_id ) {
    101                                 return true;
    102                         }
    103104                }
    104105
  • agentmatica-mcp-connector/tags/0.7.5/includes/modules/woocommerce/class-order-mutations.php

    r3766293 r3771056  
    2727                }
    2828
    29                 $login_required = Agentmatica_WooCommerce_Order_Guards::require_verified_customer_session( $verified );
     29                // Cancel and return need a logged-in store account (order number + email
     30                // is printed on packing slips and emails; app is sign-in only too).
     31                $login_required = Agentmatica_WooCommerce_Order_Guards::require_logged_in_session( $verified );
    3032                if ( $login_required !== null ) {
    3133                        return $login_required;
    … …  
    8284                        $refund_amount = (float) $order->get_total();
    8385                        if ( $refund_amount > 0 ) {
    84                                 wc_create_refund(
     86                                // refund_payment sends the money back through the gateway. Without it
     87                                // WooCommerce only records a refund and the shopper is never paid.
     88                                $refund = wc_create_refund(
    8589                                        array(
    86                                                 'amount'   => $refund_amount,
    87                                                 'reason'   => __( 'Order cancelled via Agentmatica chat.', 'agentmatica-mcp-connector' ),
    88                                                 'order_id' => (int) $order->get_id(),
     90                                                'amount'         => $refund_amount,
     91                                                'reason'         => __( 'Order cancelled via Agentmatica chat.', 'agentmatica-mcp-connector' ),
     92                                                'order_id'       => (int) $order->get_id(),
     93                                                'refund_payment' => true,
    8994                                        )
    9095                                );
     96                                if ( is_wp_error( $refund ) ) {
     97                                        $order->add_order_note(
     98                                                sprintf(
     99                                                        /* translators: %s: gateway error message */
     100                                                        __( 'Agentmatica cancelled this order but the automatic refund failed (%s). Please refund the customer manually.', 'agentmatica-mcp-connector' ),
     101                                                        $refund->get_error_message()
     102                                                )
     103                                        );
     104                                }
    91105                        }
    92106                }
    … …  
    200214                }
    201215
    202                 $login_required = Agentmatica_WooCommerce_Order_Guards::require_verified_customer_session( $verified );
     216                // Cancel and return need a logged-in store account (order number + email
     217                // is printed on packing slips and emails; app is sign-in only too).
     218                $login_required = Agentmatica_WooCommerce_Order_Guards::require_logged_in_session( $verified );
    203219                if ( $login_required !== null ) {
    204220                        return $login_required;
    … …  
    228244                if ( $ineligible !== null ) {
    229245                        return self::refused( 'create_return', $ineligible );
     246                }
     247
     248                if ( (string) $order->get_meta( '_agentmatica_return_request' ) !== '' ) {
     249                        return self::success(
     250                                'create_return',
     251                                __( 'A return has already been requested for this order.', 'agentmatica-mcp-connector' ),
     252                                'already_done',
     253                                (int) $order->get_id()
     254                        );
    230255                }
    231256
  • agentmatica-mcp-connector/tags/0.7.5/includes/modules/woocommerce/class-orders.php

    r3736423 r3771056  
    1818
    1919        private const VERIFY_TRANSIENT_PREFIX = 'agentmatica_verify_';
    20         private const VERIFY_TTL              = 45 * MINUTE_IN_SECONDS;
     20        private const VERIFY_TTL              = 15 * MINUTE_IN_SECONDS;
    2121        private const RATE_SESSION_PREFIX     = 'agentmatica_verify_rate_session_';
    2222        private const RATE_IP_PREFIX          = 'agentmatica_verify_rate_ip_';
     23        private const RATE_ORDER_PREFIX       = 'agentmatica_verify_rate_order_';
     24        private const MAX_ATTEMPTS_ORDER      = 5;
     25        private const USER_SESSIONS_META      = '_agentmatica_chat_sessions';
    2326        private const MAX_ATTEMPTS_SESSION    = 5;
    2427        private const MAX_ATTEMPTS_IP         = 20;
    … …  
    2730        public static function init(): void {
    2831                // Ability callbacks are registered via the WooCommerce module registry.
     32                add_action( 'wp_logout', array( __CLASS__, 'clear_sessions_for_user' ) );
     33        }
     34
     35        /**
     36         * Drop every chat binding for a user when they log out, so the next person on
     37         * a shared device cannot act on their orders through the same widget session.
     38         *
     39         * @param int $user_id Logged-out user id (passed by wp_logout since WP 5.5).
     40         */
     41        public static function clear_sessions_for_user( $user_id = 0 ): void {
     42                $user_id = (int) $user_id;
     43                if ( $user_id <= 0 ) {
     44                        return;
     45                }
     46                $keys = get_user_meta( $user_id, self::USER_SESSIONS_META, true );
     47                if ( is_array( $keys ) ) {
     48                        foreach ( $keys as $key ) {
     49                                delete_transient( self::VERIFY_TRANSIENT_PREFIX . Agentmatica_WooCommerce_Cart::sanitize_session_key( (string) $key ) );
     50                        }
     51                }
     52                delete_user_meta( $user_id, self::USER_SESSIONS_META );
    2953        }
    3054
    … …  
    7195                }
    7296
    73                 if ( self::is_rate_limited( $session_key ) ) {
     97                if ( self::is_rate_limited( $session_key ) || self::is_order_rate_limited( $order_number ) ) {
    7498                        return array(
    7599                                'success' => false,
    … …  
    79103
    80104                self::record_attempt( $session_key );
     105                self::record_order_attempt( $order_number );
    81106
    82107                $order = self::find_order_by_number( $order_number );
    … …  
    126151                }
    127152
    128                 $email = (string) ( $verified['email'] ?? '' );
    129                 if ( $email === '' ) {
     153                $wp_user_id = isset( $verified['wp_user_id'] ) ? (int) $verified['wp_user_id'] : 0;
     154                $email      = (string) ( $verified['email'] ?? '' );
     155                if ( $wp_user_id <= 0 && $email === '' ) {
    130156                        return array(
    131157                                'success' => false,
    … …  
    134160                }
    135161
    136                 $orders = wc_get_orders(
    137                         array(
    138                                 'limit'         => 5,
    139                                 'billing_email' => $email,
    140                                 'orderby'       => 'date',
    141                                 'order'         => 'DESC',
    142                                 'return'        => 'objects',
    143                         )
    144                 );
     162                $query = array(
     163                        'limit'   => 5,
     164                        'orderby' => 'date',
     165                        'order'   => 'DESC',
     166                        'return'  => 'objects',
     167                );
     168                if ( $wp_user_id > 0 ) {
     169                        $query['customer_id'] = $wp_user_id;
     170                } else {
     171                        $query['billing_email'] = $email;
     172                }
     173                $orders = wc_get_orders( $query );
    145174
    146175                return array(
    … …  
    182211                }
    183212
    184                 $email = (string) ( $verified['email'] ?? '' );
    185                 if ( ! self::email_matches_order( $order, $email ) ) {
     213                if ( ! Agentmatica_WooCommerce_Order_Guards::order_owned_by_session( $order, $verified ) ) {
    186214                        return array(
    187215                                'success' => false,
    … …  
    226254                        );
    227255                }
     256
     257                $sessions = get_user_meta( $wp_user_id, self::USER_SESSIONS_META, true );
     258                $sessions = is_array( $sessions ) ? $sessions : array();
     259                $sessions = array_slice( array_values( array_unique( array_merge( array( $session_key ), $sessions ) ) ), 0, 20 );
     260                update_user_meta( $wp_user_id, self::USER_SESSIONS_META, $sessions );
    228261
    229262                self::bind_verified_session(
    … …  
    325358                        set_transient( $ip_key, $ip_count + 1, HOUR_IN_SECONDS );
    326359                }
     360        }
     361
     362        /**
     363         * Attempts per order number, across all chat sessions. Requests come from the
     364         * Agentmatica server, so REMOTE_ADDR cannot tell shoppers apart and a new
     365         * session_key resets the per-session count; this caps guessing on one order.
     366         */
     367        private static function is_order_rate_limited( string $order_number ): bool {
     368                $key = self::RATE_ORDER_PREFIX . md5( strtolower( ltrim( trim( $order_number ), '#' ) ) );
     369                return (int) get_transient( $key ) >= self::MAX_ATTEMPTS_ORDER;
     370        }
     371
     372        private static function record_order_attempt( string $order_number ): void {
     373                $key = self::RATE_ORDER_PREFIX . md5( strtolower( ltrim( trim( $order_number ), '#' ) ) );
     374                set_transient( $key, (int) get_transient( $key ) + 1, HOUR_IN_SECONDS );
    327375        }
    328376
  • agentmatica-mcp-connector/tags/0.7.5/readme.txt

    r3766293 r3771056  
    55Tested up to: 7.1
    66Requires PHP: 8.0
    7 Stable tag: 0.7.3
     7Stable tag: 0.7.5
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    9797== Changelog ==
    9898
     99= 0.7.5 =
     100* Security: a signed-in shopper can only see and change orders placed from their own account. Orders are no longer matched by billing email for signed-in shoppers, because store accounts are not email-verified.
     101* Security: logging out of the store ends the shopper's verified chat session, and a verified chat session now lasts 15 minutes instead of 45.
     102* Security: a customer token only links a chat to an account once Agentmatica has verified it, not when the token is issued.
     103* Security: order-number lookups are limited per order number, so guessing the email for one order is capped.
     104* Security: the storefront cart routes only accept requests from the store's own pages.
     105* Cancelling an order with a refund now sends the money back through the payment gateway. If the gateway cannot refund automatically, the order gets a note asking you to refund manually.
     106* Asking for a return twice on the same order no longer emails you twice.
     107
     108= 0.7.4 =
     109* Cancel and return now require the shopper to be signed in to their store account, the same as shipping-address changes. Guests are passed to your team.
     110* The customer-token route only accepts a fetch from the widget: images and other embedded requests on the store are refused, and an invalid nonce is never ignored.
     111* The widget sends shoppers to your real My Account page, even if it is renamed, translated or in a subfolder.
     112* Cart sync keeps each item's chosen options and add-on data, and other plugins' cart hooks keep working.
     113* Cart routes cap a single line at 99.
     114* Also in 0.7.3 but missing from its notes: catalog search matches plural words ("screens" finds "screen"), chat cart adds respect stock, and the chat cart stays in sync with the store cart.
     115
    99116= 0.7.3 =
    100117* Logged-in shoppers can get a customer token from the storefront widget. The route requires a `wp_rest` nonce or a same-origin fetch (not a clicked link).
    … …  
    147164== Upgrade Notice ==
    148165
     166= 0.7.4 =
     167Cancel and return now need a signed-in store account. Safer customer-token checks, real My Account links, and cart sync that keeps item options.
     168
    149169= 0.7.3 =
    150170Logged-in shoppers can be identified in chat; checkout links use the WooCommerce checkout URL. Cancel/return can use order number + email; address changes stay login-only.
  • agentmatica-mcp-connector/trunk/agentmatica.php

    r3766293 r3771056  
    44 * Plugin URI: https://agentmatica.com/docs/integrations/wordpress
    55 * Description: Free MCP connector for Agentmatica.com. Expose WordPress content and optional WooCommerce tools to Agentmatica visitor chat.
    6  * Version: 0.7.3
     6 * Version: 0.7.5
    77 * Requires at least: 6.9
    88 * Requires PHP: 8.0
    … …  
    2323}
    2424
    25 define( 'AGENTMATICA_VERSION', '0.7.3' );
     25define( 'AGENTMATICA_VERSION', '0.7.5' );
    2626define( 'AGENTMATICA_PLUGIN_FILE', __FILE__ );
    2727define( 'AGENTMATICA_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
  • agentmatica-mcp-connector/trunk/includes/class-customer-token.php

    r3766293 r3771056  
    1919                add_action( 'rest_api_init', array( __CLASS__, 'register_routes' ) );
    2020                add_action( 'wp_head', array( __CLASS__, 'print_rest_nonce' ), 1 );
     21                add_action( 'wp_head', array( __CLASS__, 'print_account_url' ), 1 );
    2122                add_filter( 'rest_authentication_errors', array( __CLASS__, 'allow_cookie_auth_without_nonce' ) );
    2223        }
    … …  
    3334                $nonce = wp_create_nonce( 'wp_rest' );
    3435                echo '<meta name="agentmatica-wp-rest-nonce" content="' . esc_attr( $nonce ) . '" />' . "\n";
    35                 $json  = wp_json_encode( $nonce );
     36                $json = wp_json_encode( $nonce );
    3637                if ( is_string( $json ) ) {
    3738                        wp_print_inline_script_tag( 'window.agentmaticaWpRestNonce=' . $json . ';' );
    3839                }
     40        }
     41
     42        /**
     43         * Where the widget sends shoppers to sign in. The My Account page can be
     44         * renamed, translated or under a subdirectory, so never guess /my-account/.
     45         * Same URL for every visitor, so this is safe on cached pages.
     46         */
     47        public static function print_account_url(): void {
     48                if ( is_admin() || ! function_exists( 'wc_get_page_permalink' ) ) {
     49                        return;
     50                }
     51
     52                $url = wc_get_page_permalink( 'myaccount' );
     53                if ( ! is_string( $url ) || $url === '' ) {
     54                        return;
     55                }
     56                echo '<meta name="agentmatica-woo-account-url" content="' . esc_url( $url ) . '" />' . "\n";
    3957        }
    4058
    … …  
    5775                        return $result;
    5876                }
     77                // A nonce was sent and core rejected it: keep the error, never fall back.
     78                if ( self::request_header( 'HTTP_X_WP_NONCE' ) !== '' ) {
     79                        return $result;
     80                }
    5981                if ( ! self::request_is_same_origin_fetch() ) {
    6082                        return $result;
    … …  
    87109         * Top-level clicks send Sec-Fetch-Mode: navigate and must be refused.
    88110         */
    89         private static function request_is_same_origin_fetch(): bool {
     111        public static function request_is_same_origin_fetch(): bool {
     112                // Only fetch()/XHR: refuse navigations and subresources such as
     113                // <img src="/wp-json/.../customer-token?session_key=..."> (mode no-cors,
     114                // dest image), which also carry Sec-Fetch-Site: same-origin.
    90115                $mode = strtolower( self::request_header( 'HTTP_SEC_FETCH_MODE' ) );
    91                 if ( $mode === 'navigate' ) {
     116                if ( $mode !== '' && ! in_array( $mode, array( 'cors', 'same-origin' ), true ) ) {
     117                        return false;
     118                }
     119                $dest = strtolower( self::request_header( 'HTTP_SEC_FETCH_DEST' ) );
     120                if ( $dest !== '' && $dest !== 'empty' ) {
    92121                        return false;
    93122                }
    … …  
    215244                }
    216245
    217                 Agentmatica_WooCommerce_Orders::bind_logged_in_customer(
    218                         array(
    219                                 'session_key' => $session_key,
    220                                 'wp_user_id'  => $user_id,
    221                         )
    222                 );
     246                // Binding happens only when the Agentmatica server verifies this token
     247                // (verify_customer_token), never just because a token was minted.
    223248
    224249                $exp   = time() + self::TOKEN_TTL;
  • agentmatica-mcp-connector/trunk/includes/modules/woocommerce/class-cart.php

    r3766293 r3771056  
    4646        }
    4747
     48        /**
     49         * Storefront cart routes act on the shopper's Woo session cookie, so only the
     50         * store's own pages (the widget's fetch) may call them, not other sites.
     51         */
     52        public static function storefront_same_origin_permission(): bool {
     53                return Agentmatica_Customer_Token::request_is_same_origin_fetch();
     54        }
     55
    4856        public static function register_read_storefront_cart_route(): void {
    4957                register_rest_route(
    … …  
    5361                                'methods'             => 'GET',
    5462                                'callback'            => array( __CLASS__, 'handle_read_storefront_cart_rest' ),
    55                                 'permission_callback' => '__return_true',
     63                                'permission_callback' => array( __CLASS__, 'storefront_same_origin_permission' ),
    5664                        )
    5765                );
    … …  
    6573                                'methods'             => 'POST',
    6674                                'callback'            => array( __CLASS__, 'handle_sync_storefront_cart_rest' ),
    67                                 'permission_callback' => '__return_true',
     75                                'permission_callback' => array( __CLASS__, 'storefront_same_origin_permission' ),
    6876                        )
    6977                );
    … …  
    7785                                'methods'             => 'POST',
    7886                                'callback'            => array( __CLASS__, 'handle_add_storefront_cart_item_rest' ),
    79                                 'permission_callback' => '__return_true',
     87                                'permission_callback' => array( __CLASS__, 'storefront_same_origin_permission' ),
    8088                        )
    8189                );
    … …  
    102110                $product_id   = isset( $params['product_id'] ) ? (int) $params['product_id'] : 0;
    103111                $variation_id = isset( $params['variation_id'] ) ? (int) $params['variation_id'] : 0;
    104                 $quantity     = isset( $params['quantity'] ) ? max( 1, (int) $params['quantity'] ) : 1;
     112                $quantity     = self::clamp_line_quantity( $params['quantity'] ?? 1 );
    105113
    106114                if ( $product_id <= 0 ) {
    … …  
    319327                $backup_cart = WC()->cart->get_cart();
    320328
    321                 remove_all_actions( 'woocommerce_cart_emptied' );
    322                 WC()->cart->empty_cart( false );
     329                self::empty_cart_keeping_session();
    323330
    324331                $added_any    = false;
    … …  
    330337                        $product_id   = isset( $line['product_id'] ) ? (int) $line['product_id'] : 0;
    331338                        $variation_id = isset( $line['variation_id'] ) ? (int) $line['variation_id'] : 0;
    332                         $quantity     = isset( $line['quantity'] ) ? max( 1, (int) $line['quantity'] ) : 1;
     339                        $quantity     = self::clamp_line_quantity( $line['quantity'] ?? 1 );
    333340                        if ( $variation_id > 0 ) {
    334341                                $variation_product = wc_get_product( $variation_id );
    … …  
    345352                                continue;
    346353                        }
    347                         $added = self::add_line_to_wc_cart( $product_id, $quantity, $variation_id );
     354                        $added          = self::add_line_to_wc_cart( $product_id, $quantity, $variation_id );
    348355                        $sync_attempt[] = array(
    349356                                'product_id'   => $product_id,
    … …  
    394401         * @return string|false Cart item key.
    395402         */
     403        /** Per-line cap for public cart routes; stock limits still apply in Woo. */
     404        private const MAX_LINE_QUANTITY = 99;
     405
     406        /**
     407         * @param mixed $raw Quantity from a request.
     408         */
     409        private static function clamp_line_quantity( $raw ): int {
     410                $quantity = is_numeric( $raw ) ? (int) $raw : 1;
     411                return min( self::MAX_LINE_QUANTITY, max( 1, $quantity ) );
     412        }
     413
    396414        private static function add_line_to_wc_cart( int $product_id, int $quantity, int $variation_id ) {
    397415                if ( ! function_exists( 'WC' ) || ! WC()->cart instanceof WC_Cart ) {
    … …  
    517535                $product_id   = isset( $input['product_id'] ) ? (int) $input['product_id'] : 0;
    518536                $variation_id = isset( $input['variation_id'] ) ? (int) $input['variation_id'] : 0;
    519                 $quantity     = isset( $input['quantity'] ) ? max( 1, (int) $input['quantity'] ) : 1;
     537                $quantity     = self::clamp_line_quantity( $input['quantity'] ?? 1 );
    520538
    521539                if ( $session_key === '' ) {
    … …  
    10141032                }
    10151033
    1016                 remove_all_actions( 'woocommerce_cart_emptied' );
    1017                 WC()->cart->empty_cart( false );
     1034                self::empty_cart_keeping_session();
    10181035
    10191036                foreach ( $snapshot as $cart_item ) {
    … …  
    10271044                                continue;
    10281045                        }
    1029                         self::add_line_to_wc_cart( $product_id, $quantity, $variation_id );
     1046                        // Re-add the line exactly as it was: the saved attribute choices
     1047                        // ("Any size" variations have no fixed attributes) and any add-on
     1048                        // data. Rebuilding from get_variation_attributes() drops both.
     1049                        $variation = isset( $cart_item['variation'] ) && is_array( $cart_item['variation'] )
     1050                                ? $cart_item['variation']
     1051                                : array();
     1052                        $item_data = array_diff_key(
     1053                                $cart_item,
     1054                                array_flip( self::CORE_CART_ITEM_KEYS )
     1055                        );
     1056                        try {
     1057                                WC()->cart->add_to_cart( $product_id, $quantity, $variation_id, $variation, $item_data );
     1058                        } catch ( \Exception $exception ) {
     1059                                // Leave this line out; restoring the rest beats an empty cart.
     1060                                unset( $exception );
     1061                        }
     1062                }
     1063        }
     1064
     1065        /** Keys Woo sets on every cart item; everything else is extension data. */
     1066        private const CORE_CART_ITEM_KEYS = array(
     1067                'key',
     1068                'product_id',
     1069                'variation_id',
     1070                'variation',
     1071                'quantity',
     1072                'data',
     1073                'data_hash',
     1074                'line_tax_data',
     1075                'line_subtotal',
     1076                'line_subtotal_tax',
     1077                'line_total',
     1078                'line_tax',
     1079        );
     1080
     1081        /**
     1082         * empty_cart() fires woocommerce_cart_emptied, whose Woo callback destroys
     1083         * the cart session. Detach only that callback for this call; other
     1084         * plugins' listeners keep running.
     1085         */
     1086        private static function empty_cart_keeping_session(): void {
     1087                global $wp_filter;
     1088
     1089                $detached = array();
     1090                $hook     = $wp_filter['woocommerce_cart_emptied'] ?? null;
     1091                if ( $hook instanceof WP_Hook ) {
     1092                        foreach ( $hook->callbacks as $priority => $callbacks ) {
     1093                                foreach ( $callbacks as $callback ) {
     1094                                        $function = $callback['function'] ?? null;
     1095                                        if (
     1096                                                is_array( $function )
     1097                                                && isset( $function[0], $function[1] )
     1098                                                && $function[0] instanceof WC_Cart_Session
     1099                                                && $function[1] === 'destroy_cart_session'
     1100                                        ) {
     1101                                                remove_action( 'woocommerce_cart_emptied', $function, (int) $priority );
     1102                                                $detached[] = array( $function, (int) $priority, (int) ( $callback['accepted_args'] ?? 1 ) );
     1103                                        }
     1104                                }
     1105                        }
     1106                }
     1107
     1108                WC()->cart->empty_cart( false );
     1109
     1110                foreach ( $detached as $entry ) {
     1111                        add_action( 'woocommerce_cart_emptied', $entry[0], $entry[1], $entry[2] );
    10301112                }
    10311113        }
  • agentmatica-mcp-connector/trunk/includes/modules/woocommerce/class-order-guards.php

    r3766293 r3771056  
    9090
    9191        public static function order_owned_by_session( WC_Order $order, array $verified ): bool {
     92                $wp_user_id = isset( $verified['wp_user_id'] ) ? (int) $verified['wp_user_id'] : 0;
     93                if ( $wp_user_id > 0 ) {
     94                        // Logged-in sessions own orders through the account only. WooCommerce
     95                        // does not verify account emails, so matching the billing email would
     96                        // let anyone register with a guest shopper's email and act on their orders.
     97                        $customer_id = (int) $order->get_customer_id();
     98                        return $customer_id > 0 && $customer_id === $wp_user_id;
     99                }
     100
    92101                $email = strtolower( trim( (string) ( $verified['email'] ?? '' ) ) );
    93102                if ( $email !== '' && Agentmatica_WooCommerce_Orders::email_matches_order( $order, $email ) ) {
    94103                        return true;
    95                 }
    96 
    97                 $wp_user_id = isset( $verified['wp_user_id'] ) ? (int) $verified['wp_user_id'] : 0;
    98                 if ( $wp_user_id > 0 ) {
    99                         $customer_id = (int) $order->get_customer_id();
    100                         if ( $customer_id > 0 && $customer_id === $wp_user_id ) {
    101                                 return true;
    102                         }
    103104                }
    104105
  • agentmatica-mcp-connector/trunk/includes/modules/woocommerce/class-order-mutations.php

    r3766293 r3771056  
    2727                }
    2828
    29                 $login_required = Agentmatica_WooCommerce_Order_Guards::require_verified_customer_session( $verified );
     29                // Cancel and return need a logged-in store account (order number + email
     30                // is printed on packing slips and emails; app is sign-in only too).
     31                $login_required = Agentmatica_WooCommerce_Order_Guards::require_logged_in_session( $verified );
    3032                if ( $login_required !== null ) {
    3133                        return $login_required;
    … …  
    8284                        $refund_amount = (float) $order->get_total();
    8385                        if ( $refund_amount > 0 ) {
    84                                 wc_create_refund(
     86                                // refund_payment sends the money back through the gateway. Without it
     87                                // WooCommerce only records a refund and the shopper is never paid.
     88                                $refund = wc_create_refund(
    8589                                        array(
    86                                                 'amount'   => $refund_amount,
    87                                                 'reason'   => __( 'Order cancelled via Agentmatica chat.', 'agentmatica-mcp-connector' ),
    88                                                 'order_id' => (int) $order->get_id(),
     90                                                'amount'         => $refund_amount,
     91                                                'reason'         => __( 'Order cancelled via Agentmatica chat.', 'agentmatica-mcp-connector' ),
     92                                                'order_id'       => (int) $order->get_id(),
     93                                                'refund_payment' => true,
    8994                                        )
    9095                                );
     96                                if ( is_wp_error( $refund ) ) {
     97                                        $order->add_order_note(
     98                                                sprintf(
     99                                                        /* translators: %s: gateway error message */
     100                                                        __( 'Agentmatica cancelled this order but the automatic refund failed (%s). Please refund the customer manually.', 'agentmatica-mcp-connector' ),
     101                                                        $refund->get_error_message()
     102                                                )
     103                                        );
     104                                }
    91105                        }
    92106                }
    … …  
    200214                }
    201215
    202                 $login_required = Agentmatica_WooCommerce_Order_Guards::require_verified_customer_session( $verified );
     216                // Cancel and return need a logged-in store account (order number + email
     217                // is printed on packing slips and emails; app is sign-in only too).
     218                $login_required = Agentmatica_WooCommerce_Order_Guards::require_logged_in_session( $verified );
    203219                if ( $login_required !== null ) {
    204220                        return $login_required;
    … …  
    228244                if ( $ineligible !== null ) {
    229245                        return self::refused( 'create_return', $ineligible );
     246                }
     247
     248                if ( (string) $order->get_meta( '_agentmatica_return_request' ) !== '' ) {
     249                        return self::success(
     250                                'create_return',
     251                                __( 'A return has already been requested for this order.', 'agentmatica-mcp-connector' ),
     252                                'already_done',
     253                                (int) $order->get_id()
     254                        );
    230255                }
    231256
  • agentmatica-mcp-connector/trunk/includes/modules/woocommerce/class-orders.php

    r3736423 r3771056  
    1818
    1919        private const VERIFY_TRANSIENT_PREFIX = 'agentmatica_verify_';
    20         private const VERIFY_TTL              = 45 * MINUTE_IN_SECONDS;
     20        private const VERIFY_TTL              = 15 * MINUTE_IN_SECONDS;
    2121        private const RATE_SESSION_PREFIX     = 'agentmatica_verify_rate_session_';
    2222        private const RATE_IP_PREFIX          = 'agentmatica_verify_rate_ip_';
     23        private const RATE_ORDER_PREFIX       = 'agentmatica_verify_rate_order_';
     24        private const MAX_ATTEMPTS_ORDER      = 5;
     25        private const USER_SESSIONS_META      = '_agentmatica_chat_sessions';
    2326        private const MAX_ATTEMPTS_SESSION    = 5;
    2427        private const MAX_ATTEMPTS_IP         = 20;
    … …  
    2730        public static function init(): void {
    2831                // Ability callbacks are registered via the WooCommerce module registry.
     32                add_action( 'wp_logout', array( __CLASS__, 'clear_sessions_for_user' ) );
     33        }
     34
     35        /**
     36         * Drop every chat binding for a user when they log out, so the next person on
     37         * a shared device cannot act on their orders through the same widget session.
     38         *
     39         * @param int $user_id Logged-out user id (passed by wp_logout since WP 5.5).
     40         */
     41        public static function clear_sessions_for_user( $user_id = 0 ): void {
     42                $user_id = (int) $user_id;
     43                if ( $user_id <= 0 ) {
     44                        return;
     45                }
     46                $keys = get_user_meta( $user_id, self::USER_SESSIONS_META, true );
     47                if ( is_array( $keys ) ) {
     48                        foreach ( $keys as $key ) {
     49                                delete_transient( self::VERIFY_TRANSIENT_PREFIX . Agentmatica_WooCommerce_Cart::sanitize_session_key( (string) $key ) );
     50                        }
     51                }
     52                delete_user_meta( $user_id, self::USER_SESSIONS_META );
    2953        }
    3054
    … …  
    7195                }
    7296
    73                 if ( self::is_rate_limited( $session_key ) ) {
     97                if ( self::is_rate_limited( $session_key ) || self::is_order_rate_limited( $order_number ) ) {
    7498                        return array(
    7599                                'success' => false,
    … …  
    79103
    80104                self::record_attempt( $session_key );
     105                self::record_order_attempt( $order_number );
    81106
    82107                $order = self::find_order_by_number( $order_number );
    … …  
    126151                }
    127152
    128                 $email = (string) ( $verified['email'] ?? '' );
    129                 if ( $email === '' ) {
     153                $wp_user_id = isset( $verified['wp_user_id'] ) ? (int) $verified['wp_user_id'] : 0;
     154                $email      = (string) ( $verified['email'] ?? '' );
     155                if ( $wp_user_id <= 0 && $email === '' ) {
    130156                        return array(
    131157                                'success' => false,
    … …  
    134160                }
    135161
    136                 $orders = wc_get_orders(
    137                         array(
    138                                 'limit'         => 5,
    139                                 'billing_email' => $email,
    140                                 'orderby'       => 'date',
    141                                 'order'         => 'DESC',
    142                                 'return'        => 'objects',
    143                         )
    144                 );
     162                $query = array(
     163                        'limit'   => 5,
     164                        'orderby' => 'date',
     165                        'order'   => 'DESC',
     166                        'return'  => 'objects',
     167                );
     168                if ( $wp_user_id > 0 ) {
     169                        $query['customer_id'] = $wp_user_id;
     170                } else {
     171                        $query['billing_email'] = $email;
     172                }
     173                $orders = wc_get_orders( $query );
    145174
    146175                return array(
    … …  
    182211                }
    183212
    184                 $email = (string) ( $verified['email'] ?? '' );
    185                 if ( ! self::email_matches_order( $order, $email ) ) {
     213                if ( ! Agentmatica_WooCommerce_Order_Guards::order_owned_by_session( $order, $verified ) ) {
    186214                        return array(
    187215                                'success' => false,
    … …  
    226254                        );
    227255                }
     256
     257                $sessions = get_user_meta( $wp_user_id, self::USER_SESSIONS_META, true );
     258                $sessions = is_array( $sessions ) ? $sessions : array();
     259                $sessions = array_slice( array_values( array_unique( array_merge( array( $session_key ), $sessions ) ) ), 0, 20 );
     260                update_user_meta( $wp_user_id, self::USER_SESSIONS_META, $sessions );
    228261
    229262                self::bind_verified_session(
    … …  
    325358                        set_transient( $ip_key, $ip_count + 1, HOUR_IN_SECONDS );
    326359                }
     360        }
     361
     362        /**
     363         * Attempts per order number, across all chat sessions. Requests come from the
     364         * Agentmatica server, so REMOTE_ADDR cannot tell shoppers apart and a new
     365         * session_key resets the per-session count; this caps guessing on one order.
     366         */
     367        private static function is_order_rate_limited( string $order_number ): bool {
     368                $key = self::RATE_ORDER_PREFIX . md5( strtolower( ltrim( trim( $order_number ), '#' ) ) );
     369                return (int) get_transient( $key ) >= self::MAX_ATTEMPTS_ORDER;
     370        }
     371
     372        private static function record_order_attempt( string $order_number ): void {
     373                $key = self::RATE_ORDER_PREFIX . md5( strtolower( ltrim( trim( $order_number ), '#' ) ) );
     374                set_transient( $key, (int) get_transient( $key ) + 1, HOUR_IN_SECONDS );
    327375        }
    328376
  • agentmatica-mcp-connector/trunk/readme.txt

    r3766293 r3771056  
    55Tested up to: 7.1
    66Requires PHP: 8.0
    7 Stable tag: 0.7.3
     7Stable tag: 0.7.5
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    9797== Changelog ==
    9898
     99= 0.7.5 =
     100* Security: a signed-in shopper can only see and change orders placed from their own account. Orders are no longer matched by billing email for signed-in shoppers, because store accounts are not email-verified.
     101* Security: logging out of the store ends the shopper's verified chat session, and a verified chat session now lasts 15 minutes instead of 45.
     102* Security: a customer token only links a chat to an account once Agentmatica has verified it, not when the token is issued.
     103* Security: order-number lookups are limited per order number, so guessing the email for one order is capped.
     104* Security: the storefront cart routes only accept requests from the store's own pages.
     105* Cancelling an order with a refund now sends the money back through the payment gateway. If the gateway cannot refund automatically, the order gets a note asking you to refund manually.
     106* Asking for a return twice on the same order no longer emails you twice.
     107
     108= 0.7.4 =
     109* Cancel and return now require the shopper to be signed in to their store account, the same as shipping-address changes. Guests are passed to your team.
     110* The customer-token route only accepts a fetch from the widget: images and other embedded requests on the store are refused, and an invalid nonce is never ignored.
     111* The widget sends shoppers to your real My Account page, even if it is renamed, translated or in a subfolder.
     112* Cart sync keeps each item's chosen options and add-on data, and other plugins' cart hooks keep working.
     113* Cart routes cap a single line at 99.
     114* Also in 0.7.3 but missing from its notes: catalog search matches plural words ("screens" finds "screen"), chat cart adds respect stock, and the chat cart stays in sync with the store cart.
     115
    99116= 0.7.3 =
    100117* Logged-in shoppers can get a customer token from the storefront widget. The route requires a `wp_rest` nonce or a same-origin fetch (not a clicked link).
    … …  
    147164== Upgrade Notice ==
    148165
     166= 0.7.4 =
     167Cancel and return now need a signed-in store account. Safer customer-token checks, real My Account links, and cart sync that keeps item options.
     168
    149169= 0.7.3 =
    150170Logged-in shoppers can be identified in chat; checkout links use the WooCommerce checkout URL. Cancel/return can use order number + email; address changes stay login-only.
Note: See TracChangeset for help on using the changeset viewer.