= self::DISCLOSURE_VERSION; } /** * Domain reported to Consiva: host (and port, if non-standard) of the site's home URL. * * @return string */ public static function site_domain() { // Host, plus the port when it is not the default (backend answer to question 3.7, 30 Sep 2026): // Consiva's host check ignores ports, but its verification fetch builds https://{domain}/ verbatim, // so a site on :8443 must send "example.com:8443" or verification goes to :443 and fails. $host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); $port = self::non_standard_port(); /** * The domain sent to Consiva: host, or host:port for a non-standard port. * * @param string $domain Domain as sent. * @param int $port Non-standard port, or 0. */ return (string) apply_filters( 'consiva_consent_site_domain', $port ? $host . ':' . $port : $host, $port ); } /** * The port in the site's address if it is not the default for its scheme, else 0. It is sent as part of * the domain. Consiva does not yet keep the port in the Site's URL for scanning (a backend follow-up), * so a site that uses one is flagged on the Connect screen and in Diagnostics, and logged. * * @return int */ public static function non_standard_port() { $port = (int) wp_parse_url( home_url(), PHP_URL_PORT ); $scheme = strtolower( (string) wp_parse_url( home_url(), PHP_URL_SCHEME ) ); if ( 0 === $port || ( 'https' === $scheme && 443 === $port ) || ( 'http' === $scheme && 80 === $port ) ) { return 0; } return $port; } /** * Why this site cannot connect over https, or null. Consiva accepts only https for the site and for the * redirect_uri (Part A, as implemented), so this is checked before anything is sent. * * Skipped when CONSIVA_APP_BASE itself is http, which only happens for a local mock server. * * @return WP_Error|null */ public static function https_problem() { if ( 'https' !== strtolower( (string) wp_parse_url( Endpoints::app_base(), PHP_URL_SCHEME ) ) ) { return null; } if ( 'https' !== strtolower( (string) wp_parse_url( home_url(), PHP_URL_SCHEME ) ) ) { return new WP_Error( 'consiva_consent_not_https', sprintf( /* translators: %s: site address. */ __( 'This site\'s address (%s) does not use https. Consiva only connects to sites served over https. Set up an SSL certificate (most hosts offer one free), change the WordPress Address and Site Address in Settings → General to https, then try again. Nothing was sent to Consiva.', 'consiva-ai' ), home_url() ) ); } if ( 'https' !== strtolower( (string) wp_parse_url( self::redirect_uri(), PHP_URL_SCHEME ) ) ) { return new WP_Error( 'consiva_consent_admin_not_https', sprintf( /* translators: %s: admin address. */ __( 'This site\'s admin address (%s) does not use https, and Consiva only returns to https addresses. Change the WordPress Address in Settings → General to https (or set FORCE_SSL_ADMIN), then try again. Nothing was sent to Consiva.', 'consiva-ai' ), admin_url() ) ); } return null; } /** * Whether this site lives in a subfolder (example.com/blog, or a subdirectory multisite network). * * Consiva identifies a site by its domain alone, and domain verification fetches the domain's root * (backend question 4.4). A site in a subfolder would share its domain with whatever is at the root, * such as the main site of a network, so connecting it could collide with that site's installation. * Until Consiva supports paths, connecting is blocked with an explanation. * * @return bool */ public static function is_in_subfolder() { $path = trim( (string) wp_parse_url( home_url(), PHP_URL_PATH ), '/' ); return '' !== $path; } /** * URL Consiva redirects back to with code + state. * * @return string */ public static function redirect_uri() { $url = admin_url( 'admin-post.php?action=' . self::ACTION_CALLBACK ); $host = (string) wp_parse_url( $url, PHP_URL_HOST ); // Lowercase host, like site_domain(): Consiva requires redirect_uri's host to equal `domain` exactly. $pos = '' !== $host ? strpos( $url, $host ) : false; return false === $pos ? $url : substr_replace( $url, strtolower( $host ), $pos, strlen( $host ) ); } /** * One-time redirect to the Consiva screen after activation (§5.1). No outbound HTTP. * * @return void */ public function maybe_redirect_after_activation() { $user_id = get_transient( Activator::REDIRECT_TRANSIENT ); if ( false === $user_id ) { return; } delete_transient( Activator::REDIRECT_TRANSIENT ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only check of WordPress core's bulk-activation flag. $bulk = isset( $_GET['activate-multi'] ); if ( $bulk || wp_doing_ajax() || is_network_admin() || get_current_user_id() !== (int) $user_id || ! current_user_can( self::CAPABILITY ) ) { return; } wp_safe_redirect( Screens::url() ); exit; } /** * Installation health (GET /installations/{id}, Part A step 5), cached for 15 minutes. * * @param bool $force Bypass the cache. * @return array|WP_Error */ public static function installation_health( $force = false ) { $cached = get_transient( self::INSTALLATION_CACHE ); if ( ! $force && is_array( $cached ) ) { return $cached; } $response = ( new ConsivaClient() )->get_installation(); if ( is_wp_error( $response ) ) { return $response; } $body = $response['body']; $health = array( 'health_status' => isset( $body['health_status'] ) && is_string( $body['health_status'] ) ? sanitize_text_field( $body['health_status'] ) : '', 'plan_code' => isset( $body['plan_code'] ) && is_string( $body['plan_code'] ) ? sanitize_text_field( $body['plan_code'] ) : '', 'domain' => isset( $body['domain'] ) && is_string( $body['domain'] ) ? sanitize_text_field( $body['domain'] ) : '', 'max_domains' => isset( $body['max_domains'] ) && is_numeric( $body['max_domains'] ) ? (int) $body['max_domains'] : null, 'max_consent_logs' => isset( $body['max_consent_logs'] ) && is_numeric( $body['max_consent_logs'] ) ? (int) $body['max_consent_logs'] : null, // Only an explicit false is "incompatible"; a missing field is not treated as a problem. 'adapter_compatible' => ! ( isset( $body['adapter_compatible'] ) && false === $body['adapter_compatible'] ), 'reinstall_count' => isset( $body['reinstall_count'] ) && is_numeric( $body['reinstall_count'] ) ? (int) $body['reinstall_count'] : 0, 'checked_at' => time(), 'request_id' => $response['request_id'], ); set_transient( self::INSTALLATION_CACHE, $health, self::INSTALLATION_TTL ); State::update( array( 'last_health_at' => time() ) ); return $health; } /** * @param string[] $hosts Allowed hosts. * @return string[] */ public function allow_app_host( $hosts ) { $app_host = Endpoints::app_host(); if ( '' !== $app_host ) { $hosts[] = $app_host; } return $hosts; } /** * Connect & Scan My Site: record the authorisation, start PKCE, redirect to Consiva (§5.3 steps 1–3). * * @return void */ public function handle_connect() { $this->require_capability(); check_admin_referer( self::ACTION_CONNECT ); $https = self::https_problem(); if ( null !== $https ) { $this->fail( $https ); // Before anything is sent, and before the redirect to Consiva. } if ( self::non_standard_port() ) { Logger::warning( sprintf( 'Connecting a site on non-standard port %d as "%s"; Consiva scanning of port sites is not confirmed yet.', self::non_standard_port(), self::site_domain() ) ); } if ( self::is_in_subfolder() ) { $this->fail( new WP_Error( 'consiva_consent_subfolder', __( 'Sites in a subfolder of a domain (such as example.com/blog) cannot be connected to Consiva yet, because Consiva identifies sites by domain. Nothing was sent to Consiva.', 'consiva-ai' ) ) ); } if ( empty( $_POST['consiva_consent_authorise'] ) ) { $this->fail( new WP_Error( 'consiva_consent_not_authorised', __( 'Tick the box to authorise this site to connect to Consiva. Nothing is sent until you do.', 'consiva-ai' ) ) ); } if ( ! TokenStore::is_supported() ) { $this->fail( new WP_Error( 'consiva_consent_crypto_unsupported', __( 'This server cannot store Consiva credentials securely: the PHP OpenSSL extension with AES-256-GCM support is required. Ask your host to enable OpenSSL, then try again. Nothing was sent to Consiva.', 'consiva-ai' ) ) ); } $user_id = get_current_user_id(); update_option( self::DISCLOSURE_OPTION, array( 'disclosure_accepted_at' => time(), 'accepted_by_user_id' => $user_id, 'disclosure_version' => self::DISCLOSURE_VERSION, ), true ); $session = ConnectSession::start( $user_id ); $url = Endpoints::connect_url( array( 'platform' => 'wordpress', 'domain' => self::site_domain(), 'code_challenge' => $session['code_challenge'], 'code_challenge_method' => 'S256', 'state' => $session['state'], 'verify_token' => $session['verify_token'], 'redirect_uri' => self::redirect_uri(), ) ); // The app host is allowed only for this one redirect, not on every admin request. add_filter( 'allowed_redirect_hosts', array( $this, 'allow_app_host' ) ); wp_safe_redirect( $url ); exit; } /** * Return from Consiva: validate state + nonce, exchange code, persist (§5.3 steps 6–8). * * @return void */ public function handle_callback() { $this->require_capability(); // The nonce travels inside `state` and is verified by ConnectSession::consume() before any other // input is read (§9.2). // phpcs:disable WordPress.Security.NonceVerification.Recommended $state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : ''; $session = ConnectSession::consume( $state, get_current_user_id() ); if ( is_wp_error( $session ) ) { $this->fail( $session ); } $code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : ''; $error = isset( $_GET['error'] ) ? sanitize_key( wp_unslash( $_GET['error'] ) ) : ''; // phpcs:enable if ( '' !== $error ) { ConnectSession::clear_verify_token(); $this->fail( new WP_Error( 'consiva_consent_denied', sprintf( /* translators: %s: error code returned by Consiva. */ __( 'The connection was not approved at Consiva (%s). Nothing was saved and nothing further will be sent. You can try again at any time.', 'consiva-ai' ), $error ) ) ); } if ( '' === $code ) { $this->fail( new WP_Error( 'consiva_consent_missing_code', __( 'Consiva returned to your site without an authorisation code. Click Connect & Scan My Site to start again.', 'consiva-ai' ) ) ); } $response = ( new ConsivaClient() )->create_installation( self::installation_request_body( $code, $session['code_verifier'] ) ); if ( is_wp_error( $response ) && 'consiva_consent_conflict' === $response->get_error_code() ) { // 409 on the exchange (Part A as implemented, 30 Sep 2026): the domain is already connected to a // different Consiva account. Consiva never hands over another account's site. $this->fail( new WP_Error( 'consiva_consent_domain_taken', sprintf( /* translators: %s: site domain. */ __( '%s is already connected to a different Consiva account. Sign in to Consiva with that account and connect again, or disconnect the site from that account at app.consiva.ai first. Nothing was changed on this site.', 'consiva-ai' ), self::site_domain() ), $response->get_error_data() ) ); } if ( is_wp_error( $response ) ) { $this->fail( $response ); } $grant = $this->validate_grant( $response['body'] ); if ( is_wp_error( $grant ) ) { $this->fail( ErrorMapper::invalid_payload( $grant->get_error_message(), $response['status'], $response['request_id'] ) ); } $saved = TokenStore::save_connection( $grant, get_current_user_id() ); if ( is_wp_error( $saved ) ) { $this->fail( $saved ); } ConnectSession::clear_verify_token(); delete_transient( self::INSTALLATION_CACHE ); ScanProgress::clear_cache(); delete_option( self::RECONNECT_OPTION ); State::update( array( 'adapter_version' => CONSIVA_CONSENT_VERSION ) ); Heartbeat::schedule(); Notices::push( 'success', __( 'Your site is connected to Consiva.', 'consiva-ai' ), $response['request_id'] ); wp_safe_redirect( Screens::url() ); exit; } /** * Disconnect: tells Consiva (DELETE /installations/{id}, Part A step 7), then removes local credentials. * * Consiva revokes the tokens and pauses the banner; consent records and evidence are retained there. * Local credentials are removed even if Consiva cannot be reached, so the admin is never stuck. * * @return void */ public function handle_disconnect() { $this->require_capability(); check_admin_referer( self::ACTION_DISCONNECT ); $response = ( new ConsivaClient() )->delete_installation(); self::forget_connection(); if ( is_wp_error( $response ) ) { $data = $response->get_error_data(); Notices::push( 'error', sprintf( /* translators: %s: error message. */ __( 'This site has been disconnected in WordPress, but Consiva could not be told, so the banner may keep showing until you pause it at app.consiva.ai. Details: %s', 'consiva-ai' ), $response->get_error_message() ), is_array( $data ) && ! empty( $data['request_id'] ) ? $data['request_id'] : '' ); } else { Notices::push( 'success', __( 'This site has been disconnected. Consiva has paused the banner and revoked this site\'s credentials, which were also removed from WordPress. Your consent records and evidence are kept in your Consiva account.', 'consiva-ai' ), $response['request_id'] ); } wp_safe_redirect( Screens::url() ); exit; } /** * POST /installations body, exactly as Part A step 3 defines it. * * @param string $code Authorisation code from the callback. * @param string $code_verifier PKCE verifier from Step 1. * @return array */ public static function installation_request_body( $code, $code_verifier ) { return array( 'code' => (string) $code, 'code_verifier' => (string) $code_verifier, 'redirect_uri' => self::redirect_uri(), 'platform' => 'wordpress', 'domain' => self::site_domain(), 'platform_instance_id' => null, // Nullable in Part A; its meaning for WordPress is not defined yet. 'install_source' => null, // Nullable in Part A; allowed values are not defined yet. ); } /** * Removes every locally stored credential and connection record. Does not contact Consiva. * * @param array{reason:string,request_id:string,at:int}|null $reconnect_reason Why the admin must reconnect, if it * was not their own choice. * @return void */ public static function forget_connection( $reconnect_reason = null ) { TokenStore::clear(); ConnectSession::clear_verify_token(); State::clear(); delete_transient( self::INSTALLATION_CACHE ); ScanProgress::clear_cache(); Verification::clear_cache(); Dashboard::clear_cache(); CategoryMap::clear(); // Stops the front end printing the bootstrap and runtime. Heartbeat::unschedule(); if ( null !== $reconnect_reason ) { update_option( self::RECONNECT_OPTION, $reconnect_reason, false ); } else { delete_option( self::RECONNECT_OPTION ); } } /** * "Check connection now": re-reads installation health, bypassing the cache. * * @return void */ public function handle_check_connection() { $this->require_capability(); check_admin_referer( self::ACTION_CHECK ); $health = self::installation_health( true ); if ( is_wp_error( $health ) ) { $this->fail( $health ); } Heartbeat::sync_site_status(); Notices::push( 'success', __( 'Connection to Consiva is working.', 'consiva-ai' ), $health['request_id'] ); wp_safe_redirect( Screens::url() ); exit; } /** * Treats the POST /installations body as untrusted and allowlists what is kept. * * @param array $body Decoded response. * @return array{installation_id:string,site_id:string,refresh_token:string,access_token?:string,expires_in?:int}|WP_Error */ private function validate_grant( array $body ) { $id_pattern = '/^(?!.*\.\.)(?!\.$)[A-Za-z0-9_\-:.]{1,128}$/'; // No '.' or '..' segments: IDs are put into request paths. $grant = array(); foreach ( array( 'installation_id', 'site_id' ) as $field ) { $value = isset( $body[ $field ] ) && is_scalar( $body[ $field ] ) ? (string) $body[ $field ] : ''; if ( ! preg_match( $id_pattern, $value ) ) { /* translators: %s: field name. */ return new WP_Error( 'consiva_consent_bad_field', sprintf( __( '%s is missing or malformed', 'consiva-ai' ), $field ) ); } $grant[ $field ] = $value; } $refresh = isset( $body['refresh_token'] ) ? $body['refresh_token'] : ''; if ( ! TokenStore::is_valid_token( $refresh ) ) { return new WP_Error( 'consiva_consent_bad_field', __( 'refresh_token is missing or malformed', 'consiva-ai' ) ); } $grant['refresh_token'] = $refresh; if ( isset( $body['access_token'] ) && TokenStore::is_valid_token( $body['access_token'] ) ) { $grant['access_token'] = $body['access_token']; } if ( isset( $body['expires_in'] ) && is_numeric( $body['expires_in'] ) && (int) $body['expires_in'] > 0 ) { $grant['expires_in'] = (int) $body['expires_in']; } return $grant; } /** * @return void */ private function require_capability() { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to manage the Consiva connection.', 'consiva-ai' ), '', array( 'response' => 403 ) ); } } /** * Shows the error on the Consiva screen and stops. * * @param WP_Error $error Error. * @return void */ private function fail( WP_Error $error ) { if ( ! TokenStore::is_connected() ) { ConnectSession::clear_verify_token(); // A failed connect leaves nothing to verify (§9.5). } Notices::push_error( $error ); wp_safe_redirect( Screens::url() ); exit; } }