docs: reorder & testing

This commit is contained in:
Ilia Denisov
2026-05-07 00:58:53 +03:00
committed by GitHub
parent f446c6a2ac
commit 604fe40bcf
148 changed files with 9150 additions and 2757 deletions
@@ -15,12 +15,15 @@ import (
)
// InternalSessionsHandlers groups the gateway-only session handlers
// under `/api/v1/internal/sessions/*`. The current implementation ships real
// implementations; nil *auth.Service falls back to the Stage-3
// placeholder so the contract test continues to validate the OpenAPI
// envelope without booting a database.
// under `/api/v1/internal/sessions/*`. The internal surface only
// carries the per-request session lookup gateway needs to verify
// signed envelopes; revocation is driven through the user surface
// (self-driven) or through admin operations that call auth in-process,
// not through this listener. nil *auth.Service falls back to the
// Stage-3 placeholder so the contract test continues to validate the
// OpenAPI envelope without booting a database.
type InternalSessionsHandlers struct {
svc *auth.Service
svc *auth.Service
logger *zap.Logger
}
@@ -62,58 +65,3 @@ func (h *InternalSessionsHandlers) Get() gin.HandlerFunc {
c.JSON(http.StatusOK, deviceSessionToWire(sess))
}
}
// Revoke handles POST /api/v1/internal/sessions/{device_session_id}/revoke.
func (h *InternalSessionsHandlers) Revoke() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("internalSessionsRevoke")
}
return func(c *gin.Context) {
deviceSessionID, err := uuid.Parse(c.Param("device_session_id"))
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "device_session_id must be a valid UUID")
return
}
ctx := c.Request.Context()
sess, err := h.svc.RevokeSession(ctx, deviceSessionID)
if err != nil {
if errors.Is(err, auth.ErrSessionNotFound) {
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "device session not found")
return
}
h.logger.Error("internal sessions revoke failed",
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
)
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error")
return
}
c.JSON(http.StatusOK, deviceSessionToWire(sess))
}
}
// RevokeAllForUser handles POST /api/v1/internal/sessions/users/{user_id}/revoke-all.
func (h *InternalSessionsHandlers) RevokeAllForUser() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("internalSessionsRevokeAllForUser")
}
return func(c *gin.Context) {
userID, err := uuid.Parse(c.Param("user_id"))
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user_id must be a valid UUID")
return
}
ctx := c.Request.Context()
revoked, err := h.svc.RevokeAllForUser(ctx, userID)
if err != nil {
h.logger.Error("internal sessions revoke-all failed",
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
)
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error")
return
}
c.JSON(http.StatusOK, gin.H{
"user_id": userID.String(),
"revoked_count": len(revoked),
})
}
}
@@ -126,6 +126,8 @@ func (h *PublicAuthHandlers) ConfirmEmailCode() gin.HandlerFunc {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "code is incorrect")
case errors.Is(err, auth.ErrTooManyAttempts):
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "too many attempts")
case errors.Is(err, auth.ErrEmailPermanentlyBlocked):
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "email is not allowed")
default:
h.logger.Error("confirm-email-code failed",
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
+13 -8
View File
@@ -116,15 +116,20 @@ func (h *UserGamesHandlers) Orders() gin.HandlerFunc {
respondGameProxyError(c, h.logger, "user games orders", ctx, err)
return
}
// Orders payload uses an updatedAt + commands shape; we don't
// rewrite it here because the engine derives the actor from
// the route, not the order body. We pass the body through
// verbatim (per ARCHITECTURE.md §9: backend is the only
// caller, so rewriting is unnecessary). Unused mapping is
// kept in the lookup so 404 returns when no mapping exists.
_ = mapping
// Engine binds the order body into `gamerest.Command{Actor,
// Commands}` and rejects an empty actor with `notblank`, so
// backend rebinds the actor from the runtime player mapping
// before forwarding — the same rule as for the command
// handler. Per ARCHITECTURE.md §9 backend is the only caller
// of the engine, so the body never carries a client-supplied
// actor.
_ = order.Order{}
resp, err := h.engine.PutOrders(ctx, endpoint, body)
payload, err := rebindActor(body, mapping.RaceName)
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be a JSON object")
return
}
resp, err := h.engine.PutOrders(ctx, endpoint, payload)
if err != nil {
respondEngineProxyError(c, h.logger, "user games orders", ctx, resp, err)
return
@@ -0,0 +1,143 @@
package server
import (
"errors"
"net/http"
"galaxy/backend/internal/auth"
"galaxy/backend/internal/server/handlers"
"galaxy/backend/internal/server/httperr"
"galaxy/backend/internal/server/middleware/userid"
"galaxy/backend/internal/telemetry"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// UserSessionsHandlers groups the user-facing session handlers under
// `/api/v1/user/sessions/*`. Authenticated callers can list their own
// active device sessions, revoke a specific one (logout from one
// device), or revoke all sessions at once (logout everywhere). Every
// mutation lands an audit row in `session_revocations` through the
// auth service. nil *auth.Service falls back to the standard 501
// placeholder.
type UserSessionsHandlers struct {
svc *auth.Service
logger *zap.Logger
}
// NewUserSessionsHandlers constructs the handler set. svc may be nil
// — in that case every handler returns 501 not_implemented.
func NewUserSessionsHandlers(svc *auth.Service, logger *zap.Logger) *UserSessionsHandlers {
if logger == nil {
logger = zap.NewNop()
}
return &UserSessionsHandlers{svc: svc, logger: logger.Named("http.user.sessions")}
}
type userSessionsListResponse struct {
Items []deviceSessionPayload `json:"items"`
}
type userSessionsRevocationSummary struct {
UserID string `json:"user_id"`
RevokedCount int `json:"revoked_count"`
}
// List handles GET /api/v1/user/sessions.
func (h *UserSessionsHandlers) List() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("userSessionsList")
}
return func(c *gin.Context) {
callerID, ok := userid.FromContext(c.Request.Context())
if !ok {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
return
}
sessions := h.svc.ListActiveByUser(c.Request.Context(), callerID)
items := make([]deviceSessionPayload, 0, len(sessions))
for _, s := range sessions {
items = append(items, deviceSessionToWire(s))
}
c.JSON(http.StatusOK, userSessionsListResponse{Items: items})
}
}
// Revoke handles POST /api/v1/user/sessions/{device_session_id}/revoke.
// The target session must belong to the caller; otherwise the handler
// returns 404 (using the same shape as a missing session) so callers
// cannot probe foreign device_session_ids.
func (h *UserSessionsHandlers) Revoke() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("userSessionsRevoke")
}
return func(c *gin.Context) {
callerID, ok := userid.FromContext(c.Request.Context())
if !ok {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
return
}
deviceSessionID, err := uuid.Parse(c.Param("device_session_id"))
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "device_session_id must be a valid UUID")
return
}
// Ownership check via the cache — if the target session is not
// active and owned by the caller, surface a 404 in both
// branches so foreign sessions are not probeable.
cached, ok := h.svc.LookupSessionInCache(deviceSessionID)
if !ok || cached.UserID != callerID {
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "device session not found")
return
}
ctx := c.Request.Context()
sess, err := h.svc.RevokeSession(ctx, deviceSessionID, auth.RevokeContext{
ActorKind: auth.ActorKindUserSelf,
ActorID: callerID.String(),
})
if err != nil {
if errors.Is(err, auth.ErrSessionNotFound) {
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "device session not found")
return
}
h.logger.Error("user sessions revoke failed",
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
)
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error")
return
}
c.JSON(http.StatusOK, deviceSessionToWire(sess))
}
}
// RevokeAll handles POST /api/v1/user/sessions/revoke-all.
func (h *UserSessionsHandlers) RevokeAll() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("userSessionsRevokeAll")
}
return func(c *gin.Context) {
callerID, ok := userid.FromContext(c.Request.Context())
if !ok {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
return
}
ctx := c.Request.Context()
revoked, err := h.svc.RevokeAllForUser(ctx, callerID, auth.RevokeContext{
ActorKind: auth.ActorKindUserSelf,
ActorID: callerID.String(),
})
if err != nil {
h.logger.Error("user sessions revoke-all failed",
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
)
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error")
return
}
c.JSON(http.StatusOK, userSessionsRevocationSummary{
UserID: callerID.String(),
RevokedCount: len(revoked),
})
}
}
+9 -2
View File
@@ -68,6 +68,7 @@ type RouterDependencies struct {
UserLobbyMy *UserLobbyMyHandlers
UserLobbyRaceNames *UserLobbyRaceNamesHandlers
UserGames *UserGamesHandlers
UserSessions *UserSessionsHandlers
AdminAdminAccounts *AdminAdminAccountsHandlers
AdminUsers *AdminUsersHandlers
AdminGames *AdminGamesHandlers
@@ -162,6 +163,9 @@ func withDefaultHandlers(deps RouterDependencies) RouterDependencies {
if deps.UserGames == nil {
deps.UserGames = NewUserGamesHandlers(nil, nil, deps.Logger)
}
if deps.UserSessions == nil {
deps.UserSessions = NewUserSessionsHandlers(nil, deps.Logger)
}
if deps.AdminAdminAccounts == nil {
deps.AdminAdminAccounts = NewAdminAdminAccountsHandlers(nil, deps.Logger)
}
@@ -258,6 +262,11 @@ func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, de
userGames.POST("/:game_id/commands", deps.UserGames.Commands())
userGames.POST("/:game_id/orders", deps.UserGames.Orders())
userGames.GET("/:game_id/reports/:turn", deps.UserGames.Report())
userSessions := group.Group("/sessions")
userSessions.GET("", deps.UserSessions.List())
userSessions.POST("/revoke-all", deps.UserSessions.RevokeAll())
userSessions.POST("/:device_session_id/revoke", deps.UserSessions.Revoke())
}
func registerAdminRoutes(router *gin.Engine, instruments *metrics.Instruments, deps RouterDependencies) {
@@ -323,9 +332,7 @@ func registerInternalRoutes(router *gin.Engine, instruments *metrics.Instruments
group.Use(metrics.Middleware(instruments, metrics.GroupInternal))
sessions := group.Group("/sessions")
sessions.POST("/users/:user_id/revoke-all", deps.InternalSessions.RevokeAllForUser())
sessions.GET("/:device_session_id", deps.InternalSessions.Get())
sessions.POST("/:device_session_id/revoke", deps.InternalSessions.Revoke())
users := group.Group("/users")
users.GET("/:user_id/account-internal", deps.InternalUsers.GetAccountInternal())