diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 7515dce..b8f8535 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -391,6 +391,26 @@ func (s *Service) SendToUser(userID uint64, event string, payload any) { } } +// InvalidateUser закрывает все соединения пользователя: клиент получает +// INVALID_SESSION и уходит на экран входа. Нужно там, где сессии отозваны +// (logout-all, смена пароля, действия администратора) — иначе второе +// устройство остаётся «в приложении» до следующего запроса (AGENT.md 11.6). +func (s *Service) InvalidateUser(userID uint64, reason string) { + s.mu.Lock() + targets := make([]*clientSession, 0, 2) + for _, session := range s.sessions { + if session.userID == userID { + targets = append(targets, session) + } + } + s.mu.Unlock() + for _, session := range targets { + // Сначала кадр (writeSync дожидается записи), потом закрытие. + s.sendInvalidSession(session, reason) + session.close() + } +} + func frame(op int, payload any) ([]byte, error) { var encoded json.RawMessage if payload != nil { diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index 2391d9f..1a2727c 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -404,3 +404,42 @@ func TestResumeReplaysMissedEvents(t *testing.T) { t.Fatalf("stale resume = %q, want full READY", full.T) } } + +// TestInvalidateUserClosesConnections проверяет отзыв сессий: после +// logout-all, смены пароля или действий администратора соединения клиента +// должны закрыться с INVALID_SESSION, а не ждать следующего запроса +// (AGENT.md 11.6). Отдельно проверяется, что чужого пользователя это не задевает. +func TestInvalidateUserClosesConnections(t *testing.T) { + f := newFixture(t) + token := registerUser(t, f, "invalidate_user", "invalidate@example.com") + otherToken := registerUser(t, f, "invalidate_other", "invalidate-other@example.com") + + conn, _ := f.dial(t) + send(t, conn, gateway.OpIdentify, map[string]any{"token": token}) + if ready := readEnvelope(t, conn); ready.T != "READY" { + t.Fatalf("expected READY, got %q", ready.T) + } + + otherConn, _ := f.dial(t) + send(t, otherConn, gateway.OpIdentify, map[string]any{"token": otherToken}) + if ready := readEnvelope(t, otherConn); ready.T != "READY" { + t.Fatalf("expected READY for the other client, got %q", ready.T) + } + + user, err := f.auth.UserByEmail(context.Background(), "invalidate@example.com") + if err != nil { + t.Fatalf("UserByEmail: %v", err) + } + f.service.InvalidateUser(user.ID, "logout_all") + + invalid := readEnvelope(t, conn) + if invalid.Op != gateway.OpInvalidSess { + t.Fatalf("op = %d, want INVALID_SESSION (%d)", invalid.Op, gateway.OpInvalidSess) + } + + // Второй пользователь продолжает работать: heartbeat отвечает. + send(t, otherConn, gateway.OpHeartbeat, nil) + if ack := readEnvelope(t, otherConn); ack.Op != gateway.OpHeartbeatAck { + t.Fatalf("other client op = %d, want HEARTBEAT_ACK", ack.Op) + } +} diff --git a/internal/server/api_guilds.go b/internal/server/api_guilds.go index e0eba40..5039f9e 100644 --- a/internal/server/api_guilds.go +++ b/internal/server/api_guilds.go @@ -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 }) } diff --git a/internal/server/api_instance.go b/internal/server/api_instance.go index 74948fb..34191d8 100644 --- a/internal/server/api_instance.go +++ b/internal/server/api_instance.go @@ -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 diff --git a/internal/server/api_users.go b/internal/server/api_users.go index f0be0fa..917e6fa 100644 --- a/internal/server/api_users.go +++ b/internal/server/api_users.go @@ -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 }) diff --git a/internal/server/auth.go b/internal/server/auth.go index 53ed000..50152c0 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -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}) } diff --git a/web/eslint.config.js b/web/eslint.config.js index 4b59a0c..20038f5 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -5,7 +5,8 @@ import reactRefresh from 'eslint-plugin-react-refresh'; import tseslint from 'typescript-eslint'; export default tseslint.config( - { ignores: ['dist', 'coverage', 'node_modules'] }, + // Артефакты Playwright (test-results*, отчёты) не линтуем: их пишет прогон. + { ignores: ['dist', 'coverage', 'node_modules', 'test-results*', 'playwright-report'] }, { files: ['**/*.{ts,tsx}'], extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked], diff --git a/web/src/api/gatewayEvents.ts b/web/src/api/gatewayEvents.ts index dd31bf8..e975805 100644 --- a/web/src/api/gatewayEvents.ts +++ b/web/src/api/gatewayEvents.ts @@ -215,7 +215,9 @@ export function parseMemberRemove(value: unknown): MemberRemoveEvent | null { export function parseRoleEvent(value: unknown): RoleEvent | null { const record = asRecord(value); const guildId = asString(record?.['guild_id']); - const roleId = asString(record?.['role_id']); + // Сервер шлёт тело роли (поле id), старый формат — ссылку role_id: + // принимаем оба, иначе событие роли отбрасывалось и список не обновлялся. + const roleId = asString(record?.['role_id']) ?? asString(record?.['id']); if (record === null || guildId === undefined || roleId === undefined) { return null; } diff --git a/web/src/stores/gateway.ts b/web/src/stores/gateway.ts index 0285451..f53ef3c 100644 --- a/web/src/stores/gateway.ts +++ b/web/src/stores/gateway.ts @@ -53,6 +53,8 @@ interface GatewayState { invalidated: boolean; connect: (userId: string) => void; disconnect: () => void; + /** Сессия отозвана сервером (logout-all, смена пароля, бан): экран входа. */ + markInvalidated: () => void; reset: () => void; } @@ -107,6 +109,10 @@ export const useGatewayStore = create((set, get) => ({ set({ ...defaults }); }, + /** markInvalidated — сессия отозвана сервером: показываем экран входа. */ + markInvalidated: () => { + set({ invalidated: true, status: 'disconnected' }); + }, reset: () => { disconnectGateway(); set({ ...defaults }); @@ -199,7 +205,10 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void { } const guildId = channel.guild_id ?? session.selectedGuildId; if (guildId !== null) { - session.upsertChannel(guildId, channel); + // Права на комнату персональные: тело события их не несёт, поэтому + // перечитываем список — иначе новая или переименованная комната не + // появится в сайдбаре без перезагрузки (AGENT.md 11.6). + void session.reloadGuildChannels(guildId); } return; } @@ -274,10 +283,12 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void { } case 'MEMBER_UPDATE': case 'MEMBER_REMOVE': { - // Роли участника влияют на права: перечитываем участников и карточку сервера. + // Роли участника влияют на права: перечитываем участников, карточку + // сервера и список комнат (приватные комнаты открываются ролью). const guildId = parseGuildId(event.d); if (guildId !== null) { void invalidateMembershipQueries(guildId); + void session.reloadGuildChannels(guildId); } return; } @@ -287,6 +298,7 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void { const role = parseRoleEvent(event.d); if (role !== null) { void invalidateMembershipQueries(role.guild_id); + void session.reloadGuildChannels(role.guild_id); } return; } @@ -335,6 +347,16 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void { } return; } + case 'SESSION_INVALIDATED': { + // Сервер отозвал сессии (logout-all, смена пароля, бан, удаление): + // уходим на экран входа, не дожидаясь следующего запроса (AGENT.md 11.6). + useGatewayStore.getState().markInvalidated(); + session.reset(); + messages.reset(); + useVoiceConnectionStore.getState().reset(); + void invalidateCurrentUser(); + return; + } case 'GUILD_CHANNELS_SYNC': { // Права комнаты изменились: видимость может поменяться у всех // участников, поэтому список комнат перечитываем целиком (AGENT.md 11.6). diff --git a/web/tests/gatewayEvents.test.ts b/web/tests/gatewayEvents.test.ts index 4e5eb77..4f9d0df 100644 --- a/web/tests/gatewayEvents.test.ts +++ b/web/tests/gatewayEvents.test.ts @@ -140,28 +140,49 @@ describe('диспетчер событий шлюза', () => { expect(state.selectedChannelId).toBeNull(); }); - it('CHANNEL_CREATE добавляет комнату, CHANNEL_UPDATE заменяет её', () => { + it('CHANNEL_CREATE и CHANNEL_UPDATE перечитывают комнаты с сервера', async () => { + // Права на комнату персональные: тело события их не несёт, поэтому клиент + // перечитывает список, а не подставляет комнату из кадра (AGENDA.md 11.6). + installFetch([ + { + match: '/api/v1/guilds/g-1/channels', + response: () => + json({ + channels: [ + { id: 'c-1', guild_id: 'g-1', name: 'общий', type: 'text', position: 0 }, + { id: 'c-2', guild_id: 'g-1', name: 'флуд', type: 'text', position: 1 }, + { + id: 'c-3', + guild_id: 'g-1', + name: 'новости', + type: 'text', + position: 2, + can_send: false, + }, + ], + }), + }, + ]); seedSession(); dispatchGatewayEvent({ op: 0, t: 'CHANNEL_CREATE', s: 8, - d: { - id: 'c-3', - guild_id: 'g-1', - name: 'анонсы', - type: 'text', - position: 2, - can_send: false, - }, + d: { id: 'c-3', guild_id: 'g-1', name: 'анонсы', type: 'text', position: 2 }, }); + await waitFor(() => { + const channels = useSessionStore + .getState() + .guilds.find((guild) => guild.id === 'g-1') + ?.channels.map((channel) => channel.name); + expect(channels).toEqual(['общий', 'флуд', 'новости']); + }); const created = useSessionStore .getState() .guilds.find((guild) => guild.id === 'g-1') ?.channels.find((channel) => channel.id === 'c-3'); - expect(created?.name).toBe('анонсы'); expect(created?.can_send).toBe(false); dispatchGatewayEvent({ @@ -170,13 +191,13 @@ describe('диспетчер событий шлюза', () => { s: 9, d: { id: 'c-3', guild_id: 'g-1', name: 'новости', type: 'text', position: 2 }, }); - - const updated = useSessionStore - .getState() - .guilds.find((guild) => guild.id === 'g-1') - ?.channels.filter((channel) => channel.id === 'c-3'); - expect(updated).toHaveLength(1); - expect(updated?.[0]?.name).toBe('новости'); + await waitFor(() => { + const updated = useSessionStore + .getState() + .guilds.find((guild) => guild.id === 'g-1') + ?.channels.filter((channel) => channel.id === 'c-3'); + expect(updated).toHaveLength(1); + }); }); it('CHANNEL_DELETE удаляет комнату и переносит выбор на первую доступную', () => { @@ -197,6 +218,24 @@ describe('диспетчер событий шлюза', () => { expect(state.selectedChannelId).toBe('c-2'); }); + it('SESSION_INVALIDATED переводит клиент на экран входа', () => { + // Сервер отзывает сессии при logout-all, смене пароля и бане: второе + // устройство не должно оставаться в приложении до следующего запроса. + seedSession(); + useGatewayStore.setState({ invalidated: false, status: 'connected' }); + + dispatchGatewayEvent({ + op: 0, + t: 'SESSION_INVALIDATED', + s: 42, + d: { reason: 'logout_all' }, + }); + + expect(useGatewayStore.getState().invalidated).toBe(true); + expect(useGatewayStore.getState().status).toBe('disconnected'); + expect(useSessionStore.getState().guilds).toEqual([]); + }); + it('MEMBER_UPDATE с ролями инвалидирует участников, роли и карточку сервера', async () => { const { client, invalidated } = makeRecordingClient(); client.setQueryData(guildMembersQueryKey('g-1'), []);