Модалка создания сервера показывает блок «Мои шаблоны» рядом со
встроенными и отправляет template_id=personal:<id>; секция «Шаблоны
сервера» в настройках (видна с MANAGE_GUILD) сохраняет структуру
текущего сервера и удаляет ненужные шаблоны.
- api: fetchMyGuildTemplates, createGuildTemplate, deleteGuildTemplate,
ключ myGuildTemplatesQueryKey и лимит MY_GUILD_TEMPLATE_LIMIT;
- i18n: ключи в ru.json и en.json (паритет);
- тесты: web/tests/guildTemplates.test.tsx.
D-070: иконка группы приходит полем `icon_file_id` — у группы это её файл, у
1:1 по-прежнему аватар собеседника. Разбор беседы из REST вынесен в
`parseChannelPayload`: групповые поля и иконка не теряются, а у группы без
иконки поле очищается, чтобы не остался чужой аватар.
`api/dmIcon.ts` грузит и снимает иконку multipart-запросом (FormData уже умеет
общий `request`). В шапке групповой беседы у владельца появилось меню «Иконка
беседы»: загрузить или удалить; ответ сервера сразу кладётся в стор бесед, а
список перечитывается — снятая иконка приходит пустым полем (AGENT.md 7.8,
11.6). Строки добавлены в ru и en.
Новые разделы панели инстанса (AGENT.md 3.2, 7.18):
- вкладка «Статистика»: рост пользователей и сообщений по дням, размеры
данных, топы серверов;
- карточка пользователя из списка: устройства с отзывом, серверы и роли,
события безопасности, аудит по пользователю; отзыв устройства и сброс
второго фактора подтверждаются step-up;
- вкладка «Аудит»: фильтры по действию, актору, цели, серверу и датам,
пагинация и ссылка на выгрузку CSV;
- вкладка «Серверы»: поиск по названию, фильтр по владельцу и главному
серверу, пагинация;
- ключи i18n для новых разделов (ru/en, паритет), тесты панели.
`GUILD_EMOJIS_UPDATE` несёт набор эмодзи целиком, но раздел настроек читает
REST-кэш, а клиент на событие запрос инвалидировал. Под нагрузкой повторный
`GET /guilds/{id}/emojis` попадает под лимит частоты (429), а запрос списка идёт
с `retry: 0` — список оставался старым до перезахода. Тот же дефект, что у
раздела «Звуки» (коммит 4175b14), и та же правка: событие кладёт набор в кэш
запроса через `setQueryData`, а ошибка перезапроса больше не прячет уже
полученный список.
Регрессионный тест `web/tests/guildEmojisSettings.test.tsx` («показывает эмодзи
из GUILD_EMOJIS_UPDATE, даже если список по REST не отдался») падает на старом
коде.
Сервер присылал эти события и раньше, но клиент перечитывал список бесед только
по `DM_CHANNEL_CREATE` и READY: добавление участника, переименование группы и
выход из неё не появлялись в сайдбаре до перезагрузки, а удалённая беседа
оставалась открытой.
Теперь все три события перечитывают список (`DM_CHANNEL_UPDATE` меняет состав,
имя и аватар группы сразу), а `DM_CHANNEL_DELETE` дополнительно убирает беседу
из стора сессии и закрывает её, если она была открыта.
Тесты в `web/tests/friends.test.tsx` падают на старом коде: переименование
группы видно второму участнику, удалённая беседа исчезает и закрывается.
Под текстом сообщения показывается карточка: имя сайта, заголовок, описание и
картинка — всё с сервера (`GET /link-previews`), клиент сам в интернет не ходит.
- `web/src/lib/links.ts`: первая ссылка сообщения (markdown и «голая» формы),
блоки кода и инлайновый код игнорируются — `curl https://…` из примера не
должен тянуть чужую страницу;
- `web/src/components/chat/LinkPreviewCard.tsx`: запросов нет, пока
`meta.features.unfurl_enabled` не подтверждён; карточка молчит на
loading/ошибке/статусах empty, blocked и error (превью — украшение, а не
действие); картинка рисуется только по http(s) с
`referrerpolicy=no-referrer`, текст выводится обычными строками без
`dangerouslySetInnerHTML`; компонент монтируется только при найденной ссылке,
поэтому сообщение без ссылок не создаёт ни запроса, ни узла;
- `web/src/api/unfurl.ts`: типизированный клиент и ключ запроса.
Тесты: `web/tests/links.test.ts` (разбор ссылок) и `web/tests/linkPreview.test.tsx`
(карточка, отказ при выключенном unfurl, опасные схемы картинки, ровно один
запрос при 429).
Клиент подписывает устройство на Web Push, а показывает уведомление service
worker — приложение при этом может быть и закрыто.
- `web/public/sw.js`: обработчики `push` (показывает уведомление, иначе браузер
может отозвать подписку) и `notificationclick` (фокусирует окно и переводит
его в комнату, иначе открывает новое). Путь из payload берётся только
внутренний: `//host` и абсолютные адреса отбрасываются;
- `web/src/lib/webPush.ts`: поддержка API, запрос разрешения, подписка и
отписка, преобразование ключей в base64url, слушатель перехода по клику
(`push-navigate` → `history.pushState` без перезагрузки);
- `web/src/api/push.ts`: `GET /push/config`, `GET|POST|DELETE /push/subscriptions`;
- раздел «Push-уведомления» в настройках: тумблер «Включить на этом
устройстве», состояние «включено», объяснение отказа браузера в разрешении и
честная деградация — «браузер не поддерживает» или «на инстансе не настроено»;
- владелец инстанса управляет ключами, клиент только спрашивает публичный.
Тесты: `web/tests/webPush.test.ts` (поддержка API, ключи, подписка, отписка,
отказ в разрешении, переход по клику) и `web/tests/pushSettings.test.tsx`
(раздел настроек: деградация, включение, отключение, ошибка загрузки).
Беседа на несколько участников живёт в тех же таблицах, что и 1:1
(channels.type = 'dm' + dm_participants), добавляется только владелец
(channels.dm_owner_id, миграция 00020).
Сервер: POST /users/@me/channels/group (2–9 приглашённых, имя необязательно —
собирается из имён), PUT/DELETE /channels/{id}/recipients/{user_id}
(добавляет любой участник, удаляет других и переименовывает только владелец,
выйти может каждый сам), PATCH /channels/{id}. Правила состава: группа — это
3+ участника; при двух беседа снова обычная личная (имя и владелец
сбрасываются), при одном — удаляется вместе с перепиской. Заблокированного
нельзя ни пригласить, ни добавить; посторонним беседа не видна (404).
READY и REST отдают is_group, name, member_count, owner_id и состав;
участники получают DM_CHANNEL_CREATE/DM_CHANNEL_UPDATE, удалённый —
DM_CHANNEL_DELETE.
Клиент: группа в сайдбаре с числом участников (без точки чужого статуса),
шапка беседы с числом участников и именами в подсказке, создание группы из
списка друзей (минимум двое) с переходом в новую беседу.
Тесты: Go — полный жизненный цикл (создание, состав у каждого участника,
добавление, запрет посторонним, удаление владельцем, выход, превращение в
1:1, удаление последней беседы), валидация (меньше трёх, дубликаты,
заблокированный) и группа в READY; web — 4 vitest (сайдбар, шапка, создание,
выключенная кнопка).
После отзыва ключа виртуальный аутентификатор ещё хранит credential и
подписывает challenge, поэтому отказ приходит от сервера
(auth.passkey_unknown): проверяем, что страница входа показывает ошибку и
не пускает в приложение, а не конкретный testid клиентской ошибки.
Живой прогон на стенде: регистрация ключа, вход по нему без пароля и
отзыв — 1 passed (виртуальный аутентификатор Chromium через CDP).
Фаза 7, AGENT.md 7.1: провайдеры включаются переменными окружения
OAUTH_<PROVIDER>_CLIENT_ID/SECRET, привязка внешнего аккаунта идёт по
подтверждённому провайдером email через blind index, вход забаненному на
инстансе запрещён, все входы и привязки попадают в события безопасности,
привязка — ещё и в аудит инстанса.
Внешний идентификатор (subject) хранится только индексом HMAC-SHA-256,
токены провайдеров не сохраняются вовсе; state подписывается ключом сессий
и ограничен по времени, редирект после входа — только внутренний путь.
Если провайдер не настроен, ручка отвечает oauth.provider_not_configured,
а клиент показывает человеческий текст вместо кнопки.
Клиент: кнопки входа по списку включённых провайдеров на экране входа и
раздел «Вход через внешние сервисы» в настройках безопасности; адрес
возврата для настроек приложения виден в GET /auth/oauth/providers.
Миграция 00019 добавляет таблицу oauth_accounts. Установщик и .env.example
знают про OAUTH_* и сохраняют значения при --reconfigure.
Тесты: Go — выключенный провайдер, подписанный state (подмена и чужой
провайдер), создание и повторный вход, привязка к существующему аккаунту,
неподтверждённый email, выключенная регистрация, бан инстанса, mocked
провайдер (httptest) для обмена кода; web — кнопки провайдеров и ошибка
возврата; серверная ручка отдаёт понятный отказ без настроек.
Фаза 7, AGENT.md 7.1: регистрация ключа в настройках безопасности с
обязательным step-up, вход по ключу без пароля (в т.ч. без ввода почты —
обнаруживаемый ключ), несколько ключей на аккаунт, отзыв и переименование,
события безопасности и аудит.
Сервер: github.com/go-webauthn/webauthn (BSD-3-Clause), RP ID и Origin
берутся из конфига домена; если домен — IP-адрес (стенд без домена),
passkeys честно выключены (auth.passkey_unsupported). Церемонии живут в
памяти процесса 5 минут и одноразовые: повторная отправка challenge
отклоняется. Миграция 00018 пересобирает неиспользуемую таблицу
webauthn_credentials под полную запись credential в JSON. Новые ручки
входа ограничены по IP (10/мин).
Клиент: тонкая обёртка над navigator.credentials без тяжёлых SDK,
раздел «Ключи доступа» в настройках безопасности и кнопка «Войти по ключу»
на экране входа; понятные сообщения для браузеров без поддержки WebAuthn
и при отмене диалога.
Тесты: Go — регистрация/вход с эмулятором аутентификатора (реальная
проверка подписи P-256), отказ при чужом challenge, одноразовость
церемонии, обязательный step-up при управлении ключами, запрет входа
забаненному, ограничение allowCredentials при входе с почтой, лимит и
валидация имени; web — 12 vitest с моком navigator.credentials;
Playwright — живой сценарий с виртуальным аутентификатором Chromium.
`GUILD_SOUNDS_UPDATE` приносит набор звуков целиком, но раздел «Звуки» читает
REST-кэш: клиент инвалидировал запрос вместо того, чтобы положить в кэш сам
payload события. Под нагрузкой повторный `GET /guilds/{id}/sounds` попадает
под лимит частоты (429), а запрос списка идёт с `retry: 0` — список оставался
старым, и новый звук не появлялся в разделе. Так падал пункт 5 матрицы 11.6 в
полной прогонке (build/e2e-all-run8.log, run9): в логах стенда видно
`POST .../sounds 200`, следом `GET .../sounds 200` (первый звук) и
`GET .../sounds 429` (второй) — без единой повторной попытки.
Теперь событие пишет список в кэш запроса, а ошибка перезапроса не прячет уже
полученный список: раздел показывает актуальный набор из снапшота.
Тест: web/tests/soundSettings.test.tsx — звук из `GUILD_SOUNDS_UPDATE`
появляется, когда список по REST отвечает 429.
Клиент играет звук асинхронно: сначала скачивает файл через /files/{id} в
blob, и только потом создаёт Audio и зовёт play(). Подпись о проигрывании
появляется раньше звука, а проверка читала счётчики сразу после неё и ловила
гонку: в прогоне build/e2e-all-run8.log пункт 27 падал с __soundCalls = 0,
хотя сервер отвечал 200 и запрос файла уже шёл. Ждём факт запуска через
expect.poll — проверка снова проверяет воспроизведение, а не скорость сети.
Пункт был недоступен раньше: без медиапути LiveKit тест скипался, поэтому
гонка не проявлялась.
Пункты 3, 10 и 11 матрицы и тест саундборда падали не на продукте, а на
инфраструктуре: ICE через TURN на VPS не поднимается (STUN отвечает, но все
ICE-пары `failed` с `requestsReceived: 0`), и проверка трижды ждала кнопку
входа, после чего падал весь прогон.
В support.ts добавлен общий помощник joinVoiceIfPossible/joinVoiceOrSkip:
- три попытки, как раньше, но при уже показанной клиентом ошибке повтор не
делается, а ожидание ограничено 25 секундами;
- при неудаче проверяется серверная часть входа (POST .../voice/join →
токен LiveKit): живой сервер при мёртвом ICE — блокер среды, проверка
скипается с точной причиной; нерабочая ручка — дефект продукта, тест
падает. Проба ограничена по времени, а ответ 429 считается признаком
живого сервера (лимит частоты к медиапути отношения не имеет);
- п. 11 при недоступном голосе не отменяется: голосовой мьют пропускается
с аннотацией и предупреждением в лог, а тайм-аут, кик, возврат сервера в
рейку и бан по-прежнему проверяются.
Три локальные копии joinVoice заменены одним помощником.
Прогоны на стенде (образ с HEAD): матрица 18 passed / 0 failed / 3 skipped,
§11.5 — 8 passed, два прогона подряд совпали; без правки было 3 failed.
§11.5 требует, чтобы каждое мутирующее действие писалось в журнал сервера
с `actor.instance_admin=true`, но у PATCH /guilds/{id},
PATCH /guilds/{id}/members/{user_id} и у ручек комнат вызова `recordAudit`
не было: переименование сервера, смена ника, тайм-аут и создание, правка и
удаление комнаты не оставляли следа ни у владельца, ни у инстанс-админа.
- guild.update, member.update, member.timeout (в причине — срок тайм-аута),
channel.create, channel.update, channel.delete;
- Go-тест TestAuditCoversGuildMemberAndChannelChanges проверяет записи на
чужом сервере и флаг actor_instance_admin, а также что действие владельца
этим флагом не помечается;
- e2e 19b в instance-admin.spec.ts включён вместо test.fixme с диагностикой.
Проверки: go test -tags sqlite_fts5 -race -count=1 ./internal/... — ok;
make go-lint — 0 issues; живьём на стенде тест 19b проходит.
Проверки, которые в матрице 11.6 обходились по REST, теперь идут через
интерфейс (AGENT.md 7.13, 7.15, 11.6).
- 25: 🔗/⚙/+ видны владельцу и не видны обычному участнику, а после выдачи
роли с правами управления появляются без перезагрузки;
- 26: статус комнаты и медленный режим появляются в шапке по событию и
пропадают при снятии;
- 27: саундборд — панель открывается в голосовой комнате, проигрывание
уходит на сервер (200), звук играет локально (счётчик вызовов
`Audio.play`, подпись «включил звук»). Если медиапуть LiveKit не
поднялся, тест скипается с причиной: это внешняя зависимость;
- 28: счётчик непрочитанных в сайдбаре и явная фиксация того, что центра
уведомлений в клиенте нет (заглушка — только переключатель системных
уведомлений в настройках).
Сценариев личных серверов и личных бесед в матрице 11.6 не было
(AGENT.md 7.2, 7.8, 7.9). Новый спек идёт под тем же флагом
`GLCHAT_E2E_REALTIME=1` и тем же стилем: два браузера, `support.ts`,
`trackLoads`, уборка в `finally`.
- 21: личный (непубличный) сервер — вход по ссылке-приглашению в браузере,
сервера нет в каталоге, вход без приглашения 403, вошедший видит историю;
- 22: DM 1:1 — непрочитанное в списке бесед, история, «печатает», ответ из
интерфейса, правка и удаление без перезагрузки;
- 23: заявка в друзья и её принятие кнопкой в интерфейсе;
- 24: блокировка из списка друзей — писать нельзя в обе стороны
(`dm.blocked`), беседа не открывается, разблокировка возвращает переписку.
Приглашение (1 на прогон) создаёт владелец: суточная квота — 10 на
пользователя (AGENT.md 8.6).
Чек-лист §11.5 проверялся отдельными скриптами и живьём, но в спеках его
не было. Новый файл (`GLCHAT_E2E_INSTANCE=1`) работает на сервере, где
админ не участник: владелец — `rtprobe`, участник — `rtprobe2`.
- 14: видимость — чужой приватный сервер в `GET /instance/guilds` и в
списке серверов админа, комнаты, история, поиск, участники, роли, баны,
инвайты, эмодзи, звуки, вебхуки, аудит; чужие DM по-прежнему 404;
- 15: управление без 403 — имя сервера, ник владельца, роли (создание,
поднятие выше своей, выдача и снятие), комнаты; аудит помечает действия
`actor_instance_admin=true`;
- 16: модерация — бан с причиной и автором, разбан, кик, тайм-аут; попытки
владельца кикнуть/забанить/замутить/лишить роли админа дают 403
`instance.admin_protected`;
- 17: обход лимитов — slowmode, приватность комнаты, лимит участников
голосовой комнаты;
- 18: UI — админ входит в чужой приватный сервер из рейки без инвайта,
пишет в приватную комнату, а участник видит переименование и кик без
перезагрузки;
- 19: аудит — прежние записи не затираются; 19b (`test.fixme`) фиксирует
пробел: переименование сервера, ник и комнаты в аудит не пишутся;
- 20: безопасность не обходится — step-up на свежей сессии, чужой Origin,
лимит реакций 429.
Уборка тестовых серверов — в `finally` от имени админа (удаление чужого
сервера требует step-up).
Новые спеки (§11.5, личные серверы и шапка сервера) используют тот же
набор шагов, что матрица 11.6, но её помощники лежали внутри
`realtime.spec.ts`. Вынес в `support.ts`, чтобы не копировать их в третий
раз: сессии и лимит входов, повтор при 429, step-up (в том числе с 2FA
админа), подготовка сервера и уборка, комнаты, роли, сообщения, звуки,
личные беседы, друзья и блокировки.
По MEMBER_UPDATE/ROLE_* клиент перечитывал участников, роли и комнаты, но
не сводку сервера: `my_permissions` в сторе сессии оставались прежними, и
кнопки управления (🔗 приглашение, ⚙ настройки, + комната) появлялись
только после перезагрузки. Матрица 11.6 требует, чтобы права сразу меняли
доступные действия (AGENT.md 11.6).
- в ветках `MEMBER_UPDATE`/`MEMBER_REMOVE` и `ROLE_*` вызывается
`addGuildFromRest` — сводка сервера перечитывается и мержится;
- vitest: кнопки появляются по событию (тест падает без правки).
Описание комнаты жило только в настройках: в шапке были имя и медленный
режим, поэтому «статус комнаты» из матрицы 11.6 приходилось проверять по
REST. Теперь описание показывается рядом с названием (длинное усекается,
полный текст — в подсказке), а само поле приходит и в READY, и в событиях
комнат (AGENT.md 7.5, 11.6).
- `Channel.description` и разбор `description` в `parseChannelPayload`;
- `channel-description` в шапке комнаты;
- vitest `tests/channelHeader.test.tsx`: статус из снапшота, отсутствие
статуса без описания и обновление по `CHANNEL_UPDATE` без перезагрузки.
Два требования desktop-клиента (docs/client-tauri.md §5), которые живут в
веб-слое — том же, что и в браузере:
- **Палитра команд.** Ctrl/Cmd+K (на документе или сообщением `openPalette` от
обёртки) открывает быстрый переход: серверы (в первую доступную комнату),
комнаты выбранного сервера с группировкой по категориям, личные беседы из уже
загруженного снапшота, действия (настройки профиля/безопасности/внешнего вида,
настройки сервера, создание сервера, друзья, микрофон и «не слышу» при
активной голосовой сессии). Новых запросов палитра не делает; поиск по
подстроке без учёта регистра, 8 пунктов на раздел, стрелки со
`scrollIntoView`, `role=listbox/option` и `aria-activedescendant`;
- **Подтверждение выхода.** Формы с локальным черновиком регистрируются в общем
реестре (`stores/unsaved.ts` + хук `useUnsavedChanges`): платформа получает
один `setDirty` на переход «есть правки / нет правок», а на сообщение обёртки
`closeRequested` клиент отвечает тряской контента и диалогом «Сохранить /
Выйти без сохранения». При `prefers-reduced-motion: reduce` тряску заменяет
подсветка рамки; в браузере вместо сообщения обёртки работает штатный
`beforeunload`. Хук добавлен в формы с явной кнопкой «Сохранить»:
профиль, общие настройки сервера, ник, эмодзи, звуки, оформление, вебхуки,
лимиты инстанса, переименование сервера (автосохраняемые переключатели
намеренно не помечаются);
- Deep link `glchat://dm/<channelId>` (личная беседа без сервера) — его
присылает обёртка при клике по уведомлению о личном сообщении.
Тесты: 525 passed (501 база + 24 новых) — палитра, реестр правок, диалог
закрытия, `beforeunload`, разбор ссылок; `npm run check` и `npm run lint` зелёные.
Подготовка файла (beforeAll) входит в пробные аккаунты, а лимит — 5 входов в
минуту на IP (AGENT.md 8.6): на живом стенде подготовка не укладывалась в
180 секунд, и весь файл падал в beforeAll ещё до тестов. Значение по умолчанию
не меняется, для прогонов на стенде поднимается GLCHAT_E2E_TIMEOUT_MS.
Пункт 12 включён: клиент уходит на экран входа при отзыве сессий, в том числе
когда открыт сервер (дефект исправлен в 751ecc6), а смена пароля на сервере
больше не рвёт соединение текущего устройства (05409d0).
Пункт 11 теперь проверяет то, ради чего он написан: после кика и повторного
входа плитка сервера возвращается в рейку без перезагрузки (правка клиента в
ea1b4a7). Возврат пароля пробного участника перенесён в finally: падение теста
не оставляет постоянный аккаунт с временным паролем, а неудачный возврат
сообщается явной проверкой.
Прогон: 13 passed (2,9 мин), лог — build/e2e-realtime-run4.log.
Раздел «Фон комнаты» запрашивал вложенные ключи, а строки лежали на уровень
выше (settings.channel.title и соседние), поэтому в интерфейсе виднелись
сырые ключи i18n. Строки перенесены под settings.channel.background в обеих
локалях, паритет ru/en сохранён — web/tests/i18n.test.ts зелёный.
Сервер отзывал сессии кадром {"op":4,"reason":"…","resumable":false} и закрывал
соединение, но устройство с открытым сервером (/app/<сервер>/<комната>)
оставалось в приложении: уход на /login зависел только от 401 на запросе
профиля, а перечитывание профиля в этой ветке не срабатывало (пункт 12
матрицы 11.6, проверено перехватом WS на стенде).
Теперь признак invalidated в сторе шлюза читает AuthGuard и сразу уводит на
экран входа — в любом состоянии приложения, не дожидаясь ответа REST.
Признак снимается при успешном входе, иначе форма входа зацикливалась бы.
Тесты: «отзыв сессии на открытом сервере уводит на /login»; живая проверка
build/verify-session-invalidation.mjs дополнена сценарием с открытым сервером.
По GUILD_CREATE клиент забирал сводку серверов через fetchQuery, а список
myGuilds кэшируется на 30 секунд: после кика и повторного входа сервер
возвращался на сервере, но плитка в рейке не появлялась до перезагрузки
(AGENT.md 11.6).
Теперь обработчик GUILD_CREATE сначала помечает список серверов устаревшим и
только потом читает его, поэтому запрос уходит в REST даже при свежем кэше.
Сценарий кика в пункте 11 матрицы проверяет плитку в рейке, а не только
состав участников по ответу сервера.
Тест: «GUILD_CREATE перечитывает список серверов, а не берёт свежий кэш».
Первый инкремент Фазы 6 (docs/client-tauri.md): окно с тем же веб-клиентом
(по умолчанию https://gl.mhspx.su, адрес настраивается), трей с меню и
индикатором непрочитанных, нативные уведомления, глобальный push-to-talk и
переключение микрофона/deafen, автозапуск, single-instance, сохранение
геометрии окна, deep links glchat://…, автообновление по подписанному
манифесту.
Связь страницы с обёрткой — через платформенный адаптер
(web/src/lib/platform.ts) и минимальный типизированный набор команд;
в веб-клиенте появились системные уведомления об упоминаниях и личных
сообщениях и переключатель в настройках.
В web/src/stores/gateway.ts вместе с вызовом уведомления лежат правки по
дефектам realtime (READY и сессии) из параллельной волны: файл коммитится
целиком по договорённости с координатором.
Приглашения ограничены 10 на пользователя за 24 ч (AGENT.md 8.6), поэтому
проверка инвайтов создаёт отдельного свежего владельца сервера: у постоянных
пробных аккаунтов квота выгорает за серию прогонов. Прогон: 1 passed.
Два спектакля на общем модуле `web/e2e/support.ts` (запуск только с
`GLCHAT_E2E_REALTIME=1`):
- `realtime.spec.ts` — пункты 1–7: роли и права, приватная комната по роли,
профиль и ники, оформление сервера, эмодзи/звуки/косметика, инвайты, комнаты;
- `realtime-social.spec.ts` — пункты 8–13: сообщения, непрочитанные и
упоминания между устройствами, presence/typing/голос, модерация, два
устройства, RESUME после реконнекта.
Владелец действует по REST, второй браузер видит результат через Gateway;
полные загрузки документа запрещены и проверяются `trackLoads`. Матрица нашла
четыре дефекта продукта (исправлены в `d241714` и `7ec9dbf`).
- клиент откладывает перечитывание комнат (окно 250 мс) и не запускает второй
запрос, пока идёт первый: серия событий ролей и участников больше не даёт
лавину GET members/roles и 429 на лимите API;
- неожиданные ошибки API пишутся в лог (и в chi-, и в huma-ветке): под
параллельной нагрузкой на стенде видели разовые 500 на GET members/roles,
но без текста ошибки разобраться было нельзя.
- /.well-known/security.txt уходил в SPA-заглушку (200 text/html): добавлена
копия в web/public и явный тип text/plain в статике (RFC 9116, AGENT.md 11.3);
- make security дополнен `npm audit --omit=dev --audit-level=high`;
- новая цель `make sbom` — CycloneDX-отчёт зависимостей (trivy, если есть).
- invalidateCurrentUser перечитывает профиль, а не только удаляет кэш: без
запроса AuthGuard не видит 401 и второе устройство остаётся в приложении
после logout-all или смены пароля (AGENT.md 11.6, пункт 12);
- сброс сессии и уход из последнего сервера инвалидируют список серверов:
пустой снапшот подставлял устаревший REST-ответ, и сервер возвращался в
рейку (видно и после кика);
- joinGuild проверяет бан сервера: публичный сервер больше не обходится
нажатием «Войти» вместо приглашения (AGENT.md 7.17, 7.20), тест расширен;
- e2e/voice-ten приведён к линту, console.log разрешён в e2e (диагностика
прогона), артефакты Playwright исключены из prettier.
Дефекты, найденные e2e-матрицей AGENT.md 11.6 (пункты 1, 2, 7, 12):
- ROLE_CREATE/ROLE_UPDATE уходили телом роли без guild_id, а клиент ждал
ссылку {guild_id, role_id} — событие отбрасывалось, роли у второго
участника не обновлялись. В rolePayload добавлено guild_id, parseRoleEvent
принимает и тело роли, и старую ссылку;
- выдача роли (MEMBER_UPDATE) и правки ролей не перечитывали список комнат,
поэтому приватная комната не появлялась в сайдбаре без F5 — теперь клиент
перечитывает комнаты по этим событиям;
- CHANNEL_CREATE/CHANNEL_UPDATE уходили телом комнаты с нулевым can_view
(права персональные), и клиент прятал комнату по этому признаку. Сервер
шлёт тело события без вычисленных прав плюс GUILD_CHANNELS_SYNC, а клиент
по событию комнаты перечитывает список;
- отзыв сессий (logout-all, смена пароля, админский сброс и «выйти везде»,
бан, удаление) не доходил до Gateway: второе устройство оставалось в
приложении. Добавлен gateway.InvalidateUser — клиент получает
INVALID_SESSION и закрывает соединение; клиент дополнительно обрабатывает
событие SESSION_INVALIDATED.
Тесты: gateway (отзыв сессий закрывает соединения и не задевает чужого),
web (событие SESSION_INVALIDATED, перечитывание комнат по CHANNEL_CREATE).
- cmd/loadgen: держит заданное число WS-клиентов Gateway, шлёт сообщения с
нужной частотой, измеряет задержку доставки (p50/p95/p99/max), ответы API,
разрывы соединений и пишет отчёт в JSON;
- glchat create-user (CLI + обёртка): создаёт аккаунты в обход выключенной
регистрации, пакетно (--count/--prefix), при --sessions сразу выдаёт сессии
и складывает учётные данные в файл 600 — иначе сотня клиентов не сможет
войти из-за лимита попыток по IP;
- auth: CreateUserByOperator и IssueSessionForOperator с тестом;
- web/e2e/voice-ten.spec.ts: десять участников в одной голосовой комнате,
проверка плиток, входящего аудио и слоя 1080p60 у клиента; сессии берутся
из файла (без формы входа);
- .gitignore: каталоги вывода Playwright test-results*.
Сервер:
- store: оверрайды всех комнат сервера одним запросом (без N+1) и снятие
оверрайда с признаком «был ли он»;
- API: PUT/DELETE /guilds/{id}/channels/{cid}/overwrites/{role|user}/{tid}
(права именами через `|`, как у ролей), step-up на изменение, проверка что
роль принадлежит серверу, а участник состоит в нём;
- список комнат отдаёт permission_overwrites; после правки сбрасывается кэш
прав комнаты, пишется аудит (channel.overwrite_set/delete) и уходит событие
GUILD_CHANNELS_SYNC — видимость комнаты меняется у всех участников.
Клиент:
- редактор «Доступ к комнате»: приватность одним переключателем (запрет
VIEW_CHANNEL для @everyone), права просмотра/переписки/входа для ролей и
участников, подтверждение личности по требованию сервера;
- в настройках комнаты теперь и голосовые комнаты (фон и права), вебхуки —
только у текстовых; событие GUILD_CHANNELS_SYNC перечитывает список комнат.
Тесты: 2 Go-теста (скрытие и открытие комнаты ролями, проверка цели и прав) и
3 web-теста редактора (маски, step-up, скрытие без MANAGE_ROLES).
Сервер:
- миграция 00017: таблица instance_bans (причина, автор, дата);
- auth: ErrUserBanned, проверка бана в Login (код user.banned, 403) и в
ResolveSession — забаненный не получает сессию ни по cookie, ни по Bearer,
ни в Gateway, а прежняя сессия удаляется;
- store: BannedAt/BanReason в модели пользователя, BanInstanceUser,
UnbanInstanceUser, IsInstanceBanned, поиск и фильтр в ListUsers,
CountUsersFiltered; мягкое удаление аккаунта убирает и запись о бане;
- API: POST /instance/users/{id}/ban и /unban со step-up (AGENT.md 7.1),
отзыв сессий и SESSION_INVALIDATED, аудит instance.user_ban с причиной и
instance.user_unban; себя и инстанс-админа забанить нельзя;
- GET /instance/users: q (логин и отображаемое имя), banned=true, total.
Клиент:
- панель: поиск, фильтр «только забаненные», бейдж бана с причиной, кнопки
«Забанить» (с причиной) и «Разбанить» через общий шаг подтверждения
личности; i18n ru/en, включая текст ошибки user.banned;
- keepPreviousData в списке пользователей: без этого поле поиска
размонтировалось на первом же символе и набор обрывался.
Тесты: 4 Go-теста (бан блокирует вход и сессии, защита админов, поиск,
уборка бана при удалении), web-тест панели, живая проверка на стенде 19/19.
- лимиты медиа живут в настройках инстанса (миграция 00016): аватары,
оформление сервера, эмодзи, звуки и галерея; все загрузки берут предел
оттуда, значения по умолчанию — из AGENT.md 7.7
- действия администратора: временный пароль (показывается один раз, сессии
отзываются), выход со всех устройств, мягкое удаление пользователя
(сообщения и аудит остаются), переименование и удаление любого сервера
- админ-панель разбита на вкладки: обзор и здоровье, лимиты, пользователи,
серверы, аудит; смена администраторов подтверждается личностью (step-up)
- тесты: Go (действия администратора, лимит эмодзи из настроек) и Vitest
(вкладки, сброс пароля, выход, удаление, переименование сервера)
- internal/retention: истёкшие сессии, аудит старше RETENTION_AUDIT_DAYS и
файлы без ссылок старше RETENTION_ORPHAN_HOURS; сиротой файл считается
только если на него не ссылается ничего (вложения, аватары, оформление
сервера, комнат, ролей, эмодзи, звуки, вебхуки, приглашения)
- обслуживание запускается в приложении по RETENTION_INTERVAL_SECONDS;
команды glchat cleanup и glchat reindex делают то же вручную
- PWA: манифест с иконками и shortcuts, офлайн-страница, сервис-воркер
(кэш только /assets/*, приватные запросы не кэшируются)
- тесты: Go на чистку (сирота удаляется, используемые файлы и свежие сироты
остаются, истёкшая сессия уходит), Vitest на манифест и регистрацию воркера
- GET /files/{id} без сессии отдаёт только оформление сервера и картинку
приглашения (guild_icon, guild_banner, guild_splash, invite_background):
их показывают до входа (AGENT.md 7.9), остальное по-прежнему требует сессии
- optionalUser резолвит сессию без ответа об ошибке; путь файла проверяется на
принадлежность каталогу данных (AGENT.md 9.2)
- аватар пригласившего показывается только вошедшим
- тесты: Go (splash анонимно, вложение закрыто) и правки живого скрипта
- миграция 00015: переопределение splash у приглашения (AGENT.md 7.9)
- GET /invites/{code} доступен без сессии: гость по ссылке видит карточку
сервера, описание, число участников и пригласившего
- POST/DELETE /invites/{code}/background: своя картинка страницы под правом
создателя или MANAGE_GUILD, файл удаляется при замене и снятии
- клиент: маршрут /invite/:code с фоном (переопределение → splash → баннер),
карточкой сервера, кнопками «Присоединиться»/«Открыть сервер» и входом с
возвратом на страницу (?next=)
- тесты: Go (карточка, загрузка, права, снятие) и Vitest (splash, гость,
присоединение, недействительная ссылка)
- PUT/DELETE /users/@me/blocks/{id}: блокировка снимает дружбу и заявки в обе
стороны, разблокировка возвращает возможность писать (AGENT.md 7.2)
- блокировка запрещает личные сообщения (ошибка dm.blocked), открытие беседы и
заявки в друзья с любой стороны
- клиент: раздел «Заблокированные» в друзьях со снятием блокировки, действие
«Заблокировать» в меню строки друга и в меню модерации участника
- тесты: Go (блокировка, заявки, беседа, сообщение) и Vitest (список, снятие,
блокировка из меню)
- участники сервера отдают список бейджей из badges_json (AGENT.md 7.2):
список расширяемый, назначается только системой
- клиент: каталог бейджей (щит администратора инстанса, корона владельца,
ранний сторонник, проверенный), неизвестные показываются нейтральной меткой
- бейджи видны в ленте, списке участников и друзьях вместо текстовой плашки
- тесты: Go (бейджи в списке участников) и Vitest (каталог и отрисовка)
- миграция 00014: background_file_id у комнаты (AGENT.md 7.5, 7.7)
- API: POST/DELETE /channels/{id}/background под MANAGE_CHANNEL_BACKGROUND,
фон отдаётся в списке комнат и рассылается событием CHANNEL_UPDATE
- клиент: фон рисуется за лентой с затемнением, блок «Фон комнаты» в настройках
комнаты (загрузка, замена, удаление)
- AnimatedImage: при «уменьшить движение» показывает статичный первый кадр,
нарисованный на canvas, вместо анимации
- тесты: Go (загрузка, права, снятие) и Vitest (лента, настройки, reduced-motion)
- каталог шаблонов «Пустой», «Сообщество», «Игровой» (AGENT.md 7.3): роли,
категории, текстовые и голосовые комнаты без сообщений
- POST /guilds принимает template_id, неизвестный шаблон отклоняется; аудит
фиксирует выбранный шаблон
- GET /guild-templates отдаёт каталог с предпросмотром структуры
- клиент: выбор шаблона в окне создания сервера
- тесты: Go (структура каждого шаблона) и Vitest (выбор шаблона)
- API: список друзей отдаёт вычисленное оформление «для друзей» одним запросом
на весь список (AGENT.md 7.2)
- клиент: рамка, иконка, цвет и эффект ника в списке друзей, в ленте личных
бесед — из профиля собеседника
- тесты: Go на список друзей, Vitest на отрисовку в друзьях
- миграция 00013: guild_cosmetics (рамки и иконки) и user_styles («для друзей»
и пер-серверные стили) (AGENT.md 7.2, 7.4)
- store: CRUD галереи, личные стили и вычисление оформления поэлементно
(роль → пер-серверный стиль → «для друзей» → дефолт) с откатом, когда роль
больше не выдаёт элемент
- API: галерея сервера под MANAGE_ROLES, оформление роли в create/update,
GET/PUT/DELETE /users/@me/styles с доступными элементами по областям
- клиент: редактор личного стиля с предпросмотром, галерея оформления в
настройках сервера, оформление роли в разделе «Роли»
- рендер: рамка под аватаром, иконка рядом с ником, цвет и встроенные эффекты
ника (CSS, при reduced-motion статично) в ленте и списке участников
- миграция 00012: таблица webhooks, снимок имени и аватара в messages (AGENT.md 7.11)
- API: список/создание/правка/удаление и пересоздание токена (MANAGE_WEBHOOKS,
step-up при создании, аудит), загрузка аватара отдельной multipart-ручкой
- исполнение POST /webhooks/{id}/{token} без сессии: content, username,
avatar_url, файлы создателя вебхука, лимит 30/мин на вебхук
- клиент: пункт настроек «Комната» со списком вебхуков, копированием ссылки,
пересозданием токена и удалением
- сообщения вебхуков: имя, аватар и значок в ленте вместо «неизвестного автора»
- fix: неполный ответ REST больше не затирает данные READY-снапшота
- READY-снапшот больше не теряет banner/splash/accent: шапка сервера
показывает баннер сразу после подключения (AGENT.md 7.4)
- GUILD_UPDATE несёт всё оформление, клиент применяет его без перезагрузки
- кнопка приглашения в шапке сервера со ссылкой и копированием
- встроенные звуки интерфейса на Web Audio с разблокировкой по жесту
- object URL превью через безопасные обёртки (jsdom и урезанные webview)
Фаза 4 (AGENT.md 7.4): интерфейс для иконки, баннера, splash и акцентного цвета.
- секция «Оформление» в настройках сервера: загрузка и удаление иконки,
баннера и splash с превью, проверка типа и размера на клиенте, пресеты
акцентного цвета;
- иконка сервера показывается в рейке (заглушка-буквы остаётся, если файла
нет), акцентный цвет переопределяет `--color-accent` внутри приложения и
снимается при значении 0;
- предпросмотр приглашения показывает splash фоном и иконку сервера;
- ответ сервера обновляет карточку и список серверов — оформление видно без
перезагрузки;
- тесты: превью всех трёх видов, multipart-загрузка, удаление, применение и
снятие акцента, отказ для не-изображения, скрытие секции без MANAGE_GUILD.