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
+33 -5
View File
@@ -12,6 +12,8 @@ import (
"time"
"github.com/coder/websocket"
"glchat/internal/store"
)
// clientSession — одно подключение клиента (AGENT.md 8.3: сессий может быть
@@ -24,8 +26,16 @@ type clientSession struct {
closeOnce sync.Once
closed chan struct{}
buffer *resumeBuffer
// preAuth — сессия, восстановленная из cookie на рукопожатии: браузерный
// клиент не имеет доступа к токену (AGENT.md 8.1, 8.3).
preAuth *store.Session
}
// SessionCookieName — имя cookie сессии. Значение должно совпадать с
// server.sessionCookieName: браузерный Gateway-клиент аутентифицируется
// cookie, а не токеном в IDENTIFY (AGENT.md 8.1).
const SessionCookieName = "__Host-session"
// outbound — кадр в очереди на отправку. done используется, когда отправитель
// ждёт фактической записи в сокет (например, перед закрытием соединения).
type outbound struct {
@@ -97,6 +107,12 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
send: make(chan outbound, 128),
closed: make(chan struct{}),
}
// Cookie сессии проверяем до HELLO: ошибку вернёт IDENTIFY.
if cookie, err := r.Cookie(SessionCookieName); err == nil && cookie.Value != "" {
if _, authSession, err := s.auth.ResolveSession(ctx, cookie.Value); err == nil {
session.preAuth = authSession
}
}
conn.SetReadLimit(MaxIncomingFrame)
// HELLO с интервалом heartbeat и идентификатором сессии.
@@ -215,7 +231,21 @@ func (s *Service) sendInvalidSession(session *clientSession, reason string) {
// handleIdentify проверяет токен, регистрирует сессию и отправляет READY
// либо догоняет пропущенные события при RESUME.
func (s *Service) handleIdentify(ctx context.Context, session *clientSession, payload identifyPayload, resume bool) error {
user, authSession, err := s.auth.ResolveSession(ctx, payload.Token)
var (
user *store.User
authSession *store.Session
err error
)
switch {
case payload.Token != "":
user, authSession, err = s.auth.ResolveSession(ctx, payload.Token)
case session.preAuth != nil:
// Токен не передан: доверяем cookie, проверенной на рукопожатии.
user, err = s.store.GetUser(ctx, session.preAuth.UserID)
authSession = session.preAuth
default:
err = errors.New("identify without token and without session cookie")
}
if err != nil {
s.sendInvalidSession(session, "invalid token")
return err
@@ -340,14 +370,12 @@ func writeDirect(ctx context.Context, conn *websocket.Conn, payload []byte) erro
func decodeIdentify(raw json.RawMessage) (identifyPayload, error) {
var payload identifyPayload
if len(raw) == 0 {
return payload, errors.New("empty identify payload")
// Пустой payload допустим: браузер аутентифицируется cookie.
return payload, nil
}
if err := json.Unmarshal(raw, &payload); err != nil {
return payload, err
}
if payload.Token == "" {
return payload, errors.New("token is required")
}
return payload, nil
}