PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / trunk
ZIP AI – AI Website Builder & AI Agent (Beta) vtrunk
0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / inc / core / helper.php

helper.php in ZIP AI – AI Website Builder & AI Agent (Beta) trunk, at inc/core/helper.php

899 lines 31.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * ZIP AI - Helper.
4 *
5 * This file contains the helper functions of ZIP AI.
6 * Helpers are functions that are used throughout the library.
7 *
8 * @package zip-ai
9 */
10
11 namespace ZipAI\MCP\Classes\Core;
12
13 // Exit if accessed directly.
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit;
16 }
17
18 // Classes to be used, in alphabetical order.
19 use ZipAI\MCP\Classes\Core\Utils;
20
21 /**
22 * The Helper Class.
23 */
24 class Helper {
25
26 /**
27 * Check if SSL verification should be enabled for remote requests.
28 *
29 * SSL verification is enabled by default for security. It can be disabled
30 * for local development environments using the ZIPAI_MCP_DISABLE_SSL_VERIFY
31 * constant or the 'zip_ai_sslverify' filter.
32 *
33 * @since 1.0.0
34 * @return bool True if SSL should be verified, false otherwise.
35 */
36 public static function should_verify_ssl() {
37 // Default to true (SSL verification enabled) for security.
38 $verify_ssl = true;
39
40 // Allow disabling via constant for local development.
41 if ( defined( 'ZIPAI_MCP_DISABLE_SSL_VERIFY' ) && ZIPAI_MCP_DISABLE_SSL_VERIFY ) {
42 $verify_ssl = false;
43 }
44
45 /**
46 * Filter whether SSL verification should be enabled for remote requests.
47 *
48 * @since 1.0.0
49 * @param bool $verify_ssl Whether to verify SSL. Default true.
50 */
51 return apply_filters( 'zip_ai_sslverify', $verify_ssl );
52 }
53
54 /**
55 * Get an option from the database.
56 *
57 * @param string $key The option key.
58 * @param mixed $default The option default value if option is not available.
59 * @param boolean $network_override Whether to allow the network admin setting to be overridden on subsites.
60 * @since 1.0.0
61 * @return mixed The option value.
62 */
63 public static function get_admin_settings_option( $key, $default = false, $network_override = false ) {
64 // Get the site-wide option if we're in the network admin.
65 return $network_override && is_multisite() ? get_site_option( $key, $default ) : get_option( $key, $default );
66 }
67
68 /**
69 * Update an option from the database.
70 *
71 * @param string $key The option key.
72 * @param mixed $value The value to update.
73 * @param bool $network_override Whether to allow the network_override admin setting to be overridden on subsites.
74 * @since 1.0.0
75 * @return bool True if the option was updated, false otherwise.
76 */
77 public static function update_admin_settings_option( $key, $value, $network_override = false ) {
78 // Update the site-wide option if we're in the network admin, and return the updated status.
79 return $network_override && is_multisite() ? update_site_option( $key, $value ) : update_option( $key, $value );
80 }
81
82 /**
83 * Check if ZIP AI is authorized.
84 *
85 * @since 1.0.0
86 * @return boolean True if ZIP AI is authorized, false otherwise.
87 */
88 public static function is_authorized() {
89 // Check zip_mcp_settings for auth_token.
90 $auth_token = self::get_decrypted_auth_token();
91
92 return ! empty( $auth_token ) && ! empty( trim( $auth_token ) );
93 }
94
95 /**
96 * Get the ZIP AI Settings.
97 *
98 * If used with a key, it will return that specific setting.
99 * If used without a key, it will return the entire settings array.
100 *
101 * @param string $key The setting key.
102 * @param mixed $default The default value to return if the setting is not found.
103 * @since 1.0.0
104 * @return mixed|array The setting value, or the default.
105 */
106 public static function get_setting( $key = '', $default = array() ) {
107
108 // Get the ZIP AI settings.
109 $existing_settings = self::get_admin_settings_option( 'zip_mcp_settings' );
110
111 // If the ZIP AI settings are empty, return the fallback.
112 if ( empty( $existing_settings ) || ! is_array( $existing_settings ) ) {
113 return $default;
114 }
115
116 // If the key is empty, return the entire settings array - otherwise return the specific setting or the fallback.
117 if ( empty( $key ) ) {
118 return $existing_settings;
119 } else {
120 return isset( $existing_settings[ $key ] ) ? $existing_settings[ $key ] : $default;
121 }
122 }
123
124 /**
125 * Get the decrypted auth token from zip_mcp_settings.
126 *
127 * @since 1.0.0
128 * @return string The decrypted auth token.
129 */
130 public static function get_decrypted_auth_token() {
131 /**
132 * Narrowed type for `$resolved_token`.
133 *
134 * @var string $resolved_token
135 */
136 static $resolved_token = '';
137 static $is_resolved = false;
138
139 if ( $is_resolved ) {
140 return $resolved_token;
141 }
142
143 $is_resolved = true;
144 $mcp_settings = get_option( 'zip_mcp_settings', array() );
145
146 if ( ! is_array( $mcp_settings ) ) {
147 $resolved_token = '';
148 return $resolved_token;
149 }
150
151 $auth_token = ! empty( $mcp_settings['auth_token'] ) && is_string( $mcp_settings['auth_token'] ) ? Utils::decrypt( $mcp_settings['auth_token'] ) : '';
152 $zip_token = ! empty( $mcp_settings['zip_token'] ) && is_string( $mcp_settings['zip_token'] ) ? Utils::decrypt( $mcp_settings['zip_token'] ) : '';
153 $team_uuid = ! empty( $mcp_settings['team_uuid'] ) && is_string( $mcp_settings['team_uuid'] ) ? sanitize_text_field( $mcp_settings['team_uuid'] ) : '';
154 $email = ! empty( $mcp_settings['user_email'] ) && is_string( $mcp_settings['user_email'] ) ? sanitize_email( $mcp_settings['user_email'] ) : '';
155 $name = ! empty( $mcp_settings['user_name'] ) && is_string( $mcp_settings['user_name'] ) ? sanitize_text_field( $mcp_settings['user_name'] ) : '';
156 $current_api = self::get_credit_server_identifier();
157 $stored_api = ! empty( $mcp_settings['auth_token_server'] ) && is_string( $mcp_settings['auth_token_server'] ) ? untrailingslashit( $mcp_settings['auth_token_server'] ) : '';
158 $is_valid_auth = null;
159
160 if ( '' !== $auth_token && $stored_api === $current_api ) {
161 $resolved_token = $auth_token;
162 return $resolved_token;
163 }
164
165 if ( '' !== $auth_token && '' === $stored_api ) {
166 $is_valid_auth = self::validate_credit_server_auth_token( $auth_token );
167
168 if ( true === $is_valid_auth ) {
169 $mcp_settings['auth_token_server'] = $current_api;
170 update_option( 'zip_mcp_settings', $mcp_settings );
171 $resolved_token = $auth_token;
172 return $resolved_token;
173 }
174 }
175
176 if ( '' !== $zip_token && '' !== $email ) {
177 $exchange_result = self::exchange_zipwp_token_for_local_auth_token( $zip_token, $email, $name, $team_uuid );
178
179 if ( ! empty( $exchange_result['token'] ) ) {
180 $mcp_settings['auth_token'] = Utils::encrypt( $exchange_result['token'] );
181 $mcp_settings['auth_token_server'] = $current_api;
182
183 if ( ! empty( $exchange_result['email'] ) ) {
184 $mcp_settings['user_email'] = sanitize_email( $exchange_result['email'] );
185 }
186
187 if ( ! empty( $exchange_result['name'] ) ) {
188 $mcp_settings['user_name'] = sanitize_text_field( $exchange_result['name'] );
189 }
190
191 update_option( 'zip_mcp_settings', $mcp_settings );
192 $resolved_token = $exchange_result['token'];
193 return $resolved_token;
194 }
195 }
196
197 if ( '' !== $auth_token && null === $is_valid_auth && '' === $stored_api ) {
198 $resolved_token = $auth_token;
199 return $resolved_token;
200 }
201
202 $resolved_token = '';
203 return $resolved_token;
204 }
205
206 /**
207 * Get the decrypted Application Password Authorization header value.
208 *
209 * Returns the pre-built `Basic <b64(user:password)>` string ready to be
210 * sent as the `Authorization` header (or forwarded to MCP via the
211 * `X-Wp-Authorization` custom header). Empty when no App Password is
212 * provisioned for the current connection — caller should treat that as
213 * "MCP tools unavailable" and surface to the admin.
214 *
215 * @since 1.0.0
216 * @return string The full `Basic …` Authorization header value, or empty string.
217 */
218 public static function get_decrypted_app_password_authorization() {
219 $mcp_settings = get_option( 'zip_mcp_settings', array() );
220
221 if ( is_array( $mcp_settings ) && ! empty( $mcp_settings['app_password_authorization'] ) && is_string( $mcp_settings['app_password_authorization'] ) ) {
222 return (string) Utils::decrypt( $mcp_settings['app_password_authorization'] );
223 }
224
225 return '';
226 }
227
228 /**
229 * Mint a WordPress Application Password for the current admin user. Store
230 * the pre-built Authorization header value (`Basic <b64>`) and the App
231 * Password UUID into `zip_mcp_settings`. The stored value is encrypted.
232 *
233 * This method is idempotent. A stored UUID can still resolve to a live App
234 * Password on the user record. Then this method is a no-op and trusts the
235 * existing token. A stale UUID triggers a fresh mint. A UUID goes stale when
236 * the user deletes it in Profile then Application Passwords.
237 *
238 * The OAuth callback in `AJAX_Handlers::verify_authorization` calls this. It
239 * runs right after the Sanctum `auth_token` is persisted. This reuses the
240 * existing connection click. So the admin UX stays at one "Connect" step.
241 * The App Password is provisioned silently in the same flow.
242 *
243 * App Password plaintext is one-time-visible. WordPress hashes it and never
244 * exposes the plaintext again. This method captures it in a single call. It
245 * pre-builds the Authorization header value once (`'Basic '. base64(...)`).
246 * It encrypts the full value and stores it. The plaintext is never persisted
247 * bare. It lives only inside the `Basic …` string. That is the form needed
248 * on the wire.
249 *
250 * @since 1.0.0
251 * @return array{success: bool, code?: string, message?: string, bound?: bool} status envelope;
252 * `bound` reports whether the server accepted the credential push.
253 */
254 public static function ensure_app_password_provisioned() {
255 $user_id = get_current_user_id();
256 if ( $user_id <= 0 ) {
257 return array(
258 'success' => false,
259 'code' => 'no_current_user',
260 'message' => __( 'No current user context. Cannot provision Application Password.', 'zip-ai' ),
261 );
262 }
263
264 // Capability gate. Match the OAuth callback's own check so the App
265 // Password is only ever minted under an admin identity. The resulting
266 // token inherits the user's caps; we don't want a non-admin
267 // connection to silently mint a low-privilege token that then 401s
268 // every tool call.
269 if ( ! user_can( $user_id, 'manage_options' ) ) {
270 return array(
271 'success' => false,
272 'code' => 'insufficient_capability',
273 'message' => __( 'Connecting user lacks manage_options. Application Password not provisioned.', 'zip-ai' ),
274 );
275 }
276
277 if ( ! function_exists( 'wp_is_application_passwords_available' ) || ! wp_is_application_passwords_available() ) {
278 return array(
279 'success' => false,
280 'code' => 'app_passwords_disabled',
281 'message' => __( 'Application Passwords are disabled on this site. Enable them or contact your administrator.', 'zip-ai' ),
282 );
283 }
284
285 $user = get_user_by( 'id', $user_id );
286 if ( ! $user || ! wp_is_application_passwords_available_for_user( $user ) ) {
287 return array(
288 'success' => false,
289 'code' => 'app_passwords_disabled_for_user',
290 'message' => __( 'Application Passwords are disabled for the connecting user.', 'zip-ai' ),
291 );
292 }
293
294 // Idempotency: if we already minted one and it still exists on the
295 // user record, leave it alone. Re-minting on every reconnect would
296 // litter the user's Profile → Application Passwords screen.
297 $existing_uuid_encrypted = self::get_setting( 'app_password_uuid', '' );
298 $existing_uuid = is_string( $existing_uuid_encrypted ) && '' !== $existing_uuid_encrypted
299 ? (string) Utils::decrypt( $existing_uuid_encrypted )
300 : '';
301 if ( '' !== $existing_uuid ) {
302 $existing_record = \WP_Application_Passwords::get_user_application_password( $user_id, $existing_uuid );
303 if ( null !== $existing_record ) {
304 // Even though we already have the App Password locally, a
305 // reconnect typically issues a NEW Sanctum token on the
306 // server side — and our credential is bound to that token's
307 // meta. Re-push the stored header so the new token also
308 // has it bound; the server endpoint is idempotent.
309 $stored_header = self::get_decrypted_app_password_authorization();
310 $pushed = false;
311 if ( '' !== $stored_header ) {
312 $pushed = (bool) self::push_app_password_to_saas( $stored_header );
313 }
314 return array(
315 'success' => true,
316 'code' => 'already_provisioned',
317 // Whether the server accepted the bind — soft-fail for the
318 // connection flow, but reprovision needs the hard answer.
319 'bound' => $pushed,
320 );
321 }
322 // Stale UUID — fall through and mint fresh. The next
323 // `update_setting` call overwrites the stored ciphertext.
324 }
325
326 $result = \WP_Application_Passwords::create_new_application_password(
327 $user_id,
328 array(
329 'name' => 'ZipWP MCP Connection',
330 'app_id' => 'zip-ai-' . wp_generate_uuid4(),
331 )
332 );
333
334 if ( is_wp_error( $result ) ) {
335 return array(
336 'success' => false,
337 'code' => 'create_failed',
338 'message' => $result->get_error_message(),
339 );
340 }
341
342 // `create_new_application_password` returns [ $plaintext_password, $item ].
343 // $item carries the persisted record metadata including `uuid`.
344 list( $plaintext, $item ) = $result;
345 if ( '' === $plaintext || empty( $item['uuid'] ) ) {
346 return array(
347 'success' => false,
348 'code' => 'create_unexpected_shape',
349 'message' => __( 'WP_Application_Passwords returned an unexpected response shape.', 'zip-ai' ),
350 );
351 }
352
353 // Pre-build the full `Basic <b64(user_login:plaintext)>` header value
354 // — that's the form we actually need on the wire. Storing the
355 // pre-built string means plaintext never round-trips through the
356 // codebase at any point after this call.
357 $authorization_header = 'Basic ' . base64_encode( $user->user_login . ':' . $plaintext );
358
359 self::update_setting( 'app_password_uuid', (string) $item['uuid'] );
360 self::update_setting( 'app_password_authorization', $authorization_header );
361 self::update_setting( 'app_password_user_id', (string) $user_id );
362
363 // Server-to-server delivery — push the pre-built `Basic <b64>` value
364 // to the server so it can pick it up from its DB at turn time.
365 // The credential never has to ride along on a client request header,
366 // which means it never lands in the inline JS where any other page
367 // script could read it. Soft-fail: if the push errors we still report
368 // `success: provisioned` locally so the admin sees the connection as
369 // established — the next chat turn will surface a clear MCP-auth error
370 // and the admin can disconnect/reconnect to trigger another bind attempt.
371 $pushed = (bool) self::push_app_password_to_saas( $authorization_header );
372
373 return array(
374 'success' => true,
375 'code' => 'provisioned',
376 'bound' => $pushed,
377 );
378 }
379
380 /**
381 * Bind the pre-built `Basic <b64>` Authorization header to the active
382 * Sanctum token on the server via `POST /api/wp-credentials/bind`. Called
383 * right after a fresh App Password is minted (or proactively from the
384 * OAuth callback when an `already_provisioned` credential exists and
385 * needs to be re-bound after a server-side wipe).
386 *
387 * Idempotent on both sides — the server overwrites whatever value was
388 * previously stored under the same token's meta column.
389 *
390 * @since 1.0.0
391 * @param string $authorization_header Pre-built `Basic <b64(user:apppwd)>` value.
392 * @return bool True on HTTP 200, false on any error path.
393 */
394 public static function push_app_password_to_saas( $authorization_header ) {
395 if ( '' === $authorization_header ) {
396 return false;
397 }
398
399 $auth_token = self::get_decrypted_auth_token();
400 if ( '' === $auth_token ) {
401 // No Sanctum token yet — the bind has to happen post-OAuth.
402 return false;
403 }
404
405 $response = wp_remote_post(
406 ZIPAI_MCP_CREDIT_SERVER_API . 'wp-credentials/bind',
407 array(
408 'headers' => array(
409 'Content-Type' => 'application/json',
410 'Accept' => 'application/json',
411 'Authorization' => 'Bearer ' . $auth_token,
412 ),
413 'body' => (string) wp_json_encode(
414 array(
415 'authorization_header' => $authorization_header,
416 'site_url' => home_url(),
417 )
418 ),
419 'timeout' => 15,
420 'sslverify' => self::should_verify_ssl(),
421 )
422 );
423
424 if ( is_wp_error( $response ) ) {
425 error_log( '[zip-ai] wp-credentials/bind failed: ' . $response->get_error_message() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
426 return false;
427 }
428
429 $status_code = wp_remote_retrieve_response_code( $response );
430 if ( 200 !== (int) $status_code ) {
431 error_log( sprintf( '[zip-ai] wp-credentials/bind returned HTTP %d', (int) $status_code ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
432 return false;
433 }
434
435 return true;
436 }
437
438 /**
439 * Inverse of `push_app_password_to_saas()` — tell the server to drop the
440 * bound credential. Called from `revoke_app_password()` so the server
441 * state tracks the WP-side revoke.
442 *
443 * @since 1.0.0
444 * @return bool True on HTTP 200, false on any error path.
445 */
446 public static function unpush_app_password_from_saas() {
447 $auth_token = self::get_decrypted_auth_token();
448 if ( '' === $auth_token ) {
449 return false;
450 }
451
452 $response = wp_remote_post(
453 ZIPAI_MCP_CREDIT_SERVER_API . 'wp-credentials/unbind',
454 array(
455 'headers' => array(
456 'Content-Type' => 'application/json',
457 'Accept' => 'application/json',
458 'Authorization' => 'Bearer ' . $auth_token,
459 ),
460 'body' => (string) wp_json_encode( array() ),
461 'timeout' => 15,
462 'sslverify' => self::should_verify_ssl(),
463 )
464 );
465
466 if ( is_wp_error( $response ) ) {
467 error_log( '[zip-ai] wp-credentials/unbind failed: ' . $response->get_error_message() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
468 return false;
469 }
470
471 $status_code = wp_remote_retrieve_response_code( $response );
472 return 200 === (int) $status_code;
473 }
474
475 /**
476 * Re-mint the site's WordPress Application Password and re-bind it to the
477 * server. This replaces whatever is on file.
478 *
479 * This is the recovery path for the one failure this binding has. The stored
480 * credential can stop working. Someone revoked the password in Profile then
481 * Application Passwords. Or a restore or migration left a header whose
482 * password no longer exists. The server then gets 401 on every write into
483 * the site. The import cannot proceed. Only WordPress-side code holds the
484 * admin identity needed to issue a new one.
485 *
486 * This method revokes first, deliberately. `ensure_app_password_provisioned()`
487 * is idempotent. Without the revoke, it would re-push the SAME dead header
488 * when the stored uuid still resolves.
489 *
490 * @since 0.0.8
491 * @return bool True when a fresh credential is minted AND accepted by the server.
492 */
493 public static function reprovision_app_password() {
494 // Revoking is destructive and UNCONDITIONAL. It deletes the password,
495 // unbinds it server-side and clears the local rows. So refuse before
496 // touching anything unless this context can mint the replacement.
497 // `ensure_app_password_provisioned()` requires `manage_options` on the
498 // current user. It also requires Application Passwords to be available.
499 // Reaching those checks after the revoke would leave the site
500 // disconnected from every door (wizard, chat, MCP). There would be no
501 // way back except a manual reconnect. The MCP import door calls this
502 // under a caller who only needs `publish_pages`.
503 if ( ! current_user_can( 'manage_options' )
504 || ! function_exists( 'wp_is_application_passwords_available' )
505 || ! wp_is_application_passwords_available() ) {
506 return false;
507 }
508
509 // Best-effort: a uuid that no longer exists simply returns false here, and
510 // the point is only to clear the local record so a fresh mint happens.
511 self::revoke_app_password();
512
513 // `bound` is the server's acceptance of the push `ensure…` already made —
514 // re-pushing the same header here would just bind the same value twice.
515 $provisioned = self::ensure_app_password_provisioned();
516
517 return ! empty( $provisioned['success'] ) && ! empty( $provisioned['bound'] );
518 }
519
520 /**
521 * Revoke the stored Application Password (if any) and clear its
522 * settings rows. Called from the disconnect AJAX handler.
523 *
524 * @since 1.0.0
525 * @return bool True if a password was revoked, false if nothing was stored.
526 */
527 public static function revoke_app_password() {
528 $mcp_settings = get_option( 'zip_mcp_settings', array() );
529 if ( ! is_array( $mcp_settings ) ) {
530 return false;
531 }
532
533 $uuid = ! empty( $mcp_settings['app_password_uuid'] ) && is_string( $mcp_settings['app_password_uuid'] )
534 ? (string) Utils::decrypt( $mcp_settings['app_password_uuid'] )
535 : '';
536 $user_id = ! empty( $mcp_settings['app_password_user_id'] ) && is_string( $mcp_settings['app_password_user_id'] )
537 ? (int) Utils::decrypt( $mcp_settings['app_password_user_id'] )
538 : 0;
539
540 $revoked = false;
541 if ( '' !== $uuid && $user_id > 0 && class_exists( '\WP_Application_Passwords' ) ) {
542 $revoked = (bool) \WP_Application_Passwords::delete_application_password( $user_id, $uuid );
543 }
544
545 // Drop the server-side mirror BEFORE we wipe local meta so the bind
546 // endpoint still has access to the active Sanctum token (cleared
547 // separately by `zipwp_clear_settings()`). Soft-fail — the local
548 // revoke is authoritative; the server will 401 next turn if the
549 // unbind didn't land and the admin can reconnect.
550 self::unpush_app_password_from_saas();
551
552 unset(
553 $mcp_settings['app_password_authorization'],
554 $mcp_settings['app_password_uuid'],
555 $mcp_settings['app_password_user_id']
556 );
557 update_option( 'zip_mcp_settings', $mcp_settings );
558
559 return $revoked;
560 }
561
562 /**
563 * Validate the auth token against the configured credit server.
564 *
565 * @param string $auth_token The auth token to validate.
566 * @since 1.0.0
567 * @return bool|null True when valid, false when rejected, null when validation could not be completed.
568 */
569 public static function validate_credit_server_auth_token( $auth_token ) {
570 if ( empty( $auth_token ) ) {
571 return false;
572 }
573
574 $response = wp_remote_post(
575 ZIPAI_MCP_CREDIT_SERVER_API . 'auth/validate',
576 array(
577 'headers' => array(
578 'Content-Type' => 'application/json',
579 'Accept' => 'application/json',
580 'Authorization' => 'Bearer ' . $auth_token,
581 ),
582 'body' => (string) wp_json_encode( array() ),
583 'timeout' => 15,
584 'sslverify' => self::should_verify_ssl(),
585 )
586 );
587
588 if ( is_wp_error( $response ) ) {
589 return null;
590 }
591
592 $status_code = wp_remote_retrieve_response_code( $response );
593 $response_body = wp_remote_retrieve_body( $response );
594 $response_data = json_decode( $response_body, true );
595
596 if ( 200 === $status_code ) {
597 return is_array( $response_data ) && ! empty( $response_data['valid'] );
598 }
599
600 if ( 401 === $status_code || 403 === $status_code ) {
601 return false;
602 }
603
604 return null;
605 }
606
607 /**
608 * Exchange a ZipWP app token for a local credit-server auth token.
609 *
610 * @param string $zip_token The ZipWP app token.
611 * @param string $email The user email.
612 * @param string $name The user name.
613 * @param string $team_uuid The ZipWP team this site is being authenticated with.
614 * @since 1.0.0
615 * @return array{token?: string, email?: string, name?: string} The exchange result containing token details, or an empty array on failure.
616 */
617 public static function exchange_zipwp_token_for_local_auth_token( $zip_token, $email, $name = '', $team_uuid = '' ) {
618 if ( empty( $zip_token ) || empty( $email ) ) {
619 return array();
620 }
621
622 $request_body = array(
623 'token' => $zip_token,
624 'email' => $email,
625 'site_id' => self::get_site_id(),
626 );
627
628 if ( '' !== $name ) {
629 $request_body['name'] = $name;
630 }
631
632 // Scopes credit pooling to ONE team on the credit server: a user who
633 // belongs to several ZipWP teams must not be able to spend another
634 // team's credits from this site. Optional on the wire — an older credit
635 // server ignores it, and a newer one falls back to inferring the team
636 // when it is absent, so sending nothing is never worse than today.
637 if ( '' !== $team_uuid ) {
638 $request_body['team_uuid'] = $team_uuid;
639 }
640
641 if ( get_current_user_id() > 0 ) {
642 $request_body['user_id'] = get_current_user_id();
643 }
644
645 $response = wp_remote_post(
646 ZIPAI_MCP_CREDIT_SERVER_API . 'token/exchange',
647 array(
648 'headers' => array(
649 'Content-Type' => 'application/json',
650 'Accept' => 'application/json',
651 ),
652 'body' => (string) wp_json_encode( $request_body ),
653 'timeout' => 15,
654 'sslverify' => self::should_verify_ssl(),
655 )
656 );
657
658 if ( is_wp_error( $response ) ) {
659 return array();
660 }
661
662 $status_code = wp_remote_retrieve_response_code( $response );
663 $response_body = wp_remote_retrieve_body( $response );
664 $response_data = json_decode( $response_body, true );
665
666 if ( ! is_array( $response_data ) || ! in_array( $status_code, array( 200, 201 ), true ) || empty( $response_data['success'] ) || empty( $response_data['token'] ) ) {
667 return array();
668 }
669
670 $token_value = is_string( $response_data['token'] ) ? $response_data['token'] : '';
671 $email_value = ! empty( $response_data['email'] ) && is_string( $response_data['email'] ) ? $response_data['email'] : '';
672 $name_value = ! empty( $response_data['name'] ) && is_string( $response_data['name'] ) ? $response_data['name'] : '';
673
674 return array(
675 'token' => sanitize_text_field( $token_value ),
676 'email' => '' !== $email_value ? sanitize_email( $email_value ) : $email,
677 'name' => '' !== $name_value ? sanitize_text_field( $name_value ) : $name,
678 );
679 }
680
681 /**
682 * Get the configured credit server identifier for token compatibility checks.
683 *
684 * @since 1.0.0
685 * @return string The normalized credit server API URL.
686 */
687 private static function get_credit_server_identifier() {
688 return untrailingslashit( ZIPAI_MCP_CREDIT_SERVER_API );
689 }
690
691 /**
692 * Generate a shared secret for HMAC authentication.
693 *
694 * @since 1.0.0
695 * @return string The generated shared secret.
696 */
697 public static function generate_shared_secret() {
698 return bin2hex( random_bytes( 32 ) );
699 }
700 /**
701 * Get or generate the shared secret for HMAC authentication.
702 *
703 * @since 1.0.0
704 * @return string The shared secret.
705 */
706 public static function get_shared_secret() {
707 // Get the encrypted shared secret from settings.
708 $encrypted_shared_secret = self::get_setting( 'shared_secret', '' );
709
710 if ( empty( $encrypted_shared_secret ) ) {
711 // Generate new shared secret.
712 $shared_secret = self::generate_shared_secret();
713 self::update_setting( 'shared_secret', $shared_secret );
714 return $shared_secret;
715 }
716
717 // Decrypt and return the existing shared secret.
718 return is_string( $encrypted_shared_secret ) ? Utils::decrypt( $encrypted_shared_secret ) : '';
719 }
720
721 /**
722 * Update a specific setting in the ZIP AI settings.
723 *
724 * @param string $key The setting key.
725 * @param mixed $value The setting value.
726 * @since 1.0.0
727 * @return bool True if the setting was updated, false otherwise.
728 */
729 public static function update_setting( $key, $value ) {
730 $existing_settings = self::get_admin_settings_option( 'zip_mcp_settings', array() );
731
732 if ( ! is_array( $existing_settings ) ) {
733 $existing_settings = array();
734 }
735
736 $existing_settings[ $key ] = is_string( $value ) ? Utils::encrypt( $value ) : '';
737
738 return self::update_admin_settings_option( 'zip_mcp_settings', $existing_settings );
739 }
740
741 /**
742 * Get the site ID for HMAC authentication.
743 *
744 * @since 1.0.0
745 * @return string The site ID.
746 */
747 public static function get_site_id() {
748 return get_site_url();
749 }
750
751 /**
752 * Register the shared secret with the server during plugin activation.
753 *
754 * @since 1.0.0
755 * @return array<int|string, mixed> The registration response.
756 */
757 public static function register_shared_secret_with_laravel() {
758 $shared_secret = self::get_shared_secret();
759 $site_id = self::get_site_id();
760
761 // Register endpoint - this should point to the server.
762 $register_endpoint = ZIPAI_MCP_CREDIT_SERVER_API . 'auth/register-secret';
763
764 $registration_data = array(
765 'site_id' => $site_id,
766 'secret' => $shared_secret,
767 'site_name' => get_bloginfo( 'name' ),
768 'admin_email' => get_option( 'admin_email' ),
769 );
770
771 $response = wp_remote_post(
772 $register_endpoint,
773 array(
774 'headers' => array(
775 'Content-Type' => 'application/json',
776 ),
777 'body' => (string) wp_json_encode( $registration_data ),
778 'timeout' => 30,
779 )
780 );
781
782 if ( is_wp_error( $response ) ) {
783 return array(
784 'error' => $response->get_error_message(),
785 'code' => 'registration_failed',
786 );
787 }
788
789 $response_body = wp_remote_retrieve_body( $response );
790 $status_code = wp_remote_retrieve_response_code( $response );
791
792 if ( 200 !== $status_code ) {
793 return array(
794 'error' => __( 'Failed to register with Laravel server.', 'zip-ai' ),
795 'code' => 'registration_failed',
796 );
797 }
798
799 $response_data = json_decode( $response_body, true );
800
801 // Store the registration status.
802 self::update_setting( 'hmac_registered', 'true' );
803
804 return is_array( $response_data ) ? $response_data : array();
805 }
806
807 /**
808 * Check if HMAC is registered with the server.
809 *
810 * @since 1.0.0
811 * @return bool True if registered, false otherwise.
812 */
813 public static function is_hmac_registered() {
814 return 'true' === self::get_setting( 'hmac_registered', 'false' );
815 }
816
817 /**
818 * Prepare a block array for WordPress serialize_blocks().
819 *
820 * LLM-generated blocks only have blockName/attrs/innerBlocks.
821 * WordPress serialize_block() requires innerHTML and innerContent
822 * to know how to render the block tree. This adds the missing keys.
823 *
824 * - Blocks with innerBlocks: innerContent = [null, null, ...] (one per child)
825 * - Blocks without innerBlocks: innerContent = [] (self-closing)
826 * - Already-prepared blocks (from parse_blocks): left untouched
827 *
828 * @since 1.0.0
829 * @param array<int|string, mixed> $blocks Array of block objects.
830 * @return array<int|string, mixed> Blocks ready for serialize_blocks().
831 */
832 public static function prepare_blocks_for_serialization( $blocks ) {
833 return array_map(
834 function ( $block ) {
835 if ( ! is_array( $block ) ) {
836 return $block;
837 }
838
839 // Already prepared (e.g. from parse_blocks) — skip
840 if ( isset( $block['innerContent'] ) ) {
841 // Still recurse into innerBlocks in case they need preparation
842 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
843 $block['innerBlocks'] = Helper::prepare_blocks_for_serialization( $block['innerBlocks'] );
844 }
845 return $block;
846 }
847
848 // Ensure attrs is an array
849 if ( ! isset( $block['attrs'] ) || ! is_array( $block['attrs'] ) ) {
850 $block['attrs'] = array();
851 }
852
853 // Recursively prepare innerBlocks first
854 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
855 $block['innerBlocks'] = Helper::prepare_blocks_for_serialization( $block['innerBlocks'] );
856 // One null per inner block — tells serialize_block() where to place each child
857 $block['innerContent'] = array_fill( 0, count( $block['innerBlocks'] ), null );
858 } else {
859 $block['innerBlocks'] = array();
860 $block['innerContent'] = array();
861 }
862
863 // innerHTML is empty for Spectra blocks (content is in attrs + innerBlocks)
864 if ( ! isset( $block['innerHTML'] ) ) {
865 $block['innerHTML'] = '';
866 }
867
868 return $block;
869 },
870 $blocks
871 );
872 }
873
874 /**
875 * Resolve a wp.org plugin slug to its installed `folder/file.php`.
876 *
877 * Matches on the folder segment, so it resolves folder-shaped plugins
878 * (`sureforms/sureforms.php` ← `sureforms`) — the shape every wp.org plugin
879 * this is used to gate takes. Single-file plugins (`hello.php`) never match,
880 * which is correct for the callers here (they only ever pass folder slugs).
881 * Returns null when the slug isn't installed.
882 *
883 * @param string $slug wp.org slug (lower-case alphanumerics and hyphens).
884 * @return string|null
885 */
886 public static function find_plugin_file_for_slug( string $slug ): ?string {
887 // ONE resolver: PluginResolver carries the main-file preference
888 // (`slug/slug.php` beats whichever file get_plugins() lists first in a
889 // two-header folder). This wrapper only narrows the contract back to
890 // folder-shaped results — its callers pass wp.org folder slugs and
891 // must not match single-file plugins.
892 if ( ! function_exists( 'get_plugins' ) ) {
893 require_once ABSPATH . 'wp-admin/includes/plugin.php';
894 }
895 $file = \ZipAI\MCP\Classes\Abilities\Zipai\System\PluginResolver::resolve_plugin_file( $slug );
896 return null !== $file && false !== strpos( $file, '/' ) ? $file : null;
897 }
898 }
899