fix(voice): административный токен RoomService привязан к комнате

LiveKit принимает право `roomAdmin` только вместе с именем комнаты: без
`video.room` RoomService отвечает 401 `permissions denied` — серверный мьют и
отключение участника не доходили до SFU, хотя API отвечал 200 (AGENT.md 7.14).

- `voice.IssueAdmin(room)` кладёт в грахт `room` и `roomAdmin` и отклоняет
  пустое имя комнаты;
- `AdminClient.call` выпускает токен под конкретную комнату каждого вызова
  (GetParticipant, MutePublishedTrack, RemoveParticipant, ListParticipants);
- тест на состав грахта и отказ при пустой комнате;
- e2e: администратор и гость работают в отдельных api-контекстах (код 2FA
  одноразовый), нажатие «Войти» в комнату не ждёт стабильности исчезающей
  кнопки.
This commit is contained in:
2026-09-20 19:54:21 +03:00
parent 912a067c9a
commit a6d23a295e
4 changed files with 108 additions and 45 deletions
+8 -12
View File
@@ -53,7 +53,7 @@ func (c *AdminClient) ParticipantInfo(ctx context.Context, room, identity string
var response struct {
Participant Participant `json:"participant"`
}
err := c.call(ctx, "GetParticipant", map[string]any{"room": room, "identity": identity}, &response)
err := c.call(ctx, room, "GetParticipant", map[string]any{"room": room, "identity": identity}, &response)
if err != nil {
return nil, err
}
@@ -78,14 +78,14 @@ func (c *AdminClient) SetMicrophoneMuted(ctx context.Context, room, identity str
// Участник без микрофона: мьютить нечего, это не ошибка.
return nil
}
return c.call(ctx, "MutePublishedTrack", map[string]any{
return c.call(ctx, room, "MutePublishedTrack", map[string]any{
"room": room, "identity": identity, "track_sid": trackSID, "muted": muted,
}, nil)
}
// RemoveParticipant удаляет участника из комнаты (кик из голосовой).
func (c *AdminClient) RemoveParticipant(ctx context.Context, room, identity string) error {
return c.call(ctx, "RemoveParticipant", map[string]any{"room": room, "identity": identity}, nil)
return c.call(ctx, room, "RemoveParticipant", map[string]any{"room": room, "identity": identity}, nil)
}
// ListParticipants перечисляет участников комнаты: используется для проверки
@@ -94,14 +94,15 @@ func (c *AdminClient) ListParticipants(ctx context.Context, room string) ([]Part
var response struct {
Participants []Participant `json:"participants"`
}
if err := c.call(ctx, "ListParticipants", map[string]any{"room": room}, &response); err != nil {
if err := c.call(ctx, room, "ListParticipants", map[string]any{"room": room}, &response); err != nil {
return nil, err
}
return response.Participants, nil
}
// call выполняет запрос к Twirp-методу RoomService с административным токеном.
func (c *AdminClient) call(ctx context.Context, method string, body any, out any) error {
// call выполняет запрос к Twirp-методу RoomService с административным токеном
// этой комнаты: LiveKit принимает `roomAdmin` только вместе с именем комнаты.
func (c *AdminClient) call(ctx context.Context, room, method string, body any, out any) error {
if !c.Enabled() {
return ErrDisabled
}
@@ -109,7 +110,7 @@ func (c *AdminClient) call(ctx context.Context, method string, body any, out any
if err != nil {
return err
}
token, err := c.adminToken()
token, err := c.issuer.IssueAdmin(room)
if err != nil {
return err
}
@@ -141,8 +142,3 @@ func (c *AdminClient) call(ctx context.Context, method string, body any, out any
}
return nil
}
// adminToken подписывает административный токен RoomService.
func (c *AdminClient) adminToken() (string, error) {
return c.issuer.IssueAdmin()
}