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
+57 -1
View File
@@ -60,7 +60,7 @@ func newFixture(t *testing.T) *fixture {
t.Fatalf("bootstrap: %v", err)
}
service := gateway.New(st, authService, gateway.NewSnapshot(st), logger, []string{"https://gl.test"})
service := gateway.New(st, authService, gateway.NewSnapshot(st, nil), logger, []string{"https://gl.test"})
server := httptest.NewServer(service.Handler())
t.Cleanup(server.Close)
@@ -242,6 +242,62 @@ func TestIdentifyWithInvalidToken(t *testing.T) {
}
}
// TestIdentifyWithSessionCookie проверяет браузерный вход: токен в IDENTIFY
// не передаётся, сессия берётся из cookie рукопожатия (AGENT.md 8.1).
func TestIdentifyWithSessionCookie(t *testing.T) {
f := newFixture(t)
_, token, session, err := f.auth.Register(context.Background(), auth.RegisterInput{
Username: "cookie_user", Email: "cookie@example.com", Password: "correct-horse-battery",
})
if err != nil {
t.Fatalf("register: %v", err)
}
if session == nil {
t.Fatal("register returned no session")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
url := "ws" + strings.TrimPrefix(f.server.URL, "http")
conn, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{
HTTPHeader: map[string][]string{
"Origin": {"https://gl.test"},
"Cookie": {gateway.SessionCookieName + "=" + token},
},
})
if err != nil {
t.Fatalf("dial with cookie: %v", err)
}
t.Cleanup(func() { _ = conn.CloseNow() })
if hello := readEnvelope(t, conn); hello.Op != gateway.OpHello {
t.Fatalf("first frame op = %d, want HELLO", hello.Op)
}
// IDENTIFY без токена: сервер доверяет cookie.
send(t, conn, gateway.OpIdentify, map[string]any{})
ready := readEnvelope(t, conn)
if ready.T != "READY" {
t.Fatalf("frame = %q, want READY", ready.T)
}
var snapshot gateway.Ready
if err := json.Unmarshal(ready.D, &snapshot); err != nil {
t.Fatalf("decode READY: %v", err)
}
if snapshot.User.Username != "cookie_user" {
t.Fatalf("READY user = %q, want cookie_user", snapshot.User.Username)
}
}
func TestIdentifyWithoutTokenOrCookie(t *testing.T) {
f := newFixture(t)
conn, _ := f.dial(t)
send(t, conn, gateway.OpIdentify, map[string]any{})
response := readEnvelope(t, conn)
if response.Op != gateway.OpInvalidSess {
t.Fatalf("op = %d, want INVALID_SESSION", response.Op)
}
}
func TestHeartbeatAck(t *testing.T) {
f := newFixture(t)
token := registerUser(t, f, "heartbeat_user", "heartbeat@example.com")
+18 -2
View File
@@ -2,6 +2,7 @@ package gateway
import (
"context"
"errors"
"fmt"
"strconv"
"time"
@@ -17,8 +18,13 @@ type Snapshot struct {
calculator *permissions.Calculator
}
func NewSnapshot(st *store.Store) *Snapshot {
return &Snapshot{store: st, calculator: permissions.NewCalculator(newPermissionSource(st))}
// NewSnapshot собирает READY через переданный калькулятор прав: сервер и
// Gateway должны использовать один кэш, иначе инвалидация не видна обоим.
func NewSnapshot(st *store.Store, calculator *permissions.Calculator) *Snapshot {
if calculator == nil {
calculator = permissions.NewCalculator(newPermissionSource(st))
}
return &Snapshot{store: st, calculator: calculator}
}
var _ SnapshotBuilder = (*Snapshot)(nil)
@@ -182,6 +188,16 @@ func (p *permissionSource) MemberRoleIDs(ctx context.Context, guildID, userID ui
return p.store.MemberRoleIDs(ctx, guildID, userID)
}
func (p *permissionSource) IsMember(ctx context.Context, guildID, userID uint64) (bool, error) {
if _, err := p.store.GetGuildMember(ctx, guildID, userID); err != nil {
if errors.Is(err, store.ErrNotFound) {
return false, nil
}
return false, err
}
return true, nil
}
func (p *permissionSource) ChannelOverrides(ctx context.Context, channelID uint64) ([]permissions.OverrideData, error) {
overrides, err := p.store.ListChannelOverrides(ctx, channelID)
if err != nil {
+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
}