feat(api): ручки Фаз(ы) 1 — профиль, серверы, роли, админ инстанса

REST-слой Фазы 1 на huma (OpenAPI 3.1 генерируется из кода):

- профиль: GET/PATCH /users/@me, смена пароля со step-up, публичный профиль,
  завершение онбординга (новая миграция 00003 с onboarding_completed_at);
- серверы: создание/изменение/удаление, join/leave, список серверов
  пользователя, журнал действий;
- комнаты: список с учётом прав, создание/изменение/удаление;
- участники: список с профилями и ролями, никнейм, тайм-аут, исключение;
- роли: CRUD, выдача/снятие с проверкой иерархии и запретом выдачи прав выше
  собственных;
- админ инстанса: публичная информация, настройки, серверы, пользователи,
  аудит, выдача прав администратора со step-up; обход лимитов фиксируется в
  аудите отдельной записью limits.bypass;
- движок прав: участие в сервере стало обязательным условием (IsMember),
  не участник не получает прав роли @user; калькулятор прав общий для API и
  Gateway, инвалидация кэша после изменений;
- Gateway: браузерный клиент аутентифицируется cookie на рукопожатии, IDENTIFY
  без токена использует её; события GUILD/CHANNEL/MEMBER/ROLE рассылаются из
  ручек, USER_UPDATE — адресно;
- ошибки huma отдаются в едином конверте {"error":{"code","message"}}.

Тесты: 8 сценариев API (профиль, жизненный цикл сервера и права, лимиты и
обход админом, иерархия ролей, тайм-аут, скрытие комнаты оверрайдом,
членство в движке прав, cookie-идентификация Gateway).
This commit is contained in:
2026-09-19 21:50:06 +03:00
parent 86dff94a02
commit 1b1a679827
63 changed files with 8477 additions and 187 deletions
+54
View File
@@ -158,6 +158,9 @@ type Resolved struct {
IsOwner bool
// IsInstanceAdmin — глобальная роль «Администратор сервера» (AGENT.md 7.19).
IsInstanceAdmin bool
// IsMember — состоит ли пользователь в сервере. Не участник не получает
// прав роли @user: сервер для него невидим (AGENT.md 6.2).
IsMember bool
// TimedOut — участник в тайм-ауте: отправка сообщений запрещена (AGENT.md 7.10).
TimedOut bool
}
@@ -167,6 +170,9 @@ func (r Resolved) Has(permission Permission) bool {
if r.IsInstanceAdmin {
return true
}
if !r.IsMember {
return false
}
if r.Guild.Has(Administrator) {
return true
}
@@ -178,6 +184,9 @@ func (r Resolved) Can(permission Permission) bool {
if r.IsInstanceAdmin {
return true
}
if !r.IsMember {
return false
}
if r.Channel.Has(Administrator) {
return true
}
@@ -197,16 +206,48 @@ func (r Resolved) CanViewChannel() bool {
if r.IsInstanceAdmin {
return true
}
if !r.IsMember {
return false
}
if r.Channel.Has(Administrator) {
return true
}
return r.Channel.Has(ViewChannel)
}
// Names возвращает список названий установленных прав: клиенту удобнее
// работать со списком, чем с битовой маской (AGENT.md 8.2).
func Names(permission Permission) []string {
names := make([]string, 0, 8)
for bit := Permission(1); bit != 0 && bit <= Administrator; bit <<= 1 {
if permission&bit != 0 {
names = append(names, permissionNames[bit])
}
}
return names
}
// Parse разбирает строку с названиями прав: "VIEW_CHANNEL|SEND_MESSAGES",
// "VIEW_CHANNEL,SEND_MESSAGES" или через пробел. Пустая строка — нет прав.
func Parse(value string) (Permission, error) {
normalized := strings.NewReplacer(",", " ", "|", " ").Replace(value)
var result Permission
for _, name := range strings.Fields(normalized) {
permission, ok := ByName(name)
if !ok {
return 0, fmt.Errorf("unknown permission %q", name)
}
result |= permission
}
return result, nil
}
// Source — данные для расчёта прав: роли сервера и роли участника.
type Source interface {
GuildRoles(ctx context.Context, guildID uint64) ([]RoleData, error)
MemberRoleIDs(ctx context.Context, guildID, userID uint64) ([]uint64, error)
// IsMember отвечает, состоит ли пользователь в сервере.
IsMember(ctx context.Context, guildID, userID uint64) (bool, error)
ChannelOverrides(ctx context.Context, channelID uint64) ([]OverrideData, error)
GuildOwnerID(ctx context.Context, guildID uint64) (uint64, error)
MemberTimeout(ctx context.Context, guildID, userID uint64) (bool, error)
@@ -268,6 +309,19 @@ func (c *Calculator) Guild(ctx context.Context, guildID, userID uint64, instance
}
resolved := Resolved{IsOwner: ownerID == userID, IsInstanceAdmin: instanceAdmin}
resolved.IsMember = resolved.IsOwner
if !resolved.IsMember {
resolved.IsMember, err = c.source.IsMember(ctx, guildID, userID)
if err != nil {
return Resolved{}, fmt.Errorf("load guild membership: %w", err)
}
}
if !resolved.IsMember {
// Не участник: прав нет вовсе, роль @user на него не действует.
c.cache.PutGuild(guildID, userID, resolved)
return resolved, nil
}
assigned := make(map[uint64]bool, len(memberRoleIDs))
for _, roleID := range memberRoleIDs {
assigned[roleID] = true