Что нужно на сервере
Один порт, пять переменных окружения и билет, который живёт пятнадцать минут. Эта страница — всё, что лежит между «у нас есть агент» и «на сайте клиента появился чат».
Один порт, и только он
Браузер посетителя разговаривает не с самим api. Опубликовать наружу все маршруты, Swagger и канал инструментов ради чата в углу страницы — это огромная дверь, открытая по маленькому поводу.
Поэтому хаб слушает свой отдельный порт, и на этом порту лежит ровно одно:
PORT самого api | BRIDLE_PUBLIC_PORT | |
|---|---|---|
| что слушает | весь api | одно пространство имён WebSocket |
| маршруты | все эндпоинты, Swagger, канал инструментов | POST /visitor и больше ничего |
| дотягивается до | базы, ключа секретов, кластера | хода агента, и только |
| опубликован | нет | да, и только он |
На этом порту нет маршрута, который можно опубликовать по недосмотру. Это и есть искомое свойство, ради него разделение и сделано.
Пять переменных
# Какая половина пары — этот процесс. `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. Проверка навешена руками на один обработчик, поэтому маршрут, добавленный завтра, закрыт для ключа по умолчанию, а не по итогам ревью, — и она не заводит принципала, так что маршрут, которому её приделали бы по ошибке, отказал бы, а не расширил права.
- Чужую команду. Команда берётся из ключа, агент — из пути; если они не совпали, ответ такой же «нет такого агента», какой получает посторонний, — то есть ключом нельзя выяснить, какие идентификаторы агентов существуют.
- Кабинет. Выпуск и отзыв ключей по-прежнему требуют сессии участника.
И тогда страница не носит никакого секрета
init({
apiUrl,
agentId,
token: () => fetch('/api/agent-token').then((r) => r.json()).then((t) => t.token),
})/api/agent-token — это ВАШ маршрут, и личность он должен брать из сессии покупателя на вашем сервере, а не из того, что прислал браузер. В этом и вся разница между «сайт говорит, кто это» и «браузер говорит, кто он».
Когда истекает вставленный руками билет
Есть один случай, где вставленный билет — единственный выход: статический сайт без бекенда — собранная документация, лендинг на файловом хостинге. Позвать выпуск там некому, а ключ команды класть в сборку нельзя: сборка публикуется.
У такого сайта чат перестаёт отвечать вместе с билетом, и виджет сообщает INVALID_TOKEN. Это срок делает свою работу, а не поломка. Готовьтесь повторять: пятнадцать минут — это пятнадцать минут.
Демо работает в обоих режимах и говорит, в каком оно сейчас: с ключом команды билета в нём нет вовсе, а со вставленным — оно читает собственный билет, показывает остаток, а когда время вышло, говорит именно это, а не замолкает.
Что никогда не оказывается на вашем сайте
BRIDLE_JWT_SECRETи любой другой секрет установки.- Ключ команды. Он живёт на вашем сервере. В исходнике страницы или в опубликованной сборке это учётные данные для того, чтобы назвать любого покупателя вашей команды кем угодно.
- Учётные данные, которыми входят ваши операторы. У встраивания свой секрет и свой билет, и разделение сделано намеренно.
- Собственные секреты, ключи и подключения агента. Разговор посетителя их не читает, и никакая настройка этого не меняет.