* Connectors can carry the token by itself (?token=...), the same * pattern AI Copilot's own MCP integration uses — no separate header * field to fill in on the connecting client, which some MCP clients * don't reliably send on every request. * * @return int User id, or 0 if no token is present or it doesn't match. */ function miintg_resolve_api_key_from_header() { // Remote access stays off until the site owner confirms data sharing. if ( ! miintg_ai_consent() ) { return 0; } $key = ''; if ( ! empty( $_SERVER['HTTP_X_MIINTG_KEY'] ) ) { $key = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_MIINTG_KEY'] ) ); } elseif ( ! empty( $_GET['token'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $key = sanitize_text_field( wp_unslash( $_GET['token'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended } if ( ! $key ) { return 0; } $stored_hash = get_option( 'miintg_api_key_hash' ); $stored_user = (int) get_option( 'miintg_api_key_user_id' ); if ( ! $key || ! $stored_hash || ! $stored_user ) { return 0; } if ( ! hash_equals( $stored_hash, hash_hmac( 'sha256', $key, wp_salt() ) ) ) { return 0; } return $stored_user; } /** * Diagnostic-only route: GET /auth-check with the same header a connector * would send. Reveals whether the header arrived at all and whether it * matched, without exposing the full key or its hash — just last 4 * characters of each side, same as the admin UI already shows. Access is * restricted to administrators because the response includes token metadata * and the associated user ID. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ function miintg_rest_v1_auth_check( $request ) { $header_present = ! empty( $_SERVER['HTTP_X_MIINTG_KEY'] ); $token_present = ! empty( $_GET['token'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( $header_present ) { $received = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_MIINTG_KEY'] ) ); } elseif ( $token_present ) { $received = sanitize_text_field( wp_unslash( $_GET['token'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended } else { $received = ''; } $stored_hash = get_option( 'miintg_api_key_hash' ); $stored_user = (int) get_option( 'miintg_api_key_user_id' ); $stored_last4 = get_option( 'miintg_api_key_last4' ); $hash_match = false; if ( $received && $stored_hash ) { $hash_match = hash_equals( $stored_hash, hash_hmac( 'sha256', $received, wp_salt() ) ); } return rest_ensure_response( array( 'header_present' => $header_present, 'token_param_present' => $token_present, 'received_length' => strlen( $received ), 'received_last4' => $received ? substr( $received, -4 ) : null, 'key_generated_on_site' => (bool) $stored_hash, 'expected_last4' => $stored_last4 ?: null, 'hash_match' => $hash_match, 'resolved_user_id' => $hash_match ? $stored_user : 0, 'resolved_user_can_manage_options' => $hash_match && $stored_user ? user_can( $stored_user, 'manage_options' ) : false, ) ); } /** * Shared permission check for every route below. * * @return bool */ function miintg_rest_v1_permission() { return current_user_can( 'manage_options' ); } function miintg_register_rest_v1() { register_rest_route( 'mi-integrity/v1', '/auth-check', array( 'methods' => WP_REST_Server::READABLE, 'callback' => 'miintg_rest_v1_auth_check', 'permission_callback' => 'miintg_rest_v1_permission', ) ); register_rest_route( 'mi-integrity/v1', '/tools', array( 'methods' => WP_REST_Server::READABLE, 'callback' => function () { return rest_ensure_response( array_values( miintg_tool_defs() ) ); }, 'permission_callback' => 'miintg_rest_v1_permission', ) ); register_rest_route( 'mi-integrity/v1', '/call', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => 'miintg_rest_v1_call', 'permission_callback' => 'miintg_rest_v1_permission', 'args' => array( 'tool' => array( 'type' => 'string', 'required' => true, ), 'args' => array( 'type' => 'object', 'required' => false, ), ), ) ); register_rest_route( 'mi-integrity/v1', '/scan', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => 'miintg_rest_v1_start_scan', 'permission_callback' => 'miintg_rest_v1_permission', 'args' => array( 'checks' => array( 'type' => 'string', 'required' => false, ), ), ) ); register_rest_route( 'mi-integrity/v1', '/runs', array( 'methods' => WP_REST_Server::READABLE, 'callback' => function ( $request ) { $limit = (int) $request->get_param( 'limit' ); return rest_ensure_response( miintg_db_list_runs( $limit ?: miintg_runs_retention() ) ); }, 'permission_callback' => 'miintg_rest_v1_permission', 'args' => array( 'limit' => array( 'type' => 'integer', 'required' => false, ), ), ) ); register_rest_route( 'mi-integrity/v1', '/runs/(?P\d+)', array( 'methods' => WP_REST_Server::READABLE, 'callback' => function ( $request ) { $run = miintg_db_get_run_with_checks( (int) $request->get_param( 'id' ) ); if ( ! $run ) { return new WP_Error( 'miintg_no_run', 'No run with that id.', array( 'status' => 404 ) ); } return rest_ensure_response( $run ); }, 'permission_callback' => 'miintg_rest_v1_permission', ) ); } /** * POST /call — runs one tool synchronously and returns its result as-is. * Transport-agnostic: no JSON-RPC envelope, just the tool's own payload, * so a thin adapter for any MCP server only has to wrap this response * in whatever shape that protocol expects. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ function miintg_rest_v1_call( $request ) { $tool = (string) $request->get_param( 'tool' ); $args = (array) $request->get_param( 'args' ); $callbacks = miintg_tool_callbacks(); if ( ! isset( $callbacks[ $tool ] ) ) { return new WP_Error( 'miintg_unknown_tool', 'Unknown tool: ' . $tool, array( 'status' => 400 ) ); } $capability = miintg_tool_capability( $tool ); if ( $capability && ! current_user_can( $capability ) ) { return new WP_Error( 'miintg_forbidden', 'Not allowed to use this tool.', array( 'status' => 403 ) ); } try { $payload = call_user_func( $callbacks[ $tool ], $args ); } catch ( Throwable $e ) { return new WP_Error( 'miintg_tool_error', $e->getMessage(), array( 'status' => 500 ) ); } return rest_ensure_response( $payload ); } /** * Generates a new connection key, replacing any previous one (old key * stops working immediately — only one is active at a time). Returns * the plaintext key; only the HMAC hash is stored, so it cannot be * shown again after this call. * * @param int $user_id User the key authenticates as. * @return string Plaintext key. */ function miintg_generate_api_key( $user_id ) { $key = wp_generate_password( 40, false, false ); update_option( 'miintg_api_key_hash', hash_hmac( 'sha256', $key, wp_salt() ), false ); update_option( 'miintg_api_key_user_id', (int) $user_id, false ); update_option( 'miintg_api_key_last4', substr( $key, -4 ), false ); update_option( 'miintg_api_key_created', current_time( 'mysql', true ), false ); return $key; } /** * @return bool Whether a connection key currently exists. */ function miintg_has_api_key() { return (bool) get_option( 'miintg_api_key_hash' ); } /** * Revokes the current connection key without generating a new one. */ function miintg_revoke_api_key() { delete_option( 'miintg_api_key_hash' ); delete_option( 'miintg_api_key_user_id' ); delete_option( 'miintg_api_key_last4' ); delete_option( 'miintg_api_key_created' ); } /** * POST /scan — starts a background scan, returns immediately. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ function miintg_rest_v1_start_scan( $request ) { $checks_param = (string) $request->get_param( 'checks' ); $checks = $checks_param ? array_filter( array_map( 'trim', explode( ',', $checks_param ) ) ) : null; $run_id = miintg_schedule_scan( $checks, 'rest', get_current_user_id() ); return rest_ensure_response( array( 'run_id' => $run_id, 'status' => 'queued', ) ); }