` string ready to be * sent as the `Authorization` header (or forwarded to MCP via the * `X-Wp-Authorization` custom header). Empty when no App Password is * provisioned for the current connection — caller should treat that as * "MCP tools unavailable" and surface to the admin. * * @since 1.0.0 * @return string The full `Basic …` Authorization header value, or empty string. */ public static function get_decrypted_app_password_authorization() { $mcp_settings = get_option( 'zip_mcp_settings', array() ); if ( is_array( $mcp_settings ) && ! empty( $mcp_settings['app_password_authorization'] ) && is_string( $mcp_settings['app_password_authorization'] ) ) { return (string) Utils::decrypt( $mcp_settings['app_password_authorization'] ); } return ''; } /** * Mint a WordPress Application Password for the current admin user. Store * the pre-built Authorization header value (`Basic `) and the App * Password UUID into `zip_mcp_settings`. The stored value is encrypted. * * This method is idempotent. A stored UUID can still resolve to a live App * Password on the user record. Then this method is a no-op and trusts the * existing token. A stale UUID triggers a fresh mint. A UUID goes stale when * the user deletes it in Profile then Application Passwords. * * The OAuth callback in `AJAX_Handlers::verify_authorization` calls this. It * runs right after the Sanctum `auth_token` is persisted. This reuses the * existing connection click. So the admin UX stays at one "Connect" step. * The App Password is provisioned silently in the same flow. * * App Password plaintext is one-time-visible. WordPress hashes it and never * exposes the plaintext again. This method captures it in a single call. It * pre-builds the Authorization header value once (`'Basic '. base64(...)`). * It encrypts the full value and stores it. The plaintext is never persisted * bare. It lives only inside the `Basic …` string. That is the form needed * on the wire. * * @since 1.0.0 * @return array{success: bool, code?: string, message?: string, bound?: bool} status envelope; * `bound` reports whether the server accepted the credential push. */ public static function ensure_app_password_provisioned() { $user_id = get_current_user_id(); if ( $user_id <= 0 ) { return array( 'success' => false, 'code' => 'no_current_user', 'message' => __( 'No current user context. Cannot provision Application Password.', 'zip-ai' ), ); } // Capability gate. Match the OAuth callback's own check so the App // Password is only ever minted under an admin identity. The resulting // token inherits the user's caps; we don't want a non-admin // connection to silently mint a low-privilege token that then 401s // every tool call. if ( ! user_can( $user_id, 'manage_options' ) ) { return array( 'success' => false, 'code' => 'insufficient_capability', 'message' => __( 'Connecting user lacks manage_options. Application Password not provisioned.', 'zip-ai' ), ); } if ( ! function_exists( 'wp_is_application_passwords_available' ) || ! wp_is_application_passwords_available() ) { return array( 'success' => false, 'code' => 'app_passwords_disabled', 'message' => __( 'Application Passwords are disabled on this site. Enable them or contact your administrator.', 'zip-ai' ), ); } $user = get_user_by( 'id', $user_id ); if ( ! $user || ! wp_is_application_passwords_available_for_user( $user ) ) { return array( 'success' => false, 'code' => 'app_passwords_disabled_for_user', 'message' => __( 'Application Passwords are disabled for the connecting user.', 'zip-ai' ), ); } // Idempotency: if we already minted one and it still exists on the // user record, leave it alone. Re-minting on every reconnect would // litter the user's Profile → Application Passwords screen. $existing_uuid_encrypted = self::get_setting( 'app_password_uuid', '' ); $existing_uuid = is_string( $existing_uuid_encrypted ) && '' !== $existing_uuid_encrypted ? (string) Utils::decrypt( $existing_uuid_encrypted ) : ''; if ( '' !== $existing_uuid ) { $existing_record = \WP_Application_Passwords::get_user_application_password( $user_id, $existing_uuid ); if ( null !== $existing_record ) { // Even though we already have the App Password locally, a // reconnect typically issues a NEW Sanctum token on the // server side — and our credential is bound to that token's // meta. Re-push the stored header so the new token also // has it bound; the server endpoint is idempotent. $stored_header = self::get_decrypted_app_password_authorization(); $pushed = false; if ( '' !== $stored_header ) { $pushed = (bool) self::push_app_password_to_saas( $stored_header ); } return array( 'success' => true, 'code' => 'already_provisioned', // Whether the server accepted the bind — soft-fail for the // connection flow, but reprovision needs the hard answer. 'bound' => $pushed, ); } // Stale UUID — fall through and mint fresh. The next // `update_setting` call overwrites the stored ciphertext. } $result = \WP_Application_Passwords::create_new_application_password( $user_id, array( 'name' => 'ZipWP MCP Connection', 'app_id' => 'zip-ai-' . wp_generate_uuid4(), ) ); if ( is_wp_error( $result ) ) { return array( 'success' => false, 'code' => 'create_failed', 'message' => $result->get_error_message(), ); } // `create_new_application_password` returns [ $plaintext_password, $item ]. // $item carries the persisted record metadata including `uuid`. list( $plaintext, $item ) = $result; if ( '' === $plaintext || empty( $item['uuid'] ) ) { return array( 'success' => false, 'code' => 'create_unexpected_shape', 'message' => __( 'WP_Application_Passwords returned an unexpected response shape.', 'zip-ai' ), ); } // Pre-build the full `Basic ` header value // — that's the form we actually need on the wire. Storing the // pre-built string means plaintext never round-trips through the // codebase at any point after this call. $authorization_header = 'Basic ' . base64_encode( $user->user_login . ':' . $plaintext ); self::update_setting( 'app_password_uuid', (string) $item['uuid'] ); self::update_setting( 'app_password_authorization', $authorization_header ); self::update_setting( 'app_password_user_id', (string) $user_id ); // Server-to-server delivery — push the pre-built `Basic ` value // to the server so it can pick it up from its DB at turn time. // The credential never has to ride along on a client request header, // which means it never lands in the inline JS where any other page // script could read it. Soft-fail: if the push errors we still report // `success: provisioned` locally so the admin sees the connection as // established — the next chat turn will surface a clear MCP-auth error // and the admin can disconnect/reconnect to trigger another bind attempt. $pushed = (bool) self::push_app_password_to_saas( $authorization_header ); return array( 'success' => true, 'code' => 'provisioned', 'bound' => $pushed, ); } /** * Bind the pre-built `Basic ` Authorization header to the active * Sanctum token on the server via `POST /api/wp-credentials/bind`. Called * right after a fresh App Password is minted (or proactively from the * OAuth callback when an `already_provisioned` credential exists and * needs to be re-bound after a server-side wipe). * * Idempotent on both sides — the server overwrites whatever value was * previously stored under the same token's meta column. * * @since 1.0.0 * @param string $authorization_header Pre-built `Basic ` value. * @return bool True on HTTP 200, false on any error path. */ public static function push_app_password_to_saas( $authorization_header ) { if ( '' === $authorization_header ) { return false; } $auth_token = self::get_decrypted_auth_token(); if ( '' === $auth_token ) { // No Sanctum token yet — the bind has to happen post-OAuth. return false; } $response = wp_remote_post( ZIPAI_MCP_CREDIT_SERVER_API . 'wp-credentials/bind', array( 'headers' => array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'Authorization' => 'Bearer ' . $auth_token, ), 'body' => (string) wp_json_encode( array( 'authorization_header' => $authorization_header, 'site_url' => home_url(), ) ), 'timeout' => 15, 'sslverify' => self::should_verify_ssl(), ) ); if ( is_wp_error( $response ) ) { error_log( '[zip-ai] wp-credentials/bind failed: ' . $response->get_error_message() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log return false; } $status_code = wp_remote_retrieve_response_code( $response ); if ( 200 !== (int) $status_code ) { error_log( sprintf( '[zip-ai] wp-credentials/bind returned HTTP %d', (int) $status_code ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log return false; } return true; } /** * Inverse of `push_app_password_to_saas()` — tell the server to drop the * bound credential. Called from `revoke_app_password()` so the server * state tracks the WP-side revoke. * * @since 1.0.0 * @return bool True on HTTP 200, false on any error path. */ public static function unpush_app_password_from_saas() { $auth_token = self::get_decrypted_auth_token(); if ( '' === $auth_token ) { return false; } $response = wp_remote_post( ZIPAI_MCP_CREDIT_SERVER_API . 'wp-credentials/unbind', array( 'headers' => array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'Authorization' => 'Bearer ' . $auth_token, ), 'body' => (string) wp_json_encode( array() ), 'timeout' => 15, 'sslverify' => self::should_verify_ssl(), ) ); if ( is_wp_error( $response ) ) { error_log( '[zip-ai] wp-credentials/unbind failed: ' . $response->get_error_message() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log return false; } $status_code = wp_remote_retrieve_response_code( $response ); return 200 === (int) $status_code; } /** * Re-mint the site's WordPress Application Password and re-bind it to the * server. This replaces whatever is on file. * * This is the recovery path for the one failure this binding has. The stored * credential can stop working. Someone revoked the password in Profile then * Application Passwords. Or a restore or migration left a header whose * password no longer exists. The server then gets 401 on every write into * the site. The import cannot proceed. Only WordPress-side code holds the * admin identity needed to issue a new one. * * This method revokes first, deliberately. `ensure_app_password_provisioned()` * is idempotent. Without the revoke, it would re-push the SAME dead header * when the stored uuid still resolves. * * @since 0.0.8 * @return bool True when a fresh credential is minted AND accepted by the server. */ public static function reprovision_app_password() { // Revoking is destructive and UNCONDITIONAL. It deletes the password, // unbinds it server-side and clears the local rows. So refuse before // touching anything unless this context can mint the replacement. // `ensure_app_password_provisioned()` requires `manage_options` on the // current user. It also requires Application Passwords to be available. // Reaching those checks after the revoke would leave the site // disconnected from every door (wizard, chat, MCP). There would be no // way back except a manual reconnect. The MCP import door calls this // under a caller who only needs `publish_pages`. if ( ! current_user_can( 'manage_options' ) || ! function_exists( 'wp_is_application_passwords_available' ) || ! wp_is_application_passwords_available() ) { return false; } // Best-effort: a uuid that no longer exists simply returns false here, and // the point is only to clear the local record so a fresh mint happens. self::revoke_app_password(); // `bound` is the server's acceptance of the push `ensure…` already made — // re-pushing the same header here would just bind the same value twice. $provisioned = self::ensure_app_password_provisioned(); return ! empty( $provisioned['success'] ) && ! empty( $provisioned['bound'] ); } /** * Revoke the stored Application Password (if any) and clear its * settings rows. Called from the disconnect AJAX handler. * * @since 1.0.0 * @return bool True if a password was revoked, false if nothing was stored. */ public static function revoke_app_password() { $mcp_settings = get_option( 'zip_mcp_settings', array() ); if ( ! is_array( $mcp_settings ) ) { return false; } $uuid = ! empty( $mcp_settings['app_password_uuid'] ) && is_string( $mcp_settings['app_password_uuid'] ) ? (string) Utils::decrypt( $mcp_settings['app_password_uuid'] ) : ''; $user_id = ! empty( $mcp_settings['app_password_user_id'] ) && is_string( $mcp_settings['app_password_user_id'] ) ? (int) Utils::decrypt( $mcp_settings['app_password_user_id'] ) : 0; $revoked = false; if ( '' !== $uuid && $user_id > 0 && class_exists( '\WP_Application_Passwords' ) ) { $revoked = (bool) \WP_Application_Passwords::delete_application_password( $user_id, $uuid ); } // Drop the server-side mirror BEFORE we wipe local meta so the bind // endpoint still has access to the active Sanctum token (cleared // separately by `zipwp_clear_settings()`). Soft-fail — the local // revoke is authoritative; the server will 401 next turn if the // unbind didn't land and the admin can reconnect. self::unpush_app_password_from_saas(); unset( $mcp_settings['app_password_authorization'], $mcp_settings['app_password_uuid'], $mcp_settings['app_password_user_id'] ); update_option( 'zip_mcp_settings', $mcp_settings ); return $revoked; } /** * Validate the auth token against the configured credit server. * * @param string $auth_token The auth token to validate. * @since 1.0.0 * @return bool|null True when valid, false when rejected, null when validation could not be completed. */ public static function validate_credit_server_auth_token( $auth_token ) { if ( empty( $auth_token ) ) { return false; } $response = wp_remote_post( ZIPAI_MCP_CREDIT_SERVER_API . 'auth/validate', array( 'headers' => array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'Authorization' => 'Bearer ' . $auth_token, ), 'body' => (string) wp_json_encode( array() ), 'timeout' => 15, 'sslverify' => self::should_verify_ssl(), ) ); if ( is_wp_error( $response ) ) { return null; } $status_code = wp_remote_retrieve_response_code( $response ); $response_body = wp_remote_retrieve_body( $response ); $response_data = json_decode( $response_body, true ); if ( 200 === $status_code ) { return is_array( $response_data ) && ! empty( $response_data['valid'] ); } if ( 401 === $status_code || 403 === $status_code ) { return false; } return null; } /** * Exchange a ZipWP app token for a local credit-server auth token. * * @param string $zip_token The ZipWP app token. * @param string $email The user email. * @param string $name The user name. * @param string $team_uuid The ZipWP team this site is being authenticated with. * @since 1.0.0 * @return array{token?: string, email?: string, name?: string} The exchange result containing token details, or an empty array on failure. */ public static function exchange_zipwp_token_for_local_auth_token( $zip_token, $email, $name = '', $team_uuid = '' ) { if ( empty( $zip_token ) || empty( $email ) ) { return array(); } $request_body = array( 'token' => $zip_token, 'email' => $email, 'site_id' => self::get_site_id(), ); if ( '' !== $name ) { $request_body['name'] = $name; } // Scopes credit pooling to ONE team on the credit server: a user who // belongs to several ZipWP teams must not be able to spend another // team's credits from this site. Optional on the wire — an older credit // server ignores it, and a newer one falls back to inferring the team // when it is absent, so sending nothing is never worse than today. if ( '' !== $team_uuid ) { $request_body['team_uuid'] = $team_uuid; } if ( get_current_user_id() > 0 ) { $request_body['user_id'] = get_current_user_id(); } $response = wp_remote_post( ZIPAI_MCP_CREDIT_SERVER_API . 'token/exchange', array( 'headers' => array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', ), 'body' => (string) wp_json_encode( $request_body ), 'timeout' => 15, 'sslverify' => self::should_verify_ssl(), ) ); if ( is_wp_error( $response ) ) { return array(); } $status_code = wp_remote_retrieve_response_code( $response ); $response_body = wp_remote_retrieve_body( $response ); $response_data = json_decode( $response_body, true ); if ( ! is_array( $response_data ) || ! in_array( $status_code, array( 200, 201 ), true ) || empty( $response_data['success'] ) || empty( $response_data['token'] ) ) { return array(); } $token_value = is_string( $response_data['token'] ) ? $response_data['token'] : ''; $email_value = ! empty( $response_data['email'] ) && is_string( $response_data['email'] ) ? $response_data['email'] : ''; $name_value = ! empty( $response_data['name'] ) && is_string( $response_data['name'] ) ? $response_data['name'] : ''; return array( 'token' => sanitize_text_field( $token_value ), 'email' => '' !== $email_value ? sanitize_email( $email_value ) : $email, 'name' => '' !== $name_value ? sanitize_text_field( $name_value ) : $name, ); } /** * Get the configured credit server identifier for token compatibility checks. * * @since 1.0.0 * @return string The normalized credit server API URL. */ private static function get_credit_server_identifier() { return untrailingslashit( ZIPAI_MCP_CREDIT_SERVER_API ); } /** * Generate a shared secret for HMAC authentication. * * @since 1.0.0 * @return string The generated shared secret. */ public static function generate_shared_secret() { return bin2hex( random_bytes( 32 ) ); } /** * Get or generate the shared secret for HMAC authentication. * * @since 1.0.0 * @return string The shared secret. */ public static function get_shared_secret() { // Get the encrypted shared secret from settings. $encrypted_shared_secret = self::get_setting( 'shared_secret', '' ); if ( empty( $encrypted_shared_secret ) ) { // Generate new shared secret. $shared_secret = self::generate_shared_secret(); self::update_setting( 'shared_secret', $shared_secret ); return $shared_secret; } // Decrypt and return the existing shared secret. return is_string( $encrypted_shared_secret ) ? Utils::decrypt( $encrypted_shared_secret ) : ''; } /** * Update a specific setting in the ZIP AI settings. * * @param string $key The setting key. * @param mixed $value The setting value. * @since 1.0.0 * @return bool True if the setting was updated, false otherwise. */ public static function update_setting( $key, $value ) { $existing_settings = self::get_admin_settings_option( 'zip_mcp_settings', array() ); if ( ! is_array( $existing_settings ) ) { $existing_settings = array(); } $existing_settings[ $key ] = is_string( $value ) ? Utils::encrypt( $value ) : ''; return self::update_admin_settings_option( 'zip_mcp_settings', $existing_settings ); } /** * Get the site ID for HMAC authentication. * * @since 1.0.0 * @return string The site ID. */ public static function get_site_id() { return get_site_url(); } /** * Register the shared secret with the server during plugin activation. * * @since 1.0.0 * @return array The registration response. */ public static function register_shared_secret_with_laravel() { $shared_secret = self::get_shared_secret(); $site_id = self::get_site_id(); // Register endpoint - this should point to the server. $register_endpoint = ZIPAI_MCP_CREDIT_SERVER_API . 'auth/register-secret'; $registration_data = array( 'site_id' => $site_id, 'secret' => $shared_secret, 'site_name' => get_bloginfo( 'name' ), 'admin_email' => get_option( 'admin_email' ), ); $response = wp_remote_post( $register_endpoint, array( 'headers' => array( 'Content-Type' => 'application/json', ), 'body' => (string) wp_json_encode( $registration_data ), 'timeout' => 30, ) ); if ( is_wp_error( $response ) ) { return array( 'error' => $response->get_error_message(), 'code' => 'registration_failed', ); } $response_body = wp_remote_retrieve_body( $response ); $status_code = wp_remote_retrieve_response_code( $response ); if ( 200 !== $status_code ) { return array( 'error' => __( 'Failed to register with Laravel server.', 'zip-ai' ), 'code' => 'registration_failed', ); } $response_data = json_decode( $response_body, true ); // Store the registration status. self::update_setting( 'hmac_registered', 'true' ); return is_array( $response_data ) ? $response_data : array(); } /** * Check if HMAC is registered with the server. * * @since 1.0.0 * @return bool True if registered, false otherwise. */ public static function is_hmac_registered() { return 'true' === self::get_setting( 'hmac_registered', 'false' ); } /** * Prepare a block array for WordPress serialize_blocks(). * * LLM-generated blocks only have blockName/attrs/innerBlocks. * WordPress serialize_block() requires innerHTML and innerContent * to know how to render the block tree. This adds the missing keys. * * - Blocks with innerBlocks: innerContent = [null, null, ...] (one per child) * - Blocks without innerBlocks: innerContent = [] (self-closing) * - Already-prepared blocks (from parse_blocks): left untouched * * @since 1.0.0 * @param array $blocks Array of block objects. * @return array Blocks ready for serialize_blocks(). */ public static function prepare_blocks_for_serialization( $blocks ) { return array_map( function ( $block ) { if ( ! is_array( $block ) ) { return $block; } // Already prepared (e.g. from parse_blocks) — skip if ( isset( $block['innerContent'] ) ) { // Still recurse into innerBlocks in case they need preparation if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { $block['innerBlocks'] = Helper::prepare_blocks_for_serialization( $block['innerBlocks'] ); } return $block; } // Ensure attrs is an array if ( ! isset( $block['attrs'] ) || ! is_array( $block['attrs'] ) ) { $block['attrs'] = array(); } // Recursively prepare innerBlocks first if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { $block['innerBlocks'] = Helper::prepare_blocks_for_serialization( $block['innerBlocks'] ); // One null per inner block — tells serialize_block() where to place each child $block['innerContent'] = array_fill( 0, count( $block['innerBlocks'] ), null ); } else { $block['innerBlocks'] = array(); $block['innerContent'] = array(); } // innerHTML is empty for Spectra blocks (content is in attrs + innerBlocks) if ( ! isset( $block['innerHTML'] ) ) { $block['innerHTML'] = ''; } return $block; }, $blocks ); } /** * Resolve a wp.org plugin slug to its installed `folder/file.php`. * * Matches on the folder segment, so it resolves folder-shaped plugins * (`sureforms/sureforms.php` ← `sureforms`) — the shape every wp.org plugin * this is used to gate takes. Single-file plugins (`hello.php`) never match, * which is correct for the callers here (they only ever pass folder slugs). * Returns null when the slug isn't installed. * * @param string $slug wp.org slug (lower-case alphanumerics and hyphens). * @return string|null */ public static function find_plugin_file_for_slug( string $slug ): ?string { // ONE resolver: PluginResolver carries the main-file preference // (`slug/slug.php` beats whichever file get_plugins() lists first in a // two-header folder). This wrapper only narrows the contract back to // folder-shaped results — its callers pass wp.org folder slugs and // must not match single-file plugins. if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $file = \ZipAI\MCP\Classes\Abilities\Zipai\System\PluginResolver::resolve_plugin_file( $slug ); return null !== $file && false !== strpos( $file, '/' ) ? $file : null; } }