feat(api): REST на chi + huma с auth-ручками и OpenAPI 3.1
- переход на 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
This commit is contained in:
+265
-69
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
}`
|
||||
|
||||
Reference in New Issue
Block a user