# Cross-Domain Handoff + GA Client ID — Feature Plan

**Status:** SHIPPED — 1.3.0 (GA client ID) and 1.4.0 (handoff) are released.
**Written:** 2026-07-27. Retained as the design rationale behind those releases.
**Origin:** a client build needing to hand attribution to a third-party enrolment
portal on a separate domain, plus two additions from rian (GA client ID logging; a
login-gated "view journey" alternative to sending data off-site).

**This is a general capability, not an integration with any one vendor.** Destination
domains, allowed origins, the link parameter and the datapoint set are all
configuration. No vendor name or client hostname belongs anywhere in this plugin —
code, settings, or docs.

---

## 1. Problem

A visitor browses the WordPress site, then converts on a **different domain** — an
enrolment portal, CRM form, booking tool, off-domain checkout. The journey lives in
first-party storage on our domain and is unreadable from theirs, so the conversion
arrives with no attribution. Putting the data in the URL is ruled out: it exposes
browsing history in address bars, browser history, and the destination's server logs,
and it blows request-line limits. A short server-minted token, redeemed out of band,
avoids all of that.

Prerequisite on the destination side: a tag manager (Google Tag Manager in the first
deployment) that can read a URL parameter and act on it. Our job ends at "the data is
retrievable by token."

## 2. What ships, in what order

| Release | Feature | Size |
|---|---|---|
| **1.3.0** | GA client ID capture (merge tag + optional handoff datapoint) | Small, standalone |
| **1.4.0** | Cross-domain handoff — two modes, table, endpoints, viewer | Largest feature to date |

Split deliberately: 1.3.0 is a quick win with independent value, and it decouples the
easy part from the release that changes the plugin's attack surface.

---

## 3. GA client ID capture (1.3.0)

**Setting:** "Capture Google Analytics client ID" — one checkbox, **default off**.

**Mechanism:** when enabled, the client ID is **read live from the `_ga` cookie at
merge-tag resolution time** (parse `GA1.<n>.X.Y` → `X.Y`). We never store it ourselves —
it flows only into the places the site owner configured: a GF field via the new
`{bw:ga_client_id}` merge tag, or the handoff payload if that datapoint is ticked.
Live-read beats stored-per-visit: the ID is stable per browser, and if the visitor
clears cookies, the *current* ID is the one that joins to anything.

**Surfaces:** merge tag `{bw:ga_client_id}`; "Analytics" group in the BW Lead Data
dropdown (only when enabled — same pattern as Interactions); Test tab row; addition to
`RESERVED_TAG_NAMES`.

**What it actually covers** — worth being precise, because "fallback" hides three cases:

| Case | What bridges the domains |
|---|---|
| GA linker (`_gl`) working | GA stitches natively; ours is redundant but harmless |
| GA cookie exists, linker broken/misconfigured (common) | **Our `ga_client_id` datapoint** — carried via handoff or GF entry, joinable to GA4/BigQuery |
| GA blocked entirely | `_ga` cookie doesn't exist either — GA ID can't be the fallback here. **The handoff itself is the fallback**: our first-party attribution is unaffected by GA blocking |

**Acceptance (R20):** cookie present → tag resolves to client ID; absent → empty.
Auto-testable in the node harness with a fake `document.cookie`.

---

## 4. Cross-domain handoff (1.4.0)

### 4.1 Two redemption modes, one record

Both of rian's directions are the **same server-side record with two consumers** — not
two storage systems:

- **Mode A — Data handoff (cross-origin claim).** Destination GTM redeems the token and
  receives the configured datapoints as JSON to fill its own fields. Data leaves our
  server for a third-party origin → requires an **explicit acknowledgment checkbox** to
  enable, and the summary datapoints carry their own warning.
- **Mode B — Journey link (view in WordPress).** The token itself is what the
  destination stores (as a viewer URL in a hidden field). A marketer clicks it later and
  — behind WP login — sees the journey rendered in wp-admin. **No visitor data ever
  crosses origins; only an opaque token does.**

**Recommended configuration (and the recommendation for schools): both modes together,
with Mode A restricted to the minimal attribution set** (`channel`, `source`, `medium`,
`campaign`, `term`). The CRM gets small, low-sensitivity, reportable fields; the full
browsing history stays home behind login. Mode B alone means the CRM can't segment on
attribution; Mode A alone means shipping history off-site or having none.

### 4.2 Record lifecycle

```
mint (pending, TTL 60 min)
  ├─ claim  (Mode A) → serve datapoints ONCE, then cross-origin reads 404
  ├─ confirm (Mode B) → status confirmed, retention extended (setting; may be forever)
  └─ TTL expiry → pruned
```

**Two different clocks, and they must not be confused.** The *pending* TTL (60 min) is
an abuse control and stays short no matter what — it is what stops the create endpoint
being free permanent storage for anyone who finds it. The *confirmed* retention is a
business setting and may legitimately be years or unlimited: a confirmed record is one a
real person actually submitted a form to produce.

- **Mint happens on our domain, necessarily.** Only our pages can read the visitor's
  journey; the destination cannot create the record — it can only *promote* it. (This is
  the answer to "create the record at submission time": the submission-time event is the
  **confirm**, not the create.)
- **Burn-on-read (Mode A):** first successful claim is the only one. Implemented as an
  atomic `UPDATE … WHERE data_claimed_at IS NULL` (affected-rows check) so parallel
  claims can't double-serve. Compatible with the destination's retry-on-404 guidance —
  retries only matter when the first read *failed*.
- **Confirm-to-persist (Mode B)** dissolves the storage dilemma. At ~100 clicks/day,
  pending rows die within the hour; only actual submitters persist. Without a confirm, a
  Mode B link dies at token TTL; **a destination that can write a hidden field via GTM
  can also fire a one-line confirm ping on its submit trigger**, so confirm is a
  requirement of Mode B, not an optional nicety.
- **Retention is a setting, including unlimited** (rian, 2026-07-27). Lead cycles here
  run to *years* — a link that dies before the lead converts is a broken feature. Volume
  makes this a non-issue: confirmed records arrive at roughly the rate of form
  submissions (~1/day observed), so a decade is ~3,650 rows of a few KB — smaller than
  the site's Gravity Forms entries table, which nobody prunes either. That comparison is
  the right yardstick: **if a site is comfortable keeping GF entries forever, a confirmed
  handoff record is the same order of commitment.**
- Unlimited retention is a *privacy* decision, not a capacity one: these rows hold a
  visitor's browsing journey indefinitely. The settings copy says so, and the manual
  purge control plus per-record delete in the viewer are the safety valve. Sites with a
  data-retention policy (or minors in scope) should set a finite value.
- Expiry is enforced **at read time** (`expires_at > NOW()` in every lookup), so the
  prune cron being late is cosmetic, never a security hole.

### 4.3 Endpoints — registered only for enabled modes

Feature off → **no routes exist at all** (404, not 403), no script enqueued, no table
created.

**`POST /wp-json/bw-lead-ai/v1/handoff`** — create-or-refresh. Registered when either
mode is on.
- Body: `{ "token": "<optional existing>", "data": { ...datapoints } }`
- Server **filters `data` to the enabled datapoint keys** — unknown keys are dropped
  server-side, so the payload policy is enforced here, not in JS. Empty after filtering
  → 400.
- Refresh (token supplied) requires possession of a live pending token; updates payload,
  does not extend TTL unboundedly (cap: TTL from *first* mint + one refresh window).
- Returns `{ "token": "<32 hex>", "expires_in": 3600 }`. Never reflects the payload.
- Caps: 16 KB payload post-filter (413), per-field caps (summary_detailed ≤ 8 KB,
  summary ≤ 4 KB, scalars ≤ 300 B), rate limit 30/hr per IP-hash (429), global pending
  cap 10,000 rows (503; client degrades silently, debug logs it).

**`GET /wp-json/bw-lead-ai/v1/handoff/<token>`** — claim. Registered only when Mode A on.
- CORS: `Access-Control-Allow-Origin` echoed **only** for an exact allow-listed origin,
  `Vary: Origin`, `Cache-Control: no-store`, OPTIONS preflight handled, never `*`, no
  credentials.
- Single-use (burn above). Uniform 404 for missing/expired/burned — no state oracle.
- Rate limit 120/hr per IP-hash.

**`POST /wp-json/bw-lead-ai/v1/handoff/<token>/confirm`** — promote. Registered only
when Mode B on.
- Same CORS discipline. Idempotent. Optional `{ "context": "<≤200 chars>" }`, sanitized,
  shown in the viewer ("Confirmed by: Enrolment form"). Sets `confirmed_at`, extends
  `expires_at` to now + retention. Uniform 404. Rate limit 30/hr per IP-hash.

### 4.4 Client behaviour (`handoff.js`, enqueued only when enabled)

- Scan anchors against configured destination domains using the shared
  `BWLeadAI.hostMatches` (same semantics as referrer/social matching). MutationObserver
  for late-added links, mirroring `events.js`.
- **Mint once per session** (token cached in `sessionStorage`), on the first page that
  actually contains a matching link — never on every page.
- Decorate matching links: append `?bwlai=<token>` (param name configurable), handling
  existing query strings and fragments; never double-append; http(s) anchors only.
- On click of a decorated link: `fetch(..., { keepalive: true })` refresh with the
  current payload (fits well under the 64 KB keepalive cap). Fire-and-forget; if it's
  lost, the destination claims the page-load payload — degraded, not broken.
- Debug mode logs every step (`[BW Lead AI handoff] minted / decorated 3 links /
  refreshed on click / create failed: …`) — the designed answer to "silent failure for
  weeks".

### 4.5 Viewer (Mode B)

- Admin page (`options-general.php?page=bw-lead-ai-journey&token=…` — it is a submenu
  of Settings, hidden from the menu). Not logged in → normal WP
  login redirect.
- Capability: **`manage_options` default**, filterable via
  `bw_lead_ai_view_journey_capability` (consistent with the debug-capability precedent).
- Renders the datapoints + formatted journey + minted/confirmed timestamps + context +
  GA client ID if captured. **Every value escaped on output** — see threat table; this
  is the top internal risk.
- Settings include a "purge all handoff records" button (also serves as the practical
  GDPR-cleanup lever; retention expiry is the automatic one).

### 4.6 Storage — the plugin's first table, lazily

Earlier recommendation was transients; **that changes here, because Mode B changes
retention from minutes to months.** Long-lived transients can be silently evicted under
object-cache pressure — for a marketer clicking a three-week-old link, eviction reads as
"broken feature", undebuggable. A table is the honest store.

The no-tables principle is preserved for non-users: the table is **created lazily on
first enable** of the feature (dbDelta, version-gated option; creation failure →
admin notice, feature stays off), never on activation. Uninstall drops it.

```sql
CREATE TABLE {$wpdb->prefix}bw_lead_ai_handoffs (
  id              BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  token_hash      CHAR(64)  NOT NULL,          -- sha256 of the bearer token
  payload         LONGTEXT  NOT NULL,          -- filtered JSON, ≤16 KB
  status          VARCHAR(12) NOT NULL DEFAULT 'pending',
  data_claimed_at DATETIME  NULL,
  confirmed_at    DATETIME  NULL,
  context         VARCHAR(200) NULL,
  created_at      DATETIME  NOT NULL,
  expires_at      DATETIME  NOT NULL,
  PRIMARY KEY (id),
  UNIQUE KEY token_hash (token_hash),
  KEY expires_at (expires_at)
)
```

Hourly prune via WP-cron (`bw_lead_ai_handoff_prune`); unscheduled on disable and
uninstall. All timestamps GMT.

### 4.7 Settings (new "Handoff" tab)

| Setting | Default |
|---|---|
| Enable handoff | off |
| Mode A: send datapoints to destination (requires acknowledgment checkbox) | off |
| Mode B: journey link (view in WordPress) | off |
| Destination domains (one per line; `hostMatches` semantics) | empty |
| Allowed claim origins | derived `https://<domain>` per destination, editable |
| Parameter name | `bwlai` |
| Token lifetime (pending records — abuse control, stays short) | 60 min (5–240) |
| Confirmed retention | **Unlimited**, or a value in days. Dropdown: 90 days / 1 year / 2 years / 5 years / Unlimited / Custom. Warning text next to Unlimited |
| Included datapoints | `channel, source, medium, campaign, term` — **summary / summary_detailed OFF with an explicit warning**; `ga_client_id` listed only if GA capture is on |

The acknowledgment text states plainly: *enabling Mode A sends visitor journey data to a
third-party origin; that is a privacy decision the site owner is making and may need to
appear in their privacy policy.* The tab copy recommends Mode B (or the combo) for
schools and other minors-adjacent sites.

All settings scalar CSV/text — same passthrough rules as the Events tab
(`render_passthrough()`), so cross-tab saves can't wipe them.

### 4.8 Security model

Stated plainly first: **CORS is not authentication.** It stops browser JS on non-listed
origins; it does nothing against curl. Token secrecy is the actual control — hence
128-bit CSPRNG (`random_bytes(16)` → 32 hex), short TTL, single-use, hashed at rest.

| Threat | Control |
|---|---|
| Token theft from destination logs / history / other scripts on their page | Burn-on-read + short TTL make logged tokens stale; integration notes tell destination to `history.replaceState` the param away after reading |
| Token theft from **our** access logs (claim GET path) | Burned/expired tokens are worthless; `no-store` everywhere; accepted trade-off, documented (GET kept per spec — the token is in the destination's page URL regardless, so moving claim to POST protects only our logs) |
| DB dump / backup leak | Tokens stored **sha256-hashed** — a dump contains no live bearer tokens |
| Token enumeration | 128-bit space; uniform 404s (no oracle); claim rate limit |
| Free-storage / DB-fill abuse of create | Rate limit per IP-hash, 16 KB cap, per-field caps, server-side key filtering, 10,000-row pending cap, TTL prune |
| **Stored XSS into wp-admin via the viewer** — UTM params, URLs, referrers are attacker-controlled strings | Escape-on-output for every rendered value (the 1.2.3 `nl2br(esc_html())` pattern); `context` sanitized on input *and* escaped on output. The single most important implementation detail |
| Race: two parallel claims | Atomic `UPDATE … WHERE data_claimed_at IS NULL` |
| IP handling vs the plugin's no-IP-logging principle | Never store raw IPs: rate counters keyed by `sha256( wp_salt('auth') . gmdate('Y-m-d') . ip )` — derivable, self-rotating daily, non-reversible, nothing persisted beyond hourly counter transients. `bw_lead_ai_client_ip` filter for odd proxy stacks |
| Cache poisoning / CDN caching of claims | `Cache-Control: no-store`; `Vary: Origin`; `/wp-json/` is dynamic through Cloudflare by default |
| Late cron | Expiry enforced at read time, prune is cosmetic |
| Same-origin spoofing of create | Soft Origin/Referer check only — **deliberately no REST nonce**: page caches/CDN would serve stale nonces and silently break the feature (the exact failure mode we're designing against). Create is treated as public + rate-limited + capped |
| Forms/consent | Mode A gated behind explicit acknowledgment; conservative default datapoints; settings copy states what leaves the site |

### 4.9 Destination integration contract (hand this to the destination's integrator)

1. On portal page load: read `bwlai` param → if Mode A, `fetch` the claim endpoint;
   retry twice on 404 (≥300 ms backoff — covers the mint-vs-claim race); fill fields;
   then `history.replaceState` to strip the param.
2. If Mode B: write the viewer URL
   (`<site>/wp-admin/options-general.php?page=bw-lead-ai-journey&token=<t>`) into a
   hidden field; on the form's submit trigger, `POST …/confirm` with optional
   `context`.
   **Do not hand-build this path from these docs — copy it from the Handoff settings
   tab**, which prints it from `BW_Lead_AI_Handoff_Admin::viewer_url()`. This document
   carried the wrong path (`admin.php?…`) until 1.4.1; anything built from the old
   version wrote dead links into every destination record.
3. Staging note: the Basic-auth exemption must be **scoped to
   `/wp-json/bw-lead-ai/v1/handoff*` only** — exempting all of `/wp-json/` exposes the
   entire REST API (users, posts, settings) on an otherwise-gated host.

### 4.10 Acceptance criteria to add to SPEC.md on approval

- R20 (GA): `_ga` present → `{bw:ga_client_id}` resolves; absent → empty. *auto*
- R21 (create): 32-hex token; unknown keys dropped; >16 KB → 413; rate limit → 429. *wp-cli*
- R22 (claim): allow-listed origin gets data exactly once; second claim 404; non-listed
  origin gets no ACAO header; expired 404. *wp-cli*
- R23 (confirm): confirmed row survives past token TTL; finite retention honoured;
  **unlimited retention never pruned**; context sanitized. *wp-cli*
- R24 (viewer): capability enforced; a journey containing `<script>` in a UTM renders
  escaped. *wp-cli*
- R25 (client): links decorated once, query/fragment handled; keepalive refresh on
  click. *browser*
- R26 (off): no routes registered, no script enqueued, no table created. *wp-cli*

### 4.11 Non-goals

Field-filling on the destination (their tag manager's job, varies per vendor); vendor
SDKs; multisite; backfilling a link between a handoff record and a later GF entry from
the same visitor (worthwhile future idea, out of scope here).

---

## 5. Filters introduced

`bw_lead_ai_view_journey_capability` · `bw_lead_ai_client_ip` ·
`bw_lead_ai_handoff_rate_limits` · `bw_lead_ai_handoff_datapoints`
