Skip to content

Replace the setup flow with a one-click pairing handshake - #3

Merged
henry-mosh merged 2 commits into
mainfrom
feat/simplify-plugin-installation-faf3ec
Aug 2, 2026
Merged

Replace the setup flow with a one-click pairing handshake#3
henry-mosh merged 2 commits into
mainfrom
feat/simplify-plugin-installation-faf3ec

Conversation

@henry-mosh

Copy link
Copy Markdown
Collaborator

Connecting a site took three trips between two systems and two secrets hand-carried in opposite directions:

Step Where
Create a ck_live_… org key CiteCue
Paste it into WordPress, click Save & test connection WordPress
Enable delivery for the project CiteCue
Copy the cws_… ingest secret out WordPress → CiteCue
Verify with curl -si -A GPTBot … a terminal

…and the settings screen showed all ten fields throughout, including a project dropdown that is necessarily empty until after the first successful call.

It is now one button.

The handshake

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 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 }

Nothing is copied in either direction: the plugin gets its key and project, CiteCue gets the site's address and content-push secret.

What a reviewer should look hardest at

The code is bearer-grade — whoever presents it receives an API key — so the safety properties are the point, and they're where I'd want a second pair of eyes:

  • The state token is stored server-side, bound to the administrator who started the handshake, single-use, and burnt even on a wrong guess. It is the CSRF defence on the return leg, which cannot carry a WordPress nonce because the redirect originates at CiteCue. Citecue_Admin::maybe_claim_connect() therefore has no check_admin_referer — deliberately, with a comment saying so.
  • The ingest secret travels only in the claim body, never through a browser redirect, and that request sets redirection => 0 — a redirect would be a chance to replay it at another host.
  • Ingest stays off unless the response says ingest: true. CiteCue's connect screen is the only place the customer is told content can be pushed into their site, so it is the only place that may grant it. A response that omits the field never enables it — silence is not consent.

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" — correct for the form that owns the checkbox, destructive for one that doesn't. Partial forms now carry what they don't render as hidden inputs (preserve_toggles()). Verified by mutation: removing the call fails test_the_api_key_fallback_does_not_switch_delivery_off.
  • 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() behave the same in the filtered and unfiltered paths.

The screen

Two shapes now. Before connecting: one sentence, one button, with the API-key route collapsed underneath for installs that can't bounce a browser through CiteCue. After: a status card (project, delivery state, llms.txt, last verification ✓/✗) and only the switches an operator adjusts — API key, project selector and API base folded into Connection details. The two competing save buttons are gone.

Verify installation replaces the curl command: a loopback GET on /llms.txt as GPTBot, checking for the marker header. It runs automatically right after connecting, so "did it work?" is answered before it's asked. A 200 without the header is the full-page-cache misconfiguration — now named on screen instead of hiding in a terminal.

The API base moves behind a CITECUE_API_BASE constant (or the new citecue_pinned_api_base filter) and renders read-only when pinned.

Merging this alone changes nothing for customers

The server side doesn't exist yet, so the Connect 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 — the confirm page, the claim endpoint, storage, and the error codes the plugin already renders.

The one part of that spec I'd not let slide: return must be origin-checked against site, or it's an open redirect that hands an org API key to whoever crafted the link.

Testing

34 new tests. 251 pass in both runs (composer test:core and composer test:woocommerce), phpcs clean, php -l clean.

Covered: the state token is single-use, burnt on a wrong guess, and rejected for a different admin; the secret leaves only in the claim body; the claim doesn't follow redirects; ingest stays off unless granted; every claim error code; a failed claim leaves the site unconnected; both page shapes render (well-formedness checked with a real HTML parser).

🤖 Generated with Claude Code

henry-idingo and others added 2 commits August 2, 2026 17:50
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 <noreply@anthropic.com>
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.
@henry-mosh
henry-mosh merged commit 52e25ce into main Aug 2, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant