PluginProbe ʕ •ᴥ•ʔ
Microsoft Clarity / 0.10.29
Microsoft Clarity v0.10.29
0.10.29 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 1 week ago brandagent-content-webhooks.php 1 week ago brandagent-custom-webhooks.php 3 months ago brandagent-endpoint.php 1 week ago brandagent-rest-api.php 3 months ago brandagent-webhooks.php 3 months ago brandagent-wordpress.php 2 days ago
brandagent-wordpress.php
657 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 // The dashboard mints the secret and commits it to Key Vault before it answers, so from this
202 // point on the credential this site holds may already be stale — including when the reply never
203 // arrives (30s timeout below) or comes back non-200 after the backend call succeeded. Mark the
204 // connection unconfirmed for the whole round trip and clear it only once a readback proves this
205 // side holds the same secret. brandagent_wordpress_maybe_resume_connect() consults this so a
206 // stale-but-readable credential cannot make the site look connected and cancel its own retries,
207 // which is what turned a recoverable failure into a permanent desync.
208 update_option( 'brandagent_wp_connect_unverified', 1 );
209
210 $response = wp_remote_post( $connect_url, array(
211 'timeout' => 30,
212 'headers' => array( 'Content-Type' => 'application/json' ),
213 'body' => $body,
214 ) );
215
216 if ( is_wp_error( $response ) ) {
217 brandagent_log( 'BrandAgent WordPress Connect: transport error', array( 'error' => $response->get_error_message(), 'attempt_id' => $attempt_id ) );
218 return array( 'success' => false, 'error' => $response->get_error_message() );
219 }
220
221 $code = wp_remote_retrieve_response_code( $response );
222 $data = json_decode( wp_remote_retrieve_body( $response ), true );
223 $data = is_array( $data ) ? $data : array();
224
225 if ( $code !== 200 || empty( $data['hmac_secret'] ) ) {
226 $error_code = brandagent_wordpress_connect_error_code( $data );
227 brandagent_log( 'BrandAgent WordPress Connect: unexpected response', array( 'status' => $code, 'error_code' => $error_code, 'attempt_id' => $attempt_id ) );
228
229 // A platform conflict is durable until an explicit WooCommerce offboard/migration runs. Do not
230 // let admin_init retry it on every admin visit and create an avoidable request storm.
231 if ( 409 === $code && 'platform_mismatch' === $error_code ) {
232 brandagent_wordpress_clear_connect_retry_state();
233 }
234
235 return array(
236 'success' => false,
237 'error' => 'connect failed (status ' . $code . ')',
238 'error_code' => $error_code,
239 );
240 }
241
242 // By the time the server answers it has already committed this secret to Key Vault and advanced
243 // the advertiser metadata, so the plugin is the only party that can still drop it. Storing can
244 // fail outright (an encryption error returns false before the option is written), and a value
245 // that cannot be read back leaves brandagent_get_hmac_secret() returning false while Key Vault
246 // holds a secret this site cannot reproduce. Every signed BA -> plugin call then fails "Invalid
247 // signature": the config update that flips BAInjectFrontendScript never lands, so the widget is
248 // never injected even though the brand reports as shipped.
249 //
250 // Verify by reading the credential back before declaring the connect a success. This proves the
251 // secret is storable and readable *now*; it cannot detect a later salt rotation, which breaks
252 // decryption after the fact and is not recovered by this path. On failure leave the retry state
253 // and the unverified marker intact so the throttled, attempt-capped admin_init fallback tries
254 // again — each retry mints a fresh secret server-side, which converges only if this side
255 // actually persists it.
256 //
257 // is_string, not is_scalar: json_decode turns `"hmac_secret": true` into a bool, which casts to
258 // the string "1" and then stores and reads back as "1", so hash_equals below would compare "1"
259 // against itself and pass. That would declare success while the site signs with "1" and Key
260 // Vault holds 32 random bytes — the exact desync this guard exists to catch.
261 $raw_secret = isset( $data['hmac_secret'] ) ? $data['hmac_secret'] : null;
262 if ( ! is_string( $raw_secret ) ) {
263 brandagent_log( 'BrandAgent WordPress Connect: hmac_secret was not a string', array(
264 'store_url' => $store_url,
265 'attempt_id' => $attempt_id,
266 'type' => gettype( $raw_secret ),
267 ) );
268
269 return array(
270 'success' => false,
271 'error' => 'connect failed (invalid hmac_secret)',
272 'error_code' => 'invalid_hmac_secret',
273 );
274 }
275
276 // A secret that normalizes to empty (e.g. all whitespace) would otherwise store and read back as
277 // "" and compare equal to itself, passing the check below while leaving the site unable to sign.
278 // Short-circuit so it is never written over a credential that still works.
279 $expected_secret = str_replace( array( "\r", "\n", " " ), '', trim( $raw_secret ) );
280 $secret_stored = '' !== $expected_secret && brandagent_store_hmac_secret( $raw_secret, 'wordpress' );
281 $secret_readback = $secret_stored ? brandagent_get_hmac_secret() : false;
282
283 if ( ! $secret_stored || ! is_string( $secret_readback ) || ! hash_equals( $expected_secret, $secret_readback ) ) {
284 brandagent_log( 'BrandAgent WordPress Connect: HMAC secret failed to persist', array(
285 'store_url' => $store_url,
286 'attempt_id' => $attempt_id,
287 'stored' => (bool) $secret_stored,
288 'readable' => is_string( $secret_readback ),
289 ) );
290
291 return array(
292 'success' => false,
293 'error' => 'HMAC secret failed to persist',
294 'error_code' => 'hmac_persist_failed',
295 );
296 }
297
298 // Only now is the credential confirmed to match what the server committed.
299 delete_option( 'brandagent_wp_connect_unverified' );
300
301 update_option( 'BAOauthSuccess', true );
302 brandagent_wordpress_clear_connect_retry_state();
303 brandagent_log( 'BrandAgent WordPress Connect: success', array( 'store_url' => $store_url, 'attempt_id' => $attempt_id ) );
304
305 return array(
306 'success' => true,
307 'advertiserId' => isset( $data['advertiserId'] ) ? $data['advertiserId'] : null,
308 );
309 }
310
311 /**
312 * Read a safe machine-readable error code from a Dashboard Connect response.
313 *
314 * @param mixed $data Decoded JSON response body.
315 * @return string Sanitized scalar error code, or an empty string.
316 */
317 function brandagent_wordpress_connect_error_code( $data ) {
318 if ( ! is_array( $data ) || ! isset( $data['error'] ) || ! is_scalar( $data['error'] ) ) {
319 return '';
320 }
321
322 return sanitize_key( (string) $data['error'] );
323 }
324
325 /**
326 * Build the transient key for one ownership challenge without storing the usable nonce.
327 *
328 * Each in-flight connect gets its own key. A single shared transient lets overlapping manual and
329 * admin-resume attempts overwrite one another, making the otherwise valid callback fail with 403.
330 *
331 * @param string $nonce Raw one-time ownership nonce.
332 * @return string Fixed-length transient key derived from the nonce.
333 */
334 function brandagent_wordpress_connect_nonce_key( $nonce ) {
335 return 'brandagent_connect_nonce_' . hash( 'sha256', (string) $nonce );
336 }
337
338 /**
339 * Clear resumable-connect bookkeeping after success or a durable platform conflict.
340 *
341 * @return void
342 */
343 function brandagent_wordpress_clear_connect_retry_state() {
344 delete_option( 'brandagent_wp_connect_optin' );
345 delete_option( 'brandagent_wp_connect_attempts' );
346 delete_transient( 'brandagent_wp_connect_throttle' );
347
348 // Part of the same bookkeeping: once nothing will retry, an unverified marker has no reader left
349 // and would otherwise linger as dead state on a site that is not retrying anyway.
350 delete_option( 'brandagent_wp_connect_unverified' );
351 }
352
353 /**
354 * REST route to trigger the plain-WordPress connect from the admin UI (admin-only).
355 * POST /wp-json/adsagent/v1/wordpress/connect
356 */
357 add_action( 'rest_api_init', function () {
358 register_rest_route( 'adsagent/v1', '/wordpress/connect', array(
359 'methods' => 'POST',
360 'permission_callback' => function () {
361 return current_user_can( 'manage_options' );
362 },
363 'callback' => function () {
364 $result = brandagent_wordpress_connect();
365 return new WP_REST_Response( $result, ! empty( $result['success'] ) ? 200 : 502 );
366 },
367 ) );
368 } );
369
370 /**
371 * Store-ownership challenge for the plain-WordPress connect. The Clarity dashboard calls
372 * this back with the nonce from the connect request; a match proves the connect was
373 * initiated by this site's admin-privileged plugin, not forged elsewhere for this URL.
374 * Public route by design — at connect time no shared secret exists yet, so the one-time
375 * nonce (64 chars, 10-min TTL, consumed on match) is the proof.
376 * POST /?rest_route=/adsagent/v1/wordpress/connect-verify
377 */
378 add_action( 'rest_api_init', function () {
379 register_rest_route( 'adsagent/v1', '/wordpress/connect-verify', array(
380 'methods' => 'POST',
381 'permission_callback' => '__return_true',
382 'callback' => function ( WP_REST_Request $request ) {
383 $received = (string) $request->get_param( 'connectNonce' );
384 $key = brandagent_wordpress_connect_nonce_key( $received );
385 $stored = get_transient( $key );
386
387 if ( ! empty( $received ) && ! empty( $stored ) && hash_equals( (string) $stored, hash( 'sha256', $received ) ) ) {
388 delete_transient( $key );
389 return new WP_REST_Response( array( 'verified' => true ), 200 );
390 }
391
392 return new WP_REST_Response( array( 'verified' => false ), 401 );
393 },
394 ) );
395 } );
396
397 /**
398 * Whether the plugin already holds a usable Brand Agent connection.
399 *
400 * @return bool
401 */
402 function brandagent_wordpress_has_connection() {
403 return get_option( 'BAOauthSuccess' ) == 1 && (bool) brandagent_get_hmac_secret();
404 }
405
406 /**
407 * Sign and send an outbound Brand Agent request using the plain-WordPress (X-WordPress-*) scheme.
408 *
409 * Deliberately separate from brandagent_sign_outbound_request(), which speaks the WooCommerce
410 * scheme: that one signs `clientId + timestamp` and nothing else, so one captured header set is
411 * replayable against any route. WordPress signs a full canonical request, binding the signature
412 * to a single method, path, body and one-time nonce. The backend twin is
413 * WordPressAuthUtils::BuildInboundCanonicalRequest and the two strings must stay byte-identical —
414 * any divergence surfaces only as a 401, never as a useful error.
415 *
416 * $backend_path is the path the BRAND AGENT SERVER sees, which is not the URL we post to: these
417 * calls travel through the Clarity dashboard proxy, which forwards our headers untouched. Signing
418 * the proxy path would verify against the wrong string at the backend.
419 *
420 * @param string $proxy_url Absolute URL to send to (the Clarity dashboard proxy route).
421 * @param string $backend_path Path + query as the BA server sees it, e.g. '/api/wordpress/uninstall'.
422 * @param string $body Raw request body, or '' when there is none.
423 * @param string $method HTTP method. Default 'POST'.
424 * @param int $timeout Timeout in seconds.
425 * @return array|WP_Error wp_remote_* response, or WP_Error when no secret is available.
426 */
427 function brandagent_wordpress_sign_outbound_request( $proxy_url, $backend_path, $body = '', $method = 'POST', $timeout = 30 ) {
428 $headers = brandagent_wordpress_build_signed_headers( $backend_path, $body, $method );
429 if ( is_wp_error( $headers ) ) {
430 return $headers;
431 }
432
433 $args = array(
434 'timeout' => $timeout,
435 'headers' => array_merge( array( 'Content-Type' => 'application/json' ), $headers ),
436 );
437
438 if ( strtoupper( $method ) === 'GET' ) {
439 return wp_remote_get( $proxy_url, $args );
440 }
441
442 $args['body'] = $body;
443
444 return wp_remote_post( $proxy_url, $args );
445 }
446
447 /**
448 * Build the X-WordPress-* signed headers for one outbound Brand Agent request.
449 *
450 * Split out of brandagent_wordpress_sign_outbound_request() so callers that must drive the HTTP
451 * call themselves — the SSE init proxy sets its own streaming headers and reads the response as a
452 * stream — still sign through the single implementation of the canonical string. Duplicating that
453 * string is the one thing to avoid here: the backend twin is
454 * WordPressAuthUtils::BuildInboundCanonicalRequest and the two must stay byte-identical, so a
455 * second copy that drifts would surface only as a 401 with no useful error.
456 *
457 * $backend_path must be path + query exactly as the BRAND AGENT SERVER receives it, because the
458 * handler signs Request.Path + Request.QueryString verbatim. Callers that append a query string to
459 * the outbound URL must build it once and pass the same string here.
460 *
461 * @param string $backend_path Path + query as the BA server sees it, e.g. '/api/v1/init?clientId=abc'.
462 * @param string $body Raw request body, or '' when there is none.
463 * @param string $method HTTP method. Default 'POST'.
464 * @return array|WP_Error Header map, or WP_Error when no secret is available.
465 */
466 function brandagent_wordpress_build_signed_headers( $backend_path, $body = '', $method = 'POST' ) {
467 $secret_key = brandagent_get_hmac_secret();
468 if ( empty( $secret_key ) ) {
469 return new WP_Error( 'hmac_missing', 'HMAC secret key not found' );
470 }
471
472 $site_url = home_url();
473 $normalized_site_url = brandagent_normalize_store_url( $site_url );
474 $timestamp = (string) time();
475 $nonce = wp_generate_password( 32, false );
476
477 // The dashboard signs with a fixed client id; a merchant's identity is the site itself, so the
478 // last two canonical fields collapse to the same value here. Both are still sent because the
479 // backend reads them from different headers.
480 $client_id = $normalized_site_url;
481
482 // Field order is part of the contract. See WordPressAuthUtils::BuildInboundCanonicalRequest.
483 $canonical_request = implode( "\n", array(
484 strtoupper( $method ),
485 $backend_path,
486 $timestamp,
487 $nonce,
488 hash( 'sha256', $body ),
489 $normalized_site_url,
490 $client_id,
491 ) );
492
493 return array(
494 'X-WordPress-Client-Id' => $client_id,
495 'X-WordPress-Site-Url' => $site_url,
496 'X-WordPress-Timestamp' => $timestamp,
497 'X-WordPress-Nonce' => $nonce,
498 'X-WordPress-Signature' => base64_encode( hash_hmac( 'sha256', $canonical_request, $secret_key, true ) ),
499 );
500 }
501
502 /**
503 * Tell the backend to tear down this plain-WordPress site's Brand Agent data.
504 *
505 * The WooCommerce twin lives in handle_brandagent_uninstall(); a WooCommerce store must keep using
506 * it, because the backend's WooCommerce uninstall also unwinds credentials and webhooks that a
507 * plain site never had. The two paths are not interchangeable: the per-site secret is filed under
508 * a WordPress-specific Key Vault name, so a WordPress site calling the WooCommerce endpoint fails
509 * signature verification and the merchant's data is silently left behind.
510 *
511 * @return void
512 */
513 function brandagent_wordpress_notify_uninstall() {
514 $clarity_server_url = BrandAgent_Config::get_clarity_server_url();
515 if ( empty( $clarity_server_url ) ) {
516 brandagent_log( 'BrandAgent WordPress Uninstall: clarity_server_url not configured; skipping backend call' );
517 return;
518 }
519
520 $backend_path = '/api/wordpress/uninstall';
521 $uninstall_url = trailingslashit( $clarity_server_url ) . 'wordpress/uninstall';
522 $site_url = home_url();
523
524 brandagent_log( 'BrandAgent WordPress Uninstall: calling backend', array( 'site_url' => $site_url, 'endpoint' => $uninstall_url ) );
525
526 // Empty body on purpose. The signature covers sha256(body), and the dashboard proxy re-serializes
527 // anything it parses — PHP escapes forward slashes in JSON and JSON.stringify does not, so a body
528 // carrying the site URL would arrive with a different hash and fail verification. The backend
529 // reads the site from the signed X-WordPress-Site-Url header, which survives the hop intact.
530 //
531 // Short timeout: this runs inside the uninstall hook while the admin waits on the delete, and the
532 // local teardown must happen whether or not the backend answers.
533 $response = brandagent_wordpress_sign_outbound_request( $uninstall_url, $backend_path, '', 'POST', 15 );
534
535 if ( is_wp_error( $response ) ) {
536 brandagent_log( 'BrandAgent WordPress Uninstall: backend call failed', array( 'error' => $response->get_error_message() ) );
537 return;
538 }
539
540 brandagent_log( 'BrandAgent WordPress Uninstall: backend returned', array( 'status_code' => wp_remote_retrieve_response_code( $response ) ) );
541 }
542
543 /**
544 * admin-ajax entry point that lets the Clarity dashboard (embedded in the wp-admin
545 * iframe) trigger the plain-WordPress connect from the Brand Agent setup choice.
546 *
547 * The dashboard posts a WORDPRESS_CONNECT message to wp-admin; js/add_window_listeners.js
548 * forwards it here with the same admin nonce the project-id handler uses. We re-verify
549 * that nonce and the admin capability before running the server-to-server connect, then
550 * return JSON the listener relays back to the iframe as WORDPRESS_CONNECT_SUCCESS/FAILURE.
551 *
552 * Clicking Continue is the opt-in: we record it so a disconnected site can finish the
553 * connect server-side on later admin loads (see brandagent_wordpress_maybe_resume_connect),
554 * mirroring the pilot auto-connect resilience but without ever connecting a site that never
555 * opted in from the setup choice.
556 */
557 add_action( 'wp_ajax_brandagent_wordpress_connect', 'brandagent_wordpress_connect_ajax' );
558 function brandagent_wordpress_connect_ajax() {
559 $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
560 if ( ! wp_verify_nonce( $nonce, 'wp_ajax_edit_clarity_project_id' ) ) {
561 wp_send_json( array( 'success' => false, 'error' => 'Invalid nonce.' ) );
562 }
563
564 if ( ! current_user_can( 'manage_options' ) ) {
565 wp_send_json( array( 'success' => false, 'error' => 'User must be a WordPress admin.' ) );
566 }
567
568 // Checked here as well as inside brandagent_wordpress_connect() so a WooCommerce store never
569 // records plain-WordPress connect bookkeeping it would then retry from admin_init.
570 if ( clarity_is_woocommerce_active_for_current_blog() ) {
571 wp_send_json( array( 'success' => false, 'error' => 'WooCommerce site must use the WooCommerce connect flow.' ) );
572 }
573
574 // Resolve credential provenance before writing the opt-in marker. Legacy WooCommerce credentials
575 // predate brandagent_hmac_platform; setting opt-in first would otherwise misclassify them as WordPress.
576 if ( 'woocommerce' === brandagent_get_hmac_platform() ) {
577 brandagent_wordpress_clear_connect_retry_state();
578 wp_send_json( array(
579 'success' => false,
580 'error' => 'Store is registered as WooCommerce and must be offboarded before connecting as WordPress.',
581 'error_code' => 'platform_mismatch',
582 ) );
583 }
584
585 // Record the opt-in and start a fresh attempt budget for the server-side resume fallback.
586 update_option( 'brandagent_wp_connect_optin', 1 );
587 delete_option( 'brandagent_wp_connect_attempts' );
588 delete_transient( 'brandagent_wp_connect_throttle' );
589
590 $result = brandagent_wordpress_connect();
591 wp_send_json( $result );
592 }
593
594 /**
595 * Button-initiated connect resilience — the trunk equivalent of the pilot auto-connect.
596 *
597 * After the admin opts in from the Brand Agent setup choice (Continue), finish the
598 * plain-WordPress connect server-side on later admin page loads if the browser round trip did
599 * not complete it (e.g. the iframe closed before the reply, or the postMessage was dropped).
600 * Unlike the pilot this never runs before the admin opts in, so it is not an automatic connect.
601 * Gated to plain WordPress, throttled to one attempt every few minutes, and attempt-capped so a
602 * persistently failing backend cannot hammer the Clarity server.
603 *
604 * @return void
605 */
606 add_action( 'admin_init', 'brandagent_wordpress_maybe_resume_connect' );
607 function brandagent_wordpress_maybe_resume_connect() {
608 // admin_init also runs during admin-ajax.php. The explicit AJAX handler below owns that request;
609 // starting a hidden resume first creates two connect attempts from one click.
610 if ( wp_doing_ajax() ) {
611 return;
612 }
613
614 // WooCommerce stores use the wc-auth onboarding flow and must never take this path.
615 if ( clarity_is_woocommerce_active_for_current_blog() ) {
616 return;
617 }
618
619 // Only after the admin clicked Continue on the Brand Agent setup choice.
620 if ( ! get_option( 'brandagent_wp_connect_optin' ) ) {
621 return;
622 }
623
624 // Already connected: clear the opt-in bookkeeping and stop. The unverified marker is what keeps
625 // this from firing after a connect that may have rotated the server-side secret without this
626 // site persisting the replacement — a stale credential still decrypts, so has_connection() alone
627 // would report a live connection and cancel the very retries needed to converge.
628 if ( brandagent_wordpress_has_connection() && ! get_option( 'brandagent_wp_connect_unverified' ) ) {
629 brandagent_wordpress_clear_connect_retry_state();
630 return;
631 }
632
633 // Throttle: at most one server-side attempt per window across admin page loads.
634 if ( get_transient( 'brandagent_wp_connect_throttle' ) ) {
635 return;
636 }
637
638 // Attempt cap: give up (until the next Continue) after a bounded number of tries.
639 $attempts = (int) get_option( 'brandagent_wp_connect_attempts', 0 );
640 if ( $attempts >= 5 ) {
641 brandagent_wordpress_clear_connect_retry_state();
642 return;
643 }
644
645 set_transient( 'brandagent_wp_connect_throttle', 1, 2 * MINUTE_IN_SECONDS );
646
647 brandagent_log( 'BrandAgent WordPress Connect: server-side resume attempt', array( 'attempt' => $attempts + 1 ) );
648 $result = brandagent_wordpress_connect();
649 $error_code = isset( $result['error_code'] ) && is_scalar( $result['error_code'] ) ? (string) $result['error_code'] : '';
650
651 // A request that lost the lock never reached Connect, so it must not spend the bounded retry
652 // budget. Successful and durable-conflict paths clear opt-in state inside Connect.
653 if ( empty( $result['success'] ) && 'connect_in_progress' !== $error_code && get_option( 'brandagent_wp_connect_optin' ) ) {
654 update_option( 'brandagent_wp_connect_attempts', (int) get_option( 'brandagent_wp_connect_attempts', 0 ) + 1 );
655 }
656 }
657