# website-chat — lane state (2026-08-04)

> State file, not a log. Rewrite in place. Outranks `HANDOFF.md` for this lane.
> Mission: `plans/kickoff-prompts/website-chat.md`. Plan (locked decisions, stage
> list, gate bars): `website-chat-kickoff.md`. Frontend change: `website-chat-frontend-pr.md`.

## ⭐ Where it stands

Worktree `../channels-manager-website`, branch `channels/website-chat`, pushed
and **merged to `main`**.

**Stages 0–10 (backend half) are BUILT, GREEN, DEPLOYED and SWITCHED ON.** The
`web:chat` gate is **live**, the `web` connection exists, and Turnstile is
enforcing.

## ⭐ THE REWRITE IS LIVE ON THE PREVIEW — the frontend built it (2026-08-12)

The lane's "one thing left" is **half done**, and nobody here knew. The frontend
shipped the `vercel.json` rewrite on `https://sop-main-apple.vercel.app`; it was
reaching us and being refused only because that host was not on the allow-list.
Fixed by rewriting `allowedOrigins` (table below). Measured, not assumed:

| probe | before | after |
|---|---|---|
| `Origin: https://sop-main-apple.vercel.app` → `/api/web-chat/config` | 403 | **200** |
| `GET https://sop-main-apple.vercel.app/chat/config` (their proxy, real visitor shape) | 403 | **200** |
| `POST .../chat/session`, bad token | — | **403 "Could not verify your browser."** |
| `Origin: https://production-omega-rose.vercel.app` | 200 | **403** (host itself 404s — dead) |
| `Origin: https://evil.example.com`, and no Origin/Referer | 403 | **403** |

The session POST's error MOVED from *"Not allowed from this origin."* to *"Could
not verify your browser."* — that is the proof the origin gate is passed, and the
api log reads `Turnstile rejected: ["invalid-input-response"]`, so the secret is
still accepted. **No restart was needed**: the config is read per request.

⚠ **Still unproven, and only a browser on their preview can prove it:** whether
the Turnstile widget's Cloudflare **Hostnames list covers
`sop-main-apple.vercel.app`.** The site key is the marketing site's, whose list
covers the `silveroakglobal.com` domains; a token minted on an unlisted hostname
fails at siteverify and is INDISTINGUISHABLE from the bad-token 403 above. If
their first real session 403s, that list is the first place to look.

⚠ **`https://www.silveroakglobal.com/chat/config` still 404s** — the rewrite is on
the preview project only. The cutover needs the same `vercel.json` entry on the
production project; **it needs nothing from us**, both www and the apex are
already allow-listed.

⚠ **`/chat/widget.js` 404s through their proxy while `/chat/config` 403/200s**, so
their rewrite is not matching every path. Harmless — they built their own client
and `widget.js` is optional (`docs/web-chat-api.md`) — but it is not the
`/chat/:path*` the contract asks for.

**Live-state evidence, 2026-08-04, run from outside the network:**

- Gate read from prod `settings.channelGates`: `{"mode":"live"}`.
- Before the Turnstile secret was set, `POST /api/web-chat/session` with **no
  token and a forged `Origin`** returned **201 + a session cookie**. That was a
  real, live exposure: the per-IP limit keys off `x-forwarded-for`, which any
  caller sets, so the ceiling was the daily cap — 200 conversations × 25 turns =
  5,000 agent turns/day against the budget WhatsApp and Instagram share.
- After the secret was set, the same request returns **403**. Closed.
- The secret is **proven correct, not merely present**: a deliberately-bad token
  makes Cloudflare answer `invalid-input-response`, which means the SECRET was
  accepted. A wrong secret or a site key pasted by mistake answers
  `invalid-input-secret` — and would 403 real visitors identically. **Always
  distinguish these two before believing Turnstile works.**

⚠ **Shadow is NOT a mitigation for spend.** In shadow the agent still runs and
still costs; it only hides the reply from the visitor. The mitigations are the
Turnstile secret, or the gate off.

| stage | state | sha |
|---|---|---|
| 0 gate + `web` ConnProvider migration | done | `c6004e5` |
| 1+2 public endpoint + persistence | done | `2cad867` |
| 3 the agent answers | done | `b0726a7` |
| 4 site knowledge + navigation rail | done | `732de08` |
| 5 spend rail + graceful close | done | `c991497` |
| 6 the session-id-as-phone fix | done | `d4edf5e` |
| 7 the widget | done | `2335620` |
| 8 operator reply reaches the visitor | done | `6dfb0fc` |
| 9 CMS reports endpoint | done | `9b4214f` |
| reviews (gate bars 2+3) | run, 12 findings fixed | `88f9db9` |
| the connection is creatable from Settings | done | `061f73c` |
| the gate is flippable from Overview | done | `ef94e4b` |
| **10 deploy — backend** | **DONE 2026-08-04** | `ef94e4b` |
| **the `web` connection row on prod** | **DONE 2026-08-04** | `e9c4d54` |
| the API contract for the frontend | done — `docs/web-chat-api.md` | `5853dc2` |
| the handover brief for a fresh session | done — `docs/web-chat-frontend-prompt.md` | this session |
| `web:chat` gate flip + Turnstile secret | **DONE 2026-08-04 (operator)** | — |
| **10 deploy — frontend rewrite, PREVIEW** | **DONE by the frontend, unblocked 2026-08-12** | — |
| **10 deploy — frontend rewrite, PRODUCTION domain** | **NOT DONE — theirs to ship** | — |

## Deployed 2026-08-04 — what was actually verified

Prod `main` at `ef94e4b`. Every line below is a command run this session:

- `prisma migrate deploy` → `20260803120000_conn_provider_web` **applied**. The
  "applies on a clean DB" claim is now a fact.
- health `{"status":"ok","db":true,"redis":true,"worker":true}` after
  `pm2 restart channels-api`.
- **A request has crossed HTTP for the first time.**
  `GET /api/web-chat/config` → **503** (gate off, no row — the intended shut
  default). `GET /api/web-chat/widget.js` → **200**, so the production-only 500
  the controller warns about (nest not copying non-TS assets) is closed.
- `GET`/`PUT /api/agent/web-chat/settings` → **401** unauthenticated. The route
  exists, and an unauthenticated `{"mode":"live"}` was refused — nothing flipped.
- Web built on the VPS and published; the served bundle greps positive for
  `Website chat` and `web:"Website"`.
- Full-package suites on the merged code: **apps/api 172 files / 2271 tests ·
  apps/web 109 / 1033 · packages/shared 115 / 1435.** api + web typecheck and
  build clean. Not a scoped subset.
- The Agent → Overview screen was **looked at** in a real browser at 1600 wide
  (devtools initScript + fetch stub), not only in jsdom.

**Still NOT verified, and it cannot be until the operator acts:** no visitor has
ever held a conversation, the CMS adapter has never been called by the running
agent, and the widget has never run on the real site.

## The `web` connection — created 2026-08-04, and what is in it

Written by `scripts/set-web-chat-connection.ts` (committed, merge semantics, safe
to re-run to rotate one field). Read back through the API's own
`readWebChatConfig` against the stored row: **it resolves to a config, not null.**

| field | value | who set it |
|---|---|---|
| allowed origins | `https://www.silveroakglobal.com`, `https://silveroakglobal.com`, `https://sop-main-apple.vercel.app` | script (rewritten 2026-08-12) |
| cms api base | `https://silveroak-nuevo-dashboard.vercel.app` | script |
| turnstile SITE key | `0x4AAAAAADvh8sCY-7vKBMdJ` | script |
| session secret | minted, 32 random bytes, never printed | script |
| caps | seeded defaults (25 turns/session, 200 conversations/day) | defaults |
| **turnstile SECRET** | **SET 2026-08-04, and proven valid** (35 chars; Cloudflare answers `invalid-input-response`, not `invalid-input-secret`) | operator |

**Why `www` matters:** `https://silveroakglobal.com` 301s to
`https://www.silveroakglobal.com` (checked live 2026-08-04), so `www` is the
origin the browser actually sends. The apex is listed anyway because a redirect
is config someone can change.

**Why that site key is the right one:** the marketing site already runs Turnstile
on its contact, careers and lead forms (`assets/js/sop-api.js:49`). That is the
public half of a widget whose Cloudflare Hostnames list therefore already covers
these domains, so the chat needs no new widget and no CSP change. The SECRET half
lives only in the site's Vercel environment (`TURNSTILE_SECRET`, read by
`api/apply.js`) and is not readable from this machine — no local `.env` in the CMS
clone, no Vercel CLI.

⚠ That file also records that a secret was leaked into the site key slot on
2026-07-28 and **is burned and must be rotated**. If the secret you paste was
issued before that date, rotate it in Cloudflare first.

## What the operator has to do, in order

~~1. Paste the Turnstile secret~~ **DONE 2026-08-04.** ⚠ The field is labelled
**"Bot-check secret"**, not "Turnstile" — plain-words copy that cost the operator
a search. Rename it if it confuses anyone again.

~~2. Flip `web:chat`~~ **DONE 2026-08-04 — the gate is live.**

~~3. Hand `docs/web-chat-api.md` to the frontend~~ **MOOT 2026-08-12 — they built
the rewrite themselves on the preview.** ⚠ **Scope moved 2026-08-04: the widget
and its looks are the FRONTEND's, not ours.** We own the endpoint and that
contract. The only required hunk was ever **the `vercel.json` rewrite** — no
client works without it, because it is what makes the session cookie
first-party — and it is live on `sop-main-apple.vercel.app`. Hunk 2 (injecting
our `widget.js`) is optional and they declined it; our `widget.js` stays as the
reference implementation. **Still owed by them: the same rewrite on the
production project**, since `www.silveroakglobal.com/chat/config` 404s.

4. The scope oracle (kickoff § "The oracle for the whole scope") closes the lane.
   ⚠ It still needs **one real visitor conversation**, which nobody has held.

⚠ **`/api/web-chat/config` returns 503 for BOTH "no config" and "gate off", with
the same message** (`web-chat.service.ts:91-100`, config checked first). So a 503
cannot tell you which one you are looking at. To distinguish them, resolve the
row through `readWebChatConfig` as above — do not infer it from the status code.

## Open — only the operator can clear these

~~1. Can we merge to `aatifaneez/silver-oak-properties-main-website`?~~ and
~~2. The `sop-dubai` email~~ — **both DEAD 2026-08-12.** They were only ever
needed so WE could raise the rewrite PR; the frontend shipped it themselves. Do
not re-ask.
3. **The IG/FB half of the phone-number defect** (below) — fix now or board it.
4. **Anonymous transcript retention** — no policy set. A 90-day default is
   proposed; it needs a line in the site's `privacy.html` either way.
5. **`dev/ai-chatbot`** (`ammarsilveroak/ai-chatbot`) is a second repo against
   this same brief with recent commits. Untouched here. If any of it is meant to
   be live, say so before a third thing gets built.

## ⚠ The defect this lane found in EXISTING code

`crm_lead` built the CRM contact number from the channel id, which is right on
WhatsApp where the id IS the number. `normalizeE164` strips non-digits, so a
128-bit hex id reduces to whatever digits it contains. Measured over 100k random
ids: **5.19% land in the 8–15 digit window and would have been sent to LeadRat
as a real phone number.**

Fixed for `web` by an explicit `channelIdIsPhoneNumber` port flag that defaults
TRUE, so no existing wiring changed behaviour.

**Instagram and Facebook ids are IGSIDs, not phone numbers, and they still take
the old path.** Their gates are off so nothing has hit it. Out of this lane —
**operator's call whether to fix it now or board it.**

## ⚠ KNOWN AND DELIBERATELY NOT FIXED — read before switching the gate on

Each is real, each is written down rather than quietly dropped:

⚠ **The gate is LIVE, so 1 and 2 are no longer hypothetical.** They become real
the moment the frontend rewrite lands and visitors can reach the panel.

1. **There is NO LLM spend ceiling in force — on any channel.** Corrected
   2026-08-04 after the operator challenged the earlier wording here, which said
   the web channel "shares the process-wide budget" and implied one existed.
   Verified: `Budget` is constructed from `AGENT_BUDGET_USD`
   (`agent.service.ts:117`), the default is `0`, `0` means **no cap**
   (`pricing.ts:94` → `Infinity`), and the var is **absent from prod `.env` and
   from `.env.example`**. It landed 2026-07-06 (`f738558`) as an internal
   gap-list item, was never requested, and has never been active. It is also
   in-memory, so it would reset on every `pm2 restart`.
   What the web channel actually shares is an **uncapped provider account**; the
   provider-console cap is the only hard stop. The web-specific limits are real
   and ARE data on the connection: 25 turns/session, 200 conversations/day.
   A per-channel ceiling remains a **product decision, operator's.**
2. **No review floor on `crm_lead` for anonymous visitors.** A visitor's words
   drive a real LeadRat lead assigned to a named coordinator. The DM channels
   lean on review floors; the web channel has none. **Operator's call:** route
   web leads through the review path, or accept it.
3. **Turnstile stays optional, and the live row has no secret.** A `web`
   connection with no `turnstileSecret` is still "configured" and the endpoint
   runs unchallenged. Deliberate for local dev. **This is now a real prod
   condition, not a hypothetical** — see the connection table above.
4. **A failed agent turn is a log line only.** No retry, no fallback row; the
   widget shows typing dots until the next message. A `pm2 restart` mid-turn
   loses it the same way. Should persist a degraded reply from the `copy` block.
5. **No tests at all for `cms-site.service.ts`** (including `resolveRoute`'s slug
   regex, which IS the navigation rail — every existing test stubs it) **or for
   `widget.js`**. Three of the review's critical findings lived in the widget.
   There is also no test for the public `web-chat.controller.ts` itself.
6. **Session rotation on contact capture** is specified in the kickoff
   (decision 3) and was never built; `buildClearedSessionCookie` has no caller
   outside its own test.
7. **Unclaimed anonymous contacts are never pruned**, though kickoff decision 5
   says they are. The only retention job in the repo is `automation-retention-prune`.
8. **`web` is not a filterable channel in the Inbox or in Activity.** Both
   filters are built from the playground channel×case registry, and
   `channel-cases.test.ts` deliberately pins `web:chat` out of it (it is neither
   a `dm` nor a `comment` gate surface). Web conversations DO appear in the Inbox
   — you just cannot filter down to them. The Overview tile therefore carries no
   Activity link, and the card links to the plain Inbox, rather than shipping a
   filter that silently does nothing.
9. **The `web` connection has no live "Test" button** — `testable: false`, since
   the generic backend would answer "Unknown provider". A real check (config
   resolves, origins parse, Turnstile secret reachable) would be worth having.
10. Smaller: `readChannelGates` runs twice per `postMessage`; `startSession`
    accepts `pageUrl`/`locale` and discards both; `persistReply` duplicates
    `WebOutboundAdapter`'s row-building; `SitePort.getProperty` has no caller.

## Things that will bite the next session

- **`pnpm typecheck` and `nest build` disagree.** The build rejects test-file
  casts `tsc --noEmit` accepts. Run both before every commit.
- **A Prisma fake missing a method swallows a real exception while the test
  still passes green.** Cost an hour here (`conversation.updateMany`). Fake
  every method the code calls.
- **`Contact` has no `email` column** — an address lives in `attributes`.
- **`MsgStatus` is a Prisma enum**; there is no `shadow` value and adding one is
  a migration. Shadow output lives on the turn trace, like every other channel.
- **The web app carries its OWN copy of `AgentOverviewChannel`**
  (`apps/web/src/lib/agent-api.ts`) — widening the id union in the API service
  alone typechecks fine and then fails in the web package.
- **`dev/frontend-sop` sits on another session's branch** (`perf/lcp-fetchpriority`
  as of 2026-08-03). Do not switch it; use a worktree of that clone.
- The site's CSP is enforced and lives in `frontend-sop/vercel.json`. The design
  deliberately needs **no** change to it.
