dewee-webchat 0.2.0 → 0.4.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,15 +1,15 @@
1
1
  # dewee-webchat
2
2
 
3
3
  Put your [Dewee](https://dewee.sh) agent on any website. A dependency-free chat widget that streams the
4
- agent's **reasoning** and **tool calls**, plus a small backend handler that keeps your gateway token
5
- off the browser.
4
+ agent's answers (and, when you allow it, its **reasoning** and **tool calls**), plus a small backend
5
+ handler that keeps your integration secret off the browser.
6
6
 
7
7
  **Playground:** [webchat.dewee.sh](https://webchat.dewee.sh) · **CDN:** [webchat-cdn.dewee.sh](https://webchat-cdn.dewee.sh) ·
8
8
  **Source & examples:** [github.com/nextlevelbuilder/dewee-webchat](https://github.com/nextlevelbuilder/dewee-webchat)
9
9
 
10
10
  ```
11
- browser <dewee-chat> ──HTTPS + SSE──▶ your backend /api/dewee/* ──WebSocket──▶ Dewee gateway
12
- (visitor token only) (holds the gateway token) (agents, MCP, tools)
11
+ browser <dewee-chat> ──HTTPS + SSE──▶ your backend /api/dewee/* ──HTTPS + SSE──▶ Dewee CCP (Website chat)
12
+ (visitor token only) (holds the integration secret) (agents, sign-in, limits)
13
13
  ```
14
14
 
15
15
  ## 1. Add the backend
@@ -47,17 +47,43 @@ PHP? Use the single-file backend in [`php/`](https://github.com/nextlevelbuilder
47
47
 
48
48
  ### Environment
49
49
 
50
+ Create a **Website chat** integration in CCP (the Dewee control plane): choose its agents, allowed
51
+ sites, sign-in mode, limits and whether reasoning and tool activity are shown. Its page prints the
52
+ first three values below.
53
+
54
+ | Variable | |
55
+ |---|---|
56
+ | `DEWEE_CCP_CHAT_URL` | The ingress URL, `https://<ccp>/api/public/webchat/v1/wci_…`. https only (plain http to `localhost` for development). |
57
+ | `DEWEE_WEBCHAT_INTEGRATION_ID` | `wci_…`; must match the end of `DEWEE_CCP_CHAT_URL` |
58
+ | `DEWEE_WEBCHAT_INTEGRATION_SECRET` | `dwc_…`. **Server only.** Sent only to the `DEWEE_CCP_CHAT_URL` origin, never across a redirect. |
59
+ | `DEWEE_WEBCHAT_SECRET` | 32+ random chars to sign visitor sessions (`openssl rand -base64 32`) |
60
+ | `DEWEE_ALLOWED_ORIGINS` | Extra origins for cross-origin embedding (same origin always works) |
61
+ | `TURNSTILE_SITE_KEY` / `TURNSTILE_SECRET_KEY` | Optional Cloudflare Turnstile check for new visitors (with `ccp`, a returning visitor's hourly refresh is not challenged) |
62
+ | `DEWEE_WEBCHAT_TRANSPORT` | Optional: `ccp` or `legacy-gateway`. Without it, the variables that are set decide (gateway variables alone select `legacy-gateway`, with a deprecation warning); set it to pin the transport explicitly. |
63
+
64
+ The same values as options: `createDeweeHandler({ transport: "ccp", ccp: { chatUrl, integrationId, integrationSecret }, secret })`.
65
+ With `ccp`, the agents come from the integration: `/config` lists them, the widget's `agent` is
66
+ forwarded and CCP decides whether it is allowed. Errors from CCP reach the browser as the usual
67
+ `{ error: { code, message } }` with this package's own messages; CCP's text, URLs and secrets never do.
68
+
69
+ #### Legacy: direct Gateway (deprecated)
70
+
71
+ `transport: "legacy-gateway"` connects straight to a Dewee Gateway with a URL and token. It still
72
+ works and logs a deprecation warning once; see
73
+ [docs/migrating-to-ccp-transport.md](https://github.com/nextlevelbuilder/dewee-webchat/blob/main/docs/migrating-to-ccp-transport.md).
74
+
50
75
  | Variable | |
51
76
  |---|---|
52
77
  | `DEWEE_GATEWAY_URL` | Gateway runtime origin, e.g. `https://gateway.example.com` |
53
78
  | `DEWEE_GATEWAY_TOKEN` | Operator API key (`operator.read,operator.write`) or gateway token. **Server only.** |
54
- | `DEWEE_WEBCHAT_SECRET` | 32+ random chars to sign visitor sessions (`openssl rand -base64 32`) |
79
+ | `DEWEE_TENANT_ID` | Optional tenant UUID |
55
80
  | `DEWEE_AGENTS` | Allowlist, comma separated (`support,sales`) or `*` |
56
81
  | `DEWEE_DEFAULT_AGENT` | Optional; defaults to the first allowed agent |
57
- | `DEWEE_ALLOWED_ORIGINS` | Extra origins for cross-origin embedding (same origin always works) |
58
- | `TURNSTILE_SITE_KEY` / `TURNSTILE_SECRET_KEY` | Optional Cloudflare Turnstile check for new visitors |
82
+ | `DEWEE_ALLOW_CLIENT_CREDENTIALS`, `DEWEE_CLIENT_CREDENTIAL_HOSTS` | "Bring your own gateway" (playgrounds only) |
59
83
 
60
- Create a scoped key with the Dewee CLI: `dewee api-keys create --name "website chat" --scopes operator.read,operator.write`.
84
+ The two sets cannot be mixed: with CCP and gateway variables both set, or `transport` naming one and
85
+ the other's variables set, the handler refuses to start rather than send a credential to an origin
86
+ you did not pick. `agents`, `defaultAgent` and `allowClientCredentials` are refused with `ccp`.
61
87
 
62
88
  ## 2. Add the widget
63
89
 
@@ -119,20 +145,52 @@ Attributes use kebab-case (`show-reasoning="false"`), `data-*` on the script tag
119
145
  | `theme` | `light` | `light`, `dark`, `auto` |
120
146
  | `accentColor` | `#5446e8` | Any CSS color |
121
147
  | `locale` | page language | `en`, `vi` |
122
- | `showReasoning` | `true` | Stream the model's thinking |
123
- | `showToolCalls` | `true` | Show tool calls with arguments and results |
148
+ | `showReasoning` | `true` | Stream the model's thinking (with `ccp`, only when the integration shows it) |
149
+ | `showToolCalls` | `true` | Show tool calls with arguments and results (with `ccp`: names only, when the integration shows them) |
124
150
  | `allowAgentSwitch` | `true` | Agent picker when the backend allows several |
125
151
  | `persist` | `true` | Keep the conversation across page loads |
126
152
  | `open`, `hideBranding`, `width`, `height`, `zIndex` | | |
127
153
  | `actions`, `confirm`, `getPageContext` | | [Page actions](#agent-page-actions) (JS/props only) |
128
154
  | `maxActionRounds` | `5` | Automatic action turns per visitor message |
129
155
  | `actionTimeoutMs` | `30000` | Per-handler timeout |
156
+ | `headers` | | Extra request headers, object or `() => object \| Promise<object>` called before every request (JS/props only) |
157
+ | `fetch` | `globalThis.fetch` | Custom fetch for every widget request (JS/props only) |
130
158
 
131
159
  Methods: `open()`, `close()`, `toggle()`, `send(text)`, `stop()`, `newConversation()`, `configure(partial)`,
132
- `registerActions(actions)`, `unregisterAction(name)`.
133
- Events: `dewee:ready`, `dewee:open`, `dewee:close`, `dewee:send`, `dewee:reply`, `dewee:error`, `dewee:action`.
160
+ `registerActions(actions)`, `unregisterAction(name)`, and for [signed-in users](#signed-in-users)
161
+ `renewSession()` and `startLink()`.
162
+ Events: `dewee:ready`, `dewee:open`, `dewee:close`, `dewee:send`, `dewee:reply`, `dewee:error`, `dewee:action`, `dewee:link`.
134
163
  CSS variables: `--dw-accent`, `--dw-font`, `--dw-radius`, `--dw-width`, `--dw-height`, `--dw-z`.
135
164
 
165
+ ## Custom headers and fetch
166
+
167
+ Behind a same-origin BFF that wants a CSRF header, cookies or tracing:
168
+
169
+ ```tsx
170
+ <DeweeChat
171
+ endpoint="/dashboard/api/v1/dewee"
172
+ headers={() => ({ "x-csrf-token": readCsrfToken() })} // evaluated before every request
173
+ fetch={(input, init) => fetch(input, { ...init, credentials: "include" })}
174
+ />
175
+ ```
176
+
177
+ Vanilla: `chat.configure({ headers, fetch })` or the `chat.headers` / `chat.fetch` properties. `headers` applies to
178
+ every request to `endpoint` (config, session, history, link, chat). It cannot override `content-type`,
179
+ `authorization` (which carries the anonymous or signed-in session token), `x-dewee-gateway-url` or
180
+ `x-dewee-gateway-token` (case-insensitive; such keys are dropped). Header names are sent lower-cased. For a
181
+ cross-origin `endpoint`, the backend's CORS preflight must allow the extra headers; the built-in handlers only
182
+ allow their own. A custom `fetch` receives every request as sent, including the session bearer token of a
183
+ signed-in user, so only the trusted host page should supply it.
184
+
185
+ Anonymous visitor sessions are reused until less than 10% of the token lifetime remains (at most 24 h, at least
186
+ 30 s), so backends with short session TTLs do not mint a new session on every request. With `transport: "ccp"`,
187
+ `/session` reports the end of the CCP edge session sealed in the token as `expiresAt` (and `expiresIn`), so the
188
+ widget asks `/session` again before it lapses, about once an hour, although the token itself lives 30 days. That
189
+ refresh keeps the visitor id and re-seals a refreshed edge session, so a host that keeps no memory between
190
+ requests (serverless, PHP) never refreshes or opens one while answering a chat. Signed-in sessions are used from
191
+ memory and refreshed `min(60 s, lifetime / 2)` before they expire (see [signed-in users](#signed-in-users) for the
192
+ tab's reuse hint).
193
+
136
194
  ## Agent page actions
137
195
 
138
196
  Let the agent operate the page the visitor is on: navigate, filter a table, cancel a job. The page
@@ -195,13 +253,193 @@ or inserts it as HTML, caps calls per answer and automatic turns per message, an
195
253
  `mutate`/`destructive` handler without approval. Page context and results are labelled as data for the
196
254
  agent, but prompt injection through page content is still possible — do not put secrets in either.
197
255
 
256
+ ## Signed-in users
257
+
258
+ When your site knows who the visitor is, the agent can know it too, with private tools that act for
259
+ that user only. You pass one server-side callback. You do not mint tokens, store per-visitor
260
+ credentials or put anything secret in the page: the handler exchanges your user for a Dewee identity
261
+ session, and Dewee derives the user from it.
262
+
263
+ With `transport: "ccp"` the same `identity` option works through CCP: the handler signs the partner
264
+ assertion with `@dewee/identity` and CCP exchanges it for the identity session (`identity/exchange`),
265
+ so the website needs no gateway key. `identity.agent` is optional there (the integration default is
266
+ used), `allowInsecureHttp` does not apply, a user switch under `rebindEachRequest` revokes the
267
+ previous user's session at CCP, and logout revokes it through [`handler.revoke`](#signed-in-users).
268
+ The integration's sign-in mode must allow signed-in users.
269
+
270
+ ```ts
271
+ import { createDeweeHandler, optionsFromEnv } from "dewee-webchat/server";
272
+ import { deweeIdentitySdk } from "dewee-webchat/server/identity";
273
+
274
+ const handler = createDeweeHandler({
275
+ ...optionsFromEnv(process.env),
276
+ identity: {
277
+ sdk: deweeIdentitySdk,
278
+ // Your own login. Return null for a visitor who is not signed in.
279
+ authenticate: async (req) => {
280
+ const user = await getUserFromSessionCookie(req.headers.get("cookie"));
281
+ return user ? { subject: user.id } : null; // your stable id, never an email address
282
+ },
283
+ rebindEachRequest: true, // recommended with a cookie login, see below
284
+ appId: process.env.DEWEE_IDENTITY_APP_ID!,
285
+ issuer: process.env.DEWEE_IDENTITY_ISSUER!,
286
+ assertionSecret: process.env.DEWEE_IDENTITY_ASSERTION_SECRET!, // or privateKeyJwk for a jwks application
287
+ },
288
+ });
289
+ ```
290
+
291
+ Setup: create the application once with `dewee identity setup --name <site> --issuer <issuer>` (it
292
+ prints the application id and the assertion secret), and install the server SDK next to this package:
293
+ `npm install @dewee/identity`. It is an optional peer dependency used only by the
294
+ `dewee-webchat/server/identity` entry; `dewee-webchat/server` itself never imports it, so a site that
295
+ serves only anonymous visitors bundles fine (webpack, esbuild…) without it.
296
+ `@dewee/identity` is not on npm yet: until it is, build its tarball from the
297
+ [dewee repository](https://github.com/nextlevelbuilder/dewee/blob/dev/packages/dewee-identity/README.md)
298
+ (`pnpm --filter @dewee/identity pack`) and install that file.
299
+
300
+ | `identity` option | |
301
+ |---|---|
302
+ | `sdk` | Required. `deweeIdentitySdk` from `dewee-webchat/server/identity`. |
303
+ | `authenticate(req)` | Required. Resolves `{ subject }` for a signed-in user, `null` otherwise. Throwing means "could not check". |
304
+ | `appId`, `issuer` | Required. The Dewee application id and its issuer, exactly. |
305
+ | `assertionSecret` \| `privateKeyJwk` (+ `algorithm`) | Exactly one: `hmac` or `jwks` application. Server only. |
306
+ | `audience` | Optional check; must be `dewee:<appId>`. |
307
+ | `agent` | The agent sessions are bound to. Default: `defaultAgent`, else the first of `agents` (with `ccp`: the integration default). |
308
+ | `required` | Default `false`. When `true`, visitors who are not signed in get no session at all. |
309
+ | `rebindEachRequest` | Default `false`. Check the signed-in user again on every `/chat`, `/history` and `/link`. |
310
+ | `sessionTtlSeconds` | 60 to 43200. Default: the gateway default (3600). |
311
+ | `allowInsecureHttp` | Legacy gateway only: allow plain http to a gateway that is not loopback (private networks only). |
312
+
313
+ `AuthenticatedUser` has no scopes: the session exchange does not carry any. When a user's permissions
314
+ change, report it from your server with `lifecycle({ event: "scopes_changed", … })` from
315
+ `@dewee/identity`.
316
+
317
+ How it behaves:
318
+
319
+ - `POST /session` calls `authenticate`. For a user it returns a visitor token that carries a Dewee
320
+ identity session, encrypted (AES-256-GCM, key derived from `secret`) so the browser only holds an
321
+ opaque value. The gateway token, the assertion secret, the private key, the raw identity session and
322
+ your subject never reach the browser.
323
+ - One identity session per user is kept in memory and reused until it nears expiry, so a page load
324
+ does not cost an exchange; a session the gateway reports expired or revoked is dropped.
325
+ - With CCP, a host that keeps no memory between requests reuses it too. `/session` also returns a
326
+ `reuseHint`: the same sealed session in a token that is no bearer (`/chat`, `/history` and `/link`
327
+ refuse it). The widget keeps only that hint in the tab's `sessionStorage`, never the bearer token,
328
+ and sends it back as `reuseHint`. The handler reuses its identity and edge sessions only when it was
329
+ issued by this backend for the user `authenticate` returns now and the same agent, with more than 2
330
+ minutes left. It is never extended past its own expiry; anything else is exchanged anew, and it
331
+ never makes a signed-out visitor signed in. The legacy gateway and PHP send no hint, and the widget
332
+ then stores nothing.
333
+ - The hint lives in one tab, so on a host that keeps no memory between requests each new tab of a
334
+ signed-in user costs one identity exchange, and CCP allows 10 exchanges per visitor per 10 minutes.
335
+ Sites where users open many tabs should run the handler where its in-memory session cache survives
336
+ between requests (a long-lived process or reused instance), which serves every tab of the user.
337
+ - `/chat` and `/history` connect to the gateway with that `identity_session` and no `user_id`, and only
338
+ go on when the gateway confirms it derived a Dewee user (`tuc:…`); a gateway that ignored the session
339
+ (identity switched off) gets `503 SOURCE_UNAVAILABLE` and nothing runs. The same user gets the same
340
+ conversations on every device. The session's agent must still be in `agents` on every request.
341
+ - By default the visitor token is the credential: a token for user A keeps chatting as A even if a
342
+ later request carries B's cookie, and an altered token is refused (401). With
343
+ `rebindEachRequest: true`, `/chat`, `/history` and `/link` call `authenticate(req)` again and compare
344
+ it with a keyed digest of the subject sealed in the token: another user or no user is
345
+ `401 IDENTITY_REQUIRED` (the widget then asks `/session` again), and a throwing `authenticate` is
346
+ `503 SOURCE_UNAVAILABLE`. Turn it on when your login is a cookie: a widget token replayed under
347
+ another user's cookie is then useless. With CCP, another user also revokes the previous user's
348
+ session at CCP. That is the old user's one shared session, so it ends on their other devices too;
349
+ their widget gets a `401` there and exchanges a new one at `/session`. A request with no user
350
+ (signed out, or sent without the cookie) is only refused, because the session may still be in use
351
+ on the user's other devices.
352
+ - Logout: call `revoke(subject, request, hint?)` on the handler from your logout route. It forgets
353
+ the identity sessions the handler holds for that user and, with CCP, revokes them at CCP
354
+ (`identity/revoke`), so the user's tokens stop working on every device. On a host without shared
355
+ memory, have the logout page send the widget's hint (`chat.reuseHint()`) along: the session in it
356
+ is revoked when it belongs to that subject. Pass the logout request so CCP sees the visitor's
357
+ origin (an integration with an origin allowlist needs it; without a request, the origin the
358
+ session was issued for is used). Sessions held only by other instances end when they expire. The
359
+ legacy gateway has no revoke call, so there it only forgets them.
360
+
361
+ ```ts
362
+ // `handler` from the example above; the logout page posts { hint: chat.reuseHint() }
363
+ export async function logout(req: Request) {
364
+ const user = await getUserFromSessionCookie(req.headers.get("cookie"));
365
+ const { hint } = await req.clone().json().catch(() => ({}));
366
+ if (user) await handler.revoke(user.id, req, typeof hint === "string" ? hint : undefined); // the subject authenticate returns
367
+ // …then clear your own session cookie
368
+ }
369
+ ```
370
+ - Fail closed. If `authenticate` returns `null`, the visitor chats anonymously, exactly as without
371
+ `identity`, unless `required` is set, in which case no session is issued. If it throws, the answer
372
+ is `503 SOURCE_UNAVAILABLE` and no session is issued. If the exchange for a signed-in user fails, no
373
+ session is issued: a user is never silently downgraded. An exchange refusal meant for the operator
374
+ (an application or agent set up wrong) is `500 identity_misconfigured`, with the reason in your logs.
375
+ - What the identity session allows, until it expires or is revoked: chatting with the bound agent and
376
+ reading that chat's history as the user, starting web-first links, and listing and removing the
377
+ user's own linked chat accounts (`/v1/identity/me/bindings`). That is why it never reaches the
378
+ browser in the clear.
379
+ - `authenticate` sees the request the widget sends. A widget on another origin (`allowedOrigins`)
380
+ sends no cookies of your site, so a cookie-based `authenticate` only works same-origin.
381
+ - Legacy gateway only: `DEWEE_GATEWAY_TOKEN` must be a tenant API key without an owner: the gateway refuses an identity
382
+ session next to a key bound to a user (`IDENTITY_CONFLICT`).
383
+ - `identity` cannot be combined with `allowClientCredentials` (the handler refuses to start). Sessions
384
+ are bound to one agent, so `/config` turns the agent picker off.
385
+ - Gateway identity errors (`IDENTITY_REQUIRED`, `IDENTITY_LINK_REQUIRED`, `LINK_EXPIRED`, `LINK_REVOKED`,
386
+ `PERMISSION_DENIED`, `SOURCE_UNAVAILABLE`, `IDENTITY_CONFLICT`) reach the widget as
387
+ `{ error: { code, remediation } }`. The widget shows its own localized message and, on `LINK_EXPIRED`
388
+ or `LINK_REVOKED`, asks `/session` again.
389
+ - The widget uses a signed-in session from memory, so each page load asks your backend who the
390
+ visitor is. When `/session` returns a `reuseHint` (CCP only), it keeps that hint, and nothing
391
+ else, in `sessionStorage` (never `localStorage`), and drops it when `/session` answers anonymously
392
+ or `401`, or a signed-in request is refused. It refreshes the session `min(60 s, lifetime/2)` before it ends, counting the lifetime
393
+ (`expiresIn`) from when it received it. Call `chat.renewSession()` after a sign-in or sign-out
394
+ without a page load.
395
+ - Rate limits (default, in memory): 120 `authenticate` calls per IP per minute (checked before
396
+ `authenticate` runs), 30 signed-in `/session` calls per user per 10 minutes, 5 link starts per user per
397
+ 10 minutes. A visitor who is not signed in and still has a valid anonymous token gets it back as is;
398
+ only minting a new one counts against the 10 per IP per 10 minutes. With CCP, refreshing a
399
+ returning visitor's token counts per visitor instead, so visitors behind one address do not use up
400
+ the budget of new ones. CCP's own per-IP budget still applies to edge session creates: a visitor
401
+ back after more than a day (past CCP's refresh window) gets a new edge session, which counts there. A custom `rateLimit` receives the
402
+ buckets `preauth`, `verified` and `link` besides `session` and `chat`.
403
+ - Keep `authenticate` cheap or cached (a signed-cookie check, not a database round trip per call): it
404
+ runs on every `/session`, so on every page load, and with `rebindEachRequest` on every `/chat`,
405
+ `/history` and `/link`, with only the generous per-IP cap in front of it.
406
+
407
+ **Linking a chat app.** A signed-in user can link their Telegram, Discord or other chat account
408
+ without typing any id: call `chat.startLink()`. The widget asks `POST {endpoint}/link`, which starts
409
+ a Dewee web-first link, and shows the proof code with "Continue on Telegram" for each chat account
410
+ the code can be sent to. The user sends the code in a direct message; Dewee checks both sides and
411
+ links on its own. `dewee:link` fires with `{ expiresAt, deepLinks }`. A link that is already in
412
+ progress answers `409 link_in_progress`, a reused idempotency key `409 idempotency_key_reused`, and a
413
+ gateway with linking switched off `503 linking_unavailable`; the widget shows its own text for each.
414
+
415
+ The PHP backend does not support signed-in users (it has no JWS signing), with either transport; use
416
+ the Node/Workers handler. It refuses tokens that carry an identity session.
417
+
198
418
  ## Security model
199
419
 
200
- - The gateway token never leaves your server. Browsers get an HMAC-signed visitor id.
201
- - Each visitor gets its own gateway user (`webchat-<id>`) and session keys, so history is private to them.
202
- - Only allowlisted agents are reachable; origin allowlist, rate limits (20 messages/min per visitor, 10
420
+ - The integration secret (or, legacy, the gateway token) never leaves your server. Browsers get an
421
+ HMAC-signed visitor id; the CCP edge session travels inside it encrypted (AES-256-GCM).
422
+ - The integration secret is sent only to the `DEWEE_CCP_CHAT_URL` origin: redirects are refused, not
423
+ followed, and the URL must be https (plain http only to `localhost`).
424
+ - Each visitor gets its own visitor identity at Dewee (legacy: its own gateway user `webchat-<id>` and
425
+ session keys), so history is private to them. A [signed-in user](#signed-in-users) is the user Dewee
426
+ derives from their identity session.
427
+ - With `ccp`, reasoning and tool activity are hidden unless the integration shows them, and tool
428
+ arguments and results never reach the browser.
429
+ - Only allowed agents are reachable (the integration's, legacy: `agents`); origin allowlist, rate limits (20 messages/min per visitor, 10
203
430
  new sessions/10 min per IP by default — pass `rateLimit` for a shared store), 4000-char messages,
204
431
  optional Turnstile.
432
+ - The Dewee gateway enforces the shared admission limits on every message. A refusal reaches the widget
433
+ as a typed SSE error (`POLICY_DENIED`, or `RATE_LIMITED` with `retryAfterMs`) with a fixed message, never
434
+ the gateway's own text. The widget shows a localized error and disables sending until `retryAfterMs`
435
+ has passed, announced to screen readers. With `ccp`, CCP's refusals of a message reach the widget the
436
+ same way. The backend's in-memory limiter is defense in depth; the
437
+ gateway owns the shared limit. See [admission errors](../../docs/http-contract.md#admission-errors).
438
+ - The client IP for per-IP limits comes from `cf-connecting-ip`, `x-real-ip` or the first
439
+ `x-forwarded-for` entry. These headers are only trustworthy behind a proxy that overwrites them
440
+ (as before signed-in users existed); elsewhere a client can choose its own IP, both for this
441
+ backend's limits and for the address CCP is told (`X-Dewee-Visitor-IP`, sent only when it is a
442
+ well-formed IP address).
205
443
  - "Bring your own gateway" (`allowClientCredentials`) is off by default; when on, internal hosts are refused.
206
444
 
207
445
  The wire format is documented in [docs/http-contract.md](https://github.com/nextlevelbuilder/dewee-webchat/blob/main/docs/http-contract.md).