Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.51% covered (success)
98.51%
66 / 67
87.50% covered (warning)
87.50%
7 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Connection_Abilities
98.51% covered (success)
98.51%
66 / 67
87.50% covered (warning)
87.50%
7 / 8
16
0.00% covered (danger)
0.00%
0 / 1
 get_category_slug
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_category_definition
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 get_abilities
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 spec_get_connection_status
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
1
 can_view_connection
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_connection_status
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 get_manager
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 registration_url
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
1<?php
2/**
3 * Jetpack Connection Abilities Registration.
4 *
5 * Registers Jetpack Connection abilities with the WordPress Abilities API so
6 * AI agents can inspect the site's connection state through the standard
7 * `wp-abilities/v1` REST surface.
8 *
9 * @package automattic/jetpack-connection
10 */
11
12// @phan-file-suppress PhanUndeclaredFunction, PhanUndeclaredClassMethod @phan-suppress-current-line UnusedSuppression -- Abilities API added in WP 6.9; suppressions needed for older-WP compatibility runs.
13
14namespace Automattic\Jetpack\Connection\Abilities;
15
16use Automattic\Jetpack\Connection\Manager as Connection_Manager;
17use Automattic\Jetpack\Connection\Package_Version;
18use Automattic\Jetpack\WP_Abilities\Registrar;
19use Jetpack_Options;
20
21/**
22 * Registers Jetpack Connection abilities with the WordPress Abilities API.
23 *
24 * Exposes a single read-only ability for site-level connection state so AI
25 * agents can answer "is this site registered?" without having to
26 * reverse-engineer Jetpack_Options keys.
27 *
28 * Writes (registering a new site, disconnecting a user, transferring
29 * ownership) are deliberately deferred to a follow-up PR.
30 */
31class Connection_Abilities extends Registrar {
32
33    const CATEGORY_SLUG = 'jetpack';
34
35    /**
36     * {@inheritDoc}
37     */
38    public static function get_category_slug(): string {
39        return self::CATEGORY_SLUG;
40    }
41
42    /**
43     * {@inheritDoc}
44     *
45     * The `jetpack` ability-category is shared with other Jetpack registrars
46     * (e.g. the Modules_Abilities class in the Jetpack plugin). Only the first
47     * registration wins, so the English source string is kept byte-identical
48     * across registrars to keep the visible category text consistent
49     * regardless of load order.
50     */
51    public static function get_category_definition(): array {
52        return array(
53            // "Jetpack" is a product name and should not be translated.
54            'label'       => 'Jetpack',
55            'description' => __( 'Abilities provided by Jetpack.', 'jetpack-connection' ),
56        );
57    }
58
59    /**
60     * {@inheritDoc}
61     */
62    public static function get_abilities(): array {
63        return array(
64            'jetpack/get-connection-status' => self::spec_get_connection_status(),
65        );
66    }
67
68    /*
69    ---------------------------------------------------------------------
70     * Ability specs
71     * ---------------------------------------------------------------------
72     */
73
74    /**
75     * Spec: jetpack/get-connection-status.
76     */
77    private static function spec_get_connection_status(): array {
78        return array(
79            'label'               => __( 'Get Jetpack connection status', 'jetpack-connection' ),
80            'description'         => __(
81                'Return the site-level Jetpack connection state in one zero-argument call. Shape: { site_registered, user_connected, master_user, blog_id, registration_url, connection_version }. `site_registered` is true when the site has a blog id and a blog token. `user_connected` is true when at least one user has linked their WordPress.com account. `master_user` is the local user id of the connection owner (the user who registered the site), or null if there is no owner. `blog_id` is the WordPress.com site id, or null when the site has not been registered. `registration_url` is the wp-admin URL the site owner should visit to register the site when `site_registered` is false; null once the site is registered. `connection_version` is the running Jetpack Connection package version. Read-only and idempotent — safe to poll.',
82                'jetpack-connection'
83            ),
84            'input_schema'        => array(
85                'type'                 => 'object',
86                'properties'           => new \stdClass(),
87                'additionalProperties' => false,
88            ),
89            'output_schema'       => array(
90                'type'       => 'object',
91                'properties' => array(
92                    'site_registered'    => array( 'type' => 'boolean' ),
93                    'user_connected'     => array( 'type' => 'boolean' ),
94                    'master_user'        => array( 'type' => array( 'integer', 'null' ) ),
95                    'blog_id'            => array( 'type' => array( 'integer', 'null' ) ),
96                    'registration_url'   => array( 'type' => array( 'string', 'null' ) ),
97                    'connection_version' => array( 'type' => 'string' ),
98                ),
99            ),
100            'execute_callback'    => array( __CLASS__, 'get_connection_status' ),
101            'permission_callback' => array( __CLASS__, 'can_view_connection' ),
102            'meta'                => array(
103                'annotations'  => array(
104                    'readonly'    => true,
105                    'destructive' => false,
106                    'idempotent'  => true,
107                ),
108                'show_in_rest' => true,
109                'mcp'          => array(
110                    'public' => true,
111                    'type'   => 'tool', // default is already "tool", but can be explicit.
112                ),
113            ),
114        );
115    }
116
117    /*
118    ---------------------------------------------------------------------
119     * Permission callbacks
120     * ---------------------------------------------------------------------
121     */
122
123    /**
124     * Permission check: mirrors the capability used by the Jetpack admin page.
125     *
126     * Connection state is not sensitive in itself (the same data is exposed
127     * on the Jetpack admin page and through several existing REST endpoints),
128     * but subscribers and contributors have no legitimate need to inspect it.
129     * Gating on `jetpack_admin_page` aligns with how {@see Modules_Abilities}
130     * scopes its read.
131     *
132     * @return bool
133     */
134    public static function can_view_connection(): bool {
135        return current_user_can( 'jetpack_admin_page' );
136    }
137
138    /*
139    ---------------------------------------------------------------------
140     * Execute callbacks
141     * ---------------------------------------------------------------------
142     */
143
144    /**
145     * Execute: get-connection-status.
146     *
147     * @param array|null $input Ignored — zero-arg ability.
148     * @return array
149     */
150    public static function get_connection_status( $input = null ) {
151        unset( $input );
152
153        $manager         = self::get_manager();
154        $site_registered = (bool) $manager->is_connected();
155        $user_connected  = (bool) $manager->has_connected_user();
156
157        $master_user_raw = Jetpack_Options::get_option( 'master_user' );
158        $master_user     = is_numeric( $master_user_raw ) && (int) $master_user_raw > 0 ? (int) $master_user_raw : null;
159
160        $blog_id_raw = Jetpack_Options::get_option( 'id' );
161        $blog_id     = is_numeric( $blog_id_raw ) && (int) $blog_id_raw > 0 ? (int) $blog_id_raw : null;
162
163        return array(
164            'site_registered'    => $site_registered,
165            'user_connected'     => $user_connected,
166            'master_user'        => $master_user,
167            'blog_id'            => $blog_id,
168            'registration_url'   => $site_registered ? null : self::registration_url(),
169            'connection_version' => Package_Version::PACKAGE_VERSION,
170        );
171    }
172
173    /*
174    ---------------------------------------------------------------------
175     * Helpers
176     * ---------------------------------------------------------------------
177     */
178
179    /**
180     * Return a Connection_Manager instance. Filterable for tests so they can
181     * inject a partial mock without having to seed Jetpack_Options + tokens.
182     *
183     * @return Connection_Manager
184     */
185    protected static function get_manager(): Connection_Manager {
186        /**
187         * Filters the Connection_Manager instance used by the Connection abilities.
188         *
189         * Tests inject a partial mock here; production callers should leave
190         * the default. The filter callback receives the package-default
191         * instance and must return a Connection_Manager — non-Manager
192         * returns are discarded.
193         *
194         * @since 8.4.0
195         *
196         * @param Connection_Manager $manager The default instance.
197         */
198        $instance = apply_filters( 'jetpack_connection_abilities_manager', new Connection_Manager() );
199        return $instance instanceof Connection_Manager ? $instance : new Connection_Manager();
200    }
201
202    /**
203     * Build the wp-admin URL the site owner should visit to register the
204     * site to WordPress.com. We deliberately return a stable admin URL (no
205     * secret generation, no XML-RPC roundtrip) so this read stays side-effect
206     * free and cheap to poll. The destination page handles the actual
207     * registration handshake from there.
208     *
209     * WP 7.0+ ships a core "Connectors" screen at `wp-admin/options-connectors.php`
210     * with a Jetpack card registered by {@see Jetpack_Connector}. We probe
211     * for the file directly (rather than a `class_exists()` on the registry)
212     * because the file is what actually serves the URL — if it isn't on
213     * disk, the redirect 404s regardless of which classes have loaded.
214     *
215     * @return string
216     */
217    private static function registration_url(): string {
218        if ( defined( 'ABSPATH' ) && file_exists( ABSPATH . 'wp-admin/options-connectors.php' ) ) {
219            return admin_url( 'options-connectors.php' );
220        }
221        return admin_url( 'admin.php?page=jetpack' );
222    }
223}