'GET', 'callback' => array( self::class, 'ping' ), 'permission_callback' => '__return_true', ) ); register_rest_route( self::NS, '/info', array( 'methods' => 'GET', 'callback' => array( self::class, 'info' ), 'permission_callback' => $auth, ) ); register_rest_route( self::NS, '/policies', array( 'methods' => 'GET', 'callback' => array( self::class, 'policies' ), 'permission_callback' => $auth, ) ); register_rest_route( self::NS, '/product/(?P\d+)/private', array( 'methods' => 'GET', 'callback' => array( self::class, 'product_private' ), 'permission_callback' => $auth, 'args' => array( 'id' => array( 'required' => true, 'sanitize_callback' => 'absint', ), ), ) ); register_rest_route( self::NS, '/fix', array( 'methods' => 'POST', 'callback' => array( self::class, 'fix' ), 'permission_callback' => $auth, ) ); } /** * Unsigned. Tells AgentShoppable the plugin is here and whether a key has been set, * so it can prompt the owner instead of failing silently. Reveals nothing about the store. */ public static function ping(): array { return array( 'ok' => true, 'plugin' => self::VERSION, 'connected' => (bool) get_option( self::OPTION_KEY ), ); } /** * HMAC verification: constant-time compare, replay window, no WordPress auth involved. * * @param WP_REST_Request $request Incoming request. * @return true|WP_Error */ public static function verify( WP_REST_Request $request ) { $key = get_option( self::OPTION_KEY ); if ( ! $key ) { return new WP_Error( 'agentshoppable_not_connected', __( 'AgentShoppable is not connected on this site.', 'agentshoppable' ), array( 'status' => 403 ) ); } $timestamp = (string) $request->get_header( 'x-agentshoppable-timestamp' ); $signature = (string) $request->get_header( 'x-agentshoppable-signature' ); if ( '' === $timestamp || '' === $signature || abs( time() - (int) $timestamp ) > self::MAX_SKEW ) { return new WP_Error( 'agentshoppable_bad_signature', __( 'Missing or stale signature.', 'agentshoppable' ), array( 'status' => 401 ) ); } $route = preg_replace( '#^/' . preg_quote( self::NS, '#' ) . '#', '', $request->get_route() ); $expected = hash_hmac( 'sha256', $timestamp . '.' . $route . '.' . (string) $request->get_body(), $key ); if ( ! hash_equals( $expected, $signature ) ) { return new WP_Error( 'agentshoppable_bad_signature', __( 'Invalid signature.', 'agentshoppable' ), array( 'status' => 401 ) ); } return true; } /** * Versions, theme, active plugins and agent-surface flags. This is what tells * AgentShoppable why a scan looked the way it did, rather than guessing from outside. */ public static function info(): array { if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $active = array_values( array_filter( array_keys( get_plugins() ), 'is_plugin_active' ) ); $theme = wp_get_theme(); $base = (string) get_option( 'woocommerce_default_country', '' ); return array( 'wordpress' => get_bloginfo( 'version' ), 'woocommerce' => defined( 'WC_VERSION' ) ? WC_VERSION : null, 'php' => PHP_VERSION, 'theme' => array( 'name' => $theme->get( 'Name' ), 'version' => $theme->get( 'Version' ), 'parent' => $theme->parent() ? $theme->parent()->get( 'Name' ) : null, ), 'plugins' => $active, 'currency' => function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : null, 'country' => '' !== $base ? explode( ':', $base )[0] : null, 'permalinks' => (bool) get_option( 'permalink_structure' ), 'abilities_api' => function_exists( 'wp_register_ability' ), 'mcp_adapter' => class_exists( 'WP\\MCP\\Core\\McpAdapter' ) || defined( 'MCP_ADAPTER_VERSION' ), 'woocommerce_mcp' => function_exists( 'wc_get_container' ) && 'yes' === get_option( 'woocommerce_feature_mcp_integration_enabled' ), 'coming_soon' => 'yes' === get_option( 'woocommerce_coming_soon' ), 'private_site' => 0 === (int) get_option( 'blog_public' ), ); } /** * The pages WooCommerce and WordPress designate as policies, whatever their slugs. * Agents will not recommend a purchase they cannot state the return terms for, and the * public surface does not say which page is which. */ public static function policies(): array { $map = array( 'refund' => (int) get_option( 'woocommerce_refund_returns_page_id' ), 'terms' => (int) get_option( 'woocommerce_terms_page_id' ), 'privacy' => (int) get_option( 'wp_page_for_privacy_policy' ), // Set by some themes and plugins; 0 when unused. 'shipping' => (int) get_option( 'woocommerce_shipping_policy_page_id' ), ); $out = array(); foreach ( $map as $kind => $id ) { if ( $id <= 0 ) { continue; } $post = get_post( $id ); if ( ! $post || 'publish' !== $post->post_status ) { continue; } // Render blocks and paragraphs without running the_content, so no third-party // content filter gets invoked for a request the visitor never made. $body = wp_strip_all_tags( wpautop( do_blocks( $post->post_content ) ) ); $out[] = array( 'kind' => $kind, 'title' => $post->post_title, 'url' => get_permalink( $post ), 'body' => $body, ); } return $out; } /** * Fields the public Store API does not expose: GTIN, image alt text, stock, weight. * Read-only. * * @param WP_REST_Request $request Incoming request. * @return array|WP_Error */ public static function product_private( WP_REST_Request $request ) { $product = function_exists( 'wc_get_product' ) ? wc_get_product( absint( $request['id'] ) ) : null; if ( ! $product ) { return new WP_Error( 'agentshoppable_not_found', __( 'No such product.', 'agentshoppable' ), array( 'status' => 404 ) ); } $images = array(); foreach ( array_merge( array( $product->get_image_id() ), $product->get_gallery_image_ids() ) as $attachment_id ) { if ( $attachment_id ) { $images[] = array( 'id' => (int) $attachment_id, 'url' => wp_get_attachment_url( $attachment_id ), 'alt' => get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ), ); } } $variants = array(); $ids = $product->is_type( 'variable' ) ? $product->get_children() : array( $product->get_id() ); foreach ( $ids as $variant_id ) { $variant = wc_get_product( $variant_id ); if ( ! $variant ) { continue; } $variants[] = array( 'id' => (int) $variant_id, 'sku' => $variant->get_sku(), 'gtin' => method_exists( $variant, 'get_global_unique_id' ) ? $variant->get_global_unique_id() : null, 'stock' => $variant->get_stock_quantity(), 'weight' => $variant->get_weight(), ); } return array( 'id' => $product->get_id(), 'images' => $images, 'variants' => $variants, ); } /** * Applies one fix the store owner approved in AgentShoppable. Only whitelisted fields, * never a meta key taken from the request, and every change returns its previous value * so the change can be undone. * * @param WP_REST_Request $request Incoming request. * @return array|WP_Error */ public static function fix( WP_REST_Request $request ) { $params = (array) $request->get_json_params(); $kind = isset( $params['kind'] ) ? sanitize_key( (string) $params['kind'] ) : ''; $target = isset( $params['targetId'] ) ? absint( $params['targetId'] ) : 0; $field = isset( $params['field'] ) ? sanitize_key( (string) $params['field'] ) : ''; $value = $params['suggestedValue'] ?? null; try { switch ( $kind ) { case 'set_product_field': case 'set_variant_field': $product = function_exists( 'wc_get_product' ) ? wc_get_product( $target ) : null; if ( ! $product ) { return new WP_Error( 'agentshoppable_not_found', __( 'No such product.', 'agentshoppable' ), array( 'status' => 404 ) ); } $allowed = array( 'description' => 'set_description', 'short_description' => 'set_short_description', 'sku' => 'set_sku', 'gtin' => 'set_global_unique_id', 'weight' => 'set_weight', ); if ( ! isset( $allowed[ $field ] ) || ! method_exists( $product, $allowed[ $field ] ) ) { return new WP_Error( 'agentshoppable_bad_field', /* translators: %s: the requested product field name. */ sprintf( __( 'Field "%s" is not one this plugin will change.', 'agentshoppable' ), $field ), array( 'status' => 400 ) ); } $setter = $allowed[ $field ]; $getter = str_replace( 'set_', 'get_', $setter ); $previous = $product->{$getter}(); $product->{$setter}( is_string( $value ) ? wp_kses_post( $value ) : $value ); $product->save(); self::audit( $kind, $target, $field, $previous, $value ); return array( 'ok' => true, 'previousValue' => $previous, 'undoable' => true, ); case 'set_image_alt': if ( ! $target || 'attachment' !== get_post_type( $target ) ) { return new WP_Error( 'agentshoppable_not_found', __( 'No such image.', 'agentshoppable' ), array( 'status' => 404 ) ); } $previous = get_post_meta( $target, '_wp_attachment_image_alt', true ); update_post_meta( $target, '_wp_attachment_image_alt', sanitize_text_field( (string) $value ) ); self::audit( $kind, $target, 'alt', $previous, $value ); return array( 'ok' => true, 'previousValue' => $previous, 'undoable' => true, ); default: return new WP_Error( 'agentshoppable_bad_kind', /* translators: %s: the requested fix type. */ sprintf( __( 'Unsupported fix type "%s".', 'agentshoppable' ), $kind ), array( 'status' => 400 ) ); } } catch ( \Throwable $e ) { return new WP_Error( 'agentshoppable_fix_failed', $e->getMessage(), array( 'status' => 500 ) ); } } /** * Append one change to the on-site audit log, so the owner can see what was changed * here and not only in AgentShoppable. * * @param string $kind Fix type. * @param int $target Post ID changed. * @param string $field Field changed. * @param mixed $previous Value before the change. * @param mixed $value Value after the change. */ private static function audit( string $kind, int $target, string $field, $previous, $value ): void { $log = get_option( self::OPTION_LOG, array() ); $log = is_array( $log ) ? $log : array(); $log[] = array( 'at' => time(), 'kind' => $kind, 'target' => $target, 'field' => $field, 'previous' => $previous, 'value' => $value, ); update_option( self::OPTION_LOG, array_slice( $log, -self::LOG_LIMIT ), false ); } // -------------------------------------------------------------------- Admin public static function menu(): void { add_submenu_page( 'woocommerce', __( 'AgentShoppable', 'agentshoppable' ), __( 'AgentShoppable', 'agentshoppable' ), 'manage_woocommerce', 'agentshoppable', array( self::class, 'page' ) ); } public static function rotate_key(): void { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'You do not have permission to do that.', 'agentshoppable' ), '', array( 'response' => 403 ) ); } check_admin_referer( 'agentshoppable_rotate' ); update_option( self::OPTION_KEY, bin2hex( random_bytes( 32 ) ), false ); // Let the next page load show it once, to whoever asked for it. The key itself is // not copied here: only permission to display the one already stored. update_option( self::OPTION_REVEAL, array( 'user' => get_current_user_id(), 'expires' => time() + self::REVEAL_WINDOW, ), false ); wp_safe_redirect( admin_url( 'admin.php?page=agentshoppable' ) ); exit; } public static function disconnect(): void { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'You do not have permission to do that.', 'agentshoppable' ), '', array( 'response' => 403 ) ); } check_admin_referer( 'agentshoppable_disconnect' ); delete_option( self::OPTION_KEY ); delete_option( self::OPTION_REVEAL ); wp_safe_redirect( admin_url( 'admin.php?page=agentshoppable&disconnected=1' ) ); exit; } /** * Returns the connection key exactly once, to the user who generated it, within the * reveal window. Every other call gets null, including a second look at the same page. * * @return string|null */ private static function claim_reveal(): ?string { $reveal = get_option( self::OPTION_REVEAL ); if ( ! is_array( $reveal ) ) { return null; } // Consume it either way: expired or wrong user, the chance to see it is spent. delete_option( self::OPTION_REVEAL ); $fresh = isset( $reveal['expires'] ) && time() <= (int) $reveal['expires']; $mine = isset( $reveal['user'] ) && get_current_user_id() === (int) $reveal['user']; if ( ! $fresh || ! $mine ) { return null; } $key = get_option( self::OPTION_KEY ); return is_string( $key ) && '' !== $key ? $key : null; } public static function page(): void { $connected = (bool) get_option( self::OPTION_KEY ); $shown = self::claim_reveal(); $log = get_option( self::OPTION_LOG, array() ); $log = is_array( $log ) ? $log : array(); echo '
'; echo '

' . esc_html__( 'AgentShoppable', 'agentshoppable' ) . '

'; echo '

' . esc_html__( 'AgentShoppable tests whether AI shopping agents can find, understand, recommend and buy from this store. The testing happens on the AgentShoppable service. This plugin only lets that service read a few fields your storefront does not publish, and apply fixes you approve.', 'agentshoppable' ) . '

'; echo '

' . sprintf( /* translators: 1: opening link tag to the service, 2: closing link tag, 3: opening link tag to the privacy policy, 4: closing link tag. */ esc_html__( 'Generating a key below does not send anything anywhere. Nothing leaves this site until you paste the key into %1$sagentshoppable.com%2$s and start a scan. See the %3$sprivacy policy%4$s for what that service stores.', 'agentshoppable' ), '', '', '', '' ) . '

'; echo '

' . esc_html__( 'Status:', 'agentshoppable' ) . ' ' . ( $connected ? esc_html__( 'connection key set', 'agentshoppable' ) : esc_html__( 'not connected', 'agentshoppable' ) ) . '

'; if ( $shown ) { echo '

' . esc_html__( 'Copy this connection key into AgentShoppable now. It is shown once and cannot be retrieved later.', 'agentshoppable' ) . '

'; echo '

' . esc_html( $shown ) . '

'; } // Both actions sit on one row. This must be a div, not

: a // paragraph cannot contain a form, so the browser closes it early and the row breaks. echo '

'; echo '
'; wp_nonce_field( 'agentshoppable_rotate' ); echo ''; submit_button( $connected ? __( 'Rotate connection key', 'agentshoppable' ) : __( 'Generate connection key', 'agentshoppable' ), 'primary', 'submit', false ); echo '
'; if ( $connected ) { echo '
'; wp_nonce_field( 'agentshoppable_disconnect' ); echo ''; submit_button( __( 'Disconnect and delete the key', 'agentshoppable' ), 'delete', 'submit', false ); echo '
'; } echo '
'; echo '

' . esc_html__( 'Endpoints this plugin adds', 'agentshoppable' ) . '

'; if ( $log ) { echo '

' . esc_html__( 'Changes applied through this plugin', 'agentshoppable' ) . '

'; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; foreach ( array_reverse( array_slice( $log, -25 ) ) as $row ) { $target = (int) ( $row['target'] ?? 0 ); $edit = get_edit_post_link( $target ); // Show what the item is called. A bare post ID tells the owner nothing. $label = get_the_title( $target ); $label = '' !== $label ? $label : sprintf( '#%d', $target ); echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
' . esc_html__( 'When', 'agentshoppable' ) . '' . esc_html__( 'Item', 'agentshoppable' ) . '' . esc_html__( 'Field', 'agentshoppable' ) . '' . esc_html__( 'Previous value', 'agentshoppable' ) . '
' . esc_html( wp_date( 'Y-m-d H:i', (int) ( $row['at'] ?? 0 ) ) ) . '' . ( $edit ? '' . esc_html( $label ) . '' : esc_html( $label ) ) . '' . esc_html( (string) ( $row['field'] ?? '' ) ) . '' . esc_html( mb_strimwidth( (string) ( $row['previous'] ?? '' ), 0, 120, '…' ) ) . '
'; } echo '
'; } } AgentShoppable_Connector::init();