Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 47 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,57 @@ 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 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
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)

Expand Down Expand Up @@ -110,6 +150,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) |
Expand All @@ -119,6 +160,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 |
Expand Down
1 change: 1 addition & 0 deletions citecue.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
82 changes: 82 additions & 0 deletions docs/connect-handshake.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading