PluginProbe ʕ •ᴥ•ʔ
Microsoft Clarity / trunk
Microsoft Clarity vtrunk
0.10.28 0.10.27 0.10.26 0.10.25 0.10.24 0.8.0 0.9.0 0.9.1 0.9.2 0.9.3 0.9.4 trunk 0.10.0 0.10.1 0.10.10 0.10.11 0.10.12 0.10.13 0.10.14 0.10.15 0.10.16 0.10.17 0.10.18 0.10.19 0.10.2 0.10.20 0.10.21 0.10.22 0.10.23 0.10.3 0.10.4 0.10.5 0.10.6 0.10.7 0.10.8 0.10.9 0.2 0.4 0.5 0.6 0.6.1 0.7 0.7.1 0.7.2 0.7.3 0.7.4 0.7.5
microsoft-clarity / includes / brandagent-wordpress.php
microsoft-clarity / includes Last commit date
webhooks 5 months ago brandagent-config.php 5 days ago brandagent-content-webhooks.php 5 days ago brandagent-custom-webhooks.php 3 months ago brandagent-endpoint.php 5 days ago brandagent-rest-api.php 3 months ago brandagent-webhooks.php 3 months ago brandagent-wordpress.php 5 days ago
brandagent-wordpress.php
583 lines
1 <?php
2 /**
3 * Brand Agent — plain WordPress (non-commerce) connect.
4 *
5 * Provisions the per-store HMAC secret for a plain WordPress site without the
6 * WooCommerce wc-auth grant. Because the plugin runs in wp-admin under a
7 * privileged user, the connect handshake is a direct server-to-server call to the
8 * Clarity dashboard, which mints the secret, registers the advertiser with the
9 * BrandAgent backend (Platform=WordPress) and returns the secret to the plugin.
10 *
11 * Store ownership is proven with a one-time nonce loopback: connect stores a nonce
12 * here (admin-privileged) and sends it to the dashboard, which calls back to
13 * connect-verify below before minting. A forged connect naming another site cannot
14 * pass because only that site's plugin holds the matching nonce.
15 *
16 * @package MicrosoftClarity
17 */
18
19 defined( 'ABSPATH' ) || exit;
20
21 /**
22 * Run the plain-WordPress connect handshake.
23 *
24 * @return array Result with at least a boolean 'success' key.
25 */
26 function brandagent_wordpress_connect() {
27 $connect_lock = brandagent_wordpress_acquire_connect_lock();
28 if ( false === $connect_lock ) {
29 return array(
30 'success' => false,
31 'error' => 'WordPress connect is already in progress.',
32 'error_code' => 'connect_in_progress',
33 );
34 }
35
36 try {
37 return brandagent_wordpress_connect_locked();
38 } finally {
39 brandagent_wordpress_release_connect_lock( $connect_lock );
40 }
41 }
42
43 /**
44 * Acquire the per-site connect lock, recovering it after a crashed/timed-out request.
45 *
46 * WordPress add_option() uses ON DUPLICATE KEY UPDATE, so it cannot provide insert-if-absent lock
47 * semantics. Use an INSERT IGNORE against the option-name unique key instead: two successful
48 * connects mint different random secrets, and allowing them to overlap can make the plugin retain
49 * one response after Brand Agent has already stored the other.
50 *
51 * @return string|false The owned lock value, or false when another request holds it.
52 */
53 function brandagent_wordpress_acquire_connect_lock() {
54 $lock_key = 'brandagent_wp_connect_lock';
55 $now = time();
56 $owner = $now . ':' . wp_generate_password( 32, false );
57
58 if ( brandagent_wordpress_try_insert_connect_lock( $lock_key, $owner ) ) {
59 return $owner;
60 }
61
62 $existing = (string) get_option( $lock_key, '' );
63 $lock_time = (int) strtok( $existing, ':' );
64 if ( $lock_time > 0 && ( $now - $lock_time ) < 2 * MINUTE_IN_SECONDS ) {
65 return false;
66 }
67
68 // Delete only the exact stale value we observed. A plain delete_option() can erase a fresh lock
69 // installed by another request between our read and delete, allowing two owners at once.
70 global $wpdb;
71 $deleted = $wpdb->query( $wpdb->prepare(
72 "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
73 $lock_key,
74 $existing
75 ) );
76 if ( 1 !== $deleted ) {
77 return false;
78 }
79
80 brandagent_wordpress_clear_connect_lock_cache( $lock_key );
81 return brandagent_wordpress_try_insert_connect_lock( $lock_key, $owner ) ? $owner : false;
82 }
83
84 /**
85 * Atomically insert a connect lock without replacing an existing owner.
86 *
87 * @param string $lock_key Option name used for the lock.
88 * @param string $owner Unique owned lock value.
89 * @return bool Whether this request inserted the lock.
90 */
91 function brandagent_wordpress_try_insert_connect_lock( $lock_key, $owner ) {
92 global $wpdb;
93 $inserted = $wpdb->query( $wpdb->prepare(
94 "INSERT IGNORE INTO `{$wpdb->options}` (`option_name`, `option_value`, `autoload`) VALUES (%s, %s, %s)",
95 $lock_key,
96 $owner,
97 'no'
98 ) );
99 if ( 1 !== $inserted ) {
100 return false;
101 }
102
103 brandagent_wordpress_clear_connect_lock_cache( $lock_key );
104 return true;
105 }
106
107 /**
108 * Invalidate option caches after changing the lock directly in the database.
109 *
110 * @param string $lock_key Option name used for the lock.
111 * @return void
112 */
113 function brandagent_wordpress_clear_connect_lock_cache( $lock_key ) {
114 wp_cache_delete( $lock_key, 'options' );
115 wp_cache_delete( 'notoptions', 'options' );
116 }
117
118 /**
119 * Release the connect lock only if this request still owns it.
120 *
121 * @param string $owner Owned lock value returned by brandagent_wordpress_acquire_connect_lock().
122 * @return void
123 */
124 function brandagent_wordpress_release_connect_lock( $owner ) {
125 global $wpdb;
126 $lock_key = 'brandagent_wp_connect_lock';
127 $deleted = $wpdb->query( $wpdb->prepare(
128 "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
129 $lock_key,
130 (string) $owner
131 ) );
132 if ( 1 === $deleted ) {
133 brandagent_wordpress_clear_connect_lock_cache( $lock_key );
134 }
135 }
136
137 /**
138 * Execute Connect while the caller owns brandagent_wp_connect_lock.
139 *
140 * @return array Result with at least a boolean 'success' key.
141 */
142 function brandagent_wordpress_connect_locked() {
143 // WooCommerce stores must onboard through the wc-auth flow. The plugin is the only component
144 // that knows this reliably at request time: the dashboard decides eligibility from the
145 // hasWooCommerce flag recorded on the integration, which goes stale when WooCommerce is
146 // activated after Clarity. Without this guard a stale-eligibility connect would overwrite the
147 // shared brandagent_secret_key_{store} option with a WordPress-scoped secret while the backend
148 // still holds woocommerce-{store}-hmac-secret, silently 401ing every WooCommerce Brand Agent
149 // call. Backstop for every entry point, including the admin-only REST route below.
150 //
151 // Uses the activation state rather than class_exists(): a store whose WooCommerce is active but
152 // did not load this request (its own PHP-version guard bailing, a missing plugin file) is still
153 // a WooCommerce store, and class_exists() would wave it through. Same helper clarity.php uses to
154 // pick the lifecycle endpoint, so the plugin cannot answer "is this a store" two different ways.
155 if ( clarity_is_woocommerce_active_for_current_blog() ) {
156 brandagent_log( 'BrandAgent WordPress Connect: refused, WooCommerce is active on this site' );
157 return array( 'success' => false, 'error' => 'WooCommerce site must use the WooCommerce connect flow.' );
158 }
159
160 // A credential minted by the WooCommerce flow remains WooCommerce-owned even if that plugin is
161 // later deactivated. Reusing the option for a WordPress secret would strand the backend's Woo
162 // credentials, webhooks and indexes under a record the plugin now treats as plain WordPress.
163 if ( 'woocommerce' === brandagent_get_hmac_platform() ) {
164 brandagent_log( 'BrandAgent WordPress Connect: refused, stored credential belongs to WooCommerce' );
165 brandagent_wordpress_clear_connect_retry_state();
166 return array(
167 'success' => false,
168 'error' => 'Store is registered as WooCommerce and must be offboarded before connecting as WordPress.',
169 'error_code' => 'platform_mismatch',
170 );
171 }
172
173 $store_url = home_url();
174 $project_id = get_option( 'clarity_project_id', '' );
175 $wp_site_id = get_option( 'clarity_wordpress_site_id', '' );
176
177 $clarity_server_url = BrandAgent_Config::get_clarity_server_url();
178 if ( empty( $clarity_server_url ) ) {
179 return array( 'success' => false, 'error' => 'clarity_server_url not configured' );
180 }
181
182 $connect_url = trailingslashit( $clarity_server_url ) . 'wordpress/connect';
183
184 // Store-ownership proof: mint a one-time nonce that the dashboard verifies by calling
185 // back to connect-verify before it issues the HMAC secret. Persist only the hash, so a
186 // read of wp_options/object cache never exposes a usable nonce. Short-lived + one-time.
187 $connect_nonce = wp_generate_password( 64, false );
188 $nonce_digest = hash( 'sha256', $connect_nonce );
189 $attempt_id = substr( $nonce_digest, 0, 16 );
190 set_transient( brandagent_wordpress_connect_nonce_key( $connect_nonce ), $nonce_digest, 10 * MINUTE_IN_SECONDS );
191
192 $body = wp_json_encode( array(
193 'storeUrl' => $store_url,
194 'clarityProjectId' => $project_id,
195 'wordpressSiteId' => $wp_site_id,
196 'connectNonce' => $connect_nonce,
197 ) );
198
199 brandagent_log( 'BrandAgent WordPress Connect: starting', array( 'store_url' => $store_url, 'endpoint' => $connect_url, 'attempt_id' => $attempt_id ) );
200
201 $response = wp_remote_post( $connect_url, array(
202 'timeout' => 30,
203 'headers' => array( 'Content-Type' => 'application/json' ),
204 'body' => $body,
205 ) );
206
207 if ( is_wp_error( $response ) ) {
208 brandagent_log( 'BrandAgent WordPress Connect: transport error', array( 'error' => $response->get_error_message(), 'attempt_id' => $attempt_id ) );
209 return array( 'success' => false, 'error' => $response->get_error_message() );
210 }
211
212 $code = wp_remote_retrieve_response_code( $response );
213 $data = json_decode( wp_remote_retrieve_body( $response ), true );
214 $data = is_array( $data ) ? $data : array();
215
216 if ( $code !== 200 || empty( $data['hmac_secret'] ) ) {
217 $error_code = brandagent_wordpress_connect_error_code( $data );
218 brandagent_log( 'BrandAgent WordPress Connect: unexpected response', array( 'status' => $code, 'error_code' => $error_code, 'attempt_id' => $attempt_id ) );
219
220 // A platform conflict is durable until an explicit WooCommerce offboard/migration runs. Do not
221 // let admin_init retry it on every admin visit and create an avoidable request storm.
222 if ( 409 === $code && 'platform_mismatch' === $error_code ) {
223 brandagent_wordpress_clear_connect_retry_state();
224 }
225
226 return array(
227 'success' => false,
228 'error' => 'connect failed (status ' . $code . ')',
229 'error_code' => $error_code,
230 );
231 }
232
233 brandagent_store_hmac_secret( $data['hmac_secret'], 'wordpress' );
234 update_option( 'BAOauthSuccess', true );
235 brandagent_wordpress_clear_connect_retry_state();
236 brandagent_log( 'BrandAgent WordPress Connect: success', array( 'store_url' => $store_url, 'attempt_id' => $attempt_id ) );
237
238 return array(
239 'success' => true,
240 'advertiserId' => isset( $data['advertiserId'] ) ? $data['advertiserId'] : null,
241 );
242 }
243
244 /**
245 * Read a safe machine-readable error code from a Dashboard Connect response.
246 *
247 * @param mixed $data Decoded JSON response body.
248 * @return string Sanitized scalar error code, or an empty string.
249 */
250 function brandagent_wordpress_connect_error_code( $data ) {
251 if ( ! is_array( $data ) || ! isset( $data['error'] ) || ! is_scalar( $data['error'] ) ) {
252 return '';
253 }
254
255 return sanitize_key( (string) $data['error'] );
256 }
257
258 /**
259 * Build the transient key for one ownership challenge without storing the usable nonce.
260 *
261 * Each in-flight connect gets its own key. A single shared transient lets overlapping manual and
262 * admin-resume attempts overwrite one another, making the otherwise valid callback fail with 403.
263 *
264 * @param string $nonce Raw one-time ownership nonce.
265 * @return string Fixed-length transient key derived from the nonce.
266 */
267 function brandagent_wordpress_connect_nonce_key( $nonce ) {
268 return 'brandagent_connect_nonce_' . hash( 'sha256', (string) $nonce );
269 }
270
271 /**
272 * Clear resumable-connect bookkeeping after success or a durable platform conflict.
273 *
274 * @return void
275 */
276 function brandagent_wordpress_clear_connect_retry_state() {
277 delete_option( 'brandagent_wp_connect_optin' );
278 delete_option( 'brandagent_wp_connect_attempts' );
279 delete_transient( 'brandagent_wp_connect_throttle' );
280 }
281
282 /**
283 * REST route to trigger the plain-WordPress connect from the admin UI (admin-only).
284 * POST /wp-json/adsagent/v1/wordpress/connect
285 */
286 add_action( 'rest_api_init', function () {
287 register_rest_route( 'adsagent/v1', '/wordpress/connect', array(
288 'methods' => 'POST',
289 'permission_callback' => function () {
290 return current_user_can( 'manage_options' );
291 },
292 'callback' => function () {
293 $result = brandagent_wordpress_connect();
294 return new WP_REST_Response( $result, ! empty( $result['success'] ) ? 200 : 502 );
295 },
296 ) );
297 } );
298
299 /**
300 * Store-ownership challenge for the plain-WordPress connect. The Clarity dashboard calls
301 * this back with the nonce from the connect request; a match proves the connect was
302 * initiated by this site's admin-privileged plugin, not forged elsewhere for this URL.
303 * Public route by design — at connect time no shared secret exists yet, so the one-time
304 * nonce (64 chars, 10-min TTL, consumed on match) is the proof.
305 * POST /?rest_route=/adsagent/v1/wordpress/connect-verify
306 */
307 add_action( 'rest_api_init', function () {
308 register_rest_route( 'adsagent/v1', '/wordpress/connect-verify', array(
309 'methods' => 'POST',
310 'permission_callback' => '__return_true',
311 'callback' => function ( WP_REST_Request $request ) {
312 $received = (string) $request->get_param( 'connectNonce' );
313 $key = brandagent_wordpress_connect_nonce_key( $received );
314 $stored = get_transient( $key );
315
316 if ( ! empty( $received ) && ! empty( $stored ) && hash_equals( (string) $stored, hash( 'sha256', $received ) ) ) {
317 delete_transient( $key );
318 return new WP_REST_Response( array( 'verified' => true ), 200 );
319 }
320
321 return new WP_REST_Response( array( 'verified' => false ), 401 );
322 },
323 ) );
324 } );
325
326 /**
327 * Whether the plugin already holds a usable Brand Agent connection.
328 *
329 * @return bool
330 */
331 function brandagent_wordpress_has_connection() {
332 return get_option( 'BAOauthSuccess' ) == 1 && (bool) brandagent_get_hmac_secret();
333 }
334
335 /**
336 * Sign and send an outbound Brand Agent request using the plain-WordPress (X-WordPress-*) scheme.
337 *
338 * Deliberately separate from brandagent_sign_outbound_request(), which speaks the WooCommerce
339 * scheme: that one signs `clientId + timestamp` and nothing else, so one captured header set is
340 * replayable against any route. WordPress signs a full canonical request, binding the signature
341 * to a single method, path, body and one-time nonce. The backend twin is
342 * WordPressAuthUtils::BuildInboundCanonicalRequest and the two strings must stay byte-identical —
343 * any divergence surfaces only as a 401, never as a useful error.
344 *
345 * $backend_path is the path the BRAND AGENT SERVER sees, which is not the URL we post to: these
346 * calls travel through the Clarity dashboard proxy, which forwards our headers untouched. Signing
347 * the proxy path would verify against the wrong string at the backend.
348 *
349 * @param string $proxy_url Absolute URL to send to (the Clarity dashboard proxy route).
350 * @param string $backend_path Path + query as the BA server sees it, e.g. '/api/wordpress/uninstall'.
351 * @param string $body Raw request body, or '' when there is none.
352 * @param string $method HTTP method. Default 'POST'.
353 * @param int $timeout Timeout in seconds.
354 * @return array|WP_Error wp_remote_* response, or WP_Error when no secret is available.
355 */
356 function brandagent_wordpress_sign_outbound_request( $proxy_url, $backend_path, $body = '', $method = 'POST', $timeout = 30 ) {
357 $headers = brandagent_wordpress_build_signed_headers( $backend_path, $body, $method );
358 if ( is_wp_error( $headers ) ) {
359 return $headers;
360 }
361
362 $args = array(
363 'timeout' => $timeout,
364 'headers' => array_merge( array( 'Content-Type' => 'application/json' ), $headers ),
365 );
366
367 if ( strtoupper( $method ) === 'GET' ) {
368 return wp_remote_get( $proxy_url, $args );
369 }
370
371 $args['body'] = $body;
372
373 return wp_remote_post( $proxy_url, $args );
374 }
375
376 /**
377 * Build the X-WordPress-* signed headers for one outbound Brand Agent request.
378 *
379 * Split out of brandagent_wordpress_sign_outbound_request() so callers that must drive the HTTP
380 * call themselves — the SSE init proxy sets its own streaming headers and reads the response as a
381 * stream — still sign through the single implementation of the canonical string. Duplicating that
382 * string is the one thing to avoid here: the backend twin is
383 * WordPressAuthUtils::BuildInboundCanonicalRequest and the two must stay byte-identical, so a
384 * second copy that drifts would surface only as a 401 with no useful error.
385 *
386 * $backend_path must be path + query exactly as the BRAND AGENT SERVER receives it, because the
387 * handler signs Request.Path + Request.QueryString verbatim. Callers that append a query string to
388 * the outbound URL must build it once and pass the same string here.
389 *
390 * @param string $backend_path Path + query as the BA server sees it, e.g. '/api/v1/init?clientId=abc'.
391 * @param string $body Raw request body, or '' when there is none.
392 * @param string $method HTTP method. Default 'POST'.
393 * @return array|WP_Error Header map, or WP_Error when no secret is available.
394 */
395 function brandagent_wordpress_build_signed_headers( $backend_path, $body = '', $method = 'POST' ) {
396 $secret_key = brandagent_get_hmac_secret();
397 if ( empty( $secret_key ) ) {
398 return new WP_Error( 'hmac_missing', 'HMAC secret key not found' );
399 }
400
401 $site_url = home_url();
402 $normalized_site_url = brandagent_normalize_store_url( $site_url );
403 $timestamp = (string) time();
404 $nonce = wp_generate_password( 32, false );
405
406 // The dashboard signs with a fixed client id; a merchant's identity is the site itself, so the
407 // last two canonical fields collapse to the same value here. Both are still sent because the
408 // backend reads them from different headers.
409 $client_id = $normalized_site_url;
410
411 // Field order is part of the contract. See WordPressAuthUtils::BuildInboundCanonicalRequest.
412 $canonical_request = implode( "\n", array(
413 strtoupper( $method ),
414 $backend_path,
415 $timestamp,
416 $nonce,
417 hash( 'sha256', $body ),
418 $normalized_site_url,
419 $client_id,
420 ) );
421
422 return array(
423 'X-WordPress-Client-Id' => $client_id,
424 'X-WordPress-Site-Url' => $site_url,
425 'X-WordPress-Timestamp' => $timestamp,
426 'X-WordPress-Nonce' => $nonce,
427 'X-WordPress-Signature' => base64_encode( hash_hmac( 'sha256', $canonical_request, $secret_key, true ) ),
428 );
429 }
430
431 /**
432 * Tell the backend to tear down this plain-WordPress site's Brand Agent data.
433 *
434 * The WooCommerce twin lives in handle_brandagent_uninstall(); a WooCommerce store must keep using
435 * it, because the backend's WooCommerce uninstall also unwinds credentials and webhooks that a
436 * plain site never had. The two paths are not interchangeable: the per-site secret is filed under
437 * a WordPress-specific Key Vault name, so a WordPress site calling the WooCommerce endpoint fails
438 * signature verification and the merchant's data is silently left behind.
439 *
440 * @return void
441 */
442 function brandagent_wordpress_notify_uninstall() {
443 $clarity_server_url = BrandAgent_Config::get_clarity_server_url();
444 if ( empty( $clarity_server_url ) ) {
445 brandagent_log( 'BrandAgent WordPress Uninstall: clarity_server_url not configured; skipping backend call' );
446 return;
447 }
448
449 $backend_path = '/api/wordpress/uninstall';
450 $uninstall_url = trailingslashit( $clarity_server_url ) . 'wordpress/uninstall';
451 $site_url = home_url();
452
453 brandagent_log( 'BrandAgent WordPress Uninstall: calling backend', array( 'site_url' => $site_url, 'endpoint' => $uninstall_url ) );
454
455 // Empty body on purpose. The signature covers sha256(body), and the dashboard proxy re-serializes
456 // anything it parses — PHP escapes forward slashes in JSON and JSON.stringify does not, so a body
457 // carrying the site URL would arrive with a different hash and fail verification. The backend
458 // reads the site from the signed X-WordPress-Site-Url header, which survives the hop intact.
459 //
460 // Short timeout: this runs inside the uninstall hook while the admin waits on the delete, and the
461 // local teardown must happen whether or not the backend answers.
462 $response = brandagent_wordpress_sign_outbound_request( $uninstall_url, $backend_path, '', 'POST', 15 );
463
464 if ( is_wp_error( $response ) ) {
465 brandagent_log( 'BrandAgent WordPress Uninstall: backend call failed', array( 'error' => $response->get_error_message() ) );
466 return;
467 }
468
469 brandagent_log( 'BrandAgent WordPress Uninstall: backend returned', array( 'status_code' => wp_remote_retrieve_response_code( $response ) ) );
470 }
471
472 /**
473 * admin-ajax entry point that lets the Clarity dashboard (embedded in the wp-admin
474 * iframe) trigger the plain-WordPress connect from the Brand Agent setup choice.
475 *
476 * The dashboard posts a WORDPRESS_CONNECT message to wp-admin; js/add_window_listeners.js
477 * forwards it here with the same admin nonce the project-id handler uses. We re-verify
478 * that nonce and the admin capability before running the server-to-server connect, then
479 * return JSON the listener relays back to the iframe as WORDPRESS_CONNECT_SUCCESS/FAILURE.
480 *
481 * Clicking Continue is the opt-in: we record it so a disconnected site can finish the
482 * connect server-side on later admin loads (see brandagent_wordpress_maybe_resume_connect),
483 * mirroring the pilot auto-connect resilience but without ever connecting a site that never
484 * opted in from the setup choice.
485 */
486 add_action( 'wp_ajax_brandagent_wordpress_connect', 'brandagent_wordpress_connect_ajax' );
487 function brandagent_wordpress_connect_ajax() {
488 $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
489 if ( ! wp_verify_nonce( $nonce, 'wp_ajax_edit_clarity_project_id' ) ) {
490 wp_send_json( array( 'success' => false, 'error' => 'Invalid nonce.' ) );
491 }
492
493 if ( ! current_user_can( 'manage_options' ) ) {
494 wp_send_json( array( 'success' => false, 'error' => 'User must be a WordPress admin.' ) );
495 }
496
497 // Checked here as well as inside brandagent_wordpress_connect() so a WooCommerce store never
498 // records plain-WordPress connect bookkeeping it would then retry from admin_init.
499 if ( clarity_is_woocommerce_active_for_current_blog() ) {
500 wp_send_json( array( 'success' => false, 'error' => 'WooCommerce site must use the WooCommerce connect flow.' ) );
501 }
502
503 // Resolve credential provenance before writing the opt-in marker. Legacy WooCommerce credentials
504 // predate brandagent_hmac_platform; setting opt-in first would otherwise misclassify them as WordPress.
505 if ( 'woocommerce' === brandagent_get_hmac_platform() ) {
506 brandagent_wordpress_clear_connect_retry_state();
507 wp_send_json( array(
508 'success' => false,
509 'error' => 'Store is registered as WooCommerce and must be offboarded before connecting as WordPress.',
510 'error_code' => 'platform_mismatch',
511 ) );
512 }
513
514 // Record the opt-in and start a fresh attempt budget for the server-side resume fallback.
515 update_option( 'brandagent_wp_connect_optin', 1 );
516 delete_option( 'brandagent_wp_connect_attempts' );
517 delete_transient( 'brandagent_wp_connect_throttle' );
518
519 $result = brandagent_wordpress_connect();
520 wp_send_json( $result );
521 }
522
523 /**
524 * Button-initiated connect resilience — the trunk equivalent of the pilot auto-connect.
525 *
526 * After the admin opts in from the Brand Agent setup choice (Continue), finish the
527 * plain-WordPress connect server-side on later admin page loads if the browser round trip did
528 * not complete it (e.g. the iframe closed before the reply, or the postMessage was dropped).
529 * Unlike the pilot this never runs before the admin opts in, so it is not an automatic connect.
530 * Gated to plain WordPress, throttled to one attempt every few minutes, and attempt-capped so a
531 * persistently failing backend cannot hammer the Clarity server.
532 *
533 * @return void
534 */
535 add_action( 'admin_init', 'brandagent_wordpress_maybe_resume_connect' );
536 function brandagent_wordpress_maybe_resume_connect() {
537 // admin_init also runs during admin-ajax.php. The explicit AJAX handler below owns that request;
538 // starting a hidden resume first creates two connect attempts from one click.
539 if ( wp_doing_ajax() ) {
540 return;
541 }
542
543 // WooCommerce stores use the wc-auth onboarding flow and must never take this path.
544 if ( clarity_is_woocommerce_active_for_current_blog() ) {
545 return;
546 }
547
548 // Only after the admin clicked Continue on the Brand Agent setup choice.
549 if ( ! get_option( 'brandagent_wp_connect_optin' ) ) {
550 return;
551 }
552
553 // Already connected: clear the opt-in bookkeeping and stop.
554 if ( brandagent_wordpress_has_connection() ) {
555 brandagent_wordpress_clear_connect_retry_state();
556 return;
557 }
558
559 // Throttle: at most one server-side attempt per window across admin page loads.
560 if ( get_transient( 'brandagent_wp_connect_throttle' ) ) {
561 return;
562 }
563
564 // Attempt cap: give up (until the next Continue) after a bounded number of tries.
565 $attempts = (int) get_option( 'brandagent_wp_connect_attempts', 0 );
566 if ( $attempts >= 5 ) {
567 brandagent_wordpress_clear_connect_retry_state();
568 return;
569 }
570
571 set_transient( 'brandagent_wp_connect_throttle', 1, 2 * MINUTE_IN_SECONDS );
572
573 brandagent_log( 'BrandAgent WordPress Connect: server-side resume attempt', array( 'attempt' => $attempts + 1 ) );
574 $result = brandagent_wordpress_connect();
575 $error_code = isset( $result['error_code'] ) && is_scalar( $result['error_code'] ) ? (string) $result['error_code'] : '';
576
577 // A request that lost the lock never reached Connect, so it must not spend the bounded retry
578 // budget. Successful and durable-conflict paths clear opt-in state inside Connect.
579 if ( empty( $result['success'] ) && 'connect_in_progress' !== $error_code && get_option( 'brandagent_wp_connect_optin' ) ) {
580 update_option( 'brandagent_wp_connect_attempts', (int) get_option( 'brandagent_wp_connect_attempts', 0 ) + 1 );
581 }
582 }
583