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