package server 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" } } } } } } }, "/auth/login": { "post": { "operationId": "login", "summary": "Вход по email и паролю (с TOTP при включённой 2FA)", "tags": ["Auth"], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/LoginRequest" } } } }, "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", "otpauth_url"], "properties": { "secret": { "type": "string" }, "otpauth_url": { "type": "string" }, "qr_png": { "type": "string", "description": "QR-код otpauth-ссылки как data:image/png;base64 (рисуется локально)" }, "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": ["code", "message"], "properties": { "code": { "type": "string" }, "message": { "type": "string" }, "details": { "type": "object", "additionalProperties": true } } } } } }`