PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / 0.0.8
ZIP AI – AI Website Builder & AI Agent (Beta) v0.0.8
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 / api / connection-rest-api.php

connection-rest-api.php in ZIP AI – AI Website Builder & AI Agent (Beta) 0.0.8, at inc/api/connection-rest-api.php

355 lines 11.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Connection REST API — what the Connection screen needs to hand an AI client
4 * everything required to reach this site.
5 *
6 * Cookie/nonce authenticated and `manage_options` only: these routes mint and
7 * revoke Application Passwords, and switch the external MCP endpoint on and off.
8 * They are for a logged-in administrator in wp-admin, never for an AI client
9 * (which authenticates with the credential these routes produce).
10 *
11 * Credential storage is entirely WordPress core's (`WP_Application_Passwords`):
12 * core hashes the password, records `last_used`, and revokes. Nothing about a
13 * credential is duplicated here — the plaintext is returned exactly once, at
14 * creation, and never stored.
15 *
16 * @since 0.0.8
17 * @package zip-ai
18 */
19
20 namespace ZipAI\MCP\Classes\Api;
21
22 use ZipAI\MCP\Classes\Core\Helper;
23 use ZipAI\MCP\Classes\Core\Response;
24 use ZipAI\MCP\Classes\Core\Utils;
25
26 defined( 'ABSPATH' ) || exit;
27
28 /**
29 * Registers the Connection screen's REST routes.
30 */
31 class Connection_REST_API {
32
33 /**
34 * Prefix on every credential this screen creates, so the list can show the
35 * ones a user made here without claiming credentials other plugins own.
36 */
37 const APP_ID_PREFIX = 'zip-ai-connection-';
38
39 /**
40 * Hook route registration.
41 *
42 * @return void
43 */
44 public function __construct() {
45 add_action( 'rest_api_init', array( $this, 'register_routes' ) );
46 }
47
48 /**
49 * Register the routes.
50 *
51 * @return void
52 */
53 public function register_routes() {
54 $permission = array( $this, 'check_permission' );
55
56 register_rest_route(
57 'zip-ai/v1',
58 '/connection',
59 array(
60 'methods' => 'GET',
61 'callback' => array( $this, 'get_connection' ),
62 'permission_callback' => $permission,
63 )
64 );
65
66 register_rest_route(
67 'zip-ai/v1',
68 '/connection/enabled',
69 array(
70 'methods' => 'POST',
71 'callback' => array( $this, 'set_enabled' ),
72 'permission_callback' => $permission,
73 'args' => array(
74 'enabled' => array(
75 'type' => 'boolean',
76 'required' => true,
77 ),
78 ),
79 )
80 );
81
82 register_rest_route(
83 'zip-ai/v1',
84 '/connection/credentials',
85 array(
86 'methods' => 'POST',
87 'callback' => array( $this, 'create_credential' ),
88 'permission_callback' => $permission,
89 'args' => array(
90 'name' => array(
91 'type' => 'string',
92 'required' => false,
93 'sanitize_callback' => 'sanitize_text_field',
94 ),
95 ),
96 )
97 );
98
99 register_rest_route(
100 'zip-ai/v1',
101 '/connection/credentials/(?P<uuid>[A-Za-z0-9\-]+)',
102 array(
103 'methods' => 'DELETE',
104 'callback' => array( $this, 'delete_credential' ),
105 'permission_callback' => $permission,
106 )
107 );
108 }
109
110 /**
111 * Only a logged-in administrator may see or change connection settings.
112 *
113 * @return bool
114 */
115 public function check_permission() {
116 return current_user_can( 'manage_options' );
117 }
118
119 /**
120 * Everything the Connection screen renders: endpoint, state, the current
121 * user's credentials, and the exposed tool list.
122 *
123 * @return \WP_REST_Response
124 */
125 public function get_connection() {
126 return rest_ensure_response(
127 Response::success(
128 '',
129 array(
130 'enabled' => External_Mcp::is_enabled(),
131 'server_url' => External_Mcp::server_url(),
132 'server_name' => External_Mcp::SERVER_ID,
133 'username' => wp_get_current_user()->user_login,
134 'site_host' => (string) wp_parse_url( home_url(), PHP_URL_HOST ),
135 // Advertised tools carry full schemas at connect time; the
136 // reachable set is what the adapter's execute-ability
137 // dispatcher can run by name on top of those.
138 'tools' => \ZipAI\MCP\Classes\Core\External_Tool_Policy::ADVERTISED,
139 'reachable' => \ZipAI\MCP\Classes\Core\External_Tool_Policy::allowed(),
140 'credentials' => $this->list_credentials(),
141 )
142 )
143 );
144 }
145
146 /**
147 * Switch the external endpoint on or off.
148 *
149 * @param \WP_REST_Request $request Request.
150 * @return \WP_REST_Response
151 */
152 public function set_enabled( $request ) {
153 $enabled = (bool) $request->get_param( 'enabled' );
154 External_Mcp::set_enabled( $enabled );
155
156 // The adapter registers its routes on `rest_api_init`, which has already
157 // run for THIS request — so the endpoint appears once the next request
158 // boots. Say so rather than let the screen show a URL that 404s.
159 return rest_ensure_response(
160 Response::success(
161 $enabled
162 ? __( 'AI abilities enabled. The connection endpoint is live.', 'zip-ai' )
163 : __( 'AI abilities disabled. Connected clients can no longer reach this site.', 'zip-ai' ),
164 array( 'enabled' => $enabled )
165 )
166 );
167 }
168
169 /**
170 * Mint an Application Password for one AI client. The plaintext is in this
171 * response and nowhere else — core stores only a hash.
172 *
173 * @param \WP_REST_Request $request Request.
174 * @return \WP_REST_Response
175 */
176 public function create_credential( $request ) {
177 if ( ! class_exists( '\WP_Application_Passwords' ) ) {
178 return rest_ensure_response(
179 Response::error( __( 'Application Passwords are not available on this site.', 'zip-ai' ) )
180 );
181 }
182 if ( ! wp_is_application_passwords_available() ) {
183 // Core requires HTTPS (or an explicit override) — a truthful reason
184 // beats a generic failure the admin cannot act on.
185 return rest_ensure_response(
186 Response::error(
187 __( 'WordPress has Application Passwords turned off for this site: they require HTTPS.', 'zip-ai' ),
188 __( 'Serve the site over HTTPS, or define WP_ENVIRONMENT_TYPE as local for development.', 'zip-ai' )
189 )
190 );
191 }
192
193 $raw_name = $request->get_param( 'name' );
194 $name = is_string( $raw_name ) ? trim( $raw_name ) : '';
195 $name = '' !== $name ? $name : __( 'AI client', 'zip-ai' );
196 $user_id = get_current_user_id();
197
198 $result = \WP_Application_Passwords::create_new_application_password(
199 $user_id,
200 array(
201 'name' => $name,
202 'app_id' => self::APP_ID_PREFIX . wp_generate_uuid4(),
203 )
204 );
205 if ( is_wp_error( $result ) ) {
206 return rest_ensure_response( Response::from_wp_error( $result ) );
207 }
208
209 list( $plaintext, $item ) = $result;
210 if ( '' === $plaintext || empty( $item['uuid'] ) ) {
211 return rest_ensure_response(
212 Response::error( __( 'WordPress returned an unexpected credential shape.', 'zip-ai' ) )
213 );
214 }
215
216 $username = wp_get_current_user()->user_login;
217
218 return rest_ensure_response(
219 Response::success(
220 __( 'Copy this password now. It is not shown again.', 'zip-ai' ),
221 array(
222 'uuid' => (string) $item['uuid'],
223 'name' => $name,
224 'password' => $plaintext,
225 'username' => $username,
226 // Pre-built so the screen never has to base64 in the browser,
227 // and every client tab shows a byte-identical header.
228 'basic_auth' => 'Basic ' . base64_encode( $username . ':' . $plaintext ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- HTTP Basic credential, not obfuscation.
229 'created' => $item['created'],
230 'credentials' => $this->list_credentials(),
231 )
232 )
233 );
234 }
235
236 /**
237 * Revoke one credential. Scoped to the current user's own passwords — an
238 * administrator revoking another user's credential is a Users-screen action,
239 * not a connection setting.
240 *
241 * @param \WP_REST_Request $request Request.
242 * @return \WP_REST_Response
243 */
244 public function delete_credential( $request ) {
245 if ( ! class_exists( '\WP_Application_Passwords' ) ) {
246 return rest_ensure_response(
247 Response::error( __( 'Application Passwords are not available on this site.', 'zip-ai' ) )
248 );
249 }
250
251 $raw_uuid = $request->get_param( 'uuid' );
252 $uuid = is_string( $raw_uuid ) ? $raw_uuid : '';
253 $user_id = get_current_user_id();
254
255 $managed_uuid = $this->managed_uuid();
256 if ( '' !== $managed_uuid && $uuid === $managed_uuid ) {
257 return rest_ensure_response(
258 Response::error(
259 __( 'That credential is used by ZIP AI to write to this site and cannot be revoked here.', 'zip-ai' ),
260 __( 'Turn off AI abilities above to stop access, or disconnect the account to remove it.', 'zip-ai' )
261 )
262 );
263 }
264
265 $existing = \WP_Application_Passwords::get_user_application_password( $user_id, $uuid );
266 if ( null === $existing ) {
267 return rest_ensure_response(
268 Response::error( __( 'That credential no longer exists.', 'zip-ai' ) )
269 );
270 }
271
272 // Only credentials minted on this screen. A WP-mobile-app password or
273 // another plugin's credential looks like harmless cleanup in a list of
274 // names — revoking those is a Profile-screen action, not ours.
275 if ( 0 !== strpos( $existing['app_id'], self::APP_ID_PREFIX ) ) {
276 return rest_ensure_response(
277 Response::error(
278 __( 'That credential was not created by ZIP AI, so it cannot be revoked here.', 'zip-ai' ),
279 __( 'Manage it from your WordPress profile under Application Passwords.', 'zip-ai' )
280 )
281 );
282 }
283
284 $deleted = \WP_Application_Passwords::delete_application_password( $user_id, $uuid );
285 if ( is_wp_error( $deleted ) ) {
286 return rest_ensure_response( Response::from_wp_error( $deleted ) );
287 }
288
289 return rest_ensure_response(
290 Response::success(
291 __( 'Credential revoked. Any client using it can no longer reach this site.', 'zip-ai' ),
292 array( 'credentials' => $this->list_credentials() )
293 )
294 );
295 }
296
297 /**
298 * The uuid of the credential ZIP AI itself uses — the one bound to this site's
299 * account so the import service can write back into WordPress.
300 *
301 * Deleting it breaks imports until it is re-issued, and it is indistinguishable
302 * from leftover junk in a list of names, so the UI must not offer to revoke it.
303 *
304 * @return string Empty when nothing is bound.
305 */
306 private function managed_uuid() {
307 $stored = Helper::get_setting( 'app_password_uuid', '' );
308 if ( ! is_string( $stored ) || '' === $stored ) {
309 return '';
310 }
311
312 return (string) Utils::decrypt( $stored );
313 }
314
315 /**
316 * The current user's Application Passwords, newest first. Never includes a
317 * password — core keeps only a hash, which is the point.
318 *
319 * @return array<int,array<string,mixed>>
320 */
321 private function list_credentials() {
322 if ( ! class_exists( '\WP_Application_Passwords' ) ) {
323 return array();
324 }
325
326 // Core's record shape is fixed and typed; only `last_used` is nullable
327 // (null until the credential is first used by a client).
328 $managed = $this->managed_uuid();
329 $passwords = \WP_Application_Passwords::get_user_application_passwords( get_current_user_id() );
330 $rows = array();
331 foreach ( $passwords as $password ) {
332 $rows[] = array(
333 // Owned by the plugin, not the user — the UI hides Revoke for it.
334 'managed' => '' !== $managed && $password['uuid'] === $managed,
335 'uuid' => $password['uuid'],
336 'name' => $password['name'],
337 'created' => $password['created'],
338 'last_used' => null !== $password['last_used'] ? $password['last_used'] : 0,
339 // Distinguishes credentials made on this screen from ones the
340 // user created in their WordPress profile or another plugin did.
341 'ours' => 0 === strpos( $password['app_id'], self::APP_ID_PREFIX ),
342 );
343 }
344
345 usort(
346 $rows,
347 static function ( $a, $b ) {
348 return $b['created'] <=> $a['created'];
349 }
350 );
351
352 return $rows;
353 }
354 }
355