$origin, 'base_url' => $base_url, ]; } /** @param array{origin: string, base_url: string} $destination */ public static function pairing_endpoint(array $destination): string { return trailingslashit($destination['base_url']) . 'v1/pairing/claim'; } /** @param array{origin: string, base_url: string} $destination */ public static function snapshot_endpoint(array $destination, string $site_id): string { $path = '/v1/sites/' . rawurlencode($site_id) . '/snapshots'; return trailingslashit($destination['base_url']) . ltrim($path, '/'); } /** @param array{origin: string, base_url: string} $destination */ public static function report_endpoint(array $destination, string $site_id): string { $path = '/v1/sites/' . rawurlencode($site_id) . '/report'; return trailingslashit($destination['base_url']) . ltrim($path, '/'); } /** @param array{origin: string, base_url: string} $destination */ public static function entitlements_endpoint(array $destination, string $site_id): string { $path = '/v1/sites/' . rawurlencode($site_id) . '/entitlements'; return trailingslashit($destination['base_url']) . ltrim($path, '/'); } /** @return array */ public static function build_pairing_payload(string $pairing_code): array { return [ 'pairingCode' => $pairing_code, 'siteUrl' => home_url('/'), 'siteName' => get_bloginfo('name'), 'wordpressVersion' => get_bloginfo('version'), 'pluginVersion' => THEDOTDEV_AGENTIC_BRIDGE_VERSION, ]; } /** @param array $payload */ public static function serialize_pairing_payload(array $payload): string|false { return wp_json_encode($payload, JSON_UNESCAPED_SLASHES); } /** @return array */ public static function build_snapshot_payload(): array { return TheDotDevAgenticBridge_Snapshot::build(); } /** @param array $snapshot */ public static function serialize_snapshot_payload(array $snapshot): string|false { return wp_json_encode($snapshot, JSON_UNESCAPED_SLASHES); } public static function build_snapshot_payload_bytes(): string|false { return self::serialize_snapshot_payload(self::build_snapshot_payload()); } /** @return array|WP_Error */ public static function claim_site(string $api_base_url, string $pairing_code) { $destination = self::normalize_destination($api_base_url); if (is_wp_error($destination)) { return $destination; } $payload_bytes = self::serialize_pairing_payload(self::build_pairing_payload($pairing_code)); if ($payload_bytes === false) { return new WP_Error('thedotdev_agentic_bridge_encode_failed', __('Failed to encode pairing payload', 'agentic-bridge')); } return self::claim_site_with_payload($destination, $payload_bytes); } /** * @param array{origin: string, base_url: string} $destination * @return array|WP_Error */ public static function claim_site_with_payload(array $destination, string $payload_bytes) { $endpoint = self::pairing_endpoint($destination); $response = wp_remote_post($endpoint, [ 'timeout' => 20, 'headers' => ['Content-Type' => 'application/json'], 'body' => $payload_bytes, ]); if (is_wp_error($response)) { return $response; } $code = (int) wp_remote_retrieve_response_code($response); $body = json_decode((string) wp_remote_retrieve_body($response), true); if ($code !== 201 || !is_array($body)) { return new WP_Error('thedotdev_agentic_bridge_claim_failed', __('Pairing claim failed', 'agentic-bridge'), ['status' => $code]); } TheDotDevAgenticBridge_Storage::update([ 'api_base_url' => $destination['base_url'], 'site_id' => (string) ($body['siteId'] ?? ''), 'site_hmac_key' => (string) ($body['siteHmacKey'] ?? ''), 'organization_id' => (string) ($body['organizationId'] ?? ''), 'connection_status' => 'connected', 'last_error' => '', ]); self::refresh_entitlements(); return $body; } /** * @param array{ * destination: array{origin: string, base_url: string}, * site_id: string, * endpoint: string, * path: string * } $bound * @return array|WP_Error */ public static function push_snapshot_to_bound(array $bound, string $payload_bytes) { if ($payload_bytes === '') { return new WP_Error('thedotdev_agentic_bridge_invalid_payload', __('Snapshot push refused: payload is empty.', 'agentic-bridge')); } if (strlen($payload_bytes) > TheDotDevAgenticBridge_Outbound_Preview::MAX_PAYLOAD_BYTES) { return new WP_Error('thedotdev_agentic_bridge_oversize_payload', __('Snapshot push refused: payload exceeds safe size limits.', 'agentic-bridge')); } $decoded = json_decode($payload_bytes, true); if (!is_array($decoded)) { return new WP_Error('thedotdev_agentic_bridge_invalid_payload', __('Snapshot push refused: payload is not valid JSON.', 'agentic-bridge')); } $canonical = wp_json_encode($decoded, JSON_UNESCAPED_SLASHES); if (!is_string($canonical) || !hash_equals($canonical, $payload_bytes)) { return new WP_Error('thedotdev_agentic_bridge_invalid_payload', __('Snapshot push refused: payload is not canonical JSON.', 'agentic-bridge')); } if (!TheDotDevAgenticBridge_Storage::is_connected()) { return new WP_Error('thedotdev_agentic_bridge_not_connected', __('Site is not connected', 'agentic-bridge')); } $settings = TheDotDevAgenticBridge_Storage::get(); $stored_api_base_url = (string) ($settings['api_base_url'] ?? ''); $stored_site_id = (string) ($settings['site_id'] ?? ''); if ($stored_api_base_url === '' || $stored_site_id === '') { return new WP_Error('thedotdev_agentic_bridge_not_connected', __('Site is not connected', 'agentic-bridge')); } $current_destination = self::normalize_destination($stored_api_base_url); if (is_wp_error($current_destination)) { return $current_destination; } $bound_destination = $bound['destination'] ?? null; if (!is_array($bound_destination)) { return new WP_Error('thedotdev_agentic_bridge_invalid_bound_destination', __('Snapshot push refused: bound destination is invalid.', 'agentic-bridge')); } $bound_origin = (string) ($bound_destination['origin'] ?? ''); $bound_base_url = (string) ($bound_destination['base_url'] ?? ''); if ($bound_origin === '' || $bound_base_url === '') { return new WP_Error('thedotdev_agentic_bridge_invalid_bound_destination', __('Snapshot push refused: bound destination is invalid.', 'agentic-bridge')); } $normalized_bound_destination = self::normalize_destination($bound_base_url); if (is_wp_error($normalized_bound_destination)) { return new WP_Error('thedotdev_agentic_bridge_invalid_bound_destination', __('Snapshot push refused: bound destination is invalid.', 'agentic-bridge')); } if ( $normalized_bound_destination['origin'] !== $bound_origin || $normalized_bound_destination['base_url'] !== $bound_base_url ) { return new WP_Error('thedotdev_agentic_bridge_invalid_bound_destination', __('Snapshot push refused: bound destination is not normalized.', 'agentic-bridge')); } if ( $current_destination['origin'] !== $normalized_bound_destination['origin'] || $current_destination['base_url'] !== $normalized_bound_destination['base_url'] ) { return new WP_Error('thedotdev_agentic_bridge_bound_destination_mismatch', __('Snapshot push refused: bound destination does not match current connection.', 'agentic-bridge')); } $bound_site_id = (string) ($bound['site_id'] ?? ''); if ($bound_site_id === '' || !hash_equals($bound_site_id, $stored_site_id)) { return new WP_Error('thedotdev_agentic_bridge_bound_site_mismatch', __('Snapshot push refused: bound site ID does not match current connection.', 'agentic-bridge')); } $expected_endpoint = self::snapshot_endpoint($current_destination, $stored_site_id); $expected_path = TheDotDevAgenticBridge_Outbound_Preview::snapshot_signing_path($stored_site_id); $endpoint = (string) ($bound['endpoint'] ?? ''); $path = (string) ($bound['path'] ?? ''); if ($endpoint === '' || $path === '' || !hash_equals($expected_endpoint, $endpoint) || !hash_equals($expected_path, $path)) { return new WP_Error('thedotdev_agentic_bridge_invalid_bound_destination', __('Snapshot push refused: bound endpoint or path is invalid.', 'agentic-bridge')); } $headers = self::signed_headers_for_site($stored_site_id, 'POST', $path, $payload_bytes); if (is_wp_error($headers)) { return $headers; } $response = wp_remote_post($endpoint, [ 'timeout' => 30, 'headers' => $headers, 'body' => $payload_bytes, ]); if (is_wp_error($response)) { TheDotDevAgenticBridge_Storage::update(['last_error' => $response->get_error_message()]); return $response; } $code = (int) wp_remote_retrieve_response_code($response); $body = json_decode((string) wp_remote_retrieve_body($response), true); if ($code !== 200 && $code !== 201) { TheDotDevAgenticBridge_Storage::update(['last_error' => 'Snapshot push failed with status ' . $code]); return new WP_Error('thedotdev_agentic_bridge_snapshot_failed', __('Snapshot push failed', 'agentic-bridge'), ['status' => $code]); } TheDotDevAgenticBridge_Storage::update([ 'last_snapshot_at' => gmdate('c'), 'last_sync_at' => gmdate('c'), 'last_error' => '', ]); return is_array($body) ? $body : []; } /** @return array|WP_Error */ public static function fetch_findings() { if (!TheDotDevAgenticBridge_Storage::is_connected()) { return new WP_Error('thedotdev_agentic_bridge_not_connected', __('Site is not connected', 'agentic-bridge')); } $settings = TheDotDevAgenticBridge_Storage::get(); $api_base_url = (string) ($settings['api_base_url'] ?? ''); $site_id = (string) ($settings['site_id'] ?? ''); $destination = self::normalize_destination($api_base_url); if (is_wp_error($destination)) { return $destination; } $endpoint = self::report_endpoint($destination, $site_id); $path = '/v1/sites/' . rawurlencode($site_id) . '/report'; $headers = self::signed_headers('GET', $path, ''); if (is_wp_error($headers)) { return $headers; } $response = wp_remote_get($endpoint, [ 'timeout' => 20, 'headers' => $headers, ]); if (is_wp_error($response)) { return $response; } $code = (int) wp_remote_retrieve_response_code($response); $body = json_decode((string) wp_remote_retrieve_body($response), true); if ($code !== 200 || !is_array($body)) { return new WP_Error('thedotdev_agentic_bridge_findings_failed', __('Failed to fetch findings', 'agentic-bridge'), ['status' => $code]); } return $body; } /** * Refresh cached package entitlements from the control plane (Free when unavailable). * * @return array|WP_Error */ public static function refresh_entitlements() { if (!TheDotDevAgenticBridge_Storage::is_connected()) { TheDotDevAgenticBridge_Entitlements::clear_snapshot(); return new WP_Error('thedotdev_agentic_bridge_not_connected', __('Site is not connected', 'agentic-bridge')); } $settings = TheDotDevAgenticBridge_Storage::get(); $api_base_url = (string) ($settings['api_base_url'] ?? ''); $site_id = (string) ($settings['site_id'] ?? ''); $destination = self::normalize_destination($api_base_url); if (is_wp_error($destination)) { return $destination; } $endpoint = self::entitlements_endpoint($destination, $site_id); $path = '/v1/sites/' . rawurlencode($site_id) . '/entitlements'; $headers = self::signed_headers('GET', $path, ''); if (is_wp_error($headers)) { return $headers; } $response = wp_remote_get($endpoint, [ 'timeout' => 20, 'headers' => $headers, ]); if (is_wp_error($response)) { return $response; } $code = (int) wp_remote_retrieve_response_code($response); $body = json_decode((string) wp_remote_retrieve_body($response), true); if ($code !== 200 || !is_array($body)) { return new WP_Error('thedotdev_agentic_bridge_entitlements_failed', __('Failed to fetch package entitlements', 'agentic-bridge'), ['status' => $code]); } TheDotDevAgenticBridge_Entitlements::store_snapshot($body); return $body; } /** * @param array $extra_headers * @return array|WP_Error */ private static function signed_headers( string $method, string $path, string $payload, array $extra_headers = [], ): array|WP_Error { $settings = TheDotDevAgenticBridge_Storage::get(); $site_id = (string) ($settings['site_id'] ?? ''); $hmac_key = (string) ($settings['site_hmac_key'] ?? ''); if ($site_id === '' || $hmac_key === '') { return new WP_Error('thedotdev_agentic_bridge_not_connected', __('Site is not connected', 'agentic-bridge')); } $timestamp = gmdate('c'); $nonce = TheDotDevAgenticBridge_Signer::nonce(); $signature = TheDotDevAgenticBridge_Signer::sign($hmac_key, $site_id, $method, $path, $timestamp, $nonce, $payload); $headers = [ 'Content-Type' => 'application/json', 'x-site-id' => $site_id, 'x-timestamp' => $timestamp, 'x-nonce' => $nonce, 'x-payload-hash' => TheDotDevAgenticBridge_Signer::payload_hash($payload), 'x-signature' => $signature, ]; if (strtoupper($method) !== 'GET') { $headers['idempotency-key'] = TheDotDevAgenticBridge_Signer::idempotency_key(); } return array_merge($headers, $extra_headers); } /** * @return array|WP_Error */ private static function signed_headers_for_site(string $site_id, string $method, string $path, string $payload): array|WP_Error { $settings = TheDotDevAgenticBridge_Storage::get(); $hmac_key = (string) ($settings['site_hmac_key'] ?? ''); if ($site_id === '' || $hmac_key === '') { return new WP_Error('thedotdev_agentic_bridge_not_connected', __('Site is not connected', 'agentic-bridge')); } $timestamp = gmdate('c'); $nonce = TheDotDevAgenticBridge_Signer::nonce(); $signature = TheDotDevAgenticBridge_Signer::sign($hmac_key, $site_id, $method, $path, $timestamp, $nonce, $payload); $headers = [ 'Content-Type' => 'application/json', 'x-site-id' => $site_id, 'x-timestamp' => $timestamp, 'x-nonce' => $nonce, 'x-payload-hash' => TheDotDevAgenticBridge_Signer::payload_hash($payload), 'x-signature' => $signature, ]; if (strtoupper($method) !== 'GET') { $headers['idempotency-key'] = TheDotDevAgenticBridge_Signer::idempotency_key(); } return $headers; } }