From f61751ed26cce7ee3f9d279328a9057a375b54ff Mon Sep 17 00:00:00 2001 From: grendervill Date: Sat, 19 Sep 2026 21:36:00 +0300 Subject: [PATCH] =?UTF-8?q?feat(api):=20REST=20=D0=BD=D0=B0=20chi=20+=20hu?= =?UTF-8?q?ma=20=D1=81=20auth-=D1=80=D1=83=D1=87=D0=BA=D0=B0=D0=BC=D0=B8?= =?UTF-8?q?=20=D0=B8=20OpenAPI=203.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - переход на chi + huma (решение D-006): huma отдаёт типизированные ручки и генерирует документ, auth-ручки живут на chi (cookie и заголовки напрямую) - единый формат ошибок: код (auth.invalid_credentials, auth.2fa_required, perm.denied и т.д.) + человекочитаемое сообщение (AGENT.md 8.5) - cookie сессии __Host-session: HttpOnly, SameSite=Lax, Secure при TLS; альтернатива — Bearer-токен для desktop/CLI (AGENT.md 8.1) - ручки: register, login, logout, logout-all, sessions, step-up, 2fa/setup, 2fa/enable, users/@me; IP и User-Agent прокидываются из запроса в контекст - /api/v1/openapi.json: объединённый документ (схемы huma + контракт auth) - тесты: регистрация через API с cookie, ошибки входа, обязательная сессия, валидация, наличие всех путей в OpenAPI --- cmd/glchat/main.go | 10 +- go.mod | 2 + go.sum | 4 + internal/httpx/context.go | 31 +++ internal/httpx/middleware.go | 8 + internal/server/auth.go | 335 +++++++++++++++++++++++++++++++++ internal/server/auth_test.go | 118 ++++++++++++ internal/server/errors.go | 104 ++++++++++ internal/server/fallback.go | 29 --- internal/server/openapi.go | 334 +++++++++++++++++++++++++------- internal/server/server.go | 117 ++++++++---- internal/server/server_test.go | 40 ++-- 12 files changed, 984 insertions(+), 148 deletions(-) create mode 100644 internal/server/auth.go create mode 100644 internal/server/auth_test.go create mode 100644 internal/server/errors.go diff --git a/cmd/glchat/main.go b/cmd/glchat/main.go index 9b5b026..9df610c 100644 --- a/cmd/glchat/main.go +++ b/cmd/glchat/main.go @@ -13,9 +13,11 @@ import ( "syscall" "time" + "glchat/internal/auth" "glchat/internal/config" "glchat/internal/database" "glchat/internal/server" + "glchat/internal/store" ) // Build metadata is injected with -ldflags "-X main.version=... -X main.commit=... -X main.buildDate=...". @@ -129,7 +131,13 @@ func run() error { stopMaintenance := db.RunMaintenance(ctx, logger, cfg.Maintenance) defer stopMaintenance() - srv := server.New(cfg, db, logger) + st := store.New(db) + authService, err := auth.New(ctx, cfg, st, logger) + if err != nil { + return fmt.Errorf("initialize authentication: %w", err) + } + + srv := server.New(cfg, db, logger, server.Deps{Store: st, Auth: authService}) errCh := make(chan error, 1) go func() { logger.Info("http server listening", diff --git a/go.mod b/go.mod index b1c92a5..4dddb12 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module glchat go 1.26.0 require ( + github.com/danielgtaylor/huma/v2 v2.39.1 + github.com/go-chi/chi/v5 v5.3.2 github.com/mattn/go-sqlite3 v1.14.52 github.com/pquerna/otp v1.5.0 github.com/pressly/goose/v3 v3.28.0 diff --git a/go.sum b/go.sum index 404a921..fc4de1c 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,12 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/danielgtaylor/huma/v2 v2.39.1 h1:0kwF4ltQoYZ+IU55VPy+BcGekzgF44R64daTGde1H+g= +github.com/danielgtaylor/huma/v2 v2.39.1/go.mod h1:zcnQ38duIJ3VUHwFaBoZ6x8T+KN/mr33oyqxcj0HTug= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= +github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= diff --git a/internal/httpx/context.go b/internal/httpx/context.go index f9b4b3b..33df474 100644 --- a/internal/httpx/context.go +++ b/internal/httpx/context.go @@ -1,8 +1,10 @@ package httpx import ( + "context" "crypto/rand" "encoding/hex" + "net/http" ) func newRequestID() string { @@ -12,3 +14,32 @@ func newRequestID() string { } return hex.EncodeToString(buf[:]) } + +type requestInfoKey struct{} + +// RequestInfo — данные исходного запроса, нужные сервисам (IP, User-Agent). +type RequestInfo struct { + IP string + UserAgent string +} + +// WithRequestInfo кладёт данные запроса в контекст (используется HTTP-слоем). +func WithRequestInfo(ctx context.Context, r *http.Request) context.Context { + return context.WithValue(ctx, requestInfoKey{}, RequestInfo{ + IP: ClientIP(r, nil), + UserAgent: r.Header.Get("User-Agent"), + }) +} + +func requestInfo(ctx context.Context) RequestInfo { + if info, ok := ctx.Value(requestInfoKey{}).(RequestInfo); ok { + return info + } + return RequestInfo{} +} + +// ClientIPFromContext возвращает IP клиента для контекста huma. +func ClientIPFromContext(ctx context.Context) string { return requestInfo(ctx).IP } + +// UserAgentFromContext возвращает User-Agent для контекста huma. +func UserAgentFromContext(ctx context.Context) string { return requestInfo(ctx).UserAgent } diff --git a/internal/httpx/middleware.go b/internal/httpx/middleware.go index 65fe59c..0c04148 100644 --- a/internal/httpx/middleware.go +++ b/internal/httpx/middleware.go @@ -44,6 +44,14 @@ func (r *statusRecorder) Flush() { } } +// RequestInfoMiddleware кладёт IP и User-Agent запроса в контекст: huma-хендлеры +// не получают *http.Request, а сервисам эти данные нужны (аудит, безопасность). +func RequestInfoMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r.WithContext(WithRequestInfo(r.Context(), r))) + }) +} + func RequestID(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { id := r.Header.Get("X-Request-Id") diff --git a/internal/server/auth.go b/internal/server/auth.go new file mode 100644 index 0000000..895d243 --- /dev/null +++ b/internal/server/auth.go @@ -0,0 +1,335 @@ +package server + +import ( + "net/http" + "time" + + "github.com/go-chi/chi/v5" + + "glchat/internal/auth" + "glchat/internal/httpx" + "glchat/internal/store" +) + +// sessionCookieName — имя cookie сессии (AGENT.md 8.1: префикс __Host-). +const sessionCookieName = "__Host-session" + +const sessionCookiePath = "/" + +// sessionCookie собирает cookie сессии: HttpOnly, SameSite=Lax и Secure при TLS. +// Secure выключается только для установок без TLS (--skip-tls, стенд за туннелем): +// в этом режиме браузер не принимает Secure-cookie по http. +func (s *Server) sessionCookie(token string, expires time.Time) *http.Cookie { + return &http.Cookie{ //nolint:gosec // Secure зависит от TLS_ENABLED, HttpOnly и SameSite заданы + Name: sessionCookieName, + Value: token, + Path: sessionCookiePath, + HttpOnly: true, + Secure: s.cfg.TLSEnabled, + SameSite: http.SameSiteLaxMode, + Expires: expires.UTC(), + } +} + +func (s *Server) clearSessionCookie() *http.Cookie { + return &http.Cookie{ //nolint:gosec // Secure зависит от TLS_ENABLED, HttpOnly и SameSite заданы + Name: sessionCookieName, + Value: "", + Path: sessionCookiePath, + HttpOnly: true, + Secure: s.cfg.TLSEnabled, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + } +} + +// registerAuthRoutes вешает ручки аутентификации на chi: cookie и заголовки +// выставляются напрямую, а контракт описан в OpenAPI (docs.go). +func (s *Server) registerAuthRoutes(router chi.Router) { + router.Post("/auth/register", s.handleRegister) + router.Post("/auth/login", s.handleLogin) + router.Post("/auth/logout", s.handleLogout) + router.Post("/auth/logout-all", s.handleLogoutAll) + router.Get("/auth/sessions", s.handleListSessions) + router.Post("/auth/step-up", s.handleStepUp) + router.Post("/auth/2fa/setup", s.handleSetupTOTP) + router.Post("/auth/2fa/enable", s.handleEnableTOTP) + router.Get("/users/@me", s.handleGetMe) +} + +type registerRequest struct { + Username string `json:"username"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + Password string `json:"password"` + Locale string `json:"locale"` +} + +type currentUserPayload struct { + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Bio string `json:"bio"` + Status string `json:"status"` + CustomStatus string `json:"custom_status"` + AvatarFileID string `json:"avatar_file_id,omitempty"` + BannerFileID string `json:"banner_file_id,omitempty"` + IsInstanceAdmin bool `json:"is_instance_admin"` + Badges []string `json:"badges"` + Locale string `json:"locale"` +} + +func userPayload(user *store.User) currentUserPayload { + payload := currentUserPayload{ + ID: formatSnowflake(user.ID), + Username: user.Username, + DisplayName: user.DisplayName, + Bio: user.Bio, + Status: user.Status, + CustomStatus: user.CustomStatus, + IsInstanceAdmin: user.IsInstanceAdmin, + Badges: user.Badges, + Locale: user.Locale, + } + if payload.Badges == nil { + payload.Badges = []string{} + } + if user.AvatarFileID != nil { + payload.AvatarFileID = formatSnowflake(*user.AvatarFileID) + } + if user.BannerFileID != nil { + payload.BannerFileID = formatSnowflake(*user.BannerFileID) + } + return payload +} + +func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { + if s.auth == nil { + writeAPIError(w, auth.ErrSessionExpired) + return + } + var request registerRequest + if !decodeBody(w, r, &request) { + return + } + user, token, session, err := s.auth.Register(r.Context(), auth.RegisterInput{ + Username: request.Username, + DisplayName: request.DisplayName, + Email: request.Email, + Password: request.Password, + Locale: request.Locale, + IP: httpx.ClientIPFromContext(r.Context()), + UserAgent: httpx.UserAgentFromContext(r.Context()), + }) + if err != nil { + writeAPIError(w, err) + return + } + http.SetCookie(w, s.sessionCookie(token, session.ExpiresAt)) + writeJSON(w, map[string]any{"user": userPayload(user)}) +} + +type loginRequest struct { + Email string `json:"email"` + Password string `json:"password"` + TOTPCode string `json:"totp_code"` +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + if s.auth == nil { + writeAPIError(w, auth.ErrSessionExpired) + return + } + var request loginRequest + if !decodeBody(w, r, &request) { + return + } + user, token, session, err := s.auth.Login(r.Context(), auth.LoginInput{ + Email: request.Email, + Password: request.Password, + TOTPCode: request.TOTPCode, + IP: httpx.ClientIPFromContext(r.Context()), + UserAgent: httpx.UserAgentFromContext(r.Context()), + }) + if err != nil { + writeAPIError(w, err) + return + } + http.SetCookie(w, s.sessionCookie(token, session.ExpiresAt)) + writeJSON(w, map[string]any{"user": userPayload(user)}) +} + +// authenticate читает сессию из cookie или Bearer-токена (desktop, AGENT.md 8.1). +func (s *Server) authenticate(w http.ResponseWriter, r *http.Request) (*store.User, *store.Session, bool) { + if s.auth == nil { + writeAPIError(w, auth.ErrSessionExpired) + return nil, nil, false + } + token := "" + if cookie, err := r.Cookie(sessionCookieName); err == nil { + token = cookie.Value + } + if token == "" { + token = normalizeBearer(r.Header.Get("Authorization")) + } + if token == "" { + writeAPIError(w, auth.ErrSessionExpired) + return nil, nil, false + } + user, session, err := s.auth.ResolveSession(r.Context(), token) + if err != nil { + writeAPIError(w, err) + return nil, nil, false + } + return user, session, true +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + _, session, ok := s.authenticate(w, r) + if !ok { + // Выход без валидной сессии не ошибка: cookie всё равно очищаем. + http.SetCookie(w, s.clearSessionCookie()) + writeJSON(w, map[string]any{"ok": true}) + return + } + if err := s.auth.Logout(r.Context(), session.ID); err != nil { + writeAPIError(w, err) + return + } + http.SetCookie(w, s.clearSessionCookie()) + writeJSON(w, map[string]any{"ok": true}) +} + +func (s *Server) handleLogoutAll(w http.ResponseWriter, r *http.Request) { + user, _, ok := s.authenticate(w, r) + if !ok { + return + } + if err := s.auth.LogoutAll(r.Context(), user.ID); err != nil { + writeAPIError(w, err) + return + } + http.SetCookie(w, s.clearSessionCookie()) + writeJSON(w, map[string]any{"ok": true}) +} + +type sessionPayload struct { + ID string `json:"id"` + UserAgent string `json:"user_agent"` + IP string `json:"ip"` + CreatedAt string `json:"created_at"` + LastSeen string `json:"last_seen"` + ExpiresAt string `json:"expires_at"` + Current bool `json:"current"` + SteppedUp bool `json:"stepped_up"` +} + +func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) { + user, current, ok := s.authenticate(w, r) + if !ok { + return + } + sessions, err := s.store.ListSessions(r.Context(), user.ID) + if err != nil { + writeAPIError(w, err) + return + } + now := time.Now().UTC() + payload := make([]sessionPayload, 0, len(sessions)) + for _, session := range sessions { + payload = append(payload, sessionPayload{ + ID: formatSnowflake(session.ID), + UserAgent: session.UserAgent, + IP: session.IP, + CreatedAt: session.CreatedAt.Format(time.RFC3339), + LastSeen: session.LastSeen.Format(time.RFC3339), + ExpiresAt: session.ExpiresAt.Format(time.RFC3339), + Current: session.ID == current.ID, + SteppedUp: session.SteppedUp(now), + }) + } + writeJSON(w, map[string]any{"sessions": payload}) +} + +func (s *Server) handleGetMe(w http.ResponseWriter, r *http.Request) { + user, _, ok := s.authenticate(w, r) + if !ok { + return + } + writeJSON(w, map[string]any{"user": userPayload(user)}) +} + +type totpEnableRequest struct { + Code string `json:"code"` +} + +func (s *Server) handleSetupTOTP(w http.ResponseWriter, r *http.Request) { + user, _, ok := s.authenticate(w, r) + if !ok { + return + } + setup, err := s.auth.SetupTOTP(r.Context(), user.ID) + if err != nil { + writeAPIError(w, err) + return + } + writeJSON(w, map[string]any{ + "secret": setup.Secret, + "url": setup.URL, + "issuer": setup.IssuerName, + }) +} + +func (s *Server) handleEnableTOTP(w http.ResponseWriter, r *http.Request) { + user, _, ok := s.authenticate(w, r) + if !ok { + return + } + var request totpEnableRequest + if !decodeBody(w, r, &request) { + return + } + codes, err := s.auth.EnableTOTP(r.Context(), user.ID, request.Code) + if err != nil { + writeAPIError(w, err) + return + } + writeJSON(w, map[string]any{"recovery_codes": codes}) +} + +type stepUpRequest struct { + Password string `json:"password"` + TOTPCode string `json:"totp_code"` +} + +func (s *Server) handleStepUp(w http.ResponseWriter, r *http.Request) { + user, session, ok := s.authenticate(w, r) + if !ok { + return + } + var request stepUpRequest + if !decodeBody(w, r, &request) { + return + } + if err := s.auth.RequireStepUp(r.Context(), user, session, request.Password, request.TOTPCode); err != nil { + writeAPIError(w, err) + return + } + writeJSON(w, map[string]any{"ok": true}) +} + +// formatSnowflake сериализует идентификатор строкой: JS не хранит uint64 +// без потери точности (AGENT.md 8.1). +func formatSnowflake(id uint64) string { + if id == 0 { + return "" + } + var buf [20]byte + pos := len(buf) + for id > 0 { + pos-- + buf[pos] = byte('0' + id%10) + id /= 10 + } + return string(buf[pos:]) +} diff --git a/internal/server/auth_test.go b/internal/server/auth_test.go new file mode 100644 index 0000000..a76646d --- /dev/null +++ b/internal/server/auth_test.go @@ -0,0 +1,118 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func doJSON(t *testing.T, srv *Server, method, path, body string, cookies ...*http.Cookie) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequestWithContext(context.Background(), method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + for _, cookie := range cookies { + req.AddCookie(cookie) + } + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + return rec +} + +func TestRegisterEndpointCreatesSession(t *testing.T) { + srv, _ := newTestServer(t) + rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/register", + `{"username":"api_user","email":"api@example.com","password":"correct-horse-battery"}`) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var payload struct { + User struct { + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + } `json:"user"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode body: %v", err) + } + if payload.User.Username != "api_user" || payload.User.ID == "" { + t.Fatalf("unexpected user payload: %s", rec.Body.String()) + } + + cookies := rec.Result().Cookies() + if len(cookies) == 0 || cookies[0].Name != sessionCookieName { + t.Fatalf("session cookie is missing: %v", cookies) + } + if !cookies[0].HttpOnly { + t.Fatal("session cookie must be HttpOnly") + } + + // С полученной cookie доступен профиль. + me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", cookies[0]) + if me.Code != http.StatusOK { + t.Fatalf("GET /users/@me = %d, body = %s", me.Code, me.Body.String()) + } +} + +func TestLoginEndpointErrors(t *testing.T) { + srv, _ := newTestServer(t) + doJSON(t, srv, http.MethodPost, "/api/v1/auth/register", + `{"username":"login_user","email":"login@example.com","password":"correct-horse-battery"}`) + + rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/login", + `{"email":"login@example.com","password":"wrong-password"}`) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("wrong password status = %d, want 401", rec.Code) + } + if !strings.Contains(rec.Body.String(), "invalid credentials") { + t.Fatalf("unexpected error body: %s", rec.Body.String()) + } + + rec = doJSON(t, srv, http.MethodPost, "/api/v1/auth/login", + `{"email":"login@example.com","password":"correct-horse-battery"}`) + if rec.Code != http.StatusOK { + t.Fatalf("valid login status = %d, body = %s", rec.Code, rec.Body.String()) + } +} + +func TestAuthenticatedEndpointsRequireSession(t *testing.T) { + srv, _ := newTestServer(t) + for _, path := range []string{"/api/v1/users/@me", "/api/v1/auth/sessions"} { + rec := doJSON(t, srv, http.MethodGet, path, "") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("GET %s without session = %d, want 401", path, rec.Code) + } + } +} + +func TestValidationRejectsShortPassword(t *testing.T) { + srv, _ := newTestServer(t) + rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/register", + `{"username":"short_user","email":"short@example.com","password":"short"}`) + if rec.Code == http.StatusOK { + t.Fatalf("short password accepted: %s", rec.Body.String()) + } +} + +func TestOpenAPIDocumentsAuthEndpoints(t *testing.T) { + srv, _ := newTestServer(t) + rec := doJSON(t, srv, http.MethodGet, "/api/v1/openapi.json", "") + if rec.Code != http.StatusOK { + t.Fatalf("openapi status = %d", rec.Code) + } + var doc struct { + Paths map[string]any `json:"paths"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil { + t.Fatalf("decode openapi: %v", err) + } + for _, path := range []string{"/auth/register", "/auth/login", "/auth/logout", "/auth/sessions", "/users/@me", "/auth/2fa/setup", "/meta"} { + if _, ok := doc.Paths[path]; !ok { + t.Errorf("openapi is missing %s", path) + } + } +} diff --git a/internal/server/errors.go b/internal/server/errors.go new file mode 100644 index 0000000..f892ac4 --- /dev/null +++ b/internal/server/errors.go @@ -0,0 +1,104 @@ +package server + +import ( + "encoding/json" + "errors" + "net/http" + + "glchat/internal/auth" + "glchat/internal/httpx" + "glchat/internal/permissions" + "glchat/internal/store" +) + +// apiError — доменная ошибка с кодом для клиента (AGENT.md 8.5). +type apiError struct { + Status int `json:"-"` + Code string `json:"code"` + Message string `json:"message"` + cause error +} + +func (e apiError) Error() string { return e.Code + ": " + e.Message } +func (e apiError) Unwrap() error { return e.cause } + +// newAPIError подбирает код и статус по доменной ошибке. +func newAPIError(err error) apiError { + candidate := apiError{Status: http.StatusInternalServerError, Code: "internal.error", Message: "internal error", cause: err} + switch { + case errors.Is(err, auth.ErrInvalidCredentials): + candidate.Status, candidate.Code, candidate.Message = http.StatusUnauthorized, "auth.invalid_credentials", "invalid credentials" + case errors.Is(err, auth.ErrTOTPRequired): + candidate.Status, candidate.Code, candidate.Message = http.StatusUnauthorized, "auth.2fa_required", "two-factor code required" + case errors.Is(err, auth.ErrTOTPInvalid): + candidate.Status, candidate.Code, candidate.Message = http.StatusBadRequest, "auth.totp_invalid", "invalid two-factor code" + case errors.Is(err, auth.ErrInstanceAdminTOTP): + candidate.Status, candidate.Code = http.StatusForbidden, "auth.2fa_required" + candidate.Message = "instance administrators must enable two-factor authentication" + case errors.Is(err, auth.ErrSessionExpired): + candidate.Status, candidate.Code, candidate.Message = http.StatusUnauthorized, "auth.session_expired", "session expired" + case errors.Is(err, auth.ErrStepUpRequired): + candidate.Status, candidate.Code = http.StatusForbidden, "auth.step_up_required" + candidate.Message = "step-up authentication required" + case errors.Is(err, auth.ErrUsernameTaken): + candidate.Status, candidate.Code, candidate.Message = http.StatusConflict, "auth.username_taken", "username is already taken" + case errors.Is(err, auth.ErrEmailTaken): + candidate.Status, candidate.Code, candidate.Message = http.StatusConflict, "auth.email_taken", "email is already registered" + case errors.Is(err, auth.ErrRegistrationOff): + candidate.Status, candidate.Code = http.StatusForbidden, "auth.registration_disabled" + candidate.Message = "registration is disabled" + case errors.Is(err, auth.ErrWeakPassword): + candidate.Status, candidate.Code, candidate.Message = http.StatusUnprocessableEntity, "auth.weak_password", err.Error() + case errors.Is(err, auth.ErrInvalidUsername): + candidate.Status, candidate.Code, candidate.Message = http.StatusUnprocessableEntity, "auth.invalid_username", err.Error() + case errors.Is(err, auth.ErrTOTPAlreadyEnabled): + candidate.Status, candidate.Code = http.StatusConflict, "auth.2fa_already_enabled" + candidate.Message = "two-factor authentication is already enabled" + case errors.Is(err, auth.ErrNoTOTPSecret): + candidate.Status, candidate.Code = http.StatusBadRequest, "auth.2fa_not_configured" + candidate.Message = "two-factor authentication is not configured" + case errors.Is(err, store.ErrNotFound): + candidate.Status, candidate.Code, candidate.Message = http.StatusNotFound, "not_found", "resource not found" + case errors.Is(err, store.ErrConflict): + candidate.Status, candidate.Code, candidate.Message = http.StatusConflict, "conflict", "resource already exists" + case errors.Is(err, permissions.ErrDenied): + candidate.Status, candidate.Code, candidate.Message = http.StatusForbidden, "perm.denied", "permission denied" + } + return candidate +} + +// writeAPIError отдаёт ошибку в едином формате с машиночитаемым кодом. +func writeAPIError(w http.ResponseWriter, err error) { + apiErr := newAPIError(err) + if apiErr.Status >= http.StatusInternalServerError { + apiErr.Message = "internal error" + } + httpx.WriteJSON(w, apiErr.Status, map[string]any{ + "error": map[string]any{ + "code": apiErr.Code, + "message": apiErr.Message, + }, + }) +} + +// writeJSON пишет успешный ответ (все текущие ручки возвращают 200). +func writeJSON(w http.ResponseWriter, body any) { + httpx.WriteJSON(w, http.StatusOK, body) +} + +// decodeBody читает JSON-тело с ограничением размера. +func decodeBody(w http.ResponseWriter, r *http.Request, dst any) bool { + if r.Body == nil { + writeAPIError(w, errors.New("empty request body")) + return false + } + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(dst); err != nil { + httpx.WriteJSON(w, http.StatusBadRequest, map[string]any{ + "error": map[string]any{"code": "request.bad", "message": "malformed json body"}, + }) + return false + } + return true +} diff --git a/internal/server/fallback.go b/internal/server/fallback.go index 47acc0b..77a7a82 100644 --- a/internal/server/fallback.go +++ b/internal/server/fallback.go @@ -7,31 +7,6 @@ import ( "glchat/internal/httpx" ) -// methodOfPattern splits a Go 1.22 routing pattern such as "GET /api/v1/meta" -// into its method and path parts. Patterns without a method apply to all. -func methodOfPattern(pattern string) (method, path string) { - if i := strings.IndexByte(pattern, ' '); i > 0 { - return pattern[:i], pattern[i+1:] - } - return "", pattern -} - -func (s *Server) routeExists(method, path string) bool { - for _, pattern := range s.patterns { - patternMethod, patternPath := methodOfPattern(pattern) - if patternMethod == "" || patternMethod == method { - continue - } - if patternPath == path { - return true - } - if strings.HasSuffix(patternPath, "/") && strings.HasPrefix(path, patternPath) { - return true - } - } - return false -} - // reservedPrefixes are handled by the API, the gateway or the file CDN; an // unknown path under them is a real 404 and must not receive the SPA shell. var reservedPrefixes = []string{"/api/", "/gateway", "/files/", "/rtc"} @@ -46,10 +21,6 @@ func isReservedPath(path string) bool { } func (s *Server) handleFallback(w http.ResponseWriter, r *http.Request) { - if s.routeExists(r.Method, r.URL.Path) { - httpx.WriteErrorStatus(w, http.StatusMethodNotAllowed, httpx.CodeBadRequest, "method not allowed") - return - } if isReservedPath(r.URL.Path) { httpx.WriteError(w, httpx.NewError(httpx.CodeNotFound, "resource not found")) return diff --git a/internal/server/openapi.go b/internal/server/openapi.go index 7e7dabf..34cb392 100644 --- a/internal/server/openapi.go +++ b/internal/server/openapi.go @@ -1,81 +1,277 @@ package server -// openAPIDocument is the hand-maintained OpenAPI 3.1 contract for the endpoints -// implemented so far. Phase 1 replaces it with a schema generated from typed -// handler definitions (AGENT.md 8.1). -var openAPIDocument = []byte(`{ - "openapi": "3.1.0", - "info": { - "title": "glchat API", - "version": "0.1.0", - "description": "Self-hosted communication platform. Phase 0 exposes health and metadata endpoints only.", - "license": { "name": "AGPL-3.0-or-later", "identifier": "AGPL-3.0-or-later" } - }, - "servers": [{ "url": "/api/v1" }], - "paths": { - "/meta": { - "get": { - "operationId": "getMeta", - "summary": "Instance metadata, API version and feature flags", - "tags": ["Meta"], - "responses": { - "200": { - "description": "Instance metadata", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/Meta" } - } - } - } - } +import ( + "encoding/json" + "log/slog" + "net/http" +) + +// handleOpenAPI отдаёт объединённый документ OpenAPI 3.1: пути, описанные +// huma (meta и служебные ручки), плюс контракт auth-ручек, которые живут +// на chi и описаны в authPathsJSON (AGENT.md 8.1: единый источник типов). +func (s *Server) handleOpenAPI(w http.ResponseWriter, r *http.Request) { + document := map[string]any{} + if body, err := json.Marshal(s.api.OpenAPI()); err == nil { + if err := json.Unmarshal(body, &document); err != nil { + s.logger.ErrorContext(r.Context(), "decode generated openapi", slog.Any("error", err)) + } + } + if document == nil { + document = map[string]any{} + } + + paths, _ := document["paths"].(map[string]any) + if paths == nil { + paths = map[string]any{} + } + var extra map[string]any + if err := json.Unmarshal([]byte(authPathsJSON), &extra); err != nil { + s.logger.ErrorContext(r.Context(), "decode auth openapi paths", slog.Any("error", err)) + } + for path, item := range extra { + paths[path] = item + } + document["paths"] = paths + if components, ok := document["components"].(map[string]any); ok { + if schemas, ok := components["schemas"].(map[string]any); ok { + var extraSchemas map[string]any + if err := json.Unmarshal([]byte(authSchemasJSON), &extraSchemas); err == nil { + for name, schema := range extraSchemas { + schemas[name] = schema + } + } + } + } + + body, err := json.Marshal(document) + if err != nil { + httpxWriteInternalError(w) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + if _, err := w.Write(body); err != nil { + s.logger.ErrorContext(r.Context(), "write openapi document", slog.Any("error", err)) + } +} + +func httpxWriteInternalError(w http.ResponseWriter) { + http.Error(w, "internal error", http.StatusInternalServerError) +} + +// authPathsJSON — контракт ручек аутентификации (chi-обработчики). +const authPathsJSON = `{ + "/auth/register": { + "post": { + "operationId": "register", + "summary": "Регистрация по email и паролю", + "tags": ["Auth"], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RegisterRequest" } } } + }, + "responses": { + "200": { "description": "Аккаунт создан, сессия выдана", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AuthResponse" } } } }, + "403": { "description": "Регистрация выключена", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "409": { "description": "Email или username заняты", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, + "422": { "description": "Пароль или username не проходят политику", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, - "components": { - "schemas": { - "Meta": { - "type": "object", - "required": ["name", "version", "api_version", "base_url", "features"], - "properties": { - "name": { "type": "string" }, - "version": { "type": "string" }, - "commit": { "type": "string" }, - "build_date": { "type": "string" }, - "api_version": { "type": "string", "enum": ["v1"] }, - "base_url": { "type": "string" }, - "files_url": { "type": "string" }, - "gateway_url": { "type": "string" }, - "rtc_path": { "type": "string" }, - "max_upload_size": { "type": "integer", "format": "int64" }, - "features": { - "type": "object", - "properties": { - "registration_enabled": { "type": "boolean" }, - "anti_bot_enabled": { "type": "boolean" }, - "voice_enabled": { "type": "boolean" }, - "web_push_enabled": { "type": "boolean" }, - "oauth_enabled": { "type": "boolean" }, - "passkeys_enabled": { "type": "boolean" } - } - } - } + "/auth/login": { + "post": { + "operationId": "login", + "summary": "Вход по email и паролю (с TOTP при включённой 2FA)", + "tags": ["Auth"], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/LoginRequest" } } } }, - "Error": { + "responses": { + "200": { "description": "Вход выполнен, сессия выдана", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AuthResponse" } } } }, + "401": { "description": "Неверные данные или требуется код 2FA (auth.2fa_required)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } + } + } + }, + "/auth/logout": { + "post": { + "operationId": "logout", + "summary": "Выход: отзывает текущую сессию", + "tags": ["Auth"], + "responses": { "200": { "description": "Сессия отозвана", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } } } + } + }, + "/auth/logout-all": { + "post": { + "operationId": "logoutAll", + "summary": "Выйти везде: отзывает все сессии пользователя", + "tags": ["Auth"], + "responses": { "200": { "description": "Все сессии отозваны", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } } } + } + }, + "/auth/sessions": { + "get": { + "operationId": "listSessions", + "summary": "Активные сессии пользователя", + "tags": ["Auth"], + "responses": { "200": { "description": "Список сессий", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SessionsResponse" } } } } } + } + }, + "/auth/2fa/setup": { + "post": { + "operationId": "setupTOTP", + "summary": "Создать секрет TOTP (до подтверждения кодом)", + "tags": ["Auth"], + "responses": { "200": { "description": "Секрет и otpauth-URL", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TOTPSetup" } } } } } + } + }, + "/auth/2fa/enable": { + "post": { + "operationId": "enableTOTP", + "summary": "Включить 2FA, подтвердив код; возвращает резервные коды", + "tags": ["Auth"], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TOTPEnableRequest" } } } + }, + "responses": { "200": { "description": "2FA включена", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecoveryCodes" } } } } } + } + }, + "/auth/step-up": { + "post": { + "operationId": "stepUp", + "summary": "Подтвердить пароль (и 2FA) для чувствительных действий", + "tags": ["Auth"], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StepUpRequest" } } } + }, + "responses": { "200": { "description": "Аутентификация подтверждена", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } } } + } + }, + "/users/@me": { + "get": { + "operationId": "getMe", + "summary": "Текущий пользователь", + "tags": ["Users"], + "responses": { "200": { "description": "Профиль пользователя", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UserResponse" } } } } } + } + } +}` + +// authSchemasJSON — схемы запросов и ответов auth-ручек. +const authSchemasJSON = `{ + "RegisterRequest": { + "type": "object", + "required": ["username", "email", "password"], + "properties": { + "username": { "type": "string", "minLength": 2, "maxLength": 32 }, + "display_name": { "type": "string", "maxLength": 64 }, + "email": { "type": "string", "format": "email" }, + "password": { "type": "string", "minLength": 10 }, + "locale": { "type": "string", "enum": ["ru", "en"] } + } + }, + "LoginRequest": { + "type": "object", + "required": ["email", "password"], + "properties": { + "email": { "type": "string", "format": "email" }, + "password": { "type": "string" }, + "totp_code": { "type": "string", "description": "Код TOTP или резервный код" } + } + }, + "TOTPEnableRequest": { + "type": "object", + "required": ["code"], + "properties": { "code": { "type": "string", "minLength": 6 } } + }, + "StepUpRequest": { + "type": "object", + "required": ["password"], + "properties": { + "password": { "type": "string" }, + "totp_code": { "type": "string" } + } + }, + "User": { + "type": "object", + "required": ["id", "username", "display_name", "status", "is_instance_admin", "badges", "locale"], + "properties": { + "id": { "type": "string", "description": "Snowflake строкой" }, + "username": { "type": "string" }, + "display_name": { "type": "string" }, + "bio": { "type": "string" }, + "status": { "type": "string", "enum": ["online", "idle", "dnd", "invisible"] }, + "custom_status": { "type": "string" }, + "avatar_file_id": { "type": "string" }, + "banner_file_id": { "type": "string" }, + "is_instance_admin": { "type": "boolean" }, + "badges": { "type": "array", "items": { "type": "string" } }, + "locale": { "type": "string", "enum": ["ru", "en"] } + } + }, + "AuthResponse": { + "type": "object", + "required": ["user"], + "properties": { "user": { "$ref": "#/components/schemas/User" } } + }, + "UserResponse": { + "type": "object", + "required": ["user"], + "properties": { "user": { "$ref": "#/components/schemas/User" } } + }, + "Session": { + "type": "object", + "required": ["id", "created_at", "last_seen", "expires_at", "current", "stepped_up"], + "properties": { + "id": { "type": "string" }, + "user_agent": { "type": "string" }, + "ip": { "type": "string" }, + "created_at": { "type": "string", "format": "date-time" }, + "last_seen": { "type": "string", "format": "date-time" }, + "expires_at": { "type": "string", "format": "date-time" }, + "current": { "type": "boolean" }, + "stepped_up": { "type": "boolean" } + } + }, + "SessionsResponse": { + "type": "object", + "required": ["sessions"], + "properties": { "sessions": { "type": "array", "items": { "$ref": "#/components/schemas/Session" } } } + }, + "TOTPSetup": { + "type": "object", + "required": ["secret", "url"], + "properties": { + "secret": { "type": "string" }, + "url": { "type": "string" }, + "issuer": { "type": "string" } + } + }, + "RecoveryCodes": { + "type": "object", + "required": ["recovery_codes"], + "properties": { "recovery_codes": { "type": "array", "items": { "type": "string" } } } + }, + "OkResponse": { + "type": "object", + "required": ["ok"], + "properties": { "ok": { "type": "boolean" } } + }, + "Error": { + "type": "object", + "required": ["error"], + "properties": { + "error": { "type": "object", - "required": ["error"], + "required": ["code", "message"], "properties": { - "error": { - "type": "object", - "required": ["code", "message"], - "properties": { - "code": { "type": "string" }, - "message": { "type": "string" }, - "details": { "type": "object", "additionalProperties": true } - } - } + "code": { "type": "string" }, + "message": { "type": "string" }, + "details": { "type": "object", "additionalProperties": true } } } } } -} -`) +}` diff --git a/internal/server/server.go b/internal/server/server.go index f570e19..b4d61ae 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -4,36 +4,62 @@ import ( "context" "log/slog" "net/http" + "strings" "time" + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/adapters/humachi" + "github.com/go-chi/chi/v5" + + "glchat/internal/auth" "glchat/internal/config" "glchat/internal/database" "glchat/internal/httpx" "glchat/internal/meta" + "glchat/internal/store" ) -type Server struct { - cfg config.Config - db *database.DB - logger *slog.Logger - http *http.Server - static *staticHandler - patterns []string +// Deps — зависимости HTTP-слоя: хранилище и сервис аутентификации. +// В Фазе 0 они могут отсутствовать (инстанс без секретов ещё поднимается). +type Deps struct { + Store *store.Store + Auth *auth.Service } -func New(cfg config.Config, db *database.DB, logger *slog.Logger) *Server { +type Server struct { + cfg config.Config + db *database.DB + store *store.Store + auth *auth.Service + logger *slog.Logger + http *http.Server + static *staticHandler + api huma.API +} + +func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Server { s := &Server{ - cfg: cfg, - db: db, - logger: logger, - patterns: append([]string(nil), routePatterns...), - static: newStaticHandler(cfg.WebRoot), + cfg: cfg, + db: db, + store: deps.Store, + auth: deps.Auth, + logger: logger, + static: newStaticHandler(cfg.WebRoot), } - mux := http.NewServeMux() - s.routes(mux) - handler := httpx.Chain(mux, + + router := chi.NewRouter() + router.Route("/api/v1", func(apiRouter chi.Router) { + s.api = s.registerAPI(apiRouter) + s.registerMetaRoutes(s.api) + s.registerAuthRoutes(apiRouter) + apiRouter.Get("/openapi.json", s.handleOpenAPI) + }) + s.registerRoutes(router) + + handler := httpx.Chain(router, httpx.SecurityHeaders(cfg.FilesDomain), httpx.RequestID, + httpx.RequestInfoMiddleware, httpx.Logger(logger), httpx.Recoverer(logger), httpx.JSONBodyLimit(1<<20), @@ -50,20 +76,30 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger) *Server { return s } -var routePatterns = []string{ - "GET /healthz", - "GET /readyz", - "GET /api/v1/meta", - "GET /api/v1/openapi.json", - "/", +// registerAPI создаёт huma-API: OpenAPI 3.1 и TS-типы выводятся из Go-типов +// (AGENT.md 8.1, решение D-006). +func (s *Server) registerAPI(router chi.Router) huma.API { + cfg := huma.DefaultConfig("glchat API", s.cfg.Version) + cfg.Info.Description = "Self-hosted платформа общения: REST /api/v1 и WebSocket Gateway." + cfg.Info.License = &huma.License{Name: "AGPL-3.0-or-later", Identifier: "AGPL-3.0-or-later"} + cfg.Servers = []*huma.Server{{URL: "/api/v1"}} + cfg.OpenAPIPath = "/openapi" + cfg.DocsPath = "/docs" + cfg.Components.SecuritySchemes = map[string]*huma.SecurityScheme{ + "sessionCookie": {Type: "apiKey", In: "cookie", Name: sessionCookieName}, + "bearerAuth": {Type: "http", Scheme: "bearer"}, + } + return humachi.New(router, cfg) } -func (s *Server) routes(mux *http.ServeMux) { - mux.HandleFunc("GET /healthz", s.handleHealthz) - mux.HandleFunc("GET /readyz", s.handleReadyz) - mux.HandleFunc("GET /api/v1/meta", s.handleMeta) - mux.HandleFunc("GET /api/v1/openapi.json", s.handleOpenAPI) - mux.HandleFunc("/", s.handleFallback) +func (s *Server) registerRoutes(router chi.Router) { + router.Get("/healthz", s.handleHealthz) + router.Get("/readyz", s.handleReadyz) + + router.NotFound(s.handleFallback) + router.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) { + httpx.WriteErrorStatus(w, http.StatusMethodNotAllowed, httpx.CodeBadRequest, "method not allowed") + }) } func (s *Server) Handler() http.Handler { return s.http.Handler } @@ -104,16 +140,27 @@ func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) { }) } -func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) { - httpx.WriteJSON(w, http.StatusOK, meta.New(s.cfg)) +type metaOutput struct { + Body meta.Response } -func (s *Server) handleOpenAPI(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(http.StatusOK) - if _, err := w.Write(openAPIDocument); err != nil { - s.logger.ErrorContext(r.Context(), "write openapi document", slog.Any("error", err)) +func (s *Server) registerMetaRoutes(api huma.API) { + huma.Register(api, huma.Operation{ + OperationID: "getMeta", + Method: http.MethodGet, + Path: "/meta", + Summary: "Метаданные инстанса, версия API и флаги функций", + Tags: []string{"Meta"}, + }, func(_ context.Context, _ *struct{}) (*metaOutput, error) { + return &metaOutput{Body: meta.New(s.cfg)}, nil + }) +} + +func normalizeBearer(value string) string { + if strings.HasPrefix(strings.ToLower(value), "bearer ") { + return strings.TrimSpace(value[7:]) } + return strings.TrimSpace(value) } var startedAt = time.Now() diff --git a/internal/server/server_test.go b/internal/server/server_test.go index fdf1060..6b87f50 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -10,8 +10,10 @@ import ( "strings" "testing" + "glchat/internal/auth" "glchat/internal/config" "glchat/internal/database" + "glchat/internal/store" ) func newTestServer(t *testing.T) (*Server, *database.DB) { @@ -32,21 +34,31 @@ func newTestServer(t *testing.T) (*Server, *database.DB) { }) cfg := config.Config{ - Domain: "gl.mhspx.su", - WebRoot: filepath.Join("testdata", "web"), - FilesDomain: "files.gl.mhspx.su", - InstanceName: "glchat", - ListenAddr: "127.0.0.1:0", - Version: "v0.1.0-test", - Commit: "deadbee", - BuildDate: "2026-09-19T00:00:00Z", - MaxUploadSize: 26214400, - TLSEnabled: true, - LogLevel: "error", - LogFormat: "json", + Domain: "gl.mhspx.su", + WebRoot: filepath.Join("testdata", "web"), + FilesDomain: "files.gl.mhspx.su", + InstanceName: "glchat", + ListenAddr: "127.0.0.1:0", + Version: "v0.1.0-test", + Commit: "deadbee", + BuildDate: "2026-09-19T00:00:00Z", + MaxUploadSize: 26214400, + TLSEnabled: true, + SessionPepper: "test-pepper", + MasterKey: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + Argon2MemoryKiB: 1024, + Argon2Iterations: 1, + Argon2Parallelism: 1, + LogLevel: "error", + LogFormat: "json", } logger := slog.New(slog.DiscardHandler) - return New(cfg, db, logger), db + st := store.New(db) + authService, err := auth.New(context.Background(), cfg, st, logger) + if err != nil { + t.Fatalf("initialize authentication: %v", err) + } + return New(cfg, db, logger, Deps{Store: st, Auth: authService}), db } func TestHealthz(t *testing.T) { @@ -221,7 +233,7 @@ func TestServeWebClientReportsMissingBundle(t *testing.T) { cfg := config.Config{Domain: "localhost", WebRoot: t.TempDir(), LogFormat: "json", LogLevel: "error"} logger := slog.New(slog.DiscardHandler) - srv := New(cfg, db, logger) + srv := New(cfg, db, logger, Deps{}) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil))