fix(realtime): события ролей, комнат и отзыв сессий без перезагрузки

Дефекты, найденные 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).
This commit is contained in:
2026-09-22 20:10:53 +03:00
parent ff2fc878b0
commit d241714a6d
10 changed files with 188 additions and 26 deletions
+28 -3
View File
@@ -60,7 +60,10 @@ func channelOverwritePayloads(overrides []store.ChannelOverride) []channelOverwr
}
type rolePayload struct {
ID string `json:"id"`
ID string `json:"id"`
// GuildID нужен событиям ROLE_CREATE/ROLE_UPDATE: в REST он избыточен
// (сервер и так в пути), а в кадре шлюза без него роль некуда отнести.
GuildID string `json:"guild_id,omitempty"`
Name string `json:"name"`
Color int64 `json:"color"`
Position int `json:"position"`
@@ -110,6 +113,14 @@ type guildDetailPayload struct {
Channels []channelPayload `json:"channels,omitempty"`
}
// channelEventPayload собирает тело комнаты для события шлюза: права в нём
// не вычислены (они персональные), поэтому признаки can_* не выставляются —
// клиент по этому событию перечитывает список комнат, а не прячет комнату по
// нулевому can_view.
func channelEventPayload(channel store.Channel) channelPayload {
return channelFromStore(channel, nil, nil)
}
func channelFromStore(channel store.Channel, resolved *permissions.Resolved, overrides []store.ChannelOverride) channelPayload {
payload := channelPayload{
ID: formatSnowflake(channel.ID),
@@ -141,6 +152,7 @@ func channelFromStore(channel store.Channel, resolved *permissions.Resolved, ove
func roleFromStore(role store.Role) rolePayload {
payload := rolePayload{
ID: formatSnowflake(role.ID),
GuildID: formatSnowflake(role.GuildID),
Name: role.Name,
Color: role.Color,
Position: role.Position,
@@ -585,7 +597,12 @@ func (s *Server) registerChannelRoutes(api huma.API) {
"type": string(channel.Type),
})
s.invalidateGuild(guildID)
s.dispatchGuildEvent(guildID, "CHANNEL_CREATE", channelFromStore(*channel, nil, nil))
// Видимость комнаты считается персонально, поэтому вместо тела комнаты
// (в нём can_view был бы нулевым) просим клиентов перечитать список.
s.dispatchGuildEvent(guildID, "CHANNEL_CREATE", channelEventPayload(*channel))
s.dispatchGuildEvent(guildID, "GUILD_CHANNELS_SYNC", map[string]any{
"guild_id": formatSnowflake(guildID),
})
// Права на новую комнату считаем для автора запроса.
resolved, err := s.perms.Channel(ctx, guildID, channel.ID, user.ID, user.IsInstanceAdmin)
if err != nil {
@@ -642,7 +659,10 @@ func (s *Server) registerChannelRoutes(api huma.API) {
return nil, humaError(err)
}
s.invalidateGuild(guildID)
s.dispatchGuildEvent(guildID, "CHANNEL_UPDATE", channelFromStore(*channel, nil, nil))
s.dispatchGuildEvent(guildID, "CHANNEL_UPDATE", channelEventPayload(*channel))
s.dispatchGuildEvent(guildID, "GUILD_CHANNELS_SYNC", map[string]any{
"guild_id": formatSnowflake(guildID),
})
resolved, err := s.perms.Channel(ctx, guildID, channelID, user.ID, user.IsInstanceAdmin)
if err != nil {
return nil, humaError(err)
@@ -822,6 +842,11 @@ func (s *Server) registerChannelRoutes(api huma.API) {
"channel_id": formatSnowflake(channelID),
"guild_id": formatSnowflake(guildID),
})
// Удалили комнату — у части участников могли остаться права на неё,
// поэтому список перечитывают все (AGENT.md 11.6).
s.dispatchGuildEvent(guildID, "GUILD_CHANNELS_SYNC", map[string]any{
"guild_id": formatSnowflake(guildID),
})
return newOKOutput(), nil
})
}
+8 -2
View File
@@ -488,6 +488,9 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
if err := s.auth.SetPassword(ctx, target.ID, password); err != nil {
return nil, humaError(err)
}
if s.gateway != nil {
s.gateway.InvalidateUser(target.ID, "password_reset")
}
s.recordAudit(ctx, admin, 0, "instance.user_password_reset", "user", &target.ID, "")
output := &adminPasswordOutput{}
output.Body.UserID = formatSnowflake(target.ID)
@@ -521,6 +524,9 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
if _, err := s.store.RevokeUserSessions(ctx, targetID); err != nil {
return nil, humaError(err)
}
if s.gateway != nil {
s.gateway.InvalidateUser(targetID, "logout_all")
}
s.recordAudit(ctx, admin, 0, "instance.user_logout", "user", &targetID, "")
return newOKOutput(), nil
})
@@ -575,7 +581,7 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
return nil, humaError(err)
}
if s.gateway != nil {
s.gateway.SendToUser(target.ID, "SESSION_INVALIDATED", map[string]any{"reason": "user_banned"})
s.gateway.InvalidateUser(target.ID, "user_banned")
}
s.recordAudit(ctx, admin, 0, "instance.user_ban", "user", &target.ID, reason)
updated, err := s.store.GetUser(ctx, target.ID)
@@ -663,7 +669,7 @@ func (s *Server) registerInstanceRoutes(api huma.API) {
return nil, humaError(err)
}
if s.gateway != nil {
s.gateway.SendToUser(targetID, "SESSION_INVALIDATED", map[string]any{"reason": "user_deleted"})
s.gateway.InvalidateUser(targetID, "user_deleted")
}
s.recordAudit(ctx, admin, 0, "instance.user_delete", "user", &targetID, "")
return newOKOutput(), nil
+4
View File
@@ -270,6 +270,10 @@ func (s *Server) registerUserRoutes(api huma.API) {
if err := s.auth.ChangePassword(ctx, user.ID, session.ID, input.Body.CurrentPassword, input.Body.NewPassword); err != nil {
return nil, humaError(err)
}
// Смена пароля отзывает остальные сессии: соединения закрываем сразу.
if s.gateway != nil {
s.gateway.InvalidateUser(user.ID, "password_changed")
}
return newOKOutput(), nil
})
+4
View File
@@ -247,6 +247,10 @@ func (s *Server) handleLogoutAll(w http.ResponseWriter, r *http.Request) {
writeAPIError(w, err)
return
}
// Другие устройства должны узнать об отзыве сессий сразу (AGENT.md 11.6).
if s.gateway != nil {
s.gateway.InvalidateUser(user.ID, "logout_all")
}
http.SetCookie(w, s.clearSessionCookie())
writeJSON(w, map[string]any{"ok": true})
}