dewee-webchat 0.4.1 → 0.5.0-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
@@ -56,7 +56,7 @@ first three values below.
56
56
  | `DEWEE_CCP_CHAT_URL` | The ingress URL, `https://<ccp>/api/public/webchat/v1/wci_…`. https only (plain http to `localhost` for development). |
57
57
  | `DEWEE_WEBCHAT_INTEGRATION_ID` | `wci_…`; must match the end of `DEWEE_CCP_CHAT_URL` |
58
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`) |
59
+ | `DEWEE_WEBCHAT_SECRET` | At least 32 random UTF-8 bytes to sign visitor sessions (`openssl rand -base64 32`) |
60
60
  | `DEWEE_ALLOWED_ORIGINS` | Extra origins for cross-origin embedding (same origin always works) |
61
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
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. |
@@ -85,8 +85,26 @@ The two sets cannot be mixed: with CCP and gateway variables both set, or `trans
85
85
  the other's variables set, the handler refuses to start rather than send a credential to an origin
86
86
  you did not pick. `agents`, `defaultAgent` and `allowClientCredentials` are refused with `ccp`.
87
87
 
88
+ Both transports need a stable `secret` shared by every backend instance. For local development only,
89
+ the legacy transport can opt in to a generated process-local secret with `allowEphemeralSecret: true`.
90
+ Sessions and identity bindings then stop working after a restart or when a request reaches another
91
+ instance. CCP always requires a configured secret of at least 32 bytes.
92
+
88
93
  ## 2. Add the widget
89
94
 
95
+ ### Optional current-page context
96
+
97
+ Page context is off by default. Set `page-context="metadata"` or `page-context="content"` on the
98
+ widget (or `pageContext` in framework configuration) to offer it. The visitor previews the page
99
+ and explicitly chooses whether to share it for each message. Only a supported Gateway/CCP version
100
+ receives the attachment; ordinary chat continues when support is absent. For content, the default
101
+ source is `main`, `article` or `[role=main]`; `contextSelector` and `excludeSelectors` narrow it.
102
+ Form controls, editable areas and hidden content are excluded. Configure a client-side
103
+ `contextProvider` for host-filtered data when needed; the host is responsible for excluding private
104
+ data from its provider. In Astro, set that function with `element.configure()` in a client script,
105
+ not as a serialized component prop. The preview contains the data to be sent: review it before
106
+ sharing. See the [HTTP contract](https://github.com/nextlevelbuilder/dewee-webchat/blob/main/docs/http-contract.md#page-context-attachment).
107
+
90
108
  **Script tag** (no build step):
91
109
 
92
110
  ```html
@@ -140,6 +158,7 @@ Attributes use kebab-case (`show-reasoning="false"`), `data-*` on the script tag
140
158
  | `title`, `subtitle`, `greeting`, `intro`, `placeholder` | | Texts |
141
159
  | `suggestions` | | Quick replies (`a|b` or JSON array as attribute) |
142
160
  | `avatar`, `launcherAvatar` | | Image URL; also use it on the launcher |
161
+ | `imageOrigins` | `[]` | Additional trusted HTTPS origins for Markdown images |
143
162
  | `mode` | `floating` | `floating` (bubble + panel) or `inline` (fills its container) |
144
163
  | `position` | `bottom-right` | or `bottom-left` |
145
164
  | `theme` | `light` | `light`, `dark`, `auto` |
@@ -153,6 +172,8 @@ Attributes use kebab-case (`show-reasoning="false"`), `data-*` on the script tag
153
172
  | `actions`, `confirm`, `getPageContext` | | [Page actions](#agent-page-actions) (JS/props only) |
154
173
  | `maxActionRounds` | `5` | Automatic action turns per visitor message |
155
174
  | `actionTimeoutMs` | `30000` | Per-handler timeout |
175
+ | `domActions` | `false` | [Built-in DOM actions](#built-in-dom-actions): snapshot, scroll, click, fill |
176
+ | `domActionsRoot` | `document.body` | Selector or element the built-in DOM actions are limited to |
156
177
  | `headers` | | Extra request headers, object or `() => object \| Promise<object>` called before every request (JS/props only) |
157
178
  | `fetch` | `globalThis.fetch` | Custom fetch for every widget request (JS/props only) |
158
179
 
@@ -162,6 +183,33 @@ Methods: `open()`, `close()`, `toggle()`, `send(text)`, `stop()`, `newConversati
162
183
  Events: `dewee:ready`, `dewee:open`, `dewee:close`, `dewee:send`, `dewee:reply`, `dewee:error`, `dewee:action`, `dewee:link`.
163
184
  CSS variables: `--dw-accent`, `--dw-font`, `--dw-radius`, `--dw-width`, `--dw-height`, `--dw-z`.
164
185
 
186
+ ## Markdown images
187
+
188
+ Replies may include `![Product](https://cdn.example.com/photo.jpg)` or a same-origin relative image
189
+ URL. Images are disabled until HTTPS directory prefixes are approved in the backend. Configure the backend
190
+ with `imageSources: ["https://cdn.example.com/products/", "https://qr.example.com/images/"]` (or the comma-separated
191
+ `DEWEE_IMAGE_SOURCES` environment variable). Include a safe image directory on your site's HTTPS origin for relative images.
192
+ For external origins, also configure the widget:
193
+
194
+ ```html
195
+ <dewee-chat image-origins='["https://cdn.example.com", "https://qr.example.com"]'></dewee-chat>
196
+ ```
197
+
198
+ The script-tag equivalent is `data-image-origins`; vanilla JS uses
199
+ `chat.configure({ imageOrigins: ["https://cdn.example.com"] })`, and React, Vue and Astro accept the
200
+ `imageOrigins` prop. Unapproved or broken images fall back to alt text. The widget loads images from
201
+ `{endpoint}/image` using a short-lived encrypted ticket issued to the visitor session; the backend makes the request without cookies or Gateway credentials, rejects
202
+ redirects, and accepts PNG, JPEG, GIF and WebP only after validating the content type and file
203
+ signature. Node and PHP pin a checked public DNS address; Cloudflare Workers use the platform's
204
+ network boundary. Workers conservatively refuse sources sharing the endpoint's last two hostname
205
+ labels (including sibling subdomains), to avoid bypassing Cloudflare's own-zone protections; host
206
+ images on a separate approved domain there. Files are limited to 2 MiB and 4096
207
+ pixels per edge, and responses are not cached. Animated GIF/PNG/WebP are refused. Raw HTML,
208
+ SVG, unsafe schemes, credentials in URLs and unapproved origins are rejected.
209
+
210
+ The proxy URL contains an encrypted, short-lived ticket, not the source URL or the visitor session.
211
+ Avoid secrets in image URLs: the ticket request contains the source URL in its body.
212
+
165
213
  ## Custom headers and fetch
166
214
 
167
215
  Behind a same-origin BFF that wants a CSRF header, cookies or tracing:
@@ -178,12 +226,12 @@ Vanilla: `chat.configure({ headers, fetch })` or the `chat.headers` / `chat.fetc
178
226
  every request to `endpoint` (config, session, history, link, chat). It cannot override `content-type`,
179
227
  `authorization` (which carries the anonymous or signed-in session token), `x-dewee-gateway-url` or
180
228
  `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
229
+ cross-origin `endpoint`, configure the backend's `allowedOrigins` and `allowedHeaders: ["x-csrf-token"]`.
230
+ The built-in TS and PHP handlers accept header names containing letters, digits and hyphens. A custom `fetch` receives every request as sent, including the session bearer token of a
183
231
  signed-in user, so only the trusted host page should supply it.
184
232
 
185
233
  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"`,
234
+ 30 s, and no more than half the lifetime), so backends with short session TTLs do not mint a new session on every request. With `transport: "ccp"`,
187
235
  `/session` reports the end of the CCP edge session sealed in the token as `expiresAt` (and `expiresIn`), so the
188
236
  widget asks `/session` again before it lapses, about once an hour, although the token itself lives 30 days. That
189
237
  refresh keeps the visitor id and re-seals a refreshed edge session, so a host that keeps no memory between
@@ -253,6 +301,43 @@ or inserts it as HTML, caps calls per answer and automatic turns per message, an
253
301
  `mutate`/`destructive` handler without approval. Page context and results are labelled as data for the
254
302
  agent, but prompt injection through page content is still possible — do not put secrets in either.
255
303
 
304
+ ### Built-in DOM actions
305
+
306
+ Without writing handlers, a page can let the agent scroll, click and type on it. They are off by
307
+ default:
308
+
309
+ ```html
310
+ <script src="https://webchat-cdn.dewee.sh/v/latest/dewee-webchat.js"
311
+ data-endpoint="/api/dewee" data-dom-actions="true" data-dom-actions-root="#app" defer></script>
312
+ ```
313
+
314
+ ```tsx
315
+ <DeweeChat endpoint="/api/dewee" domActions domActionsRoot="#app" />
316
+ ```
317
+
318
+ | Action | Sensitivity | What it does |
319
+ |---|---|---|
320
+ | `page.snapshot` | `read` | Lists visible links, buttons and form fields as `e3 textbox "Email" value="…"`, with a `generation` |
321
+ | `page.scroll` | `read` | Scrolls one screen up/down, to the top/bottom, or an element (`ref`) into view |
322
+ | `page.click` | `mutate` | Clicks an element by `ref` + `generation` |
323
+ | `page.fill` | `mutate` | Sets the value of a text field, textarea, select or editable area, then fires `input` and `change` |
324
+
325
+ - `page.click` and `page.fill` always ask the visitor first; the dialog (or your `confirm` hook, as
326
+ `args.target`) names the element, e.g. `button "Delete run"`.
327
+ - Refs come only from the latest snapshot. A ref from an older snapshot, or an element that is gone,
328
+ hidden or now sensitive, is refused, and the agent is told to take a new snapshot.
329
+ - Never listed or touched: password, payment card and one-time-code fields (by `type`, `autocomplete`
330
+ and name), hidden elements, the widget itself, and anything inside `[data-dewee-ignore]`. Put
331
+ `data-dewee-ignore` on whatever else the agent must not reach.
332
+ - `page.fill` uses the native value setter, so React and Vue controlled inputs see the change. Events are
333
+ synthetic (`isTrusted` is false): pages that require trusted events, file pickers and popups opened by a
334
+ click will not react.
335
+ - Your own actions keep working; registering an action with a built-in name (say `page.click`)
336
+ replaces the built-in one.
337
+ - The built-ins operate the DOM, not your app's permissions: the agent can do whatever the visitor's
338
+ clicks can, after approval. Narrow it with `domActionsRoot` and `data-dewee-ignore`, and keep the server
339
+ as the authority.
340
+
256
341
  ## Signed-in users
257
342
 
258
343
  When your site knows who the visitor is, the agent can know it too, with private tools that act for
@@ -355,7 +440,7 @@ How it behaves:
355
440
  memory, have the logout page send the widget's hint (`chat.reuseHint()`) along: the session in it
356
441
  is revoked when it belongs to that subject. Pass the logout request so CCP sees the visitor's
357
442
  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
443
+ session was issued for is used). Without a shared store, sessions held only by other instances end when they expire. The
359
444
  legacy gateway has no revoke call, so there it only forgets them.
360
445
 
361
446
  ```ts
@@ -367,6 +452,14 @@ How it behaves:
367
452
  // …then clear your own session cookie
368
453
  }
369
454
  ```
455
+ - For multiple handler instances, supply `identity.sessionStore` implementing the exported
456
+ `IdentitySessionStore` interface. Share the same protected store and visitor secret across those
457
+ instances. The default is a bounded, per-handler in-memory store. Store records contain bearer
458
+ credentials: encrypt them at rest, restrict access and never log values. The interface requires
459
+ atomic revision checks and updates across instances, unique non-reused revisions even after
460
+ eviction, conditional token deletion, and expiry by `deadline`; a simple non-atomic KV get/set is
461
+ insufficient. Keys use scoped HMAC digests, not raw host user IDs. Store failures refuse session
462
+ issuance; logout remains best effort, logs store failure and still attempts the supplied hint.
370
463
  - Fail closed. If `authenticate` returns `null`, the visitor chats anonymously, exactly as without
371
464
  `identity`, unless `required` is set, in which case no session is issued. If it throws, the answer
372
465
  is `503 SOURCE_UNAVAILABLE` and no session is issued. If the exchange for a signed-in user fails, no
@@ -412,8 +505,8 @@ links on its own. `dewee:link` fires with `{ expiresAt, deepLinks }`. A link tha
412
505
  progress answers `409 link_in_progress`, a reused idempotency key `409 idempotency_key_reused`, and a
413
506
  gateway with linking switched off `503 linking_unavailable`; the widget shows its own text for each.
414
507
 
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.
508
+ The PHP backend also supports signed-in users and a server-side logout hook; see the
509
+ [PHP identity setup](../../php/README.md#signed-in-users). Its stateless reuse uses the CCP reuse hint.
417
510
 
418
511
  ## Security model
419
512
 
@@ -429,6 +522,9 @@ the Node/Workers handler. It refuses tokens that carry an identity session.
429
522
  - Only allowed agents are reachable (the integration's, legacy: `agents`); origin allowlist, rate limits (20 messages/min per visitor, 10
430
523
  new sessions/10 min per IP by default — pass `rateLimit` for a shared store), 4000-char messages,
431
524
  optional Turnstile.
525
+ - The default rate limiter is in memory and applies per handler instance, not across replicas or
526
+ serverless invocations. For a shared backend limit, provide an atomic shared-store `rateLimit`
527
+ implementation; do not rely on the default as a cluster-wide limit.
432
528
  - The Dewee gateway enforces the shared admission limits on every message. A refusal reaches the widget
433
529
  as a typed SSE error (`POLICY_DENIED`, or `RATE_LIMITED` with `retryAfterMs`) with a fixed message, never
434
530
  the gateway's own text. The widget shows a localized error and disables sending until `retryAfterMs`
@@ -2,11 +2,11 @@
2
2
  // Astro component: <DeweeChat endpoint="/api/dewee" title="Support" showReasoning={false} />
3
3
  import type { DeweeChatConfig } from "dewee-webchat";
4
4
 
5
- type Props = Partial<DeweeChatConfig> & { class?: string };
5
+ type Props = Omit<Partial<DeweeChatConfig>, "contextProvider"> & { class?: string };
6
6
  const { class: className, ...config } = Astro.props;
7
7
  const attrs: Record<string, string> = {};
8
8
  for (const [key, value] of Object.entries(config)) {
9
- if (value === undefined || value === null) continue;
9
+ if (value === undefined || value === null || typeof value === "function") continue;
10
10
  const name = key.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase());
11
11
  attrs[name] = Array.isArray(value) ? JSON.stringify(value) : String(value);
12
12
  }