@burdenoff/website-sdk 2026.1101.3 → 2026.1102.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.
Files changed (40) hide show
  1. package/README.md +234 -14
  2. package/dist/avatar-live-view/index.d.mts +1 -1
  3. package/dist/avatar-live-view/index.d.ts +1 -1
  4. package/dist/avatar-live-view/index.js +71 -24
  5. package/dist/avatar-live-view/index.js.map +1 -1
  6. package/dist/avatar-live-view/index.mjs +69 -22
  7. package/dist/avatar-live-view/index.mjs.map +1 -1
  8. package/dist/{chunk-Y4V5CEWD.mjs → chunk-2LPDCXQH.mjs} +7 -4
  9. package/dist/chunk-2LPDCXQH.mjs.map +1 -0
  10. package/dist/{chunk-FFADMVIK.mjs → chunk-ETI2HQEC.mjs} +7 -5
  11. package/dist/chunk-ETI2HQEC.mjs.map +1 -0
  12. package/dist/{chunk-23H4JUL4.js → chunk-OIQHIGGU.js} +20 -3
  13. package/dist/chunk-OIQHIGGU.js.map +1 -0
  14. package/dist/{chunk-OYEUNZJY.js → chunk-PIXTBL63.js} +7 -3
  15. package/dist/chunk-PIXTBL63.js.map +1 -0
  16. package/dist/{chunk-BX2USEWF.mjs → chunk-QOHPLTGT.mjs} +19 -4
  17. package/dist/chunk-QOHPLTGT.mjs.map +1 -0
  18. package/dist/{chunk-O76QDGRM.js → chunk-R742FS5E.js} +8 -4
  19. package/dist/chunk-R742FS5E.js.map +1 -0
  20. package/dist/components/explore-cta.d.mts +7 -0
  21. package/dist/components/explore-cta.d.ts +7 -0
  22. package/dist/components/explore-cta.js +2 -2
  23. package/dist/components/explore-cta.mjs +1 -1
  24. package/dist/components/explore.js +13 -13
  25. package/dist/components/explore.mjs +1 -1
  26. package/dist/{explore-avatar-live-config-B171o1Yo.d.ts → explore-avatar-live-config-C1ILEcY8.d.ts} +16 -4
  27. package/dist/{explore-avatar-live-config-BGsGwbcq.d.mts → explore-avatar-live-config-D-hO90-A.d.mts} +16 -4
  28. package/dist/index.d.mts +107 -4
  29. package/dist/index.d.ts +107 -4
  30. package/dist/index.js +209 -43
  31. package/dist/index.js.map +1 -1
  32. package/dist/index.mjs +173 -7
  33. package/dist/index.mjs.map +1 -1
  34. package/package.json +1 -1
  35. package/dist/chunk-23H4JUL4.js.map +0 -1
  36. package/dist/chunk-BX2USEWF.mjs.map +0 -1
  37. package/dist/chunk-FFADMVIK.mjs.map +0 -1
  38. package/dist/chunk-O76QDGRM.js.map +0 -1
  39. package/dist/chunk-OYEUNZJY.js.map +0 -1
  40. package/dist/chunk-Y4V5CEWD.mjs.map +0 -1
package/README.md CHANGED
@@ -298,8 +298,9 @@ publishes audio, video or data, and it sends the avatar nothing to say.
298
298
  live: {
299
299
  site: 'healthybowl',
300
300
  load: () => import('@burdenoff/website-sdk/avatar-live'),
301
- turnstileSiteKey: TURNSTILE_SITE_KEY,
302
301
  manifestVersion: 'hb-en-1', // the version of `suggestedQuestions`
302
+ // Optional: used only where the server checks for bots (see "The bot check is the server's setting").
303
+ turnstileSiteKey: TURNSTILE_SITE_KEY,
303
304
  },
304
305
  }}
305
306
  />
@@ -339,7 +340,24 @@ publishes audio, video or data, and it sends the avatar nothing to say.
339
340
  - **Needs the panel under an `ExploreChatProvider`.** The live avatar reads the
340
341
  provider's conversation. Without a provider, on a shared (read-only)
341
342
  conversation, in a locale whose strings have not been reviewed yet (English
342
- only, for now), or with an incomplete `live`, the Avatar view stays as it was.
343
+ only, for now), or with an incomplete `live` (no `site`, `load` or
344
+ `manifestVersion`, or a `turnstileSiteKey` or `allowTestSiteKey` of the wrong
345
+ shape), the Avatar view stays as it was.
346
+ - **The bot check is the server's setting (web API contract v1.2, additive).**
347
+ The availability answer says whether the server checks for bots when a
348
+ session is created: `"botCheck": {"mode": "off"}` or
349
+ `{"mode": "turnstile"}`. With `off`, Start loads the room module and creates
350
+ the session, and nothing else: no Cloudflare script, no widget, no WebCrypto
351
+ needed and no `x-web-avatar-bot-token` header; the server's own guards and
352
+ caps decide. With `turnstile`, Start runs the Cloudflare Turnstile check with
353
+ the page's own `turnstileSiteKey`, as before; without a key the page may use
354
+ (none, or one of Cloudflare's test keys where `allowTestSiteKey` is `false`)
355
+ the Avatar view stays as it was. A server older than v1.2 sends no `botCheck`
356
+ and always checks, so it is read as `turnstile`. Any other `botCheck` (an
357
+ unknown mode, another field, another shape) leaves the view as it was. So
358
+ turning the bot check on or off is a server config change: a site needs no
359
+ new build for it, as long as its build carries a site key for `turnstile` and
360
+ its CSP allows Cloudflare (below).
343
361
  - **One answer: the first, to a suggested question.** The avatar service
344
362
  accepts a conversation only when it holds exactly two messages: the first user
345
363
  message (a suggested question) and the first assistant answer, completed. The
@@ -356,8 +374,8 @@ publishes audio, video or data, and it sends the avatar nothing to say.
356
374
  the conversation has its first answer, a consent card explains what happens
357
375
  and links to `/privacy#live-avatar`; with no answer yet, the view asks the
358
376
  visitor to choose a suggested question in Chat first. Start runs a
359
- Cloudflare Turnstile check (action `avatar_start`), creates the session and
360
- joins its room: the video, the captions, an "AI avatar · not a real person"
377
+ Cloudflare Turnstile check (action `avatar_start`) where the server checks
378
+ for bots, creates the session and joins its room: the video, the captions, an "AI avatar · not a real person"
361
379
  badge, a countdown, Stop and Continue in Chat. Where a browser holds sound
362
380
  back (Safari, iOS), a "Tap to turn on sound" button appears. A refusal (busy,
363
381
  limits, region, language, paused, a question the avatar cannot read) is one
@@ -379,6 +397,14 @@ publishes audio, video or data, and it sends the avatar nothing to say.
379
397
  digest for all of them (the card's title, intro, processing, storage and
380
398
  privacy-link lines, each ending with a line feed: 760 bytes, SHA-256
381
399
  `2e02b051ceb1163c14bc58fa63a954a12c0d1f2695029a6d4805922d74c793f7`).
400
+ `web-consent-2026-11-03` is the same card without "When you start,
401
+ Cloudflare checks for bots.", for a server whose bot check is `off` (716
402
+ bytes, SHA-256
403
+ `bb7516df3cf3a910c6ec1623391f859e6c48e7e7d305d4fb333f18d4115da203`).
404
+ Each version says which bot check its words describe, and the card is shown
405
+ only where the server's `botCheck` is the same: a server that runs no bot
406
+ check must name `web-consent-2026-11-03`, and one that checks must name a
407
+ version that says so, or the view stays as it was.
382
408
  `web-consent-2026-10-31` (which names the product, "a suggested Healthy Bowl
383
409
  question") and `web-consent-2026-11` stay, unchanged, for servers that still
384
410
  name them.
@@ -389,22 +415,24 @@ publishes audio, video or data, and it sends the avatar nothing to say.
389
415
  apps-first hand-over, so it does not say which.
390
416
  - **Requests.** All three go to the gateway without credentials. The create
391
417
  request carries `content-type: application/json`, `idempotency-key` (a UUID
392
- v4) and `x-web-avatar-bot-token` (the Turnstile token, which the gateway
393
- verifies and does not forward); the availability request and the end request
394
- carry no header of the SDK's own. The conversation token is sent in the create
418
+ v4) and, only where the server checks for bots, `x-web-avatar-bot-token` (the
419
+ Turnstile token, which the gateway verifies and does not forward); the
420
+ availability request and the end request carry no header of the SDK's own. The conversation token is sent in the create
395
421
  body only. Neither it, nor the session's end secret, nor the room token, nor
396
422
  the bot check's token is ever written into the page, the console or storage by
397
423
  the live avatar (the chat keeps its own copy of the conversation in
398
424
  `localStorage`, as it always has).
399
- - **Bot-check binding (web API contract v1.1, additive).** The Turnstile widget
425
+ - **Bot-check binding (web API contract v1.1, additive; `turnstile` mode
426
+ only).** The Turnstile widget
400
427
  is rendered with `cData` set to
401
428
  `base64url_nopad(SHA-256(UTF-8("boff-web-avatar-cdata/v1\n" + site + "\n" + locale + "\n" + conversationToken + "\n" + messageId)))`,
402
429
  made with WebCrypto from the exact values the create body then carries, before
403
430
  the widget is rendered, so the gateway can check that the token was minted for
404
431
  this request. The action stays `avatar_start`, and nothing else in v1
405
- changes. A browser without WebCrypto (a page that is not a secure context)
406
- cannot start the live avatar: Start loads neither the room module nor the bot
407
- check's script there, and shows the view as it was. Pinned vector: site
432
+ changes. Where the server checks for bots, a browser without WebCrypto (a
433
+ page that is not a secure context) cannot start the live avatar: Start loads
434
+ neither the room module nor the bot check's script there, and shows the view
435
+ as it was. Pinned vector: site
408
436
  `healthybowl`, locale `en`, conversation token
409
437
  `ct_vector_0123456789abcdefghijklmnopqrstuvwx`, message id
410
438
  `11111111-2222-4333-8444-555555555555` give
@@ -418,7 +446,9 @@ publishes audio, video or data, and it sends the avatar nothing to say.
418
446
  preflight.
419
447
  - **Your page's headers.** A site with a Content Security Policy must allow:
420
448
  - `script-src` and `frame-src`: `https://challenges.cloudflare.com` (the
421
- Turnstile bot check);
449
+ Turnstile bot check). Only needed where the server's bot check is
450
+ `turnstile`; keep it anyway, so that turning the bot check on later is a
451
+ server change and not a new release of the site;
422
452
  - `connect-src`, the gateway origin (the three requests);
423
453
  - `connect-src`, the media room: `wss://<project>.livekit.cloud`. For a
424
454
  LiveKit Cloud room the media library also asks
@@ -446,10 +476,200 @@ Testing hooks (`data-boff-explore`): `avatar-live` (with `data-phase`),
446
476
  `avatar-live-captions`, `avatar-live-countdown`, `avatar-live-sound`,
447
477
  `avatar-live-continue`, `avatar-live-back`, `avatar-live-bot-check`.
448
478
 
479
+ #### Site assistant dock (`SiteAssistantDock`, every product site)
480
+
481
+ `SiteAssistantDock` is the site-wide assistant in one component: the floating
482
+ "Ask AI" button, which opens the assistant panel (`ExplorePanel`) over the
483
+ current page, and that panel, with the site's product scope and its avatar. It
484
+ replaces `<ExploreCta variant="floating" />` in a site's layout. The behaviour
485
+ is HealthyBowl's, as approved there (BOFF-9697, BOFF-9796):
486
+
487
+ - The button is still a real link to `/explore`: a modified click (new tab) and
488
+ a visitor without JavaScript reach the page; a plain click opens the compact
489
+ panel without leaving the page.
490
+ - Expand and Restore keep the same conversation, and so do closing, reopening
491
+ and going to `/explore` and back: the panel and `/explore` share the
492
+ provider's one conversation. Nothing is asked of a server until the first
493
+ opening; the dock starts the provider's conversation then.
494
+ - Escape and the phone's Back go expanded, then compact, then closed, through
495
+ history entries of the panel's own (the same URL, so the router stays put).
496
+ Closing stops the microphone and reading aloud, and focus goes back to the
497
+ button, which carries `aria-controls` and `aria-expanded`.
498
+ - On `/explore` (`explorePath`, matched as the router matches it: ignoring case
499
+ and a trailing slash) there is no button and the panel hides; an open panel
500
+ shows again on the way out.
501
+ - The panel keeps `bottomOffset` clear at the bottom of the window, so it never
502
+ covers the cookie banner's controls, and the floating button sits that much
503
+ higher (`calc(1.25rem + bottomOffset)`), so the banner does not cover the
504
+ button before the visitor answers it. Unset, the button is where it always was.
505
+ - One dock, one panel, one conversation: render it once, in the layout every
506
+ route shares. A second dock mounted while one is there renders nothing, in
507
+ every build (a development build also logs an error); if the first one
508
+ unmounts, the second takes over.
509
+
510
+ It **needs an `ExploreChatProvider` above it**. Without one a development build
511
+ throws `SiteAssistantDock must be rendered under an ExploreChatProvider`, and a
512
+ production build logs that error and renders the plain floating link to
513
+ `/explore` that sites had before the dock.
514
+
515
+ A site adds these lines.
516
+
517
+ 1. **The provider, at the app root**, inside the router and around the routes.
518
+ The dock starts it on the first opening, so `enabled` is for `/explore` only:
519
+
520
+ ```tsx
521
+ import { ExploreChatProvider, normalizeRoutePath } from '@burdenoff/website-sdk'
522
+
523
+ function SiteShell() {
524
+ const { pathname, search } = useLocation()
525
+ const onExplore = normalizeRoutePath(pathname) === '/explore'
526
+ // A shared conversation is /explore?c=<token> and nothing else.
527
+ const shareToken = onExplore ? new URLSearchParams(search).get('c') : null
528
+ const pageUrl =
529
+ typeof window === 'undefined' ? undefined : new URL(`${pathname}${search}`, window.location.origin).href
530
+ return (
531
+ <ExploreChatProvider
532
+ key={shareToken ?? ''}
533
+ enabled={onExplore}
534
+ productSlug={PRODUCT_SLUG}
535
+ locale={ASSISTANT.locale}
536
+ pageUrl={pageUrl}
537
+ shareToken={shareToken}
538
+ >
539
+ <AppRoutes />
540
+ </ExploreChatProvider>
541
+ )
542
+ }
543
+ ```
544
+
545
+ 2. **The config objects** (`src/config/assistant.ts`), built once at module
546
+ scope so the panel's memoised prompt list is not rebuilt on every render:
547
+
548
+ ```ts
549
+ import { websiteAvatarLive } from '@burdenoff/website-sdk'
550
+ import manifest from '@/data/avatar-manifest.<site>.en.json'
551
+
552
+ export const ASSISTANT = {
553
+ productSlug: PRODUCT_SLUG,
554
+ scope: 'product',
555
+ productName: 'Healthy Bowl', // the brand as a reader sees it
556
+ examplePrompts: PROBLEMS.map(({ problem }) => problem), // the questions the manifest lists
557
+ locale: import.meta.env.VITE_DEPLOY_ENV === 'alpha' ? 'en' : undefined,
558
+ } as const
559
+
560
+ export const AVATAR = {
561
+ src: '/avatar-faces/av_stock_001.webp',
562
+ live: websiteAvatarLive({
563
+ deployEnv: import.meta.env.VITE_DEPLOY_ENV,
564
+ siteKey: import.meta.env.VITE_AVATAR_TURNSTILE_SITE_KEY,
565
+ site: '<site>', // as the live avatar service knows it: `healthybowl`
566
+ manifestVersion: manifest.version,
567
+ load: () => import('@burdenoff/website-sdk/avatar-live'),
568
+ }),
569
+ }
570
+ ```
571
+
572
+ 3. **The dock in the layout**, in place of `<ExploreCta variant="floating" />`:
573
+
574
+ ```tsx
575
+ import { SiteAssistantDock } from '@burdenoff/website-sdk'
576
+
577
+ const { pathname } = useLocation()
578
+ const navigate = useNavigate()
579
+
580
+ <SiteAssistantDock
581
+ {...ASSISTANT}
582
+ avatar={AVATAR}
583
+ currentPath={pathname}
584
+ onNavigate={(path) => navigate(path)}
585
+ bottomOffset="var(--hb-cookie-banner-height, 0px)" // YOUR banner's variable, see below
586
+ />
587
+ ```
588
+
589
+ `bottomOffset` must name **the site's own cookie banner's height
590
+ variable**. The SDK sets no such variable and there is no shared name:
591
+ HealthyBowl's banner publishes `--hb-cookie-banner-height`, and another
592
+ site passes whatever its banner publishes. A banner that publishes none
593
+ leaves the panel and the button under it. We recommend every site's banner
594
+ publish its height as a CSS variable on `<html>` while it shows, and remove
595
+ it when it goes:
596
+
597
+ ```tsx
598
+ // In the site's cookie banner component.
599
+ const bannerRef = useRef<HTMLDivElement>(null)
600
+ useLayoutEffect(() => {
601
+ const banner = bannerRef.current
602
+ if (!banner) return
603
+ const root = document.documentElement
604
+ const publish = () =>
605
+ root.style.setProperty('--<site>-cookie-banner-height', `${banner.offsetHeight}px`)
606
+ publish()
607
+ const observer = new ResizeObserver(publish) // wrapping text, a resized window
608
+ observer.observe(banner)
609
+ return () => {
610
+ observer.disconnect()
611
+ root.style.removeProperty('--<site>-cookie-banner-height') // answered: nothing to keep clear
612
+ }
613
+ }, [])
614
+ ```
615
+
616
+ and keep the `, 0px` fallback in `bottomOffset`, so the dock sits at its
617
+ usual place whenever the banner is not on screen.
618
+
619
+ 4. **The build env var** `VITE_AVATAR_TURNSTILE_SITE_KEY`: the site's
620
+ Cloudflare Turnstile site key, used only where the server checks for bots
621
+ (its availability's `botCheck` is `turnstile`). The server decides whether
622
+ the live avatar is offered on the site (its reviewed site list, its pause,
623
+ its caps) and whether it checks for bots, so the key is no longer the
624
+ opt-in: when it is unset or empty, `websiteAvatarLive` returns the live
625
+ config without a key, which runs where the server's bot check is `off` and
626
+ stays the still picture where it is `turnstile`. On alpha
627
+ (`VITE_DEPLOY_ENV=alpha`) it accepts Cloudflare's always-pass test key
628
+ `1x00000000000000000000AA` (the alpha gateway runs Turnstile in dummy mode)
629
+ or a real key; everywhere else, production included, only a real-looking key
630
+ (`0x…`, letters, digits, `_` and `-`, 18 to 64 characters) that is not one of
631
+ Cloudflare's test keys (`1x…AA`, `2x…AB`, `1x…BB`, `2x…BB`, `3x…FF`, or
632
+ anything of their shape). A key that is set but refused returns `undefined`
633
+ (the still picture and "Live avatar coming soon"), never a live avatar
634
+ without it. It also sets `allowTestSiteKey` (true on alpha only), so the
635
+ view itself refuses a test key on any other build. Pass `enabled: false` to
636
+ keep the still picture on a build whatever the server offers. Install
637
+ `livekit-client` on the site (see [Live avatar](#live-avatar-opt-in-receive-only)),
638
+ and declare the variable in `vite-env.d.ts`.
639
+ `websiteAvatarLive` trusts `VITE_DEPLOY_ENV`, so a production build must
640
+ never be labelled `alpha` (that would let a test key through the check);
641
+ the production gateway's real Turnstile secret would still reject a test
642
+ token, so the live avatar would fail to start rather than run unchecked.
643
+
644
+ 5. **The privacy anchor** `/privacy#live-avatar`: the live avatar's consent
645
+ card links there, so the site's privacy page needs an element with
646
+ `id="live-avatar"` describing the live avatar.
647
+
648
+ 6. **The still picture** `public/avatar-faces/av_stock_001.webp`: the approved
649
+ avatar picture the Avatar view shows (and shows instead of the live avatar
650
+ when it is off), copied from HealthyBowl's website.
651
+
652
+ **`SiteAssistantDock` props**
653
+
654
+ | Prop | Default | What it does |
655
+ |---|---|---|
656
+ | `productSlug`, `scope`, `productName`, `examplePrompts`, `locale` | – | Passed to the panel's chat unchanged. See [One product's own assistant](#one-products-own-assistant). |
657
+ | `avatar` | – | `{ src, name?, live? }`: the Avatar view's still picture, and `live` (from `websiteAvatarLive`) to opt in to the live avatar. Absent: no Avatar view. |
658
+ | `onNavigate` | – | The router's navigate, for links in the conversation. |
659
+ | `bottomOffset` | `0px` | A CSS length kept clear at the bottom: the site's own cookie banner's height variable (HealthyBowl: `var(--hb-cookie-banner-height, 0px)`). The panel keeps it clear and the floating button sits that much higher. |
660
+ | `className` | – | Extra classes for the floating button. |
661
+ | `panelClassName` | – | Extra classes for the panel. |
662
+ | `currentPath` | `window.location.pathname` | The router's path. Pass it: the fallback cannot see `pushState` navigations. |
663
+ | `explorePath` | `/explore` | The button's link, the route the dock hides on, and the panel's share links. |
664
+ | `label` | `Ask AI` | The button's label and accessible name. |
665
+ | `id` | `boff-explore-panel` | The panel's id, for the button's `aria-controls`. |
666
+ | `zIndex` | `50` | The panel's `z-index`. |
667
+
449
668
  **`ExploreCta` props**: `variant` (`'header' \| 'mobile' \| 'floating'`, required),
450
669
  `href` (default `/explore`), `label` (default `Ask AI`), `className`,
451
- `onNavigate`, `onClick`, `currentPath`. The `floating` variant hides itself on
452
- `/explore`. Pass `currentPath` from your router — the fallback
670
+ `onNavigate`, `onClick`, `currentPath`, and, for `floating` only,
671
+ `bottomOffset` (a CSS length to lift the bubble by, as on the dock). The
672
+ `floating` variant hides itself on `/explore`. Pass `currentPath` from your router — the fallback
453
673
  (`window.location.pathname` + `popstate`) cannot observe SPA `pushState`
454
674
  navigations.
455
675
 
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode } from 'react';
3
- import { E as ExplorePanelAvatarLive, a as ExploreLocale, A as AvatarConversation } from '../explore-avatar-live-config-BGsGwbcq.mjs';
3
+ import { E as ExplorePanelAvatarLive, a as ExploreLocale, A as AvatarConversation } from '../explore-avatar-live-config-D-hO90-A.mjs';
4
4
  import '../types-CndbmBK-.mjs';
5
5
  import '../use-explore-chat-zRuoAin6.mjs';
6
6
  import '../explore-types-Bb2Ih3d8.mjs';
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode } from 'react';
3
- import { E as ExplorePanelAvatarLive, a as ExploreLocale, A as AvatarConversation } from '../explore-avatar-live-config-B171o1Yo.js';
3
+ import { E as ExplorePanelAvatarLive, a as ExploreLocale, A as AvatarConversation } from '../explore-avatar-live-config-C1ILEcY8.js';
4
4
  import '../types-CndbmBK-.js';
5
5
  import '../use-explore-chat-DunjG-3p.js';
6
6
  import '../explore-types-Bb2Ih3d8.js';
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var chunk23H4JUL4_js = require('../chunk-23H4JUL4.js');
3
+ var chunkOIQHIGGU_js = require('../chunk-OIQHIGGU.js');
4
4
  var chunkO4GS3XPZ_js = require('../chunk-O4GS3XPZ.js');
5
5
  var chunkC6OJIHNO_js = require('../chunk-C6OJIHNO.js');
6
6
  require('../chunk-ZSTFX7FV.js');
@@ -35,6 +35,20 @@ var AVAILABILITY_REASONS = /* @__PURE__ */ new Map([
35
35
  ["busy", "busy"],
36
36
  ["limits", "limits"]
37
37
  ]);
38
+ var BOT_CHECK_MODES = /* @__PURE__ */ new Set([
39
+ "off",
40
+ "turnstile"
41
+ ]);
42
+ function readBotCheck(value) {
43
+ if (value === void 0) return "turnstile";
44
+ if (!isRecord(value)) return null;
45
+ const keys = Object.keys(value);
46
+ const { mode } = value;
47
+ if (keys.length !== 1 || keys[0] !== "mode" || typeof mode !== "string" || !BOT_CHECK_MODES.has(mode)) {
48
+ return null;
49
+ }
50
+ return mode === "off" ? "off" : "turnstile";
51
+ }
38
52
  function readAvailability(status, body) {
39
53
  if (status !== 200 || !isRecord(body)) return INERT;
40
54
  const { available, reason } = body;
@@ -43,10 +57,14 @@ function readAvailability(status, body) {
43
57
  }
44
58
  if (reason === "ok") {
45
59
  const { consentVersion, maxSessionSeconds } = body;
46
- if (available !== true || typeof consentVersion !== "string" || !chunk23H4JUL4_js.isAvatarVersion(consentVersion) || typeof maxSessionSeconds !== "number" || !Number.isInteger(maxSessionSeconds) || maxSessionSeconds < 1) {
60
+ const botCheck = readBotCheck(body.botCheck);
61
+ if (available !== true || typeof consentVersion !== "string" || !chunkOIQHIGGU_js.isAvatarVersion(consentVersion) || typeof maxSessionSeconds !== "number" || !Number.isInteger(maxSessionSeconds) || maxSessionSeconds < 1 || botCheck === null) {
47
62
  return INERT;
48
63
  }
49
- return { kind: "available", offer: { consentVersion, maxSessionSeconds } };
64
+ return {
65
+ kind: "available",
66
+ offer: { consentVersion, maxSessionSeconds, botCheck }
67
+ };
50
68
  }
51
69
  const meaning = available ? void 0 : AVAILABILITY_REASONS.get(reason);
52
70
  return meaning === void 0 || meaning === "inert" ? INERT : { kind: "refused", reason: meaning };
@@ -128,7 +146,7 @@ function deadline(timeoutMs, outer) {
128
146
  };
129
147
  }
130
148
  async function requestAvailability(target) {
131
- if (!chunk23H4JUL4_js.isAvatarSite(target.site) || !LOCALE.test(target.locale)) return INERT;
149
+ if (!chunkOIQHIGGU_js.isAvatarSite(target.site) || !LOCALE.test(target.locale)) return INERT;
132
150
  const query = new URLSearchParams({
133
151
  site: target.site,
134
152
  locale: target.locale
@@ -169,7 +187,7 @@ async function requestCreate(target) {
169
187
  ...target.headers,
170
188
  "Content-Type": "application/json",
171
189
  "Idempotency-Key": target.idempotencyKey,
172
- "x-web-avatar-bot-token": target.botToken
190
+ ...target.botToken === void 0 ? {} : { "x-web-avatar-bot-token": target.botToken }
173
191
  },
174
192
  body: payload,
175
193
  signal: timer.signal
@@ -457,7 +475,8 @@ var CONSENT = /* @__PURE__ */ new Map([
457
475
  intro: "This is an AI avatar, not a real person. It reads the assistant's first eligible answer to a suggested question aloud for up to a minute. The answer is AI-generated and can be wrong.",
458
476
  processing: "When you start, Cloudflare checks for bots. The answer text goes to Google to create the voice. Your browser connects to LiveKit, which sees your IP address. LiveKit fetches our temporary audio file and passes the voice to Protoface, which makes the avatar video streamed through LiveKit.",
459
477
  storage: "Your microphone and camera are not used. We do not record the live session. We temporarily store the generated voice file and keep a record of this consent. The privacy notice explains our safeguards, storage periods and the providers' processing. For people aged eighteen and over.",
460
- privacyLink: "How we handle this"
478
+ privacyLink: "How we handle this",
479
+ botCheck: "turnstile"
461
480
  }
462
481
  }
463
482
  ],
@@ -471,7 +490,8 @@ var CONSENT = /* @__PURE__ */ new Map([
471
490
  intro: "This is an AI avatar, not a real person. It reads the assistant's first eligible answer to a suggested question aloud for up to a minute. The answer is AI-generated and can be wrong.",
472
491
  processing: "When you start, Cloudflare checks for bots. The answer text goes to Google to create the voice. Our server sends that voice through LiveKit to Protoface, which makes the avatar video streamed through LiveKit. Your browser connects to LiveKit, which sees your IP address.",
473
492
  storage: "Your microphone and camera are not used. We do not record the live session or store the generated voice. We keep a record of this consent. The privacy notice explains our safeguards, storage periods and the providers' processing. For people aged eighteen and over.",
474
- privacyLink: "How we handle this"
493
+ privacyLink: "How we handle this",
494
+ botCheck: "turnstile"
475
495
  }
476
496
  }
477
497
  ],
@@ -486,13 +506,30 @@ var CONSENT = /* @__PURE__ */ new Map([
486
506
  intro: "This is an AI avatar, not a real person. It reads the assistant's first eligible answer to a suggested question aloud for up to a minute. The answer is AI-generated and can be wrong.",
487
507
  processing: "When you start, Cloudflare checks for bots. The answer text goes to Google to create the voice. Our server sends that voice through LiveKit to Protoface, which makes the avatar video streamed through LiveKit. Your browser connects to LiveKit, which sees your IP address.",
488
508
  storage: "Your microphone and camera are not used. We do not record the live session or store the generated voice. We keep a record of this consent. The privacy notice explains our safeguards, storage periods and the providers' processing. For people aged eighteen and over.",
489
- privacyLink: "How we handle this"
509
+ privacyLink: "How we handle this",
510
+ botCheck: "turnstile"
511
+ }
512
+ }
513
+ ],
514
+ [
515
+ // The words of web-consent-2026-11-02 without "When you start, Cloudflare checks for bots.", for a server that runs
516
+ // no bot check (its availability's botCheck is `off`). Product-neutral too: one notice and one digest for every site.
517
+ "web-consent-2026-11-03",
518
+ {
519
+ en: {
520
+ title: "Live AI avatar (beta)",
521
+ introProduct: null,
522
+ intro: "This is an AI avatar, not a real person. It reads the assistant's first eligible answer to a suggested question aloud for up to a minute. The answer is AI-generated and can be wrong.",
523
+ processing: "The answer text goes to Google to create the voice. Our server sends that voice through LiveKit to Protoface, which makes the avatar video streamed through LiveKit. Your browser connects to LiveKit, which sees your IP address.",
524
+ storage: "Your microphone and camera are not used. We do not record the live session or store the generated voice. We keep a record of this consent. The privacy notice explains our safeguards, storage periods and the providers' processing. For people aged eighteen and over.",
525
+ privacyLink: "How we handle this",
526
+ botCheck: "off"
490
527
  }
491
528
  }
492
529
  ]
493
530
  ]);
494
531
  function avatarConsentCopy(version, locale) {
495
- if (!chunk23H4JUL4_js.isAvatarLiveLocaleReviewed(locale)) return null;
532
+ if (!chunkOIQHIGGU_js.isAvatarLiveLocaleReviewed(locale)) return null;
496
533
  return CONSENT.get(version)?.[locale] ?? null;
497
534
  }
498
535
 
@@ -757,7 +794,11 @@ function AvatarLiveView({
757
794
  return;
758
795
  }
759
796
  const copy = avatarConsentCopy(result.offer.consentVersion, locale2);
760
- if (copy === null) {
797
+ if (copy === null || copy.botCheck !== result.offer.botCheck) {
798
+ go(INERT2);
799
+ return;
800
+ }
801
+ if (result.offer.botCheck === "turnstile" && chunkOIQHIGGU_js.turnstileSiteKeyOf(live2) === null) {
761
802
  go(INERT2);
762
803
  return;
763
804
  }
@@ -853,11 +894,12 @@ function AvatarLiveView({
853
894
  if (current.name !== "consent") return;
854
895
  const { live: live2, base: base2, headers: headers2 } = latest.current;
855
896
  if (base2 === null) return;
856
- if (!canBindBotCheck()) {
897
+ const { offer, messageId } = current;
898
+ const siteKey = offer.botCheck === "turnstile" ? chunkOIQHIGGU_js.turnstileSiteKeyOf(live2) : null;
899
+ if (offer.botCheck === "turnstile" && (siteKey === null || !canBindBotCheck())) {
857
900
  go(INERT2);
858
901
  return;
859
902
  }
860
- const { offer, messageId } = current;
861
903
  const conversationToken = conversationRef.current?.getConversationToken() ?? null;
862
904
  const idempotencyKey = newIdempotencyKey();
863
905
  if (conversationToken === null || !isAvatarConversationToken(conversationToken) || idempotencyKey === null) {
@@ -874,31 +916,36 @@ function AvatarLiveView({
874
916
  };
875
917
  const run = { cancelled: null, turnstile: null };
876
918
  startRef.current = run;
877
- go({ name: "bot-check", offer, messageId });
878
919
  const module = loadModule(live2.load);
879
- const turnstile = runTurnstile(
880
- botBoxRef.current,
881
- live2.turnstileSiteKey,
882
- "avatar_start",
883
- avatarBotBinding(body2)
884
- );
885
- run.turnstile = turnstile;
920
+ let turnstile = null;
921
+ if (siteKey === null) {
922
+ go({ name: "creating" });
923
+ } else {
924
+ go({ name: "bot-check", offer, messageId });
925
+ turnstile = runTurnstile(
926
+ botBoxRef.current,
927
+ siteKey,
928
+ "avatar_start",
929
+ avatarBotBinding(body2)
930
+ );
931
+ run.turnstile = turnstile;
932
+ }
886
933
  void (async () => {
887
934
  let loaded;
888
935
  let botToken;
889
936
  try {
890
- [loaded, botToken] = await Promise.all([module, turnstile.token]);
937
+ [loaded, botToken] = await Promise.all([module, turnstile?.token]);
891
938
  } catch {
892
- turnstile.cancel();
939
+ turnstile?.cancel();
893
940
  if (run.cancelled === null) {
894
941
  startRef.current = null;
895
942
  go(INERT2);
896
943
  }
897
944
  return;
898
945
  }
899
- turnstile.cancel();
946
+ turnstile?.cancel();
900
947
  if (run.cancelled !== null) return;
901
- go({ name: "creating" });
948
+ if (turnstile !== null) go({ name: "creating" });
902
949
  const result = await requestCreate({
903
950
  base: base2,
904
951
  headers: headers2,