$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 request_question_preview(string $question, int $id, string $reviewed, string $operation, bool $consent): array|WP_Error { require_once __DIR__ . '/class-question-provider.php'; $envelope = TheDotDevAgenticBridge_Question_Provider::prepare($question, $id, $reviewed, $operation, $consent); if (is_wp_error($envelope)) return $envelope; if (TheDotDevAgenticBridge_Storage::kill_switch_enabled() || !TheDotDevAgenticBridge_Storage::is_connected()) return new WP_Error('question_disconnected', __('Connect this site and resume the connection before testing.', 'agentic-bridge')); $settings = TheDotDevAgenticBridge_Storage::get(); $destination = self::normalize_destination((string) ($settings['api_base_url'] ?? '')); if (is_wp_error($destination)) return $destination; $path = '/v1/sites/' . rawurlencode((string) $settings['site_id']) . '/scenario-preview/consented-runs'; $payload = wp_json_encode($envelope, JSON_UNESCAPED_SLASHES); if ($payload === false || strlen($payload) > 24000) return new WP_Error('question_size', __('The request exceeds the safe size limit.', 'agentic-bridge')); $headers = self::signed_headers('POST', $path, $payload, ['idempotency-key'=>$operation]); if (is_wp_error($headers)) return $headers; $response = wp_remote_post($destination['base_url'] . $path, ['timeout'=>40, 'redirection'=>0, 'limit_response_size'=>131072, 'headers'=>$headers, 'body'=>$payload]); if (is_wp_error($response)) return new WP_Error('question_uncertain', __('No response received. Retry this same request only; usage may already have been counted.', 'agentic-bridge')); $body = json_decode((string) wp_remote_retrieve_body($response), true); if (wp_remote_retrieve_response_code($response) !== 200 || !is_array($body) || ($body['schemaVersion'] ?? null) !== '1.0' || ($body['question'] ?? null) !== $question || ($body['operationKey'] ?? null) !== $operation || ($body['mode'] ?? null) !== 'provider_context' || !is_bool($body['usageCounted'] ?? null) || !in_array($body['status'] ?? null, ['completed','idempotent_replay','quota_blocked','unavailable','timeout','refusal','no_citation','invalid_response','insufficient_evidence','invalid_selection','uncertain_dispatch'], true) || !array_key_exists('answerText', $body) || ($body['answerText'] !== null && (!is_string($body['answerText']) || mb_strlen($body['answerText']) > 8000))) { return new WP_Error('question_response', __('The response could not be verified. Do not start another paid request until its status is checked.', 'agentic-bridge')); } $fresh = TheDotDevAgenticBridge_Question_Provider::prepare($question, $id, $reviewed, $operation, $consent); $current_settings = TheDotDevAgenticBridge_Storage::get(); if (is_wp_error($fresh) || $fresh !== $envelope || TheDotDevAgenticBridge_Storage::kill_switch_enabled() || !TheDotDevAgenticBridge_Storage::is_connected() || ($current_settings['site_id'] ?? null) !== ($settings['site_id'] ?? null) || ($current_settings['api_base_url'] ?? null) !== ($settings['api_base_url'] ?? null) || ($current_settings['site_hmac_key'] ?? null) !== ($settings['site_hmac_key'] ?? null)) { return new WP_Error('question_changed', __('The source or consent settings changed while waiting. The answer is withheld; usage may have been counted.', 'agentic-bridge')); } $assessment = null; if (in_array($body['status'], ['completed','idempotent_replay'], true) && $body['answerText'] !== null) { $decoded = json_decode($body['answerText'], true); if (!is_array($decoded) || !is_array($decoded['evidence'] ?? null) || !is_array($decoded['ownerQuestions'] ?? null) || count($decoded['ownerQuestions']) > 5) return new WP_Error('question_assessment', __('The structured assessment could not be verified.', 'agentic-bridge')); foreach ($decoded['ownerQuestions'] as $owner_question) { if (!is_string($owner_question) || trim($owner_question) === '' || mb_strlen($owner_question) > 300) return new WP_Error('question_assessment', __('The owner questions could not be verified.', 'agentic-bridge')); } require_once __DIR__ . '/class-question-assessment.php'; $sources = [['id'=>$id, 'text'=>$envelope['facts'][0]['value']]]; $quotes = []; foreach ($decoded['evidence'] as $evidence) { if (!is_array($evidence) || ($evidence['sourceIndex'] ?? null) !== 0) return new WP_Error('question_assessment', __('The evidence source could not be verified.', 'agentic-bridge')); $quotes[] = ['sourceId'=>$id, 'quote'=>$evidence['quote'] ?? null]; } $validated = TheDotDevAgenticBridge_Question_Assessment::validate([ 'fingerprint'=>TheDotDevAgenticBridge_Question_Assessment::fingerprint($question, $sources), 'status'=>$decoded['status'] ?? null, 'explanation'=>$decoded['explanation'] ?? null, 'evidence'=>$quotes, ], $question, $sources); if (!$validated['ok'] || (($decoded['status'] ?? '') === 'supported' && $decoded['ownerQuestions'] !== [])) return new WP_Error('question_assessment', __('The assessment evidence could not be verified.', 'agentic-bridge')); $assessment = $validated['assessment'] + ['ownerQuestions'=>$decoded['ownerQuestions']]; } return ['status'=>$body['status'], 'answerText'=>null, 'assessment'=>$assessment, 'usageCounted'=>$body['usageCounted']]; } 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; } }