request( 'GET', $path, null, $query ); } /** * POST. * * @param string $path API path. * @param array|null $body JSON body. * @param array $query Query args. * @return array|WP_Error */ public function post( $path, $body = null, $query = array() ) { return $this->request( 'POST', $path, $body, $query ); } /** * PATCH. * * @param string $path API path. * @param array|null $body JSON body. * @param array $query Query args. * @return array|WP_Error */ public function patch( $path, $body = null, $query = array() ) { return $this->request( 'PATCH', $path, $body, $query ); } /** * DELETE. * * @param string $path API path. * @param array|null $body JSON body. * @param array $query Query args. * @return array|WP_Error */ public function delete( $path, $body = null, $query = array() ) { return $this->request( 'DELETE', $path, $body, $query ); } /** * Perform a request. * * @param string $method HTTP method. * @param string $path API path beginning with a slash. * @param array|null $body Body to JSON-encode, or null for no body. * @param array $query Query args; null/'' values are dropped. * @return array|WP_Error Decoded JSON (array) on success. */ public function request( $method, $path, $body = null, $query = array() ) { $base = kasp_api_url(); // The API base carries a bearer token, so it must be https:// — a filter // or wp-config constant must never be able to downgrade it to plaintext. // http:// is tolerated only for local dev hosts (localhost / *.local). if ( ! self::is_secure_base( $base ) ) { return new WP_Error( 'kasp_insecure_api', __( 'The helpo API base URL must use HTTPS.', 'helpo' ), array( 'status' => 500 ) ); } $url = $base . $path; $query = array_filter( (array) $query, function ( $value ) { return null !== $value && '' !== $value; } ); if ( ! empty( $query ) ) { // Not add_query_arg(): it does not URL-encode values, so a caller // value containing "&", "=" or "#" would inject or truncate // parameters — including the projectId pin the REST proxy appends. $url .= ( false === strpos( $url, '?' ) ? '?' : '&' ) . http_build_query( $query, '', '&', PHP_QUERY_RFC3986 ); } $headers = array( 'Accept' => 'application/json', ); $token = Kasp_Settings::get_token(); if ( '' !== $token ) { $headers['Authorization'] = 'Bearer ' . $token; } $args = array( 'method' => strtoupper( $method ), 'timeout' => self::TIMEOUT, 'redirection' => 0, 'headers' => $headers, 'user-agent' => 'kasp-wordpress/' . KASP_VERSION . '; ' . home_url( '/' ), ); if ( null !== $body ) { $args['headers']['Content-Type'] = 'application/json'; $args['body'] = wp_json_encode( $body ); } $response = wp_remote_request( $url, $args ); if ( is_wp_error( $response ) ) { return new WP_Error( 'kasp_request_failed', sprintf( /* translators: %s: error message from the HTTP layer. */ __( 'Could not reach helpo: %s', 'helpo' ), $response->get_error_message() ), array( 'status' => 502 ) ); } $status = (int) wp_remote_retrieve_response_code( $response ); $raw = wp_remote_retrieve_body( $response ); $data = '' === $raw ? array() : json_decode( $raw, true ); if ( ! is_array( $data ) ) { $data = array(); } if ( $status >= 200 && $status < 300 ) { // A working call proves the token is still good. if ( Kasp_Settings::is_connection_broken() ) { Kasp_Settings::clear_connection_broken(); } return $data; } return $this->to_error( $status, $data ); } /** * Is the resolved API base URL safe to send a bearer token to? * * https:// always passes. http:// passes only for loopback / local dev hosts * (localhost, 127.0.0.1, ::1, *.local, *.localhost), so a misconfigured * filter or constant cannot ship the token over plaintext in production. * * @param string $base API base URL. * @return bool */ public static function is_secure_base( $base ) { $parts = wp_parse_url( (string) $base ); if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) { return false; } $scheme = strtolower( (string) $parts['scheme'] ); if ( 'https' === $scheme ) { return true; } if ( 'http' === $scheme ) { $host = strtolower( (string) $parts['host'] ); return 'localhost' === $host || '127.0.0.1' === $host || '::1' === $host || ( strlen( $host ) > 6 && '.local' === substr( $host, -6 ) ) || ( strlen( $host ) > 10 && '.localhost' === substr( $host, -10 ) ); } return false; } /** * Normalize a helpo error response into a WP_Error the REST layer can pass * straight back to the admin app. * * helpo errors are always { error, code } plus an optional `cause` object * carrying structured detail (apps/server/src/lib/http-error.ts). The * entitlements layer's 402s put the numbers there — { code: "limit_reached", * key, limit, current, plan } — which is what turns "Upgrade to add more" * into "5 of 5 knowledge pages used", so `cause` is forwarded verbatim. * `cause.code` mirrors the top-level `code`; both are read for safety. * * @param int $status HTTP status. * @param array $data Decoded body. * @return WP_Error */ private function to_error( $status, $data ) { $message = ''; if ( isset( $data['error'] ) && is_string( $data['error'] ) ) { $message = $data['error']; } elseif ( isset( $data['message'] ) && is_string( $data['message'] ) ) { $message = $data['message']; } $code = ''; if ( isset( $data['code'] ) && is_string( $data['code'] ) ) { $code = $data['code']; } elseif ( isset( $data['cause']['code'] ) && is_string( $data['cause']['code'] ) ) { $code = $data['cause']['code']; } $cause = isset( $data['cause'] ) && is_array( $data['cause'] ) ? $data['cause'] : array(); if ( 401 === $status ) { // The token itself was rejected — missing, malformed or revoked // (middleware/session.ts). Flag it so the admin app can prompt for a // reconnect instead of showing a generic failure forever. Kasp_Settings::mark_connection_broken(); return new WP_Error( 'kasp_unauthorized', '' !== $message ? $message : __( 'The helpo connection is no longer valid. Please reconnect this site.', 'helpo' ), array( 'status' => 401, 'kasp_code' => '' !== $code ? $code : 'unauthorized', 'reconnect' => true, ) ); } if ( 403 === $status ) { // Deliberately NOT a reconnect: 403 is helpo's role gate // (middleware/require-role.ts) — the token is valid, but the kasp // account it belongs to is no longer a manager of that workspace, or // left it altogether. Reconnecting cannot fix either (approval itself // requires the manager role), and marking the whole connection broken // over one gated call would put a permanent banner on every screen. return new WP_Error( 'kasp_forbidden', '' !== $message ? $message : __( 'The helpo account this site is connected with no longer has permission for that. Ask a helpo workspace owner to restore it, or reconnect with an account that can manage agents.', 'helpo' ), array( 'status' => 403, 'kasp_code' => '' !== $code ? $code : 'forbidden', ) ); } if ( 402 === $status ) { $data = array( 'status' => 402, 'kasp_code' => '' !== $code ? $code : 'plan_required', // Deep link the admin app turns into an "Upgrade" button. 'upgrade' => kasp_app_url() . '/dashboard/billing', ); if ( ! empty( $cause ) ) { $data['cause'] = $cause; } return new WP_Error( 'limit_reached' === $code ? 'kasp_limit_reached' : 'kasp_plan_required', '' !== $message ? $message : __( 'Your helpo plan does not allow this yet.', 'helpo' ), $data ); } return new WP_Error( 'kasp_api_error', '' !== $message ? $message : sprintf( /* translators: %d: HTTP status code. */ __( 'Helpo returned an unexpected response (HTTP %d).', 'helpo' ), $status ), array( 'status' => ( $status >= 400 && $status < 600 ) ? $status : 502, 'kasp_code' => '' !== $code ? $code : 'error', ) ); } }