PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.1
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / modules / Mcp / Mcp_Hub.php

Mcp_Hub.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.1.1, at includes/modules/Mcp/Mcp_Hub.php

370 lines 13.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * xSpeed Hub — the site side of the multi-site hub attach flow.
4 *
5 * The Hub (xspeedcache.com) lets one AI connection manage many sites. A
6 * site is always attached FROM the plugin (the admin has proof of
7 * ownership here). Two methods, both surfaced in the MCP Server panel's
8 * "xSpeed Hub" card:
9 *
10 * Method 1 — Token: the plugin surfaces this site's URL + its MCP
11 * `site_token`; the admin pastes both into their xspeedcache.com
12 * account. The Hub then presents that token back to us on every call
13 * (X-XSpeed-MCP-Token, validated by Mcp_Auth) — identical to the
14 * per-site broker credential, so nothing new is trusted.
15 *
16 * Method 2 — OAuth (one click): redirect to the Hub to log in + approve.
17 * Lands with the OAuth front door (Phase 4). Not wired here yet.
18 *
19 * This class holds ONLY the hub-link bookkeeping (which account this site
20 * reports being attached to, for the panel's status line). The credential
21 * itself is the existing Mcp_Pairing::site_token() — we do not mint a
22 * second secret.
23 *
24 * State lives in its own option so it is orthogonal to the per-site
25 * pairing state (disconnecting one never disturbs the other).
26 *
27 * @package XSpeed
28 */
29
30 declare(strict_types=1);
31
32 namespace XSpeed\Modules\Mcp;
33
34 defined( 'ABSPATH' ) || exit;
35
36 final class Mcp_Hub {
37
38 /** Option key holding hub-link state (separate from pairing state). */
39 public const OPTION = 'xspeed_module_mcp_hub';
40
41 /** Per-user meta key holding THIS admin's hub connection state. A WP site
42 * can have many admins, each managing it from their own hub account, so
43 * the connection is per-user, not site-wide. */
44 public const USER_META = 'xspeed_hub_link';
45
46 /** Default hub dashboard base — where the user manages their account. */
47 public const DEFAULT_HUB_URL = 'https://app.xspeedcache.com';
48
49 /**
50 * The hub dashboard base URL. Overridable via the XSPEED_HUB_URL
51 * constant (wp-config.php) and the `xspeed_hub_url` filter so dev /
52 * self-hosted deployments can point elsewhere.
53 */
54 public static function hub_url(): string {
55 $url = self::DEFAULT_HUB_URL;
56 if ( defined( 'XSPEED_HUB_URL' ) && is_string( constant( 'XSPEED_HUB_URL' ) ) && '' !== constant( 'XSPEED_HUB_URL' ) ) {
57 $url = (string) constant( 'XSPEED_HUB_URL' );
58 }
59 /** Filter the xSpeed Hub base URL. */
60 $url = (string) apply_filters( 'xspeed_hub_url', $url );
61 return untrailingslashit( $url );
62 }
63
64 /**
65 * Hub-link state for the CURRENT admin (per-user). Falls back to the legacy
66 * site-wide option for sites attached before the per-user migration, so an
67 * existing connection still shows until re-attached.
68 *
69 * @param int|null $user_id Which user (defaults to the current user).
70 * @return array{attached:bool,account_email:string,attached_at:int}
71 */
72 public static function state( ?int $user_id = null ): array {
73 $user_id = $user_id ?? get_current_user_id();
74 $stored = $user_id ? get_user_meta( $user_id, self::USER_META, true ) : array();
75
76 // Backward-compat: fall back to the old site-wide option if this user
77 // has no per-user record yet (pre-1.1 attach).
78 if ( ! is_array( $stored ) || empty( $stored ) ) {
79 $legacy = get_option( self::OPTION, array() );
80 $stored = is_array( $legacy ) ? $legacy : array();
81 }
82
83 return array(
84 'attached' => ! empty( $stored['attached'] ),
85 'account_email' => isset( $stored['account_email'] ) ? (string) $stored['account_email'] : '',
86 'attached_at' => isset( $stored['attached_at'] ) ? (int) $stored['attached_at'] : 0,
87 );
88 }
89
90 /**
91 * Public snapshot for the dashboard "xSpeed Hub" card.
92 *
93 * Includes the paste-in values for Method 1 (this site's URL + token)
94 * and a link to the hub dashboard. The token is admin-only (the whole
95 * REST route is gated by manage_options in McpModule).
96 *
97 * @return array<string,mixed>
98 */
99 public static function public_status( ?int $user_id = null ): array {
100 $state = self::state( $user_id );
101 return array(
102 'attached' => $state['attached'],
103 'account_email' => $state['account_email'],
104 'attached_at' => $state['attached_at'],
105 'site_url' => home_url( '/' ),
106 // Method 1 paste-in credential — the existing per-site token.
107 // Empty until generate_token() (or a per-site Connect) mints one.
108 'site_token' => Mcp_Pairing::site_token(),
109 'hub_url' => self::hub_url(),
110 // Where the user goes to paste the URL + token (Add site form).
111 'add_site_url' => self::hub_url() . '/sites/add',
112 // Method 2 (OAuth attach) — one-click redirect with a fresh nonce.
113 'attach_url' => self::attach_url(),
114 );
115 }
116
117 /**
118 * Method 1 — ensure a site_token exists and return the paste-in values.
119 *
120 * Reuses Mcp_Pairing::connect() so the Hub credential is the SAME token
121 * the per-site path uses (no second secret, no drift). Idempotent: if a
122 * token already exists it is reused, not rotated, so an already-attached
123 * hub keeps working.
124 *
125 * @return array<string,mixed> Public status including site_url + site_token.
126 */
127 public static function generate_token(): array {
128 if ( '' === Mcp_Pairing::site_token() ) {
129 // Mint (read-write by default) so the token exists to hand over.
130 Mcp_Pairing::connect( false );
131 }
132 return self::public_status();
133 }
134
135 /**
136 * Method 2 (OAuth attach) — the one-click flow.
137 *
138 * The admin clicks "Connect via OAuth" in the Hub tab. We mint a
139 * short-lived signed nonce and redirect the browser to the hub's
140 * /attach page carrying { site_url, nonce }. The hub (after the user
141 * logs into their account) calls BACK to this site's
142 * /xspeed/v1/mcp/attach with the nonce; verify_attach_nonce() checks
143 * it and hands the hub the site_token. Because the nonce is HMAC-signed
144 * with this site's secret AND minting is admin-only, only a site admin
145 * can start an attach — no token is ever pasted or shown.
146 */
147
148 /** Nonce validity window (seconds). */
149 private const NONCE_TTL = 600;
150
151 /** Per-site signing secret for attach nonces (derived from WP salts). */
152 private static function nonce_secret(): string {
153 return wp_hash( 'xspeed_hub_attach|' . self::site_url_canonical() );
154 }
155
156 /** Canonical site URL used in the nonce + sent to the hub. */
157 private static function site_url_canonical(): string {
158 return untrailingslashit( home_url( '/' ) );
159 }
160
161 /**
162 * Mint a signed, time-bound attach nonce. Format: <ts>.<uid>.<hmac>.
163 * Admin-only (the REST route that calls this is gated by manage_options).
164 * The minting admin's user ID is embedded so the (WP-userless) attach
165 * callback can record the connection PER-USER — each admin sees their own
166 * "Connected via <their account>" status.
167 */
168 public static function mint_attach_nonce(): string {
169 // Ensure a site_token exists to hand over on the callback.
170 if ( '' === Mcp_Pairing::site_token() ) {
171 Mcp_Pairing::connect( false );
172 }
173 $ts = time();
174 $uid = get_current_user_id();
175 $hmac = hash_hmac( 'sha256', $ts . '.' . $uid, self::nonce_secret() );
176 return $ts . '.' . $uid . '.' . $hmac;
177 }
178
179 /**
180 * Verify an attach nonce (constant-time, within TTL). On success returns
181 * the paste-in values (site_url + site_token) plus the minting admin's
182 * user ID; on failure returns null. Called by the token-authless
183 * /mcp/attach route.
184 *
185 * @return array{site_url:string,site_token:string,user_id:int}|null
186 */
187 public static function verify_attach_nonce( string $nonce ): ?array {
188 $parts = explode( '.', $nonce, 3 );
189 if ( 3 !== count( $parts ) ) {
190 return null;
191 }
192 list( $ts, $uid, $hmac ) = $parts;
193 if ( ! ctype_digit( (string) $ts ) || ! ctype_digit( (string) $uid ) ) {
194 return null;
195 }
196 if ( abs( time() - (int) $ts ) > self::NONCE_TTL ) {
197 return null; // expired
198 }
199 $expected = hash_hmac( 'sha256', $ts . '.' . $uid, self::nonce_secret() );
200 if ( ! hash_equals( $expected, (string) $hmac ) ) {
201 return null; // bad signature
202 }
203 return array(
204 'site_url' => self::site_url_canonical(),
205 'site_token' => Mcp_Pairing::site_token(),
206 'user_id' => (int) $uid,
207 );
208 }
209
210 /**
211 * The URL to redirect the admin to for OAuth attach. Carries the site
212 * URL + a fresh nonce; the hub reads these, logs the user in, and calls
213 * back to confirm.
214 */
215 /**
216 * Self-heal: ask the Hub whether THIS site (by its own token) is attached,
217 * and reconcile the local per-user state. This makes the "Connected" badge
218 * reliable even if the attach callback never fired (failed/slow/cached) —
219 * the Hub is the source of truth. Cached in a short transient so the panel
220 * doesn't make an outbound call on every render.
221 *
222 * @param bool $force Skip the cache (e.g. right after a Connect attempt).
223 */
224 public static function reconcile_with_hub( bool $force = false ): void {
225 $token = Mcp_Pairing::site_token();
226 if ( '' === $token ) {
227 return; // no token minted yet → definitely not attached
228 }
229
230 $cache_key = 'xspeed_hub_reconcile';
231 if ( ! $force && false !== get_transient( $cache_key ) ) {
232 return; // reconciled recently
233 }
234
235 $url = add_query_arg(
236 array( 'site_url' => rawurlencode( self::site_url_canonical() ) ),
237 self::hub_url() . '/api/site/attached'
238 );
239 $resp = wp_remote_get(
240 $url,
241 array(
242 'timeout' => 8,
243 'headers' => array( 'X-XSpeed-Site-Token' => $token ),
244 )
245 );
246 // Cache for 5 min regardless — don't hammer the Hub on transient errors.
247 set_transient( $cache_key, 1, 5 * MINUTE_IN_SECONDS );
248
249 if ( is_wp_error( $resp ) || 200 !== wp_remote_retrieve_response_code( $resp ) ) {
250 return; // leave local state as-is on any error
251 }
252 $body = json_decode( (string) wp_remote_retrieve_body( $resp ), true );
253 if ( ! is_array( $body ) ) {
254 return;
255 }
256
257 $uid = get_current_user_id();
258 if ( ! empty( $body['attached'] ) ) {
259 // The Hub says attached — mark THIS admin connected if not already.
260 $state = self::state( $uid );
261 if ( empty( $state['attached'] ) ) {
262 self::mark_attached( (string) ( $body['account_email'] ?? '' ), $uid );
263 }
264 } elseif ( $uid ) {
265 // The Hub says NOT attached (e.g. removed on the dashboard) — clear
266 // any stale local "connected" so the badge doesn't lie.
267 $state = self::state( $uid );
268 if ( ! empty( $state['attached'] ) ) {
269 delete_user_meta( $uid, self::USER_META );
270 }
271 }
272 }
273
274 public static function attach_url(): string {
275 $args = array(
276 'site_url' => self::site_url_canonical(),
277 'nonce' => self::mint_attach_nonce(),
278 );
279 // Prefill hint only — the current admin's email, so a brand-new user
280 // can create/sign into their Hub account in one click without typing.
281 // The Hub NEVER trusts this for auth; it only pre-populates the field
282 // and still requires the user to verify (magic-link / Google).
283 $email = self::current_admin_email();
284 if ( '' !== $email ) {
285 $args['email'] = $email;
286 }
287 return self::hub_url() . '/attach?' . http_build_query( $args );
288 }
289
290 /** The logged-in admin's email (used only as a Hub sign-in prefill hint). */
291 private static function current_admin_email(): string {
292 $user = wp_get_current_user();
293 if ( $user && ! empty( $user->user_email ) && is_email( $user->user_email ) ) {
294 return (string) $user->user_email;
295 }
296 $admin = get_option( 'admin_email' );
297 return is_string( $admin ) && is_email( $admin ) ? $admin : '';
298 }
299
300 /**
301 * Record that this site is attached to a hub account, PER USER. Called
302 * from the attach callback with the minting admin's user id (from the
303 * nonce), so each admin gets their own status. Bookkeeping only.
304 *
305 * @param string $account_email The hub account the site was attached to.
306 * @param int|null $user_id The admin who attached (defaults to current).
307 */
308 public static function mark_attached( string $account_email, ?int $user_id = null ): array {
309 $user_id = $user_id ?? get_current_user_id();
310 if ( $user_id ) {
311 update_user_meta(
312 $user_id,
313 self::USER_META,
314 array(
315 'attached' => true,
316 'account_email' => sanitize_email( $account_email ),
317 'attached_at' => time(),
318 )
319 );
320 }
321 // Bust the reconcile cache so a reconnect reflects immediately (not the
322 // stale 'not attached' cached during the disconnected window).
323 delete_transient( 'xspeed_hub_reconcile' );
324 return self::public_status( $user_id );
325 }
326
327 /**
328 * Disconnect the CURRENT admin from the hub: clear their per-user link.
329 * Other admins' connections are untouched. Does NOT rotate the site_token
330 * (still used by the per-site connection); to fully cut off the hub the
331 * user rotates the token, which the Hub's stored copy then fails on.
332 */
333 public static function disconnect(): array {
334 $user_id = get_current_user_id();
335 $state = $user_id ? self::state( $user_id ) : array();
336 $email = isset( $state['account_email'] ) ? (string) $state['account_email'] : '';
337
338 // Detach from the Hub for THIS admin's account only (multi-admin: other
339 // admins who attached keep their link). The site token proves ownership;
340 // account_email scopes the removal.
341 $token = Mcp_Pairing::site_token();
342 if ( '' !== $token && '' !== $email ) {
343 wp_remote_post(
344 self::hub_url() . '/api/site/detach',
345 array(
346 'timeout' => 8,
347 'headers' => array(
348 'Content-Type' => 'application/json',
349 'X-XSpeed-Site-Token' => $token,
350 ),
351 'body' => wp_json_encode(
352 array(
353 'site_url' => self::site_url_canonical(),
354 'account_email' => $email,
355 )
356 ),
357 )
358 );
359 }
360
361 if ( $user_id ) {
362 delete_user_meta( $user_id, self::USER_META );
363 }
364 // Bust the reconcile cache so the next status read reflects reality
365 // immediately (not the stale 'attached' cached before disconnect).
366 delete_transient( 'xspeed_hub_reconcile' );
367 return self::public_status( $user_id );
368 }
369 }
370