Replace the setup flow with a one-click pairing handshake - #3
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Connecting a site took three trips between two systems and two secrets hand-carried in opposite directions:
ck_live_…org keycws_…ingest secret outcurl -si -A GPTBot ……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
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:
Citecue_Admin::maybe_claim_connect()therefore has nocheck_admin_referer— deliberately, with a comment saying so.redirection => 0— a redirect would be a chance to replay it at another host.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
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 failstest_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 emptyapi_keymeans "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 makesdisconnect()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.txtas 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_BASEconstant (or the newcitecue_pinned_api_basefilter) 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.mdis the contractcitecue_appneeds — 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:
returnmust be origin-checked againstsite, 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:coreandcomposer test:woocommerce), phpcs clean,php -lclean.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