From e74122c806d4e11abebcf3beccce1fee3dd2609a Mon Sep 17 00:00:00 2001 From: henry-idingo Date: Sun, 2 Aug 2026 17:50:32 +0200 Subject: [PATCH 1/2] Replace the setup flow with a one-click pairing handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connecting a site took three trips between two systems and carried two secrets in opposite directions: create a ck_live_ key in CiteCue, paste it into WordPress, go back to CiteCue to enable delivery, then copy the cws_ ingest secret out of WordPress and into CiteCue. The settings screen showed all ten fields at once throughout, including a project dropdown that is necessarily empty until after the first successful call. It is now one button. Citecue_Connect mints a state token and redirects to {app}/connect/wordpress; CiteCue confirms the project and redirects back with a one-time code; the plugin spends that code server-to-server, sending its site URL, REST base and ingest secret and storing the key and project it receives. Nothing is copied in either direction. The code is bearer-grade — whoever presents it receives an API key — so the safety properties are the point: - The state token is stored server-side, bound to the administrator who started the handshake, single-use, and burnt even on a failed guess. It is the CSRF defence on the return leg, which cannot carry a WordPress nonce because it originates at CiteCue. - The ingest secret travels only in the claim body, never through a browser redirect, and that request does not follow redirects — a redirect would be a chance to replay it at another host. - Ingest stays off unless the claim response says ingest:true. CiteCue's connect screen is where the customer is told content can be pushed into their site, so it is the only place that may grant it. Silence is not consent. The screen now has two shapes. Before connecting: one sentence, one button, with the API-key route collapsed underneath for installs that cannot bounce a browser through CiteCue. After: a status card (project, delivery state, llms.txt, last verification) and only the switches an operator adjusts, with the API key, project selector and API base folded into Connection details. The two competing save buttons are gone. Verify installation replaces the curl command the README used to ask for: a loopback GET on /llms.txt as GPTBot, checking for the marker header. It runs automatically right after connecting, so the "did it work?" question is answered before it is asked. A 200 without the header is the full-page-cache misconfiguration, which is now named on screen instead of hiding in a terminal. Two fixes that fell out of the restructure: - The partial setup form would have switched delivery off on save. sanitize() reads an absent checkbox as "off", which is correct for the form that owns the checkbox and destructive for one that does not, so any partial form now carries what it does not render as hidden inputs. - Settings::update() cached what it passed in rather than what was stored. sanitize() can legitimately store something else (an empty api_key means "keep the stored one", not "erase it"), so it now drops the cache and lets the next read see the truth. This is also what makes disconnect() work in both the filtered and unfiltered paths. The API base moves behind a CITECUE_API_BASE constant (or the new citecue_pinned_api_base filter) and renders read-only when pinned: pointing a site at another CiteCue deployment is a deployment decision, not something to get wrong while pasting a key. The server side does not exist yet, so the button 404s until it ships; the API-key path is untouched and remains the working route meanwhile. docs/connect-handshake.md is the contract citecue_app needs to implement, including the return-URL origin check that keeps the redirect from becoming an open redirect that hands out an org API key. 34 new tests; 251 pass in both the core and WooCommerce runs. Co-Authored-By: Claude Opus 5 --- README.md | 48 ++- citecue.php | 1 + docs/connect-handshake.md | 82 ++++ includes/class-citecue-admin.php | 446 +++++++++++++++++---- includes/class-citecue-api-client.php | 97 ++++- includes/class-citecue-connect.php | 276 +++++++++++++ includes/class-citecue-plugin.php | 8 + includes/class-citecue-settings.php | 60 ++- readme.txt | 19 +- tests/cases/test-admin-screen.php | 213 ++++++++++ tests/cases/test-connect.php | 395 ++++++++++++++++++ tests/includes/class-citecue-http-mock.php | 8 + 12 files changed, 1564 insertions(+), 89 deletions(-) create mode 100644 docs/connect-handshake.md create mode 100644 includes/class-citecue-connect.php create mode 100644 tests/cases/test-admin-screen.php create mode 100644 tests/cases/test-connect.php diff --git a/README.md b/README.md index 1e03fa0..5aa70f6 100644 --- a/README.md +++ b/README.md @@ -34,17 +34,53 @@ AI crawler (GPTBot, ClaudeBot, …) Human visitor ## Setup 1. Install and activate the plugin (upload this repo as a zip or drop it into `wp-content/plugins/`). -2. In CiteCue, create an organization API key (Settings → API keys, `ck_live_…`). -3. In WordPress, open **Settings → CiteCue**, paste the key, click **Test connection**. The project whose domain matches the site is selected automatically. -4. Make sure delivery is enabled for the project on CiteCue's Auto-Fix page, and add/generate optimized pages there. -5. Verify from a terminal: +2. Open **Settings → CiteCue** and click **Connect to CiteCue**. +3. Confirm the project for this site in CiteCue. You are redirected back, and the plugin checks itself. + +That is the whole setup. Nothing is copied or pasted in either direction: the handshake brings the API key and project back to WordPress, and hands CiteCue this site's address and content-push secret on the way through. + +Then add or generate optimized pages on CiteCue's Auto-Fix page. + +### How the handshake works + +``` +WordPress: [Connect to CiteCue] + │ browser redirect + ▼ +{app}/connect/wordpress?site=…&state=…&return=…&v=… + │ admin confirms the project; CiteCue mints a per-site key + ▼ redirect back to `return` with ?citecue_code=…&citecue_state=… +WordPress verifies the state, then server-to-server: + POST {app}/api/delivery/v2/connect/claim + { code, site_url, rest_url, ingest_secret, plugin_version, woocommerce } + → { apiKey, publicKey, domain, ingest } +``` + +- **`state`** is minted by the plugin, stored server-side, bound to the administrator who started the handshake, single-use and valid for 15 minutes. It is the CSRF defence on the return leg, which cannot carry a WordPress nonce because it originates at CiteCue. +- **The code is bearer-grade** — whoever presents it receives an API key — so it is single-use, short-lived, and CiteCue binds it to the `site_url` presented at claim time. +- **The ingest secret only ever travels in the claim body**, server-to-server, never through a browser redirect. That request does not follow redirects, so a redirect cannot replay it at another host. +- **Ingest stays off unless CiteCue's connect screen says otherwise** (`ingest: true`). A response that omits the field never grants write access — silence is not consent. + +The full server-side contract is in [`docs/connect-handshake.md`](docs/connect-handshake.md). + +### Verifying + +**Settings → CiteCue → Verify installation** requests this site's own `/llms.txt` as GPTBot and checks for the plugin's marker header; the result is shown on the settings screen and re-checked automatically right after connecting. By hand: ```bash curl -si -A GPTBot https://your-site.com/llms.txt # expect: x-citecue: llms-txt curl -si -A GPTBot https://your-site.com/optimized-page/ # expect: x-citecue: served ``` -Or click **Verify installation** in CiteCue. +### Connecting with an API key instead + +An install that cannot bounce a browser through CiteCue — an intranet site, a locked-down staging host — can still connect the original way: **Connect with an API key instead** on the settings screen takes a `ck_live_…` organization key (CiteCue → Settings → API keys) and selects the project whose domain matches the site. CiteCue does not learn the ingest secret this way, so content pushes need it copied over from **Connection details → Shared secret**. + +To pin the CiteCue origin for a self-hosted deployment, define it in `wp-config.php` — the settings field then shows it read-only rather than inviting an edit: + +```php +define( 'CITECUE_API_BASE', 'https://citecue.example.com' ); +``` ## Content push API (create posts) @@ -110,6 +146,7 @@ With WooCommerce active: | Endpoint | Auth | Used for | |---|---|---| +| `POST /api/delivery/v2/connect/claim` | one-time code | Pairing handshake: code → this site's API key + project | | `GET /api/delivery/v2/config` | `Bearer ck_live_…` | Connection test + project auto-selection by domain | | `GET /api/delivery/v2/page?k&u&b` | `Bearer ck_live_…` + `X-Citecue-Channel: wordpress` | Optimized page for a crawler request (ETag/304; 404 = pass through; hit recorded server-side) | | `GET /api/delivery/v2/llms.txt?k` | `Bearer ck_live_…` | llms.txt body (ETag/304) | @@ -119,6 +156,7 @@ With WooCommerce active: | Hook | Type | Purpose | |---|---|---| +| `citecue_pinned_api_base` | filter | Fix the CiteCue app origin from code (defaults to the `CITECUE_API_BASE` constant); a non-empty value makes the settings field read-only | | `citecue_crawler_tokens` | filter | Add/remove AI-crawler UA tokens | | `citecue_matched_crawler` | filter | Override per-request crawler matching | | `citecue_should_serve` | filter | Veto serving for a specific request | diff --git a/citecue.php b/citecue.php index 87d7930..820b5f3 100644 --- a/citecue.php +++ b/citecue.php @@ -28,6 +28,7 @@ require_once CITECUE_PLUGIN_DIR . 'includes/class-citecue-cache.php'; require_once CITECUE_PLUGIN_DIR . 'includes/class-citecue-activity-log.php'; require_once CITECUE_PLUGIN_DIR . 'includes/class-citecue-api-client.php'; +require_once CITECUE_PLUGIN_DIR . 'includes/class-citecue-connect.php'; require_once CITECUE_PLUGIN_DIR . 'includes/class-citecue-proxy.php'; require_once CITECUE_PLUGIN_DIR . 'includes/class-citecue-llms-txt.php'; require_once CITECUE_PLUGIN_DIR . 'includes/class-citecue-ingest.php'; diff --git a/docs/connect-handshake.md b/docs/connect-handshake.md new file mode 100644 index 0000000..81e546c --- /dev/null +++ b/docs/connect-handshake.md @@ -0,0 +1,82 @@ +# Connect handshake — server-side contract + +What `citecue_app` has to implement for the WordPress plugin's one-click connect. The plugin side is done and shipped in this repo ([`includes/class-citecue-connect.php`](../includes/class-citecue-connect.php)); until the two pieces below exist, the button leads to a 404 and customers fall back to **Connect with an API key instead**, which still works exactly as it did. + +The goal is that a customer installs the plugin, clicks one button, confirms in CiteCue, and is done — instead of carrying a `ck_live_…` key into WordPress and a `cws_…` secret back out. + +## 1. `GET /connect/wordpress` — the confirm page + +An authenticated Nuxt page. Query params sent by the plugin: + +| Param | Example | Notes | +|---|---|---| +| `site` | `https://example.com/` | `home_url('/')` — the WordPress site asking to connect | +| `state` | 32 hex chars | Opaque. Echo it back untouched; never interpret it | +| `return` | `https://example.com/wp-admin/options-general.php?page=citecue` | Where to redirect after confirming | +| `v` | `1.0.0` | Plugin version | + +Behaviour: + +1. Require a session. Not logged in → the usual login redirect, preserving the full URL. +2. Resolve the org and pick the `brand_projects` row whose domain matches `site`'s host (compare lowercased, `www.` stripped, as `Citecue_Admin::handle_test_connection()` does). Offer a picker when several match or none do; offer to create one when the org has no project for that domain. +3. Show what connecting will do — serve optimized pages to AI crawlers on that domain, publish its llms.txt, and (checkbox, default **on**) allow CiteCue to push draft content into the site. This screen is the **only** place the customer is told about content pushes, which is why the plugin refuses to enable ingest unless the claim response says so. +4. On confirm: + - Mint a `ck_live_` key scoped to the org, named `WordPress — {host}`, via the same path as `POST /api/orgs/[orgSlug]/api-keys` (`ck_live_${newToken(24)}`, store `sha256Hex` only). One key per connected site: revoking one site must not break the others, and the API-keys screen should show which site each key belongs to. Reuse the existing key when the same `site` reconnects rather than accumulating rows. + - Store a one-time code — random, ≥128 bits — against `{ orgId, projectId, keyId, secret, siteUrl, ingest }` with a **10-minute TTL**, single use. KV with TTL is the natural home; the secret is held only until it is claimed. + - Redirect to `return` with `citecue_code` and `citecue_state` appended (proper query-arg append — `return` already contains `?page=citecue`). + +### Validating `return` — do not skip this + +`return` decides where a bearer-grade code is delivered. Require that its **origin equals `site`'s origin** and that its path is under the site's `/wp-admin/`. Anything else is an open redirect that hands an org API key to whoever crafted the link. Reject with an error page rather than redirecting somewhere safe — a mismatch means the request was tampered with. + +## 2. `POST /api/delivery/v2/connect/claim` — the exchange + +Unauthenticated: the code *is* the credential. The plugin sends `X-Citecue-Channel: wordpress` and does not follow redirects. + +Request body: + +```json +{ + "code": "…", + "site_url": "https://example.com/", + "rest_url": "https://example.com/wp-json/citecue/v1/", + "ingest_secret": "cws_…", + "plugin_version": "1.0.0", + "woocommerce": true +} +``` + +Steps: + +1. Look up the code. Missing → `400 {"error":"invalid_code"}`. Already spent → `409 {"error":"code_used"}`. Past TTL → `410 {"error":"code_expired"}`. +2. **Mark it spent before doing anything else**, so two concurrent claims cannot both succeed. +3. Compare `site_url`'s origin with the `siteUrl` captured when the code was issued. Mismatch → `403 {"error":"site_mismatch"}`. +4. Persist against the project: `site_url`, `rest_url`, `ingest_secret` (encrypted at rest — mirror `google_connections.refreshTokenCiphertext`), `plugin_version`, `woocommerce`, connected-at. Either two columns on `delivery_settings` or a `wordpress_connections` table next to `google_connections`; prefer the table if one project may ever have several sites. +5. Set `delivery_settings.enabled = true` and stamp `installVerifiedAt` — connecting is the customer saying yes, so they should not then have to find a second switch on the Auto-Fix page. +6. Respond `200`: + +```json +{ "apiKey": "ck_live_…", "publicKey": "pk_…", "domain": "example.com", "ingest": true } +``` + +`apiKey` and `publicKey` are required; the plugin rejects a payload without both. `domain` is shown on the settings screen. `ingest` reflects the checkbox from step 3 — **omit it and the plugin leaves content pushes off**, which is the correct failure mode. + +Never return the key on any non-200. Log claim attempts with the code id, outcome and source IP; repeated `invalid_code` from one address is code-guessing. + +## Once this lands + +CiteCue holds the ingest secret and the site's REST base, so the content-push endpoint works without the customer touching **Settings → CiteCue → Shared secret**. The push itself is unchanged — same HMAC-SHA256 scheme documented in the [README](../README.md#content-push-api-create-posts). + +Worth doing at the same time, since the data is now there: **Verify installation** in the app can call `GET {rest_url}health` (public, returns `{plugin, version, delivery, ingest, woocommerce}`) instead of probing headers blind. + +## Errors the plugin already renders + +| Status | `error` | Shown to the customer | +|---|---|---| +| 400 | `invalid_code` | That connection link is not valid. Start the connection again from WordPress. | +| 409 | `code_used` | That connection link has already been used. Start the connection again from WordPress. | +| 410 | `code_expired` | That connection link expired. Start the connection again from WordPress. | +| 403 | `site_mismatch` | CiteCue issued that link for a different site address than this one. | +| other | — | Unexpected response from CiteCue (HTTP *n*). | + +Any of these leaves the site unconnected, with the setup screen and its API-key fallback intact. diff --git a/includes/class-citecue-admin.php b/includes/class-citecue-admin.php index ad7f489..5e3d10f 100644 --- a/includes/class-citecue-admin.php +++ b/includes/class-citecue-admin.php @@ -1,7 +1,12 @@ array( 'success', __( 'Connected to CiteCue.', 'citecue' ) ), 'auto_selected' => array( 'success', __( 'Connected to CiteCue — the project matching this site was selected automatically.', 'citecue' ) ), + 'paired' => array( 'success', __( 'Connected. CiteCue now knows this site’s address and can serve optimized pages to AI crawlers.', 'citecue' ) ), + 'pair_state' => array( 'error', __( 'That connection link did not match this WordPress session, so it was not used. Start the connection again.', 'citecue' ) ), + 'pair_fail' => array( 'error', __( 'The connection could not be completed.', 'citecue' ) ), + 'disconnected' => array( 'success', __( 'Disconnected from CiteCue. Optimized pages are no longer served.', 'citecue' ) ), + 'verified' => array( 'success', __( 'Verified — this site answers AI crawlers with CiteCue’s headers.', 'citecue' ) ), + 'verify_fail' => array( 'warning', __( 'Verification failed. See the details below.', 'citecue' ) ), 'auth' => array( 'error', __( 'CiteCue rejected the API key.', 'citecue' ) ), - 'conn_fail' => array( 'error', __( 'Could not reach CiteCue. Check the API base URL and try again.', 'citecue' ) ), + 'conn_fail' => array( 'error', __( 'Could not reach CiteCue. Check your connection and try again.', 'citecue' ) ), 'crawlers_ok' => array( 'success', __( 'Crawler registry refreshed.', 'citecue' ) ), 'crawlers_fail' => array( 'warning', __( 'Could not refresh the crawler registry; the current list stays active.', 'citecue' ) ), 'flushed' => array( 'success', __( 'Delivery cache flushed.', 'citecue' ) ), @@ -141,9 +156,107 @@ public function notices() { ); $code = sanitize_key( wp_unslash( $_GET['citecue_msg'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended - if ( isset( $messages[ $code ] ) ) { - echo '

' . esc_html( $messages[ $code ][1] ) . '

'; + if ( ! isset( $messages[ $code ] ) ) { + return; + } + + // The handshake failure reason is carried out-of-band rather than in the + // URL: it is API text, not something to reflect back from a query arg. + $detail = 'pair_fail' === $code ? get_transient( 'citecue_connect_error' ) : ''; + if ( $detail ) { + delete_transient( 'citecue_connect_error' ); } + + echo '

' + . esc_html( $messages[ $code ][1] ) + . ( $detail ? ' ' . esc_html( $detail ) : '' ) + . '

'; + } + + /** + * Completes a handshake when CiteCue redirects back with a one-time code. + * + * There is no nonce on this request and there cannot be one — the redirect + * originates at CiteCue. The state token minted by Citecue_Connect::start() + * is the CSRF defence: it lives server-side, is bound to this user, is + * single-use, and expires in fifteen minutes. + * + * @return void + */ + public function maybe_claim_connect() { + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- the state token below is the nonce. + if ( ! isset( $_GET['page'], $_GET['citecue_code'] ) || 'citecue' !== $_GET['page'] ) { + return; + } + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + $state = isset( $_GET['citecue_state'] ) ? sanitize_text_field( wp_unslash( $_GET['citecue_state'] ) ) : ''; + $code = sanitize_text_field( wp_unslash( $_GET['citecue_code'] ) ); + // phpcs:enable WordPress.Security.NonceVerification.Recommended + + if ( ! $this->plugin->connect->verify_state( $state ) ) { + $this->redirect_with( 'pair_state' ); + } + + $result = $this->plugin->connect->claim( $code ); + if ( is_wp_error( $result ) ) { + set_transient( 'citecue_connect_error', $result->get_error_message(), MINUTE_IN_SECONDS ); + $this->redirect_with( 'pair_fail' ); + } + + // Answer the "did it work?" question before it is asked. + $this->plugin->connect->verify_install(); + + $this->redirect_with( 'paired' ); + } + + /** + * Sends the admin to CiteCue to pick the project for this site. + * + * @return void + */ + public function handle_connect_start() { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You are not allowed to do that.', 'citecue' ) ); + } + check_admin_referer( 'citecue_connect_start' ); + + // Not wp_safe_redirect(): this one deliberately leaves the site, to the + // configured CiteCue app and nowhere else. + wp_redirect( $this->plugin->connect->start( $this->settings_url() ) ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect + exit; + } + + /** + * Forgets the CiteCue credentials. + * + * @return void + */ + public function handle_disconnect() { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You are not allowed to do that.', 'citecue' ) ); + } + check_admin_referer( 'citecue_disconnect' ); + + $this->plugin->connect->disconnect(); + $this->redirect_with( 'disconnected' ); + } + + /** + * Runs the loopback install check. + * + * @return void + */ + public function handle_verify_install() { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You are not allowed to do that.', 'citecue' ) ); + } + check_admin_referer( 'citecue_verify_install' ); + + $result = $this->plugin->connect->verify_install(); + $this->redirect_with( $result['ok'] ? 'verified' : 'verify_fail' ); } /** @@ -269,73 +382,102 @@ public function render_page() { if ( ! current_user_can( 'manage_options' ) ) { return; } - - $settings = $this->plugin->settings; - $projects = get_option( 'citecue_projects_cache', array() ); - $projects = is_array( $projects ) ? $projects : array(); - $secret = $settings->ensure_ingest_secret(); - $registry = $this->plugin->crawlers->registry_info(); - $api_key = (string) $settings->get( 'api_key' ); ?>

+ plugin->settings->is_connected() ) { + $this->render_connected(); + } else { + $this->render_setup(); + } + ?> +
+ plugin->settings; + $host = (string) wp_parse_url( home_url( '/' ), PHP_URL_HOST ); + ?> +

+ +

+ +
+

- + ' . esc_html( $host ) . '' + ); + ?>

+
+ + + +
+
-
- - -

+
+ + + preserve_toggles( array( 'serve_enabled', 'llms_txt_enabled', 'ingest_enabled' ) ); + ?> - - - - - - - - + render_api_base_row( $settings ); ?> + + +
+ + /** + * The running site: status first, then the handful of switches an operator + * changes. Everything used only to establish or repair the connection is + * folded into "Connection details" at the bottom. + * + * @return void + */ + private function render_connected() { + $settings = $this->plugin->settings; + $projects = get_option( 'citecue_projects_cache', array() ); + $projects = is_array( $projects ) ? $projects : array(); + $secret = $settings->ensure_ingest_secret(); + $registry = $this->plugin->crawlers->registry_info(); + + $this->render_status_card(); + ?> + +
+ + +

@@ -366,7 +508,7 @@ public function render_page() { -

+

@@ -431,24 +573,67 @@ public function render_page() { - -

+
+ +

+
+

-

- + + +

+ + + + + + + + + + + render_api_base_row( $settings ); ?> + -

+

@@ -478,23 +663,132 @@ public function render_page() { } ?>

-

- curl -si -A GPTBot ' . esc_html( home_url( '/llms.txt' ) ) . '' - ); - ?> -

render_activity(); ?>

+ plugin->settings; + $domain = (string) $settings->get( 'project_domain' ); + $verified = $this->plugin->connect->last_verification(); + ?> +
+

+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
get( 'serve_enabled' ) ? esc_html__( 'Served to AI crawlers', 'citecue' ) : esc_html__( 'Off', 'citecue' ); ?>
+ get( 'llms_txt_enabled' ) ) : ?> + + + + +
get( 'ingest_enabled' ) ? esc_html__( 'Accepted (as drafts, unless raised below)', 'citecue' ) : esc_html__( 'Not accepted', 'citecue' ); ?>
+ + + + + + + + + +
+

+ action_button( 'citecue_verify_install', __( 'Verify installation', 'citecue' ) ); ?> + action_button( 'citecue_disconnect', __( 'Disconnect', 'citecue' ) ); ?> +

+ + + + api_base_is_locked() ) : ?> + api_base() ); ?> +

+ + +

+ + + + plugin->settings->get( $key ) ) { + continue; + } + printf( + '', + esc_attr( Citecue_Settings::OPTION ), + esc_attr( $key ) + ); + } + } + /** * Renders a small admin-post action button. * diff --git a/includes/class-citecue-api-client.php b/includes/class-citecue-api-client.php index 87d6c52..d73f486 100644 --- a/includes/class-citecue-api-client.php +++ b/includes/class-citecue-api-client.php @@ -7,8 +7,10 @@ * GET /api/delivery/v2/config — org projects (connection test / selection) * GET /api/delivery/v2/page — optimized page for an AI crawler (ETag/304) * GET /api/delivery/v2/llms.txt — llms.txt body (ETag/304) - * and the public keyless registry feed: + * the public keyless registry feed: * GET /api/delivery/v1/crawlers — AI crawler UA token registry + * and the code-gated pairing exchange: + * POST /api/delivery/v2/connect/claim — one-time code → this site's key * * The v2 page endpoint records the crawler hit server-side (served on * 200/304, passthrough on 404), so one request both serves and reports. @@ -99,6 +101,99 @@ private function get( $url, array $headers, $timeout ) { ); } + /** + * Performs a JSON POST and normalizes the response. + * + * @param string $url Full URL. + * @param array $payload Body, JSON-encoded. + * @param int $timeout Timeout in seconds. + * @return array|WP_Error {status:int, body:string} + */ + private function post_json( $url, array $payload, $timeout ) { + $response = wp_remote_post( + $url, + array( + 'timeout' => $timeout, + 'redirection' => 0, + 'user-agent' => 'CiteCue-WordPress/' . CITECUE_VERSION . ' (+' . home_url( '/' ) . ')', + 'headers' => array( + 'Content-Type' => 'application/json', + 'X-Citecue-Channel' => 'wordpress', + ), + 'body' => wp_json_encode( $payload ), + ) + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + return array( + 'status' => (int) wp_remote_retrieve_response_code( $response ), + 'body' => (string) wp_remote_retrieve_body( $response ), + ); + } + + /** + * POST /api/delivery/v2/connect/claim — spends a one-time connect code. + * + * Unauthenticated by design: the code *is* the credential, which is why it + * is single-use, short-lived and bound server-side to the site URL sent + * here. Redirection is disabled — a redirect would be a chance to replay + * the ingest secret at somewhere other than the configured API base. + * + * @param string $code One-time code from the connect redirect. + * @param array $site {site_url, rest_url, ingest_secret, plugin_version, woocommerce}. + * @return array|WP_Error {apiKey, publicKey, domain, ingest?} + */ + public function claim_connect_code( $code, array $site ) { + $result = $this->post_json( + $this->settings->api_base() . '/api/delivery/v2/connect/claim', + array_merge( array( 'code' => (string) $code ), $site ), + 15 + ); + if ( is_wp_error( $result ) ) { + return $result; + } + + $data = json_decode( $result['body'], true ); + $data = is_array( $data ) ? $data : array(); + + if ( 200 !== $result['status'] ) { + $reasons = array( + 'invalid_code' => __( 'That connection link is not valid. Start the connection again from WordPress.', 'citecue' ), + 'code_used' => __( 'That connection link has already been used. Start the connection again from WordPress.', 'citecue' ), + 'code_expired' => __( 'That connection link expired. Start the connection again from WordPress.', 'citecue' ), + 'site_mismatch' => __( 'CiteCue issued that link for a different site address than this one.', 'citecue' ), + ); + $code_key = isset( $data['error'] ) ? (string) $data['error'] : ''; + + if ( isset( $reasons[ $code_key ] ) ) { + return new WP_Error( 'citecue_connect_' . $code_key, $reasons[ $code_key ] ); + } + /* translators: %d: HTTP status code. */ + return new WP_Error( 'citecue_http_error', sprintf( __( 'Unexpected response from CiteCue (HTTP %d).', 'citecue' ), $result['status'] ) ); + } + + if ( empty( $data['apiKey'] ) || empty( $data['publicKey'] ) ) { + return new WP_Error( 'citecue_bad_payload', __( 'CiteCue returned an unexpected payload.', 'citecue' ) ); + } + + $connection = array( + 'apiKey' => sanitize_text_field( (string) $data['apiKey'] ), + 'publicKey' => sanitize_text_field( (string) $data['publicKey'] ), + 'domain' => sanitize_text_field( (string) ( isset( $data['domain'] ) ? $data['domain'] : '' ) ), + ); + + // Absent, not false, when CiteCue says nothing about content pushes — + // the caller must be able to tell "denied" from "not mentioned". + if ( isset( $data['ingest'] ) ) { + $connection['ingest'] = (bool) $data['ingest']; + } + + return $connection; + } + /** * GET /api/delivery/v2/config — the API key's org projects. * diff --git a/includes/class-citecue-connect.php b/includes/class-citecue-connect.php new file mode 100644 index 0000000..37888f0 --- /dev/null +++ b/includes/class-citecue-connect.php @@ -0,0 +1,276 @@ +plugin = $plugin; + } + + /** + * Mints the handshake state and returns the CiteCue URL to send the admin + * to. The state is bound to the current user so a code redirected into a + * different admin's browser cannot be claimed. + * + * @param string $return_url Admin URL CiteCue should redirect back to. + * @return string + */ + public function start( $return_url ) { + $state = bin2hex( random_bytes( 16 ) ); + + set_transient( + self::STATE_TRANSIENT, + array( + 'state' => $state, + 'user_id' => get_current_user_id(), + ), + self::STATE_TTL + ); + + return add_query_arg( + array( + 'site' => rawurlencode( home_url( '/' ) ), + 'state' => rawurlencode( $state ), + 'return' => rawurlencode( $return_url ), + 'v' => rawurlencode( CITECUE_VERSION ), + ), + $this->plugin->settings->api_base() . '/connect/wordpress' + ); + } + + /** + * Checks the state token echoed back by CiteCue and consumes it. + * + * Consumed on failure too: a state that has been guessed at once is burnt, + * so a wrong guess cannot be retried against the same handshake. + * + * @param string $state State echoed back in the redirect. + * @return bool + */ + public function verify_state( $state ) { + $stored = get_transient( self::STATE_TRANSIENT ); + delete_transient( self::STATE_TRANSIENT ); + + if ( ! is_array( $stored ) || empty( $stored['state'] ) ) { + return false; + } + if ( get_current_user_id() !== (int) $stored['user_id'] ) { + return false; + } + + return hash_equals( (string) $stored['state'], (string) $state ); + } + + /** + * Spends a one-time connect code: sends CiteCue everything it needs to + * push content back (site URL, REST base, ingest secret) and stores the + * credentials it returns. + * + * @param string $code One-time code from the redirect. + * @return array|WP_Error {publicKey, domain, ingest} on success. + */ + public function claim( $code ) { + $settings = $this->plugin->settings; + + $result = $this->plugin->api->claim_connect_code( + $code, + array( + 'site_url' => home_url( '/' ), + 'rest_url' => rest_url( 'citecue/v1/' ), + 'ingest_secret' => $settings->ensure_ingest_secret(), + 'plugin_version' => CITECUE_VERSION, + 'woocommerce' => class_exists( 'WooCommerce' ), + ) + ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + $update = array( + 'api_key' => $result['apiKey'], + 'public_key' => $result['publicKey'], + 'project_domain' => $result['domain'], + ); + + // CiteCue's connect screen is where the customer is told that content + // can be pushed into their site, so it is the only place that may turn + // ingest on. A response that omits the flag leaves the opt-in exactly + // as it was — silence never grants write access. + if ( isset( $result['ingest'] ) ) { + $update['ingest_enabled'] = (bool) $result['ingest']; + } + + $settings->update( $update ); + + // A fresh key retires any earlier rejection and the back-off it opened. + delete_option( 'citecue_auth_failed' ); + delete_transient( 'citecue_circuit' ); + update_option( 'citecue_last_config_at', time(), false ); + + return $result; + } + + /** + * Undoes a connection locally: forgets the credentials so the settings + * screen returns to its "not connected" state. + * + * The ingest secret is deliberately kept — regenerating it is a separate, + * explicit action, and keeping it means reconnecting the same site does + * not invalidate a secret CiteCue may still hold. + * + * @return void + */ + public function disconnect() { + $this->plugin->settings->update( + array( + // Both spellings on purpose. An empty api_key means "keep the + // stored one" to sanitize(), so erasing it needs the explicit + // flag — but sanitize() only runs once register_setting() has, + // and the empty value is what a write that bypasses the filter + // has to see. Either path must end up with no key. + 'api_key' => '', + 'api_key_clear' => 1, + 'public_key' => '', + 'project_domain' => '', + 'ingest_enabled' => false, + ) + ); + + delete_option( 'citecue_projects_cache' ); + delete_option( 'citecue_auth_failed' ); + delete_option( self::VERIFY_OPTION ); + $this->plugin->cache->flush(); + } + + /** + * Runs the check the README used to ask customers to run by hand: request + * this site's own llms.txt as an AI crawler and look for the marker header + * the plugin sets. + * + * A failure here is nearly always a full-page cache or CDN answering ahead + * of PHP, which is exactly the misconfiguration worth surfacing in the + * admin rather than in a terminal. + * + * @return array {ok:bool, status:int, marker:string, message:string} + */ + public function verify_install() { + $response = wp_remote_get( + home_url( '/llms.txt' ), + array( + 'timeout' => 10, + 'redirection' => 2, + 'user-agent' => self::VERIFY_USER_AGENT, + 'headers' => array( 'Cache-Control' => 'no-cache' ), + // Loopback requests hit whatever certificate the site presents + // to itself, which on staging is routinely self-signed. Core's + // own loopback checks (WP_Site_Health) make the same trade: + // nothing secret is sent and nothing but a header is read. + 'sslverify' => false, + ) + ); + + if ( is_wp_error( $response ) ) { + $result = array( + 'ok' => false, + 'status' => 0, + 'marker' => '', + 'message' => $response->get_error_message(), + ); + } else { + $status = (int) wp_remote_retrieve_response_code( $response ); + $marker = (string) wp_remote_retrieve_header( $response, 'x-citecue' ); + + $result = array( + 'ok' => 200 === $status && '' !== $marker, + 'status' => $status, + 'marker' => $marker, + 'message' => '', + ); + + if ( ! $result['ok'] ) { + $result['message'] = '' === $marker + ? __( 'The response did not carry the “x-citecue” header. A full-page cache or CDN in front of PHP is the usual cause — exclude AI-crawler user agents from it, or use CiteCue’s Cloudflare Worker instead.', 'citecue' ) + /* translators: %d: HTTP status code. */ + : sprintf( __( 'The site answered with HTTP %d.', 'citecue' ), $status ); + } + } + + $result['checked_at'] = time(); + update_option( self::VERIFY_OPTION, $result, false ); + + return $result; + } + + /** + * The last verification result, or null if the check has never run. + * + * @return array|null + */ + public function last_verification() { + $stored = get_option( self::VERIFY_OPTION ); + return is_array( $stored ) ? $stored : null; + } +} diff --git a/includes/class-citecue-plugin.php b/includes/class-citecue-plugin.php index 2e2081c..5f78449 100644 --- a/includes/class-citecue-plugin.php +++ b/includes/class-citecue-plugin.php @@ -59,6 +59,13 @@ final class Citecue_Plugin { */ public $api; + /** + * Pairing handshake with the CiteCue app. + * + * @var Citecue_Connect + */ + public $connect; + /** * Returns (and boots) the singleton. * @@ -80,6 +87,7 @@ private function __construct() { $this->cache = new Citecue_Cache(); $this->activity = new Citecue_Activity_Log(); $this->api = new Citecue_Api_Client( $this->settings ); + $this->connect = new Citecue_Connect( $this ); ( new Citecue_Llms_Txt( $this ) )->register(); ( new Citecue_Proxy( $this ) )->register(); diff --git a/includes/class-citecue-settings.php b/includes/class-citecue-settings.php index 610c67d..95c8761 100644 --- a/includes/class-citecue-settings.php +++ b/includes/class-citecue-settings.php @@ -84,9 +84,39 @@ public function get( $key ) { * @return void */ public function update( array $partial ) { - $merged = array_merge( $this->all(), $partial ); - update_option( self::OPTION, $merged ); - $this->values = $merged; + update_option( self::OPTION, array_merge( $this->all(), $partial ) ); + // Drop the cache rather than assume what was written: register_setting() + // routes this through sanitize(), which may legitimately store + // something other than what was passed in (an empty api_key means + // "keep the stored one", not "erase it"). Re-reading is the only way to + // hold the value that actually landed. + $this->values = null; + } + + /** + * A CiteCue origin fixed by the install rather than by the settings form, + * or '' when the stored value governs. + * + * Pointing a site at a different CiteCue deployment is a deployment + * decision — a self-hosted app, a staging origin — not something an + * administrator should be invited to get wrong while pasting a key. When + * one is pinned, api_base() ignores the stored value and the settings + * screen shows the origin read-only. + * + * @return string + */ + public function pinned_api_base() { + $pinned = defined( 'CITECUE_API_BASE' ) ? (string) CITECUE_API_BASE : ''; + + /** + * Filters the pinned CiteCue app origin. Defaults to the + * CITECUE_API_BASE constant; '' leaves the setting editable. + * + * @param string $pinned Pinned origin, or ''. + */ + $pinned = (string) apply_filters( 'citecue_pinned_api_base', $pinned ); + + return '' !== $pinned ? untrailingslashit( $pinned ) : ''; } /** @@ -95,10 +125,34 @@ public function update( array $partial ) { * @return string */ public function api_base() { + $pinned = $this->pinned_api_base(); + if ( '' !== $pinned ) { + return $pinned; + } + $base = untrailingslashit( (string) $this->get( 'api_base' ) ); return '' !== $base ? $base : self::DEFAULT_API_BASE; } + /** + * Whether the API base is fixed by the install, so the UI must not offer + * to edit it. + * + * @return bool + */ + public function api_base_is_locked() { + return '' !== $this->pinned_api_base(); + } + + /** + * Whether this site has been paired with a CiteCue project. + * + * @return bool + */ + public function is_connected() { + return '' !== (string) $this->get( 'api_key' ); + } + /** * Whether the delivery proxy has everything it needs to serve. * diff --git a/readme.txt b/readme.txt index bf21c0f..d8bce07 100644 --- a/readme.txt +++ b/readme.txt @@ -19,17 +19,27 @@ CiteCue AI Auto-Fix connects your WordPress site to CiteCue: * **Content from CiteCue** — a signed endpoint through which CiteCue can push new brand-building content (content briefs, FAQ packs, gap-filling pages) into WordPress as drafts for your review. * **WooCommerce-aware** — cart, checkout, account pages and cart-modifying links are never intercepted, while product and shop pages are served optimized. Pushed content can also create or enrich WooCommerce products (draft by default, matched by SKU with explicit consent). -Requires a CiteCue account and an organization API key. +Requires a CiteCue account. == Installation == 1. Upload the plugin to `/wp-content/plugins/` and activate it. -2. In CiteCue, create an API key under Settings → API keys. -3. In WordPress, go to Settings → CiteCue, paste the key and click "Test connection". +2. Go to Settings → CiteCue and click "Connect to CiteCue". +3. Confirm the project for this site in CiteCue. You are redirected back and the plugin checks itself. 4. Add and generate optimized pages on CiteCue's Auto-Fix page. +There is nothing to copy or paste: the connection brings the API key back to WordPress and hands CiteCue this site's address and content-push secret. Sites that cannot complete a browser round-trip to CiteCue can still connect with an organization API key — "Connect with an API key instead" on the settings screen. + == Frequently Asked Questions == += Do I need to create an API key by hand? = + +No. Clicking "Connect to CiteCue" issues a key for this site and stores it for you. The manual route stays available for installs that cannot redirect through CiteCue. + += How do I check it is working? = + +Settings → CiteCue → "Verify installation" requests your own llms.txt as an AI crawler and confirms the plugin answered. It runs automatically right after you connect. The usual cause of a failure is a full-page cache or CDN answering before WordPress loads. + = Will human visitors ever see the optimized version? = No. Only requests whose User-Agent matches the AI-crawler registry are served optimized content, and those responses are never cached for regular traffic. @@ -51,4 +61,5 @@ Yes. Store pages (cart, checkout, account, all WooCommerce endpoints) are never = 1.0.0 = * Initial release: AI-crawler delivery middleware (CiteCue delivery API v2), llms.txt serving, signed content-ingest endpoint, daily crawler-registry refresh, admin settings screen. * WooCommerce support: store-page exclusions for the middleware; product create/enrich through the ingest endpoint. -* Hardening: single-use ingest signatures (replay rejection), per-minute delivery lookup budget, CiteCue-compatible cache-key URL normalization, cache eviction on delivery misses, crawler-registry downgrade rejection with the bundled token floor, and a combined "Save & test connection" flow. +* Hardening: single-use ingest signatures (replay rejection), per-minute delivery lookup budget, CiteCue-compatible cache-key URL normalization, cache eviction on delivery misses, and crawler-registry downgrade rejection with the bundled token floor. +* One-click connect: a pairing handshake sets up the site without copying an API key in or a signing secret out, with a built-in "Verify installation" check. Connecting with an API key remains available as a fallback. diff --git a/tests/cases/test-admin-screen.php b/tests/cases/test-admin-screen.php new file mode 100644 index 0000000..932dec0 --- /dev/null +++ b/tests/cases/test-admin-screen.php @@ -0,0 +1,213 @@ +admin = new Citecue_Admin( $this->plugin ); + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + } + + /** + * The rendered settings page. + * + * @return string + */ + private function render() { + ob_start(); + $this->admin->render_page(); + return (string) ob_get_clean(); + } + + /** + * @return void + */ + public function test_setup_leads_with_the_one_click_connect() { + $html = $this->render(); + + $this->assertStringContainsString( 'citecue_connect_start', $html ); + $this->assertStringContainsString( 'Connect to CiteCue', $html ); + } + + /** + * Everything an operator adjusts on a running site is noise on a site that + * has not been connected yet. + * + * @return void + */ + public function test_setup_hides_the_running_site_settings() { + $html = $this->render(); + + $this->assertStringNotContainsString( 'ingest_post_status', $html ); + $this->assertStringNotContainsString( 'Recent AI crawler activity', $html ); + } + + /** + * The key route has to survive for installs that cannot bounce a browser + * through CiteCue, but as a fallback rather than the front door. + * + * @return void + */ + public function test_setup_keeps_the_api_key_route_available() { + $html = $this->render(); + + $this->assertStringContainsString( 'citecue_api_key', $html ); + $this->assertStringContainsString( 'Connect with an API key instead', $html ); + } + + /** + * The fallback form renders no delivery checkboxes, and sanitize() reads + * an absent checkbox as "off" — so submitting it would silently stop the + * site serving anything unless the values it does not render travel with + * it. Driving the rendered fields through sanitize() is the only way to + * catch that; asserting on the markup alone would miss a rename. + * + * @return void + */ + public function test_the_api_key_fallback_does_not_switch_delivery_off() { + $this->plugin->settings->update( + array( + 'serve_enabled' => true, + 'llms_txt_enabled' => true, + ) + ); + + $posted = $this->posted_fields( $this->render() ); + $posted['api_key'] = 'ck_live_pasted'; + $out = $this->plugin->settings->sanitize( $posted ); + + $this->assertTrue( $out['serve_enabled'] ); + $this->assertTrue( $out['llms_txt_enabled'] ); + } + + /** + * An off toggle must stay off, rather than being resurrected by a + * blanket hidden field. + * + * @return void + */ + public function test_the_api_key_fallback_does_not_switch_ingest_on() { + $posted = $this->posted_fields( $this->render() ); + + $out = $this->plugin->settings->sanitize( $posted ); + + $this->assertFalse( $out['ingest_enabled'] ); + } + + /** + * The `citecue_settings[…]` fields a rendered form would submit, as the + * nested array sanitize() receives. + * + * @param string $html Rendered page. + * @return array + */ + private function posted_fields( $html ) { + preg_match_all( '/name="citecue_settings\[(\w+)\]"[^>]*value="([^"]*)"/', $html, $matches, PREG_SET_ORDER ); + + $fields = array(); + foreach ( $matches as $match ) { + $fields[ $match[1] ] = $match[2]; + } + return $fields; + } + + /** + * @return void + */ + public function test_a_connected_site_shows_its_status_and_settings() { + $this->configure_delivery( array( 'project_domain' => 'example.org' ) ); + + $html = $this->render(); + + $this->assertStringContainsString( 'Connected to CiteCue', $html ); + $this->assertStringContainsString( 'example.org', $html ); + $this->assertStringContainsString( 'citecue_verify_install', $html ); + $this->assertStringContainsString( 'ingest_post_status', $html ); + $this->assertStringNotContainsString( 'citecue_connect_start', $html ); + } + + /** + * @return void + */ + public function test_a_connected_site_reports_the_last_verification() { + $this->configure_delivery(); + $this->http->queue( 'loopback', 200, 'llms', array( 'x-citecue' => 'llms-txt' ) ); + $this->plugin->connect->verify_install(); + + $html = $this->render(); + + $this->assertStringContainsString( 'Serving CiteCue headers', $html ); + } + + /** + * @return void + */ + public function test_a_failed_verification_says_what_went_wrong() { + $this->configure_delivery(); + $this->http->queue( 'loopback', 200, 'theme output' ); + $this->plugin->connect->verify_install(); + + $html = $this->render(); + + $this->assertStringContainsString( 'x-citecue', $html ); + } + + /** + * Pointing a site at a different CiteCue deployment is a wp-config + * decision; offering it as an editable field invites getting it wrong + * while pasting a key. + * + * @return void + */ + public function test_a_pinned_api_base_is_shown_but_not_editable() { + add_filter( 'citecue_pinned_api_base', array( $this, 'pin_api_base' ) ); + + $html = $this->render(); + + $this->assertStringContainsString( 'https://citecue.internal', $html ); + $this->assertStringNotContainsString( 'name="citecue_settings[api_base]"', $html ); + } + + /** + * @return void + */ + public function test_an_unpinned_api_base_stays_editable() { + $html = $this->render(); + + $this->assertStringContainsString( 'name="citecue_settings[api_base]"', $html ); + } + + /** + * Filter callback pinning the app origin. + * + * @return string + */ + public function pin_api_base() { + return 'https://citecue.internal'; + } +} diff --git a/tests/cases/test-connect.php b/tests/cases/test-connect.php new file mode 100644 index 0000000..349acaf --- /dev/null +++ b/tests/cases/test-connect.php @@ -0,0 +1,395 @@ +connect = new Citecue_Connect( $this->plugin ); + } + + /** + * A successful claim response. + * + * @param array $overrides Payload overrides. + * @return string + */ + private function claim_payload( array $overrides = array() ) { + return wp_json_encode( + array_merge( + array( + 'apiKey' => 'ck_live_frompairing', + 'publicKey' => 'pk_paired', + 'domain' => 'example.org', + ), + $overrides + ) + ); + } + + /** + * @return void + */ + public function test_the_start_url_points_at_the_configured_app() { + $url = $this->connect->start( admin_url( 'options-general.php?page=citecue' ) ); + + $this->assertStringStartsWith( Citecue_Settings::DEFAULT_API_BASE . '/connect/wordpress', $url ); + } + + /** + * CiteCue needs to know which site is asking and where to send the admin + * back to, and the plugin needs to recognise its own handshake on return. + * + * @return void + */ + public function test_the_start_url_carries_the_site_return_and_state() { + $return = admin_url( 'options-general.php?page=citecue' ); + + $url = $this->connect->start( $return ); + parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $query ); + + $this->assertSame( home_url( '/' ), $query['site'] ); + $this->assertSame( $return, $query['return'] ); + $this->assertNotEmpty( $query['state'] ); + $this->assertTrue( $this->connect->verify_state( $query['state'] ) ); + } + + /** + * @return void + */ + public function test_a_wrong_state_is_rejected() { + $this->connect->start( admin_url( 'options-general.php?page=citecue' ) ); + + $this->assertFalse( $this->connect->verify_state( 'not-the-state' ) ); + } + + /** + * The state is the handshake's only CSRF defence, so a code cannot be + * replayed against a second visit to the return URL. + * + * @return void + */ + public function test_a_state_can_only_be_spent_once() { + $url = $this->connect->start( admin_url( 'options-general.php?page=citecue' ) ); + parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $query ); + + $this->assertTrue( $this->connect->verify_state( $query['state'] ) ); + $this->assertFalse( $this->connect->verify_state( $query['state'] ) ); + } + + /** + * A guess burns the handshake too — otherwise the token could be brute + * forced against a still-open window. + * + * @return void + */ + public function test_a_failed_guess_burns_the_handshake() { + $url = $this->connect->start( admin_url( 'options-general.php?page=citecue' ) ); + parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $query ); + + $this->connect->verify_state( 'wrong' ); + + $this->assertFalse( $this->connect->verify_state( $query['state'] ) ); + } + + /** + * A code redirected into a different administrator's browser must not + * connect the site on their behalf. + * + * @return void + */ + public function test_the_state_is_bound_to_the_admin_who_started_it() { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + $url = $this->connect->start( admin_url( 'options-general.php?page=citecue' ) ); + parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $query ); + + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + + $this->assertFalse( $this->connect->verify_state( $query['state'] ) ); + } + + /** + * @return void + */ + public function test_a_claim_stores_the_credentials_it_receives() { + $this->http->queue( 'connect', 200, $this->claim_payload() ); + + $result = $this->connect->claim( 'one-time-code' ); + + $this->assertNotWPError( $result ); + $this->reset_settings_cache(); + $this->assertSame( 'ck_live_frompairing', $this->plugin->settings->get( 'api_key' ) ); + $this->assertSame( 'pk_paired', $this->plugin->settings->get( 'public_key' ) ); + $this->assertSame( 'example.org', $this->plugin->settings->get( 'project_domain' ) ); + $this->assertTrue( $this->plugin->settings->is_delivery_configured() ); + } + + /** + * The whole point of the handshake: CiteCue is handed the ingest secret + * and this site's addresses instead of the customer copying them over. + * + * @return void + */ + public function test_a_claim_hands_citecue_the_ingest_secret_and_site_urls() { + $this->http->queue( 'connect', 200, $this->claim_payload() ); + $secret = $this->plugin->settings->ensure_ingest_secret(); + + $this->connect->claim( 'one-time-code' ); + + $request = $this->http->last( 'connect' ); + $sent = json_decode( $request['args']['body'], true ); + + $this->assertSame( 'one-time-code', $sent['code'] ); + $this->assertSame( $secret, $sent['ingest_secret'] ); + $this->assertSame( home_url( '/' ), $sent['site_url'] ); + $this->assertSame( rest_url( 'citecue/v1/' ), $sent['rest_url'] ); + $this->assertSame( CITECUE_VERSION, $sent['plugin_version'] ); + } + + /** + * The secret must never ride in the browser redirect, only in the + * server-to-server exchange — and a redirect there would be a chance to + * replay it somewhere other than the configured API base. + * + * @return void + */ + public function test_the_claim_never_follows_a_redirect() { + $this->http->queue( 'connect', 200, $this->claim_payload() ); + + $this->connect->claim( 'one-time-code' ); + + $request = $this->http->last( 'connect' ); + $this->assertSame( 0, $request['args']['redirection'] ); + } + + /** + * Accepting content pushes is the customer's decision, taken on CiteCue's + * connect screen where it is spelled out. Silence is not consent. + * + * @return void + */ + public function test_ingest_stays_off_when_the_response_says_nothing_about_it() { + $this->http->queue( 'connect', 200, $this->claim_payload() ); + + $this->connect->claim( 'one-time-code' ); + + $this->reset_settings_cache(); + $this->assertFalse( $this->plugin->settings->get( 'ingest_enabled' ) ); + } + + /** + * @return void + */ + public function test_ingest_is_enabled_when_the_connect_screen_granted_it() { + $this->http->queue( 'connect', 200, $this->claim_payload( array( 'ingest' => true ) ) ); + + $this->connect->claim( 'one-time-code' ); + + $this->reset_settings_cache(); + $this->assertTrue( $this->plugin->settings->get( 'ingest_enabled' ) ); + } + + /** + * @return void + */ + public function test_a_claim_lifts_an_earlier_auth_failure() { + update_option( 'citecue_auth_failed', time() ); + $this->plugin->cache->trip_circuit( Citecue_Cache::AUTH_CIRCUIT_TTL ); + $this->http->queue( 'connect', 200, $this->claim_payload() ); + + $this->connect->claim( 'one-time-code' ); + + $this->assertFalse( get_option( 'citecue_auth_failed' ) ); + $this->assertFalse( $this->plugin->cache->is_circuit_open() ); + } + + /** + * @return void + */ + public function test_an_expired_code_is_reported_as_such() { + $this->http->queue( 'connect', 410, wp_json_encode( array( 'error' => 'code_expired' ) ) ); + + $result = $this->connect->claim( 'stale-code' ); + + $this->assertWPError( $result ); + $this->assertSame( 'citecue_connect_code_expired', $result->get_error_code() ); + } + + /** + * @return void + */ + public function test_a_used_code_is_reported_as_such() { + $this->http->queue( 'connect', 409, wp_json_encode( array( 'error' => 'code_used' ) ) ); + + $result = $this->connect->claim( 'spent-code' ); + + $this->assertWPError( $result ); + $this->assertSame( 'citecue_connect_code_used', $result->get_error_code() ); + } + + /** + * A half-finished handshake must not leave the site believing it is + * connected — that would silently stop serving with no way to tell why. + * + * @return void + */ + public function test_a_failed_claim_leaves_the_site_unconnected() { + $this->http->queue( 'connect', 400, wp_json_encode( array( 'error' => 'invalid_code' ) ) ); + + $this->connect->claim( 'nonsense' ); + + $this->reset_settings_cache(); + $this->assertFalse( $this->plugin->settings->is_connected() ); + $this->assertSame( '', $this->plugin->settings->get( 'api_key' ) ); + } + + /** + * @return void + */ + public function test_a_transport_failure_is_an_error_not_a_connection() { + $this->http->queue_error( 'connect', 'Operation timed out' ); + + $result = $this->connect->claim( 'one-time-code' ); + + $this->assertWPError( $result ); + $this->reset_settings_cache(); + $this->assertFalse( $this->plugin->settings->is_connected() ); + } + + /** + * @return void + */ + public function test_a_payload_without_credentials_is_rejected() { + $this->http->queue( 'connect', 200, wp_json_encode( array( 'ok' => true ) ) ); + + $result = $this->connect->claim( 'one-time-code' ); + + $this->assertWPError( $result ); + $this->assertSame( 'citecue_bad_payload', $result->get_error_code() ); + } + + /** + * @return void + */ + public function test_disconnecting_forgets_the_credentials() { + $this->configure_delivery(); + + $this->connect->disconnect(); + + $this->reset_settings_cache(); + $this->assertFalse( $this->plugin->settings->is_connected() ); + $this->assertSame( '', $this->plugin->settings->get( 'public_key' ) ); + $this->assertFalse( $this->plugin->settings->is_delivery_configured() ); + } + + /** + * Reconnecting the same site should not invalidate a secret CiteCue may + * still be holding, so rotating it stays a separate, deliberate action. + * + * @return void + */ + public function test_disconnecting_keeps_the_ingest_secret() { + $this->configure_delivery(); + $secret = $this->plugin->settings->ensure_ingest_secret(); + + $this->connect->disconnect(); + + $this->reset_settings_cache(); + $this->assertSame( $secret, $this->plugin->settings->get( 'ingest_secret' ) ); + } + + /** + * The check that used to be a curl command in the README: ask this site + * for its own llms.txt as a crawler and look for the marker header. + * + * @return void + */ + public function test_verification_passes_when_the_marker_header_comes_back() { + $this->http->queue( 'loopback', 200, 'llms', array( 'x-citecue' => 'llms-txt' ) ); + + $result = $this->connect->verify_install(); + + $this->assertTrue( $result['ok'] ); + $this->assertSame( 'llms-txt', $result['marker'] ); + } + + /** + * A 200 without the header is the full-page-cache misconfiguration — the + * failure most worth naming, and the one a curl command hid in a terminal. + * + * @return void + */ + public function test_verification_fails_when_something_else_answered() { + $this->http->queue( 'loopback', 200, 'theme output' ); + + $result = $this->connect->verify_install(); + + $this->assertFalse( $result['ok'] ); + $this->assertNotSame( '', $result['message'] ); + } + + /** + * @return void + */ + public function test_verification_survives_an_unreachable_site() { + $this->http->queue_error( 'loopback', 'Connection refused' ); + + $result = $this->connect->verify_install(); + + $this->assertFalse( $result['ok'] ); + $this->assertSame( 0, $result['status'] ); + } + + /** + * @return void + */ + public function test_the_last_verification_is_remembered() { + $this->assertNull( $this->connect->last_verification() ); + $this->http->queue( 'loopback', 200, 'llms', array( 'x-citecue' => 'llms-txt' ) ); + + $this->connect->verify_install(); + + $stored = $this->connect->last_verification(); + $this->assertTrue( $stored['ok'] ); + $this->assertGreaterThan( 0, $stored['checked_at'] ); + } + + /** + * The loopback has to look like the traffic being verified, or it proves + * nothing about what a crawler would receive. + * + * @return void + */ + public function test_verification_requests_as_a_crawler() { + $this->http->queue( 'loopback', 200, 'llms', array( 'x-citecue' => 'llms-txt' ) ); + + $this->connect->verify_install(); + + $request = $this->http->last( 'loopback' ); + $this->assertSame( home_url( '/llms.txt' ), $request['url'] ); + $this->assertNotEmpty( $this->plugin->crawlers->match( $request['args']['user-agent'] ) ); + } +} diff --git a/tests/includes/class-citecue-http-mock.php b/tests/includes/class-citecue-http-mock.php index f1b121b..b368262 100644 --- a/tests/includes/class-citecue-http-mock.php +++ b/tests/includes/class-citecue-http-mock.php @@ -187,6 +187,9 @@ public function intercept( $preempt, $args, $url ) { * @return string */ private static function classify( $url ) { + if ( false !== strpos( $url, '/api/delivery/v2/connect/claim' ) ) { + return 'connect'; + } if ( false !== strpos( $url, '/api/delivery/v2/page' ) ) { return 'page'; } @@ -199,6 +202,11 @@ private static function classify( $url ) { if ( false !== strpos( $url, '/api/delivery/v1/crawlers' ) ) { return 'crawlers'; } + // The install check requests this very site, so a loopback is a + // distinct endpoint rather than an unexpected call. + if ( 0 === strpos( $url, home_url( '/' ) ) ) { + return 'loopback'; + } return 'other'; } From e47a21f5988e020086fbb23a2e30a4ad1ff7a2a4 Mon Sep 17 00:00:00 2001 From: henry-idingo Date: Sun, 2 Aug 2026 18:13:09 +0200 Subject: [PATCH 2/2] Address review: connection state, verify strictness, uninstall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the Codex review of the pairing handshake. is_connected() treated any stored API key as a connection. The key-entry fallback saves what was submitted *before* testing it, so a key CiteCue had just rejected was still on disk — the screen then showed the "Connected" panel and hid the Connect button on a site that had never connected. A connection is now evidenced by CiteCue's own answer: a selected project, or the org's project list cached by a successful config call. Neither survives a 401 or a transport failure, so a failed first attempt stays on the setup screen. A site that connected once and whose key was later revoked deliberately stays "connected" — its settings are still worth showing, and the rejected-key notice already says what is wrong. No migration: existing installs have a public_key. Verification accepted any non-empty X-Citecue header on a 200. Both Citecue_Llms_Txt and Citecue_Proxy hook template_redirect at priority 0 and the proxy does not exclude /llms.txt, so whenever llms.txt falls through — switched off locally, or 404 from CiteCue — a crawler request to that URL can be answered by the proxy with `X-Citecue: served`. The check now requires exactly `llms-txt` and names what answered instead. Being strict creates a case that was previously masked: with llms.txt switched off the check can prove nothing. It now reports that it could not run (a third state, `skipped`, rendered neutrally rather than as a failure) instead of blaming the site for a check that never applied. uninstall.php did not remove citecue_install_verified, so a reinstall could show the previous installation's verification result indefinitely. Removed along with the connect-state transient. Each fix is mutation-checked: reverting it fails the test that covers it. 260 tests pass in both runs. --- README.md | 6 +- includes/class-citecue-admin.php | 11 ++- includes/class-citecue-connect.php | 113 +++++++++++++++++++++------- includes/class-citecue-settings.php | 24 +++++- tests/cases/test-admin-screen.php | 45 ++++++++++- tests/cases/test-connect.php | 33 ++++++++ tests/cases/test-lifecycle.php | 5 ++ tests/cases/test-settings.php | 62 +++++++++++++++ uninstall.php | 2 + 9 files changed, 269 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 5aa70f6..66ff6da 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,11 @@ The full server-side contract is in [`docs/connect-handshake.md`](docs/connect-h ### Verifying -**Settings → CiteCue → Verify installation** requests this site's own `/llms.txt` as GPTBot and checks for the plugin's marker header; the result is shown on the settings screen and re-checked automatically right after connecting. By hand: +**Settings → CiteCue → Verify installation** requests this site's own `/llms.txt` as GPTBot and requires exactly `x-citecue: llms-txt` back. The result is shown on the settings screen and re-checked automatically right after connecting. + +The strictness matters: when llms.txt falls through — switched off here, or no llms.txt for the project on CiteCue — the crawler proxy is next on `template_redirect` and can answer the same URL with `x-citecue: served`. Accepting any marker would read that as proof llms.txt works, which is what it disproves. With `Serve llms.txt` switched off the check reports that it could not run, rather than a failure the site did not have. + +By hand: ```bash curl -si -A GPTBot https://your-site.com/llms.txt # expect: x-citecue: llms-txt diff --git a/includes/class-citecue-admin.php b/includes/class-citecue-admin.php index 5e3d10f..fafaf32 100644 --- a/includes/class-citecue-admin.php +++ b/includes/class-citecue-admin.php @@ -145,8 +145,9 @@ public function notices() { 'pair_state' => array( 'error', __( 'That connection link did not match this WordPress session, so it was not used. Start the connection again.', 'citecue' ) ), 'pair_fail' => array( 'error', __( 'The connection could not be completed.', 'citecue' ) ), 'disconnected' => array( 'success', __( 'Disconnected from CiteCue. Optimized pages are no longer served.', 'citecue' ) ), - 'verified' => array( 'success', __( 'Verified — this site answers AI crawlers with CiteCue’s headers.', 'citecue' ) ), + 'verified' => array( 'success', __( 'Verified — this site answers AI crawlers with CiteCue’s llms.txt.', 'citecue' ) ), 'verify_fail' => array( 'warning', __( 'Verification failed. See the details below.', 'citecue' ) ), + 'verify_skip' => array( 'info', __( 'The check could not run. See the details below.', 'citecue' ) ), 'auth' => array( 'error', __( 'CiteCue rejected the API key.', 'citecue' ) ), 'conn_fail' => array( 'error', __( 'Could not reach CiteCue. Check your connection and try again.', 'citecue' ) ), 'crawlers_ok' => array( 'success', __( 'Crawler registry refreshed.', 'citecue' ) ), @@ -256,6 +257,9 @@ public function handle_verify_install() { check_admin_referer( 'citecue_verify_install' ); $result = $this->plugin->connect->verify_install(); + if ( ! empty( $result['skipped'] ) ) { + $this->redirect_with( 'verify_skip' ); + } $this->redirect_with( $result['ok'] ? 'verified' : 'verify_fail' ); } @@ -723,10 +727,13 @@ private function render_status_card() { + + + diff --git a/includes/class-citecue-connect.php b/includes/class-citecue-connect.php index 37888f0..e2b0443 100644 --- a/includes/class-citecue-connect.php +++ b/includes/class-citecue-connect.php @@ -53,6 +53,15 @@ class Citecue_Connect { */ const VERIFY_USER_AGENT = 'Mozilla/5.0 (compatible; GPTBot/1.2; +https://openai.com/gptbot)'; + /** + * The exact X-Citecue value Citecue_Llms_Txt::serve() emits. Nothing else + * counts: when llms.txt falls through — switched off here, or 404 from + * CiteCue — the crawler proxy is next on `template_redirect` and may + * answer the same URL with `X-Citecue: served`. Accepting any marker + * would read that as proof llms.txt works. + */ + const VERIFY_MARKER = 'llms-txt'; + /** * Plugin container. * @@ -214,9 +223,35 @@ public function disconnect() { * of PHP, which is exactly the misconfiguration worth surfacing in the * admin rather than in a terminal. * - * @return array {ok:bool, status:int, marker:string, message:string} + * @return array {ok:bool, skipped:bool, status:int, marker:string, message:string, checked_at:int} */ public function verify_install() { + $result = $this->run_verification(); + $result['checked_at'] = time(); + update_option( self::VERIFY_OPTION, $result, false ); + + return $result; + } + + /** + * The check itself, without the bookkeeping. + * + * @return array {ok:bool, skipped:bool, status:int, marker:string, message:string} + */ + private function run_verification() { + // The only thing this check can prove is that llms.txt is served, so + // with llms.txt switched off it proves nothing. Say that rather than + // reporting a failure the site did not have. + if ( ! $this->plugin->settings->get( 'llms_txt_enabled' ) ) { + return self::verdict( + false, + 0, + '', + __( 'This check asks for the site’s llms.txt, which is switched off below. Turn on “Serve llms.txt” to run it.', 'citecue' ), + true + ); + } + $response = wp_remote_get( home_url( '/llms.txt' ), array( @@ -233,35 +268,61 @@ public function verify_install() { ); if ( is_wp_error( $response ) ) { - $result = array( - 'ok' => false, - 'status' => 0, - 'marker' => '', - 'message' => $response->get_error_message(), - ); - } else { - $status = (int) wp_remote_retrieve_response_code( $response ); - $marker = (string) wp_remote_retrieve_header( $response, 'x-citecue' ); - - $result = array( - 'ok' => 200 === $status && '' !== $marker, - 'status' => $status, - 'marker' => $marker, - 'message' => '', - ); + return self::verdict( false, 0, '', $response->get_error_message() ); + } + + $status = (int) wp_remote_retrieve_response_code( $response ); + $marker = (string) wp_remote_retrieve_header( $response, 'x-citecue' ); - if ( ! $result['ok'] ) { - $result['message'] = '' === $marker - ? __( 'The response did not carry the “x-citecue” header. A full-page cache or CDN in front of PHP is the usual cause — exclude AI-crawler user agents from it, or use CiteCue’s Cloudflare Worker instead.', 'citecue' ) - /* translators: %d: HTTP status code. */ - : sprintf( __( 'The site answered with HTTP %d.', 'citecue' ), $status ); - } + if ( 200 === $status && self::VERIFY_MARKER === $marker ) { + return self::verdict( true, $status, $marker, '' ); } - $result['checked_at'] = time(); - update_option( self::VERIFY_OPTION, $result, false ); + if ( 200 !== $status ) { + /* translators: %d: HTTP status code. */ + return self::verdict( false, $status, $marker, sprintf( __( 'The site answered with HTTP %d.', 'citecue' ), $status ) ); + } - return $result; + if ( '' === $marker ) { + return self::verdict( + false, + $status, + $marker, + __( 'The response did not carry the “x-citecue” header. A full-page cache or CDN in front of PHP is the usual cause — exclude AI-crawler user agents from it, or use CiteCue’s Cloudflare Worker instead.', 'citecue' ) + ); + } + + return self::verdict( + false, + $status, + $marker, + sprintf( + /* translators: 1: header value received, 2: header value expected. */ + __( 'Something other than the llms.txt handler answered — the response carried “x-citecue: %1$s” rather than “%2$s”. CiteCue most likely has no llms.txt for this project yet.', 'citecue' ), + $marker, + self::VERIFY_MARKER + ) + ); + } + + /** + * One verification result. + * + * @param bool $ok Whether the site answered as it should. + * @param int $status HTTP status, 0 when no response was obtained. + * @param string $marker X-Citecue header value received. + * @param string $message Explanation, '' when ok. + * @param bool $skipped Whether the check could not run at all. + * @return array + */ + private static function verdict( $ok, $status, $marker, $message, $skipped = false ) { + return array( + 'ok' => $ok, + 'skipped' => $skipped, + 'status' => $status, + 'marker' => $marker, + 'message' => $message, + ); } /** diff --git a/includes/class-citecue-settings.php b/includes/class-citecue-settings.php index 95c8761..c742c18 100644 --- a/includes/class-citecue-settings.php +++ b/includes/class-citecue-settings.php @@ -145,12 +145,32 @@ public function api_base_is_locked() { } /** - * Whether this site has been paired with a CiteCue project. + * Whether this site has been paired with CiteCue. + * + * Holding an API key is not evidence of a connection. The key-entry + * fallback saves the submitted key *before* testing it, so a key CiteCue + * has just rejected is still on disk — treating that as connected would + * replace the setup screen and its Connect button with a "Connected" + * panel the site has not earned. What only a successful exchange can + * produce is CiteCue's own answer: a selected project, or the org's + * project list cached from a config call. + * + * A site that connected once and whose key was later revoked stays + * "connected" on purpose — its settings are still worth showing, and the + * rejected-key notice already says what is wrong. * * @return bool */ public function is_connected() { - return '' !== (string) $this->get( 'api_key' ); + if ( '' === (string) $this->get( 'api_key' ) ) { + return false; + } + if ( '' !== (string) $this->get( 'public_key' ) ) { + return true; + } + + $projects = get_option( 'citecue_projects_cache', array() ); + return is_array( $projects ) && array() !== $projects; } /** diff --git a/tests/cases/test-admin-screen.php b/tests/cases/test-admin-screen.php index 932dec0..a21f4ea 100644 --- a/tests/cases/test-admin-screen.php +++ b/tests/cases/test-admin-screen.php @@ -161,7 +161,50 @@ public function test_a_connected_site_reports_the_last_verification() { $html = $this->render(); - $this->assertStringContainsString( 'Serving CiteCue headers', $html ); + $this->assertStringContainsString( 'Serving CiteCue', $html ); + } + + /** + * The reviewer's scenario: the key-entry fallback saves what was typed + * before testing it, so a rejected key is still on disk. The screen must + * keep offering Connect rather than claiming the site is set up. + * + * @return void + */ + public function test_a_rejected_key_leaves_the_setup_screen_in_place() { + // What handle_test_connection() leaves behind when the test fails: + // the submitted key, and nothing from CiteCue. + $this->plugin->settings->update( array( 'api_key' => 'ck_live_rejected' ) ); + update_option( 'citecue_auth_failed', time() ); + + $html = $this->render(); + + $this->assertStringContainsString( 'citecue_connect_start', $html ); + $this->assertStringNotContainsString( 'Connected to CiteCue', $html ); + } + + /** + * A key that did work reaches the connected screen even when no project + * matched this site's domain, because picking one is what happens next. + * + * @return void + */ + public function test_a_working_key_without_a_matching_project_is_connected() { + $this->plugin->settings->update( array( 'api_key' => 'ck_live_works' ) ); + update_option( + 'citecue_projects_cache', + array( + array( + 'publicKey' => 'pk_elsewhere', + 'domain' => 'other.example', + 'enabled' => true, + ), + ) + ); + + $html = $this->render(); + + $this->assertStringContainsString( 'Connected to CiteCue', $html ); } /** diff --git a/tests/cases/test-connect.php b/tests/cases/test-connect.php index 349acaf..55c0186 100644 --- a/tests/cases/test-connect.php +++ b/tests/cases/test-connect.php @@ -351,6 +351,39 @@ public function test_verification_fails_when_something_else_answered() { $this->assertNotSame( '', $result['message'] ); } + /** + * When llms.txt falls through — no llms.txt for the project upstream — + * the crawler proxy is next on template_redirect and can answer the same + * URL with `X-Citecue: served`. Accepting any marker would read that as + * proof llms.txt works, which is precisely what it disproves. + * + * @return void + */ + public function test_verification_rejects_the_proxys_marker() { + $this->http->queue( 'loopback', 200, 'optimized page', array( 'x-citecue' => 'served' ) ); + + $result = $this->connect->verify_install(); + + $this->assertFalse( $result['ok'] ); + $this->assertStringContainsString( 'served', $result['message'] ); + } + + /** + * With llms.txt switched off the check can prove nothing, so it reports + * that rather than a failure the site did not have. + * + * @return void + */ + public function test_verification_is_skipped_when_llms_txt_is_off() { + $this->plugin->settings->update( array( 'llms_txt_enabled' => false ) ); + + $result = $this->connect->verify_install(); + + $this->assertTrue( $result['skipped'] ); + $this->assertFalse( $result['ok'] ); + $this->assertSame( 0, $this->http->count( 'loopback' ) ); + } + /** * @return void */ diff --git a/tests/cases/test-lifecycle.php b/tests/cases/test-lifecycle.php index 3724bc7..653dd3b 100644 --- a/tests/cases/test-lifecycle.php +++ b/tests/cases/test-lifecycle.php @@ -139,6 +139,8 @@ public function test_the_daily_sync_survives_an_outage() { public function test_uninstall_removes_plugin_options() { $this->configure_delivery(); $this->plugin->activity->record( 'GPTBot', '/a/', 'served' ); + $this->http->queue( 'loopback', 200, 'llms', array( 'x-citecue' => 'llms-txt' ) ); + $this->plugin->connect->verify_install(); Citecue_Plugin::activate(); $this->run_uninstall(); @@ -146,6 +148,9 @@ public function test_uninstall_removes_plugin_options() { $this->assertFalse( get_option( Citecue_Settings::OPTION ) ); $this->assertFalse( get_option( Citecue_Activity_Log::OPTION ) ); $this->assertFalse( get_option( Citecue_Crawlers::OPTION ) ); + // Left behind, this outlives a reinstall and reports the previous + // installation's result as if it were the new one's. + $this->assertFalse( get_option( Citecue_Connect::VERIFY_OPTION ) ); $this->assertFalse( wp_next_scheduled( Citecue_Plugin::CRON_HOOK ) ); } diff --git a/tests/cases/test-settings.php b/tests/cases/test-settings.php index 5baca80..4e58383 100644 --- a/tests/cases/test-settings.php +++ b/tests/cases/test-settings.php @@ -59,6 +59,68 @@ public function test_delivery_needs_both_keys() { $this->assertTrue( $this->settings->is_delivery_configured() ); } + /** + * The key-entry fallback stores the submitted key before testing it, so a + * key CiteCue has just rejected is still on disk. Only CiteCue's own + * answer — a project, or the cached project list — proves a connection. + * + * @return void + */ + public function test_a_stored_key_alone_is_not_a_connection() { + $this->settings->update( array( 'api_key' => 'ck_live_rejected' ) ); + + $this->assertFalse( $this->settings->is_connected() ); + } + + /** + * @return void + */ + public function test_a_selected_project_makes_it_a_connection() { + $this->settings->update( + array( + 'api_key' => 'ck_live_works', + 'public_key' => 'pk_one', + ) + ); + + $this->assertTrue( $this->settings->is_connected() ); + } + + /** + * A working key whose org has no project for this domain still connected — + * choosing the project is the next step, not a reason to start over. + * + * @return void + */ + public function test_a_cached_project_list_makes_it_a_connection() { + $this->settings->update( array( 'api_key' => 'ck_live_works' ) ); + update_option( 'citecue_projects_cache', array( array( 'publicKey' => 'pk_elsewhere' ) ) ); + + $this->assertTrue( $this->settings->is_connected() ); + } + + /** + * An empty cache is what a failed config call leaves behind, and must not + * read as success. + * + * @return void + */ + public function test_an_empty_project_list_is_not_a_connection() { + $this->settings->update( array( 'api_key' => 'ck_live_rejected' ) ); + update_option( 'citecue_projects_cache', array() ); + + $this->assertFalse( $this->settings->is_connected() ); + } + + /** + * @return void + */ + public function test_no_key_is_never_a_connection() { + update_option( 'citecue_projects_cache', array( array( 'publicKey' => 'pk_one' ) ) ); + + $this->assertFalse( $this->settings->is_connected() ); + } + /** * Re-saving the settings form posts an empty key field (the real key is * never rendered), so an empty value must mean "unchanged", not "erase". diff --git a/uninstall.php b/uninstall.php index c31a360..229eced 100644 --- a/uninstall.php +++ b/uninstall.php @@ -18,9 +18,11 @@ delete_option( 'citecue_auth_failed' ); delete_option( 'citecue_projects_cache' ); delete_option( 'citecue_last_config_at' ); +delete_option( 'citecue_install_verified' ); delete_transient( 'citecue_circuit' ); delete_transient( 'citecue_ingest_rate' ); +delete_transient( 'citecue_connect_state' ); // Page/llms.txt transients are salt-keyed and expire on their own within a day. wp_clear_scheduled_hook( 'citecue_daily_sync' );