Skip to content

Что нужно на сервере

Один порт, пять переменных окружения и билет, который живёт пятнадцать минут. Эта страница — всё, что лежит между «у нас есть агент» и «на сайте клиента появился чат».

Один порт, и только он

Браузер посетителя разговаривает не с самим api. Опубликовать наружу все маршруты, Swagger и канал инструментов ради чата в углу страницы — это огромная дверь, открытая по маленькому поводу.

Поэтому хаб слушает свой отдельный порт, и на этом порту лежит ровно одно:

PORT самого apiBRIDLE_PUBLIC_PORT
что слушаетвесь apiодно пространство имён WebSocket
маршрутывсе эндпоинты, Swagger, канал инструментовPOST /visitor и больше ничего
дотягивается добазы, ключа секретов, кластерахода агента, и только
опубликованнетда, и только он

На этом порту нет маршрута, который можно опубликовать по недосмотру. Это и есть искомое свойство, ради него разделение и сделано.

Пять переменных

bash
# Какая половина пары — этот процесс. `hub` (по умолчанию) значит, что браузеры
# подключаются СЮДА и второй сервис не нужен.
BRIDLE_ROLE=hub

# ЕДИНСТВЕННЫЙ порт, который вы публикуете. Значения по умолчанию нет: порт,
# открывшийся сам из-за незаполненной переменной, — та случайность, ради которой
# стоит написать одну строку.
BRIDLE_PUBLIC_PORT=3334

# Публичный адрес этого порта. Именно он попадёт на страницу как `apiUrl`.
BRIDLE_URL=https://хаб.вашей.установки

# Секрет, которым подписываются билеты встраивания. НЕ тот, которым входят ваши
# операторы: у встраивания свой, и смешивать их — ошибка с настоящими зубами.
BRIDLE_JWT_SECRET=<64 случайных шестнадцатеричных символа>

# Откуда берётся бандл виджета. Необязательно; по умолчанию — стоковая сборка.
BRIDLE_SDK_URL=https://bridle.cleanslice.org/sdk/latest.js

Не заполнено — значит, эта установка не публикует агентов. Привязки по-прежнему сохраняются, выпуск билета отказывает и называет недостающую переменную, порт не открывается и сокет никуда не идёт. Ничего не открывается наполовину.

BRIDLE_JWT_SECRET не покидает сервер

Это не то значение, которое когда-либо держит код сайта. Сайт, умеющий подписать билет сам, мог бы назвать любого посетителя любым другим. Если вы ловите себя на том, что вставляете его во фронтенд, — вам на самом деле нужен раздел Как выпустить билет на этой же странице.

Вторая роль

BRIDLE_ROLE=runtime — более ранняя схема: браузеры разговаривают с отдельным хабом Bridle, а api сам дозванивается до него ключом BRIDLE_API_KEY. Живой всегда ровно одна из двух ролей. Агент, опубликованный при одной, остаётся опубликованным после переключения — идентификатор, уже вставленный в страницу, продолжает работать.

При runtime здесь нет POST /visitor: публичная дверь чужая. Билеты посетителей в такой схеме выпускает ваш собственный бекенд.

Публикация агента

В кабинете, на экране агента «Сайт»: опубликовать агента в канал сайта. До этого даже верный билет ничего не даёт — опубликован ли агент, проверяется при открытии соединения и заново на каждом сообщении.

Тот же экран говорит, может ли установка публиковать в принципе. Если не хватает BRIDLE_PUBLIC_PORT (или, при runtime, BRIDLE_API_KEY), он скажет это и назовёт переменную — до того, как что-то нажато.

Как выпустить билет

Билет выпускает участник команды агента, вошедший в систему:

POST /agents/:id/channels/bridle/token

Неаутентифицированного выпуска нет, и нет способа выпустить билет для агента чужой команды.

  • Срок по умолчанию — 15 минут. Потолок — 24 часа. Попросите больше — получите потолок; попросите меньше — получите ровно столько.
  • Билет привязан к одному агенту. Билет, выпущенный для агента A и вставленный на страницу, указывающую на агента B, разговора с B не откроет. Это проверяется дважды: когда открывается сокет и потом на каждом сообщении — потому что вкладка, оставленная открытой на неделю, переживает билет в ней.
  • Билет — не личность, не сессия и не право. Он говорит, какой агент и до какого момента. От чьего имени идёт разговор, решает запись самого агента — см. Границы и права.

Почему он не может жить дольше

Потому что он лежит в HTML страницы открытым текстом и виден каждому, кто открыл ваш сайт. Положить его больше некуда: браузеру нужно что-то предъявить раньше, чем у него что-либо есть. Настолько публичное — это сессия, а не ключ, и потолок в сутки — то место, где эта разница закреплена.

Два вида билета

Билет, лежащий на вашей странице, не называет никого. Если бы каждый браузер предъявлял его как есть, все посетители были бы одним человеком: один разговор на всех, один лимит потока и чужие сообщения в своей переписке.

Поэтому есть вторая форма и один обмен между ними:

POST https://хаб.вашей.установки/visitor
     { "token": "<билет страницы>" }
  →  { "token": "<билет, называющий одного посетителя>", "expiresAt": "…" }
  • Обмену не нужна сессия — обращается браузер незнакомого человека с вашего сайта.
  • Он ничего не открывает заново: тот, у кого есть билет страницы, и так мог говорить с агентом. Это тот же доступ, только поделённый.
  • Билет посетителя истекает вместе с билетом страницы, до мгновения.
  • Предъявленный обратно билет посетителя возвращает того же человека — это и возвращает перезагруженную вкладку в её собственный разговор.

Вставка, которую даёт кабинет, делает этот обмен за вас, держит результат в sessionStorage — привязан к вкладке, сам ни к одному запросу не прикладывается, исчезает вместе с вкладкой — и намеренно не откатывается на билет страницы, если обмен не удался. Откат оставил бы рабочий чат, в котором двое незнакомых людей тихо сидят в одной переписке.

Как правильно: билет выпускает ваш бекенд, вставлять нечего

Всё, что выше, описывает билет, который человек делает руками. На настоящем сайте так не надо. Пятнадцать минут — это пятнадцать минут, а чат, умерший за ночь, выглядит для всех как сломанная программа.

Токен принимается функцией, поэтому страница может спрашивать билет у вашего собственного бекенда, а не носить его в себе. А бекенд получает билет по ключу команды: это учётные данные, которые живут на вашем сервере и умеют ровно одно.

1. Выпустить ключ

Участник команды, один раз:

POST /teams/{teamId}/embed-keys
     { "name": "бекенд магазина, прод", "expiresAt": "2027-01-01T00:00:00.000Z" }
  →  { "id": "…", "key": "emk.0f3a…c19.Zm9vYmFy…", … }
  • Секрет показывается один раз. Копии здесь не остаётся. Потеряли — отзовите и выпустите новый; это и так правильное лекарство.
  • Задайте expiresAt, если можете. Отзыв требует, чтобы кто-то помнил про ключ; срок не требует никого.
  • Отзыв — DELETE /teams/{teamId}/embed-keys/{keyId}. Немедленно, без отсрочки, и запись переживает отзыв, чтобы инцидент можно было потом прочитать.

2. Выпускать билет на конкретного человека, сервер-сервер

POST /agents/{agentId}/channels/bridle/visitor
     X-Embed-Key: emk.…
     { "externalId": "customer-4471", "name": "Анна", "email": "anna@example.com" }
  →  { "token": "…", "expiresAt": "…" }
  • externalId — это личность, а имя, почта и телефон — то, что о ней ИЗВЕСТНО. Тот же externalId назавтра — тот же лид, а не второй. Почта сохраняется и показывается, но по ней никогда не ищут: два покупателя могут оказаться с одной почтой по ошибке самого сайта.
  • Человек записывается ДО того, как билет выдан, поэтому живой билет никогда не называет того, о ком нет строки. В списке лидов команды он появляется с тем, что вы прислали, а не непрозрачной строкой.
  • Заголовок X-Embed-Key, а не Authorization — намеренно: ключ, отправленный как Bearer, будет отвергнут как испорченный JWT, а сессионный токен, отправленный сюда, — как «это не ключ». Ни один из отказов не зависит от того, помнит ли кто-то разницу.

Чего ключ не открывает

  • Ни одного другого маршрута этого api. Проверка навешена руками на один обработчик, поэтому маршрут, добавленный завтра, закрыт для ключа по умолчанию, а не по итогам ревью, — и она не заводит принципала, так что маршрут, которому её приделали бы по ошибке, отказал бы, а не расширил права.
  • Чужую команду. Команда берётся из ключа, агент — из пути; если они не совпали, ответ такой же «нет такого агента», какой получает посторонний, — то есть ключом нельзя выяснить, какие идентификаторы агентов существуют.
  • Кабинет. Выпуск и отзыв ключей по-прежнему требуют сессии участника.

И тогда страница не носит никакого секрета

ts
init({
  apiUrl,
  agentId,
  token: () => fetch('/api/agent-token').then((r) => r.json()).then((t) => t.token),
})

/api/agent-token — это ВАШ маршрут, и личность он должен брать из сессии покупателя на вашем сервере, а не из того, что прислал браузер. В этом и вся разница между «сайт говорит, кто это» и «браузер говорит, кто он».

Когда истекает вставленный руками билет

Есть один случай, где вставленный билет — единственный выход: статический сайт без бекенда — собранная документация, лендинг на файловом хостинге. Позвать выпуск там некому, а ключ команды класть в сборку нельзя: сборка публикуется.

У такого сайта чат перестаёт отвечать вместе с билетом, и виджет сообщает INVALID_TOKEN. Это срок делает свою работу, а не поломка. Готовьтесь повторять: пятнадцать минут — это пятнадцать минут.

Демо работает в обоих режимах и говорит, в каком оно сейчас: с ключом команды билета в нём нет вовсе, а со вставленным — оно читает собственный билет, показывает остаток, а когда время вышло, говорит именно это, а не замолкает.

Что никогда не оказывается на вашем сайте

  • BRIDLE_JWT_SECRET и любой другой секрет установки.
  • Ключ команды. Он живёт на вашем сервере. В исходнике страницы или в опубликованной сборке это учётные данные для того, чтобы назвать любого покупателя вашей команды кем угодно.
  • Учётные данные, которыми входят ваши операторы. У встраивания свой секрет и свой билет, и разделение сделано намеренно.
  • Собственные секреты, ключи и подключения агента. Разговор посетителя их не читает, и никакая настройка этого не меняет.