feat: backend service

This commit is contained in:
Ilia Denisov
2026-05-06 10:14:55 +03:00
committed by GitHub
parent 3e2622757e
commit f446c6a2ac
1486 changed files with 49720 additions and 266401 deletions
@@ -0,0 +1,41 @@
// Package clientip exposes the helper that resolves the originating client
// IP for an inbound HTTP request. Backend trusts the value because the
// network segment between gateway and backend is the trust boundary
// (`ARCHITECTURE.md` §15-16): gateway is responsible for sanitising and
// populating `X-Forwarded-For` before the request reaches backend.
//
// Both the public-auth handler chain (handlers_auth_helpers.go) and the
// user-surface geo-counter middleware reuse the same extraction so the two
// surfaces never disagree about the IP they record.
package clientip
import (
"net"
"strings"
"github.com/gin-gonic/gin"
)
// ExtractSourceIP returns the originating client IP for the request behind
// c. The leftmost entry of `X-Forwarded-For` is preferred; when the header
// is absent or empty, the connection RemoteAddr is used (with the port
// stripped). The empty string is returned when neither source yields a
// usable value, which lets callers treat the result as "no IP available"
// and skip dependent work.
func ExtractSourceIP(c *gin.Context) string {
if c == nil || c.Request == nil {
return ""
}
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
first := xff
if idx := strings.IndexByte(first, ','); idx >= 0 {
first = first[:idx]
}
return strings.TrimSpace(first)
}
addr := c.Request.RemoteAddr
if host, _, err := net.SplitHostPort(addr); err == nil {
return host
}
return addr
}
@@ -0,0 +1,84 @@
package clientip
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestExtractSourceIP(t *testing.T) {
t.Parallel()
cases := []struct {
name string
header string
remoteAddr string
want string
}{
{
name: "single XFF entry trimmed",
header: " 198.51.100.7 ",
remoteAddr: "10.0.0.1:5000",
want: "198.51.100.7",
},
{
name: "first XFF entry wins",
header: "198.51.100.7, 10.0.0.1, 192.168.1.1",
remoteAddr: "10.0.0.1:5000",
want: "198.51.100.7",
},
{
name: "fallback to RemoteAddr without port",
header: "",
remoteAddr: "203.0.113.42:65000",
want: "203.0.113.42",
},
{
name: "RemoteAddr without port preserved",
header: "",
remoteAddr: "203.0.113.42",
want: "203.0.113.42",
},
{
name: "no header and no RemoteAddr returns empty",
header: "",
remoteAddr: "",
want: "",
},
}
gin.SetMode(gin.TestMode)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = tc.remoteAddr
if tc.header != "" {
req.Header.Set("X-Forwarded-For", tc.header)
}
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = req
got := ExtractSourceIP(c)
if got != tc.want {
t.Fatalf("ExtractSourceIP() = %q, want %q", got, tc.want)
}
})
}
}
func TestExtractSourceIPNilSafety(t *testing.T) {
t.Parallel()
if got := ExtractSourceIP(nil); got != "" {
t.Fatalf("nil context: want empty, got %q", got)
}
c, _ := gin.CreateTestContext(httptest.NewRecorder())
if got := ExtractSourceIP(c); got != "" {
t.Fatalf("context with nil Request: want empty, got %q", got)
}
}
+418
View File
@@ -0,0 +1,418 @@
package server
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"runtime"
"sort"
"strings"
"testing"
"galaxy/backend/internal/server/middleware/basicauth"
"github.com/getkin/kin-openapi/openapi3"
"github.com/getkin/kin-openapi/openapi3filter"
"github.com/getkin/kin-openapi/routers"
"github.com/getkin/kin-openapi/routers/gorillamux"
)
// contractStubAdminPassword is the password the contract test injects
// into the stub Basic Auth verifier wired by NewRouter. The value
// never leaves the test binary; production wires the verifier from
// the Postgres-backed admin.Service.
const contractStubAdminPassword = "contract-test-secret"
// stubUserID is the deterministic UUID injected into `X-User-ID` for the
// authenticated user surface.
const stubUserID = "00000000-0000-0000-0000-0000000000a1"
// pathParamStubs lists the deterministic substitutions used to fill the path
// templates declared in `openapi.yaml`. Every parameter that appears in a path
// must have an entry here; the test fails loudly if a new parameter is added
// to the spec without updating this map.
var pathParamStubs = map[string]string{
"game_id": "00000000-0000-0000-0000-000000000001",
"application_id": "00000000-0000-0000-0000-000000000002",
"invite_id": "00000000-0000-0000-0000-000000000003",
"membership_id": "00000000-0000-0000-0000-000000000004",
"notification_id": "00000000-0000-0000-0000-000000000005",
"delivery_id": "00000000-0000-0000-0000-000000000006",
"user_id": "00000000-0000-0000-0000-000000000007",
"device_session_id": "00000000-0000-0000-0000-000000000008",
"id": "1.2.3",
"username": "alice",
"turn": "42",
}
// requestBodyStubs lists the JSON request bodies the contract test sends for
// each operationId. Operations missing from the map default to an empty
// object `{}`, which is a valid placeholder thanks to `additionalProperties:
// true` in the matching schemas. Operations that require specific fields
// listed in `openapi.yaml` get a hand-crafted body here.
var requestBodyStubs = map[string]map[string]any{
"publicAuthSendEmailCode": {
"email": "pilot@example.test",
},
"publicAuthConfirmEmailCode": {
"challenge_id": "challenge-123",
"code": "654321",
"client_public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"time_zone": "Europe/Kaliningrad",
},
"adminAdminAccountsCreate": {
"username": "carol",
"password": "carol-secret",
},
"adminAdminAccountsResetPassword": {
"password": "carol-new-secret",
},
"adminEngineVersionsCreate": {
"version": "1.2.3",
"image_ref": "registry.test/galaxy/engine:1.2.3",
},
"adminRuntimesPatch": {
"target_version": "1.2.4",
},
"userLobbyRaceNamesRegister": {
"name": "AndromedaConfederacy",
},
"adminUsersAddSanction": {
"sanction_code": "permanent_block",
"scope": "platform",
"reason_code": "tos_violation",
"actor": map[string]any{
"type": "admin",
"id": "operator",
},
},
"adminUsersAddLimit": {
"limit_code": "max_active_games",
"value": 3,
"reason_code": "manual_review",
"actor": map[string]any{
"type": "admin",
"id": "operator",
},
},
"adminUsersAddEntitlement": {
"tier": "monthly",
"source": "admin",
"actor": map[string]any{
"type": "admin",
"id": "operator",
},
},
"userLobbyGamesCreate": {
"game_name": "Contract Test Game",
"visibility": "private",
"min_players": 2,
"max_players": 8,
"start_gap_hours": 24,
"start_gap_players": 2,
"enrollment_ends_at": "2099-01-02T03:04:05Z",
"turn_schedule": "0 0 * * *",
"target_engine_version": "1.0.0",
},
"adminGamesCreate": {
"game_name": "Contract Test Public Game",
"min_players": 4,
"max_players": 12,
"start_gap_hours": 12,
"start_gap_players": 4,
"enrollment_ends_at": "2099-01-02T03:04:05Z",
"turn_schedule": "0 6 * * *",
"target_engine_version": "1.0.0",
},
"userLobbyApplicationsSubmit": {
"race_name": "ContractTestRace",
},
"userLobbyInvitesIssue": {
"invited_user_id": pathParamStubs["user_id"],
"race_name": "ContractTestRace",
},
"adminGamesBanMember": {
"user_id": pathParamStubs["user_id"],
"reason": "ToS violation",
},
}
// TestOpenAPIContract is the top-level OpenAPI contract test. It
// validates that:
//
// 1. backend/openapi.yaml is well-formed;
// 2. every documented operation maps to a registered gin handler;
// 3. each handler returns a representable HTTP response that satisfies
// the declared response schema (any defensive `501 not_implemented`
// placeholder also conforms).
func TestOpenAPIContract(t *testing.T) {
t.Parallel()
doc, specPath := loadOpenAPISpec(t)
spec := mustValidateSpec(t, doc, specPath)
router := mustNewGorillamuxRouter(t, doc)
engine := mustBuildEngine(t)
expectedOps := collectOperations(t, doc)
if len(expectedOps) == 0 {
t.Fatalf("openapi.yaml declares no operations")
}
_ = spec
processed := 0
for _, op := range expectedOps {
t.Run(op.method+" "+op.path, func(t *testing.T) {
t.Parallel()
runContractCase(t, router, engine, op)
})
processed++
}
t.Logf("contract test exercised %d operations from %s", processed, specPath)
}
type contractOperation struct {
method string
path string
operationID string
op *openapi3.Operation
}
func collectOperations(t *testing.T, doc *openapi3.T) []contractOperation {
t.Helper()
var ops []contractOperation
for path, item := range doc.Paths.Map() {
if item == nil {
continue
}
for method, op := range item.Operations() {
if op == nil {
continue
}
ops = append(ops, contractOperation{
method: method,
path: path,
operationID: op.OperationID,
op: op,
})
}
}
sort.Slice(ops, func(i, j int) bool {
if ops[i].path == ops[j].path {
return ops[i].method < ops[j].method
}
return ops[i].path < ops[j].path
})
return ops
}
func loadOpenAPISpec(t *testing.T) (*openapi3.T, string) {
t.Helper()
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatalf("runtime.Caller(0) failed")
}
specPath := filepath.Join(filepath.Dir(thisFile), "..", "..", "openapi.yaml")
loader := openapi3.NewLoader()
loader.IsExternalRefsAllowed = false
doc, err := loader.LoadFromFile(specPath)
if err != nil {
t.Fatalf("load openapi spec %s: %v", specPath, err)
}
return doc, specPath
}
func mustValidateSpec(t *testing.T, doc *openapi3.T, specPath string) *openapi3.T {
t.Helper()
if err := doc.Validate(context.Background()); err != nil {
t.Fatalf("openapi spec %s did not validate: %v", specPath, err)
}
if doc.Info == nil || doc.Info.Version != "v1" {
t.Fatalf("openapi spec must declare info.version v1, got %+v", doc.Info)
}
return doc
}
func mustNewGorillamuxRouter(t *testing.T, doc *openapi3.T) routers.Router {
t.Helper()
router, err := gorillamux.NewRouter(doc)
if err != nil {
t.Fatalf("build gorillamux router: %v", err)
}
return router
}
func mustBuildEngine(t *testing.T) http.Handler {
t.Helper()
verifier := basicauth.NewStaticVerifier(contractStubAdminPassword)
handler, err := NewRouter(RouterDependencies{
AdminVerifier: verifier,
})
if err != nil {
t.Fatalf("build router: %v", err)
}
return handler
}
func runContractCase(t *testing.T, router routers.Router, engine http.Handler, c contractOperation) {
t.Helper()
if c.operationID == "" {
t.Fatalf("operation %s %s has no operationId", c.method, c.path)
}
req := buildRequest(t, c)
route, pathParams, err := router.FindRoute(req)
if err != nil {
t.Fatalf("find route for %s %s: %v", c.method, req.URL.Path, err)
}
requestInput := &openapi3filter.RequestValidationInput{
Request: req,
PathParams: pathParams,
Route: route,
Options: &openapi3filter.Options{
AuthenticationFunc: openapi3filter.NoopAuthenticationFunc,
},
}
if err := openapi3filter.ValidateRequest(req.Context(), requestInput); err != nil {
t.Fatalf("ValidateRequest %s %s (%s): %v", c.method, c.path, c.operationID, err)
}
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
expectPlaceholder := groupForPath(c.path) != "probe"
if expectPlaceholder {
if got, want := recorder.Code, http.StatusNotImplemented; got != want {
t.Fatalf("operation %s %s (%s) returned status %d, want %d (body: %s)",
c.method, c.path, c.operationID, got, want, recorder.Body.String())
}
} else if recorder.Code/100 != 2 {
t.Fatalf("probe operation %s %s (%s) returned non-2xx status %d (body: %s)",
c.method, c.path, c.operationID, recorder.Code, recorder.Body.String())
}
if ct := recorder.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Fatalf("operation %s %s (%s) returned Content-Type %q, want application/json", c.method, c.path, c.operationID, ct)
}
responseInput := &openapi3filter.ResponseValidationInput{
RequestValidationInput: requestInput,
Status: recorder.Code,
Header: recorder.Header(),
Options: &openapi3filter.Options{
IncludeResponseStatus: true,
},
}
responseInput.SetBodyBytes(recorder.Body.Bytes())
if err := openapi3filter.ValidateResponse(req.Context(), responseInput); err != nil {
t.Fatalf("ValidateResponse %s %s (%s): %v", c.method, c.path, c.operationID, err)
}
}
func buildRequest(t *testing.T, c contractOperation) *http.Request {
t.Helper()
target := substitutePathParams(t, c.path)
url := "http://backend.internal" + target
body := bodyFor(t, c)
req, err := http.NewRequest(c.method, url, body.reader)
if err != nil {
t.Fatalf("construct request %s %s: %v", c.method, url, err)
}
if body.contentType != "" {
req.Header.Set("Content-Type", body.contentType)
}
req.Header.Set("Accept", "application/json")
switch groupForPath(c.path) {
case "user":
req.Header.Set("X-User-ID", stubUserID)
case "admin":
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("operator:"+contractStubAdminPassword)))
}
req = req.WithContext(context.Background())
return req
}
type requestBody struct {
reader io.Reader
contentType string
}
func bodyFor(t *testing.T, c contractOperation) requestBody {
t.Helper()
if c.op.RequestBody == nil || c.op.RequestBody.Value == nil {
return requestBody{}
}
media := c.op.RequestBody.Value.Content.Get("application/json")
if media == nil {
return requestBody{}
}
stub, ok := requestBodyStubs[c.operationID]
if !ok {
stub = map[string]any{}
}
encoded, err := json.Marshal(stub)
if err != nil {
t.Fatalf("marshal request body for %s: %v", c.operationID, err)
}
return requestBody{
reader: bytes.NewReader(encoded),
contentType: "application/json",
}
}
func substitutePathParams(t *testing.T, templated string) string {
t.Helper()
result := templated
for {
open := strings.Index(result, "{")
if open < 0 {
break
}
close := strings.Index(result[open:], "}")
if close < 0 {
t.Fatalf("malformed path template %q", templated)
}
name := result[open+1 : open+close]
value, ok := pathParamStubs[name]
if !ok {
t.Fatalf("path template %q references unknown parameter %q", templated, name)
}
result = result[:open] + value + result[open+close+1:]
}
return result
}
// groupForPath returns the route family that contractOperation.path belongs
// to. The classification drives test-side header injection (X-User-ID,
// Authorization).
func groupForPath(path string) string {
switch {
case strings.HasPrefix(path, "/api/v1/public"):
return "public"
case strings.HasPrefix(path, "/api/v1/user"):
return "user"
case strings.HasPrefix(path, "/api/v1/admin"):
return "admin"
case strings.HasPrefix(path, "/api/v1/internal"):
return "internal"
default:
return "probe"
}
}
@@ -0,0 +1,29 @@
// Package handlers exposes shared helpers used by the per-domain HTTP
// handlers under `internal/server/handlers_*.go`. The only helper is
// NotImplemented, which serves the standard `501 not_implemented`
// envelope when a route is registered but the handler body is not
// wired (a defensive fallback for new endpoints in flight).
package handlers
import (
"net/http"
"galaxy/backend/internal/server/httperr"
"github.com/gin-gonic/gin"
)
// NotImplemented returns a gin handler that emits the standard
// `501 not_implemented` envelope. operationID names the OpenAPI operation the
// handler will eventually implement; it is interpolated into the human-readable
// message so that contract tests and operators can identify the endpoint.
func NotImplemented(operationID string) gin.HandlerFunc {
message := "endpoint is not implemented yet"
if operationID != "" {
message = "endpoint " + operationID + " is not implemented yet"
}
return func(c *gin.Context) {
httperr.Abort(c, http.StatusNotImplemented, httperr.CodeNotImplemented, message)
}
}
@@ -0,0 +1,220 @@
package server
import (
"context"
"errors"
"net/http"
"galaxy/backend/internal/admin"
"galaxy/backend/internal/server/handlers"
"galaxy/backend/internal/server/httperr"
"galaxy/backend/internal/telemetry"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// AdminAdminAccountsHandlers groups the admin-account CRUD handlers
// under `/api/v1/admin/admin-accounts/*`. The current implementation ships real
// implementations backed by `*admin.Service`; tests that supply a nil
// service fall back to the Stage-3 placeholder body so the contract
// test continues to validate the OpenAPI envelope without booting a
// database.
type AdminAdminAccountsHandlers struct {
svc *admin.Service
logger *zap.Logger
}
// NewAdminAdminAccountsHandlers constructs the handler set. svc may be
// nil — in that case every handler returns 501 not_implemented,
// matching the pre-Stage-5.3 placeholder. logger may also be nil;
// zap.NewNop is used in that case.
func NewAdminAdminAccountsHandlers(svc *admin.Service, logger *zap.Logger) *AdminAdminAccountsHandlers {
if logger == nil {
logger = zap.NewNop()
}
return &AdminAdminAccountsHandlers{svc: svc, logger: logger.Named("http.admin.admin-accounts")}
}
// List handles GET /api/v1/admin/admin-accounts.
func (h *AdminAdminAccountsHandlers) List() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("adminAdminAccountsList")
}
return func(c *gin.Context) {
ctx := c.Request.Context()
admins, err := h.svc.List(ctx)
if err != nil {
respondAdminAccountError(c, h.logger, "admin admin-accounts list", ctx, err)
return
}
c.JSON(http.StatusOK, adminAccountListToWire(admins))
}
}
// Create handles POST /api/v1/admin/admin-accounts.
func (h *AdminAdminAccountsHandlers) Create() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("adminAdminAccountsCreate")
}
return func(c *gin.Context) {
var req adminAccountCreateRequestWire
if err := c.ShouldBindJSON(&req); err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
return
}
ctx := c.Request.Context()
created, err := h.svc.Create(ctx, admin.CreateInput{
Username: req.Username,
Password: req.Password,
})
if err != nil {
respondAdminAccountError(c, h.logger, "admin admin-accounts create", ctx, err)
return
}
c.JSON(http.StatusCreated, adminAccountToWire(created))
}
}
// Get handles GET /api/v1/admin/admin-accounts/{username}.
func (h *AdminAdminAccountsHandlers) Get() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("adminAdminAccountsGet")
}
return func(c *gin.Context) {
ctx := c.Request.Context()
got, err := h.svc.Get(ctx, c.Param("username"))
if err != nil {
respondAdminAccountError(c, h.logger, "admin admin-accounts get", ctx, err)
return
}
c.JSON(http.StatusOK, adminAccountToWire(got))
}
}
// Disable handles POST /api/v1/admin/admin-accounts/{username}/disable.
func (h *AdminAdminAccountsHandlers) Disable() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("adminAdminAccountsDisable")
}
return func(c *gin.Context) {
ctx := c.Request.Context()
updated, err := h.svc.Disable(ctx, c.Param("username"))
if err != nil {
respondAdminAccountError(c, h.logger, "admin admin-accounts disable", ctx, err)
return
}
c.JSON(http.StatusOK, adminAccountToWire(updated))
}
}
// Enable handles POST /api/v1/admin/admin-accounts/{username}/enable.
func (h *AdminAdminAccountsHandlers) Enable() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("adminAdminAccountsEnable")
}
return func(c *gin.Context) {
ctx := c.Request.Context()
updated, err := h.svc.Enable(ctx, c.Param("username"))
if err != nil {
respondAdminAccountError(c, h.logger, "admin admin-accounts enable", ctx, err)
return
}
c.JSON(http.StatusOK, adminAccountToWire(updated))
}
}
// ResetPassword handles POST /api/v1/admin/admin-accounts/{username}/reset-password.
func (h *AdminAdminAccountsHandlers) ResetPassword() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("adminAdminAccountsResetPassword")
}
return func(c *gin.Context) {
var req adminAccountResetPasswordRequestWire
if err := c.ShouldBindJSON(&req); err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
return
}
ctx := c.Request.Context()
updated, err := h.svc.ResetPassword(ctx, c.Param("username"), req.Password)
if err != nil {
respondAdminAccountError(c, h.logger, "admin admin-accounts reset-password", ctx, err)
return
}
c.JSON(http.StatusOK, adminAccountToWire(updated))
}
}
// respondAdminAccountError maps admin-package sentinels to the standard
// JSON envelope. Unknown errors fall through to 500 with a structured
// log so operators can correlate.
func respondAdminAccountError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) {
switch {
case errors.Is(err, admin.ErrNotFound):
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "admin account not found")
case errors.Is(err, admin.ErrUsernameTaken):
httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, "username already in use")
case errors.Is(err, admin.ErrInvalidInput):
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error())
default:
logger.Error(op+" failed",
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
)
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "internal error")
}
}
// adminAccountToWire renders an admin.Admin into the OpenAPI
// `AdminAccount` schema declared at openapi.yaml:2596.
func adminAccountToWire(a admin.Admin) adminAccountWire {
out := adminAccountWire{
Username: a.Username,
CreatedAt: a.CreatedAt.UTC().Format(timestampLayout),
}
if a.LastUsedAt != nil {
t := a.LastUsedAt.UTC().Format(timestampLayout)
out.LastUsedAt = &t
}
if a.DisabledAt != nil {
t := a.DisabledAt.UTC().Format(timestampLayout)
out.DisabledAt = &t
}
return out
}
// adminAccountListToWire renders the admin slice into the OpenAPI
// `AdminAccountList` schema.
func adminAccountListToWire(admins []admin.Admin) adminAccountListWire {
out := adminAccountListWire{
Items: make([]adminAccountWire, 0, len(admins)),
}
for _, a := range admins {
out.Items = append(out.Items, adminAccountToWire(a))
}
return out
}
// adminAccountWire mirrors `AdminAccount`.
type adminAccountWire struct {
Username string `json:"username"`
CreatedAt string `json:"created_at"`
LastUsedAt *string `json:"last_used_at,omitempty"`
DisabledAt *string `json:"disabled_at,omitempty"`
}
// adminAccountListWire mirrors `AdminAccountList`.
type adminAccountListWire struct {
Items []adminAccountWire `json:"items"`
}
// adminAccountCreateRequestWire mirrors `AdminAccountCreateRequest`.
type adminAccountCreateRequestWire struct {
Username string `json:"username"`
Password string `json:"password"`
}
// adminAccountResetPasswordRequestWire mirrors
// `AdminAccountResetPasswordRequest`.
type adminAccountResetPasswordRequestWire struct {
Password string `json:"password"`
}
@@ -0,0 +1,174 @@
package server
import (
"context"
"errors"
"net/http"
"galaxy/backend/internal/runtime"
"galaxy/backend/internal/server/handlers"
"galaxy/backend/internal/server/httperr"
"galaxy/backend/internal/telemetry"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// AdminEngineVersionsHandlers groups the engine-version-registry
// handlers under `/api/v1/admin/engine-versions/*`. The implementation swaps
// the placeholder bodies for real `*runtime.EngineVersionService`
// calls; tests that omit the service fall back to the Stage-3 501
// envelope.
type AdminEngineVersionsHandlers struct {
svc *runtime.EngineVersionService
logger *zap.Logger
}
// NewAdminEngineVersionsHandlers constructs the handler set. svc may
// be nil — in that case every handler returns 501.
func NewAdminEngineVersionsHandlers(svc *runtime.EngineVersionService, logger *zap.Logger) *AdminEngineVersionsHandlers {
if logger == nil {
logger = zap.NewNop()
}
return &AdminEngineVersionsHandlers{svc: svc, logger: logger.Named("http.admin.engine-versions")}
}
// List handles GET /api/v1/admin/engine-versions.
func (h *AdminEngineVersionsHandlers) List() gin.HandlerFunc {
if h == nil || h.svc == nil {
return handlers.NotImplemented("adminEngineVersionsList")
}
return func(c *gin.Context) {
ctx := c.Request.Context()
items, err := h.svc.List(ctx)
if err != nil {
respondEngineVersionError(c, h.logger, "admin engine-versions list", ctx, err)
return
}
c.JSON(http.StatusOK, engineVersionListToWire(items))
}
}
// Create handles POST /api/v1/admin/engine-versions.
func (h *AdminEngineVersionsHandlers) Create() gin.HandlerFunc {
if h == nil || h.svc == nil {
return handlers.NotImplemented("adminEngineVersionsCreate")
}
return func(c *gin.Context) {
var req engineVersionCreateRequestWire
if err := c.ShouldBindJSON(&req); err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
return
}
ctx := c.Request.Context()
input := runtime.RegisterInput{Version: req.Version, ImageRef: req.ImageRef}
if req.Enabled != nil {
input.Enabled = req.Enabled
}
v, err := h.svc.Register(ctx, input)
if err != nil {
respondEngineVersionError(c, h.logger, "admin engine-versions create", ctx, err)
return
}
c.JSON(http.StatusCreated, engineVersionToWire(v))
}
}
// Update handles PATCH /api/v1/admin/engine-versions/{id}.
func (h *AdminEngineVersionsHandlers) Update() gin.HandlerFunc {
if h == nil || h.svc == nil {
return handlers.NotImplemented("adminEngineVersionsUpdate")
}
return func(c *gin.Context) {
var req engineVersionUpdateRequestWire
if err := c.ShouldBindJSON(&req); err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
return
}
ctx := c.Request.Context()
updated, err := h.svc.Update(ctx, c.Param("id"), runtime.UpdateInput{
ImageRef: req.ImageRef,
Enabled: req.Enabled,
})
if err != nil {
respondEngineVersionError(c, h.logger, "admin engine-versions update", ctx, err)
return
}
c.JSON(http.StatusOK, engineVersionToWire(updated))
}
}
// Disable handles POST /api/v1/admin/engine-versions/{id}/disable.
func (h *AdminEngineVersionsHandlers) Disable() gin.HandlerFunc {
if h == nil || h.svc == nil {
return handlers.NotImplemented("adminEngineVersionsDisable")
}
return func(c *gin.Context) {
ctx := c.Request.Context()
updated, err := h.svc.Disable(ctx, c.Param("id"))
if err != nil {
respondEngineVersionError(c, h.logger, "admin engine-versions disable", ctx, err)
return
}
c.JSON(http.StatusOK, engineVersionToWire(updated))
}
}
func respondEngineVersionError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) {
switch {
case errors.Is(err, runtime.ErrNotFound):
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "engine version not found")
case errors.Is(err, runtime.ErrEngineVersionTaken):
httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, "engine version already registered")
case errors.Is(err, runtime.ErrInvalidInput):
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error())
default:
logger.Error(op+" failed",
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
)
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "internal error")
}
}
func engineVersionToWire(v runtime.EngineVersion) engineVersionWire {
return engineVersionWire{
Version: v.Version,
ImageRef: v.ImageRef,
Enabled: v.Enabled,
CreatedAt: v.CreatedAt.UTC().Format(timestampLayout),
}
}
func engineVersionListToWire(items []runtime.EngineVersion) engineVersionListWire {
out := engineVersionListWire{Items: make([]engineVersionWire, 0, len(items))}
for _, v := range items {
out.Items = append(out.Items, engineVersionToWire(v))
}
return out
}
// engineVersionWire mirrors `EngineVersion` from openapi.yaml.
type engineVersionWire struct {
Version string `json:"version"`
ImageRef string `json:"image_ref"`
Enabled bool `json:"enabled"`
CreatedAt string `json:"created_at"`
}
// engineVersionListWire mirrors `EngineVersionList`.
type engineVersionListWire struct {
Items []engineVersionWire `json:"items"`
}
// engineVersionCreateRequestWire mirrors `EngineVersionCreateRequest`.
type engineVersionCreateRequestWire struct {
Version string `json:"version"`
ImageRef string `json:"image_ref"`
Enabled *bool `json:"enabled,omitempty"`
}
// engineVersionUpdateRequestWire mirrors `EngineVersionUpdateRequest`.
type engineVersionUpdateRequestWire struct {
ImageRef *string `json:"image_ref,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
@@ -0,0 +1,216 @@
package server
import (
"net/http"
"time"
"galaxy/backend/internal/lobby"
"galaxy/backend/internal/server/handlers"
"galaxy/backend/internal/server/httperr"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// AdminGamesHandlers groups the admin-side game-management handlers
// under `/api/v1/admin/games/*`. The current implementation ships real implementations
// backed by `*lobby.Service` and adds the `Create` handler used by the
// new POST /api/v1/admin/games endpoint for public-game creation.
type AdminGamesHandlers struct {
svc *lobby.Service
logger *zap.Logger
}
// NewAdminGamesHandlers constructs the handler set. svc may be nil —
// in that case every handler returns 501 not_implemented.
func NewAdminGamesHandlers(svc *lobby.Service, logger *zap.Logger) *AdminGamesHandlers {
if logger == nil {
logger = zap.NewNop()
}
return &AdminGamesHandlers{svc: svc, logger: logger.Named("http.admin.games")}
}
// List handles GET /api/v1/admin/games.
func (h *AdminGamesHandlers) List() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("adminGamesList")
}
return func(c *gin.Context) {
page := parsePositiveQueryInt(c.Query("page"), 1)
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
ctx := c.Request.Context()
result, err := h.svc.ListAdminGames(ctx, page, pageSize)
if err != nil {
respondLobbyError(c, h.logger, "admin games list", ctx, err)
return
}
out := adminGameListWire{
Items: make([]lobbyGameDetailWire, 0, len(result.Items)),
Page: result.Page,
PageSize: result.PageSize,
Total: result.Total,
}
for _, g := range result.Items {
out.Items = append(out.Items, lobbyGameDetailToWire(g))
}
c.JSON(http.StatusOK, out)
}
}
// Get handles GET /api/v1/admin/games/{game_id}.
func (h *AdminGamesHandlers) Get() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("adminGamesGet")
}
return func(c *gin.Context) {
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
ctx := c.Request.Context()
game, err := h.svc.GetGame(ctx, gameID)
if err != nil {
respondLobbyError(c, h.logger, "admin games get", ctx, err)
return
}
c.JSON(http.StatusOK, lobbyGameDetailToWire(game))
}
}
// Create handles POST /api/v1/admin/games — admin-only public-game
// creation. The body intentionally omits `visibility`; the handler
// hard-codes `visibility=public` and `owner_user_id=NULL`.
func (h *AdminGamesHandlers) Create() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("adminGamesCreate")
}
return func(c *gin.Context) {
var req adminGameCreateRequestWire
if err := c.ShouldBindJSON(&req); err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
return
}
enrollmentEndsAt, err := time.Parse(time.RFC3339Nano, req.EnrollmentEndsAt)
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "enrollment_ends_at must be RFC 3339")
return
}
ctx := c.Request.Context()
game, err := h.svc.CreateGame(ctx, lobby.CreateGameInput{
OwnerUserID: nil,
Visibility: lobby.VisibilityPublic,
GameName: req.GameName,
Description: req.Description,
MinPlayers: req.MinPlayers,
MaxPlayers: req.MaxPlayers,
StartGapHours: req.StartGapHours,
StartGapPlayers: req.StartGapPlayers,
EnrollmentEndsAt: enrollmentEndsAt,
TurnSchedule: req.TurnSchedule,
TargetEngineVersion: req.TargetEngineVersion,
})
if err != nil {
respondLobbyError(c, h.logger, "admin games create", ctx, err)
return
}
c.JSON(http.StatusCreated, lobbyGameDetailToWire(game))
}
}
// ForceStart handles POST /api/v1/admin/games/{game_id}/force-start.
func (h *AdminGamesHandlers) ForceStart() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("adminGamesForceStart")
}
return func(c *gin.Context) {
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
ctx := c.Request.Context()
updated, err := h.svc.AdminForceStart(ctx, gameID)
if err != nil {
respondLobbyError(c, h.logger, "admin games force-start", ctx, err)
return
}
c.JSON(http.StatusAccepted, lobbyGameStateChangeToWire(updated))
}
}
// ForceStop handles POST /api/v1/admin/games/{game_id}/force-stop.
func (h *AdminGamesHandlers) ForceStop() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("adminGamesForceStop")
}
return func(c *gin.Context) {
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
ctx := c.Request.Context()
updated, err := h.svc.AdminForceStop(ctx, gameID)
if err != nil {
respondLobbyError(c, h.logger, "admin games force-stop", ctx, err)
return
}
c.JSON(http.StatusOK, lobbyGameStateChangeToWire(updated))
}
}
// BanMember handles POST /api/v1/admin/games/{game_id}/ban-member.
func (h *AdminGamesHandlers) BanMember() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("adminGamesBanMember")
}
return func(c *gin.Context) {
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
var req adminGameBanMemberRequestWire
if err := c.ShouldBindJSON(&req); err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
return
}
userID, err := uuid.Parse(req.UserID)
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user_id must be a valid UUID")
return
}
ctx := c.Request.Context()
updated, err := h.svc.AdminBanMember(ctx, gameID, userID, req.Reason)
if err != nil {
respondLobbyError(c, h.logger, "admin games ban-member", ctx, err)
return
}
c.JSON(http.StatusOK, lobbyMembershipDetailToWire(updated))
}
}
// adminGameListWire mirrors `AdminGameList`.
type adminGameListWire struct {
Items []lobbyGameDetailWire `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int `json:"total"`
}
// adminGameCreateRequestWire mirrors `AdminGameCreateRequest`.
type adminGameCreateRequestWire struct {
GameName string `json:"game_name"`
Description string `json:"description"`
MinPlayers int32 `json:"min_players"`
MaxPlayers int32 `json:"max_players"`
StartGapHours int32 `json:"start_gap_hours"`
StartGapPlayers int32 `json:"start_gap_players"`
EnrollmentEndsAt string `json:"enrollment_ends_at"`
TurnSchedule string `json:"turn_schedule"`
TargetEngineVersion string `json:"target_engine_version"`
}
// adminGameBanMemberRequestWire mirrors `AdminGameBanMemberRequest`.
type adminGameBanMemberRequestWire struct {
UserID string `json:"user_id"`
Reason string `json:"reason"`
}
@@ -0,0 +1,98 @@
package server
import (
"context"
"net/http"
"galaxy/backend/internal/geo"
"galaxy/backend/internal/server/handlers"
"galaxy/backend/internal/server/httperr"
"galaxy/backend/internal/telemetry"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// AdminGeoLister is the narrow contract the admin geo handler needs
// from the geo domain. `*geo.Service` satisfies it directly; tests
// pass a recording fake.
type AdminGeoLister interface {
ListUserCounters(ctx context.Context, userID uuid.UUID) ([]geo.CountryCounter, error)
}
// AdminGeoHandlers groups the admin-side geo-counter handlers under
// `/api/v1/admin/geo/*`.
type AdminGeoHandlers struct {
svc AdminGeoLister
logger *zap.Logger
}
// NewAdminGeoHandlers constructs the handler set. svc may be nil — in
// that case every handler returns 501 not_implemented, matching the
// pre-Stage-5.8 placeholder behaviour. logger may also be nil; zap.NewNop
// is used in that case.
func NewAdminGeoHandlers(svc AdminGeoLister, logger *zap.Logger) *AdminGeoHandlers {
if logger == nil {
logger = zap.NewNop()
}
return &AdminGeoHandlers{svc: svc, logger: logger.Named("http.admin.geo")}
}
// adminGeoCountryWire mirrors `GeoCountryCounter` from `openapi.yaml`.
type adminGeoCountryWire struct {
Country string `json:"country"`
Count int64 `json:"count"`
LastSeenAt *string `json:"last_seen_at,omitempty"`
}
// adminGeoListWire mirrors `GeoCountryCounterList` from `openapi.yaml`.
type adminGeoListWire struct {
UserID string `json:"user_id"`
Items []adminGeoCountryWire `json:"items"`
}
// ListUserCountries handles GET /api/v1/admin/geo/users/{user_id}/countries.
func (h *AdminGeoHandlers) ListUserCountries() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("adminGeoListUserCountries")
}
return func(c *gin.Context) {
userID, ok := parseUserIDParam(c)
if !ok {
return
}
ctx := c.Request.Context()
entries, err := h.svc.ListUserCounters(ctx, userID)
if err != nil {
h.logger.Error("admin geo list user countries failed",
append(telemetry.TraceFieldsFromContext(ctx),
zap.String("user_id", userID.String()),
zap.Error(err),
)...,
)
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error")
return
}
c.JSON(http.StatusOK, geoCountersToWire(userID, entries))
}
}
func geoCountersToWire(userID uuid.UUID, entries []geo.CountryCounter) adminGeoListWire {
out := adminGeoListWire{
UserID: userID.String(),
Items: make([]adminGeoCountryWire, 0, len(entries)),
}
for _, e := range entries {
item := adminGeoCountryWire{
Country: e.Country,
Count: e.Count,
}
if e.LastSeenAt != nil {
formatted := e.LastSeenAt.UTC().Format("2006-01-02T15:04:05.000Z07:00")
item.LastSeenAt = &formatted
}
out.Items = append(out.Items, item)
}
return out
}
@@ -0,0 +1,150 @@
package server
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"galaxy/backend/internal/geo"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap/zaptest"
)
type fakeAdminGeoLister struct {
entries []geo.CountryCounter
err error
}
func (f *fakeAdminGeoLister) ListUserCounters(_ context.Context, _ uuid.UUID) ([]geo.CountryCounter, error) {
if f.err != nil {
return nil, f.err
}
return f.entries, nil
}
func newAdminGeoEngine(t *testing.T, fake AdminGeoLister) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
h := NewAdminGeoHandlers(fake, zaptest.NewLogger(t))
r.GET("/api/v1/admin/geo/users/:user_id/countries", h.ListUserCountries())
return r
}
func TestAdminGeoListUserCountriesSuccess(t *testing.T) {
t.Parallel()
now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
fake := &fakeAdminGeoLister{entries: []geo.CountryCounter{
{Country: "AU", Count: 3, LastSeenAt: &now},
{Country: "DE", Count: 1, LastSeenAt: nil},
}}
r := newAdminGeoEngine(t, fake)
userID := uuid.New()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/geo/users/"+userID.String()+"/countries", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: want 200, got %d (%s)", rec.Code, rec.Body.String())
}
var body adminGeoListWire
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if body.UserID != userID.String() {
t.Errorf("user_id: want %s, got %s", userID, body.UserID)
}
if len(body.Items) != 2 {
t.Fatalf("items: want 2, got %d (%+v)", len(body.Items), body.Items)
}
if body.Items[0].Country != "AU" || body.Items[0].Count != 3 || body.Items[0].LastSeenAt == nil {
t.Errorf("items[0] mismatch: %+v", body.Items[0])
}
if body.Items[1].Country != "DE" || body.Items[1].Count != 1 || body.Items[1].LastSeenAt != nil {
t.Errorf("items[1] mismatch: %+v", body.Items[1])
}
}
func TestAdminGeoListUserCountriesEmpty(t *testing.T) {
t.Parallel()
r := newAdminGeoEngine(t, &fakeAdminGeoLister{})
userID := uuid.New()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/geo/users/"+userID.String()+"/countries", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: want 200, got %d", rec.Code)
}
var body adminGeoListWire
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if body.Items == nil {
t.Fatal("items: want non-nil empty slice, got nil")
}
if len(body.Items) != 0 {
t.Fatalf("items: want empty, got %+v", body.Items)
}
}
func TestAdminGeoListUserCountriesInvalidUserID(t *testing.T) {
t.Parallel()
r := newAdminGeoEngine(t, &fakeAdminGeoLister{})
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/geo/users/not-a-uuid/countries", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status: want 400, got %d", rec.Code)
}
}
func TestAdminGeoListUserCountriesStoreError(t *testing.T) {
t.Parallel()
r := newAdminGeoEngine(t, &fakeAdminGeoLister{err: errors.New("boom")})
userID := uuid.New()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/geo/users/"+userID.String()+"/countries", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status: want 500, got %d", rec.Code)
}
}
func TestAdminGeoListUserCountriesNilServiceReturns501(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
r := gin.New()
h := NewAdminGeoHandlers(nil, zaptest.NewLogger(t))
r.GET("/api/v1/admin/geo/users/:user_id/countries", h.ListUserCountries())
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/geo/users/"+uuid.New().String()+"/countries", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusNotImplemented {
t.Fatalf("status: want 501, got %d", rec.Code)
}
}
@@ -0,0 +1,285 @@
package server
import (
"context"
"errors"
"net/http"
"time"
"galaxy/backend/internal/mail"
"galaxy/backend/internal/server/handlers"
"galaxy/backend/internal/server/httperr"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// AdminMailHandlers groups the admin-side mail-outbox handlers under
// `/api/v1/admin/mail/*`. The wiring connects real bodies backed by
// `*mail.Service`; tests that supply a nil service fall back to the
// Stage-3 placeholder body so the contract test continues to validate
// the OpenAPI envelope without booting Postgres.
type AdminMailHandlers struct {
svc *mail.Service
logger *zap.Logger
}
// NewAdminMailHandlers constructs the handler set. svc may be nil — in
// that case every handler returns 501 not_implemented, matching the
// pre-Stage-5.6 placeholder. logger may also be nil; zap.NewNop is
// used in that case.
func NewAdminMailHandlers(svc *mail.Service, logger *zap.Logger) *AdminMailHandlers {
if logger == nil {
logger = zap.NewNop()
}
return &AdminMailHandlers{svc: svc, logger: logger.Named("http.admin.mail")}
}
// ListDeliveries handles GET /api/v1/admin/mail/deliveries.
func (h *AdminMailHandlers) ListDeliveries() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("adminMailListDeliveries")
}
return func(c *gin.Context) {
page := parsePositiveQueryInt(c.Query("page"), 1)
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
ctx := c.Request.Context()
out, err := h.svc.AdminListDeliveries(ctx, page, pageSize)
if err != nil {
respondMailError(c, h.logger, "admin mail list deliveries", ctx, err)
return
}
c.JSON(http.StatusOK, mailDeliveryListToWire(out))
}
}
// GetDelivery handles GET /api/v1/admin/mail/deliveries/{delivery_id}.
func (h *AdminMailHandlers) GetDelivery() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("adminMailGetDelivery")
}
return func(c *gin.Context) {
deliveryID, ok := parseDeliveryIDParam(c)
if !ok {
return
}
ctx := c.Request.Context()
d, err := h.svc.AdminGetDelivery(ctx, deliveryID)
if err != nil {
respondMailError(c, h.logger, "admin mail get delivery", ctx, err)
return
}
c.JSON(http.StatusOK, mailDeliveryToWire(d))
}
}
// ListDeliveryAttempts handles GET /api/v1/admin/mail/deliveries/{delivery_id}/attempts.
func (h *AdminMailHandlers) ListDeliveryAttempts() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("adminMailListDeliveryAttempts")
}
return func(c *gin.Context) {
deliveryID, ok := parseDeliveryIDParam(c)
if !ok {
return
}
ctx := c.Request.Context()
attempts, err := h.svc.AdminListAttempts(ctx, deliveryID)
if err != nil {
respondMailError(c, h.logger, "admin mail list attempts", ctx, err)
return
}
c.JSON(http.StatusOK, mailAttemptListToWire(attempts))
}
}
// ResendDelivery handles POST /api/v1/admin/mail/deliveries/{delivery_id}/resend.
func (h *AdminMailHandlers) ResendDelivery() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("adminMailResendDelivery")
}
return func(c *gin.Context) {
deliveryID, ok := parseDeliveryIDParam(c)
if !ok {
return
}
ctx := c.Request.Context()
d, err := h.svc.AdminResendDelivery(ctx, deliveryID)
if err != nil {
respondMailError(c, h.logger, "admin mail resend delivery", ctx, err)
return
}
c.JSON(http.StatusAccepted, mailDeliveryToWire(d))
}
}
// ListDeadLetters handles GET /api/v1/admin/mail/dead-letters.
func (h *AdminMailHandlers) ListDeadLetters() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("adminMailListDeadLetters")
}
return func(c *gin.Context) {
page := parsePositiveQueryInt(c.Query("page"), 1)
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
ctx := c.Request.Context()
out, err := h.svc.AdminListDeadLetters(ctx, page, pageSize)
if err != nil {
respondMailError(c, h.logger, "admin mail list dead-letters", ctx, err)
return
}
c.JSON(http.StatusOK, mailDeadLetterListToWire(out))
}
}
// parseDeliveryIDParam reads `delivery_id` from the path. On invalid
// input it writes the standard 400 envelope and returns
// (uuid.Nil, false).
func parseDeliveryIDParam(c *gin.Context) (uuid.UUID, bool) {
parsed, err := uuid.Parse(c.Param("delivery_id"))
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "delivery_id must be a valid UUID")
return uuid.Nil, false
}
return parsed, true
}
// respondMailError translates the mail-domain sentinels to HTTP. Any
// other error is logged and surfaced as 500 internal_error so the
// handler always emits the documented envelope.
func respondMailError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) {
switch {
case errors.Is(err, mail.ErrDeliveryNotFound):
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "mail delivery not found")
case errors.Is(err, mail.ErrResendOnSent):
httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, "delivery already sent")
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
httperr.Abort(c, http.StatusServiceUnavailable, httperr.CodeServiceUnavailable, "request cancelled")
default:
logger.Error(op+" failed", zap.Error(err))
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "internal server error")
}
_ = ctx
}
// Wire DTOs mirror the schemas in `backend/openapi.yaml`.
type mailDeliveryWire struct {
DeliveryID string `json:"delivery_id"`
TemplateID string `json:"template_id"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
Status string `json:"status"`
Attempts int32 `json:"attempts"`
NextAttemptAt *string `json:"next_attempt_at,omitempty"`
CreatedAt string `json:"created_at"`
}
type mailDeliveryListWire struct {
Items []mailDeliveryWire `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
}
type mailAttemptWire struct {
AttemptID string `json:"attempt_id"`
DeliveryID string `json:"delivery_id"`
AttemptNo int32 `json:"attempt_no"`
StartedAt string `json:"started_at"`
FinishedAt *string `json:"finished_at,omitempty"`
Outcome string `json:"outcome,omitempty"`
Error string `json:"error,omitempty"`
}
type mailAttemptListWire struct {
Items []mailAttemptWire `json:"items"`
}
type mailDeadLetterWire struct {
DeadLetterID string `json:"dead_letter_id"`
DeliveryID string `json:"delivery_id"`
ArchivedAt string `json:"archived_at"`
Reason string `json:"reason,omitempty"`
}
type mailDeadLetterListWire struct {
Items []mailDeadLetterWire `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
}
func mailDeliveryToWire(d mail.Delivery) mailDeliveryWire {
out := mailDeliveryWire{
DeliveryID: d.DeliveryID.String(),
TemplateID: d.TemplateID,
IdempotencyKey: d.IdempotencyKey,
Status: d.Status,
Attempts: d.Attempts,
CreatedAt: d.CreatedAt.UTC().Format(time.RFC3339Nano),
}
if d.NextAttemptAt != nil {
s := d.NextAttemptAt.UTC().Format(time.RFC3339Nano)
out.NextAttemptAt = &s
}
return out
}
func mailDeliveryListToWire(p mail.AdminListDeliveriesPage) mailDeliveryListWire {
items := make([]mailDeliveryWire, 0, len(p.Items))
for _, d := range p.Items {
items = append(items, mailDeliveryToWire(d))
}
return mailDeliveryListWire{
Items: items,
Page: p.Page,
PageSize: p.PageSize,
Total: p.Total,
}
}
func mailAttemptToWire(a mail.Attempt) mailAttemptWire {
out := mailAttemptWire{
AttemptID: a.AttemptID.String(),
DeliveryID: a.DeliveryID.String(),
AttemptNo: a.AttemptNo,
StartedAt: a.StartedAt.UTC().Format(time.RFC3339Nano),
Outcome: a.Outcome,
Error: a.Error,
}
if a.FinishedAt != nil {
s := a.FinishedAt.UTC().Format(time.RFC3339Nano)
out.FinishedAt = &s
}
return out
}
func mailAttemptListToWire(items []mail.Attempt) mailAttemptListWire {
out := mailAttemptListWire{Items: make([]mailAttemptWire, 0, len(items))}
for _, a := range items {
out.Items = append(out.Items, mailAttemptToWire(a))
}
return out
}
func mailDeadLetterToWire(dl mail.DeadLetter) mailDeadLetterWire {
return mailDeadLetterWire{
DeadLetterID: dl.DeadLetterID.String(),
DeliveryID: dl.DeliveryID.String(),
ArchivedAt: dl.ArchivedAt.UTC().Format(time.RFC3339Nano),
Reason: dl.Reason,
}
}
func mailDeadLetterListToWire(p mail.AdminListDeadLettersPage) mailDeadLetterListWire {
items := make([]mailDeadLetterWire, 0, len(p.Items))
for _, dl := range p.Items {
items = append(items, mailDeadLetterToWire(dl))
}
return mailDeadLetterListWire{
Items: items,
Page: p.Page,
PageSize: p.PageSize,
Total: p.Total,
}
}
@@ -0,0 +1,255 @@
package server
import (
"context"
"errors"
"net/http"
"time"
"galaxy/backend/internal/notification"
"galaxy/backend/internal/server/handlers"
"galaxy/backend/internal/server/httperr"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// AdminNotificationsHandlers groups the admin-side notification handlers
// under `/api/v1/admin/notifications/*`. The wiring connects real bodies
// backed by `*notification.Service`; tests that supply a nil service
// fall back to the Stage-3 placeholder body so the contract test
// continues to validate the OpenAPI envelope without booting Postgres.
type AdminNotificationsHandlers struct {
svc *notification.Service
logger *zap.Logger
}
// NewAdminNotificationsHandlers constructs the handler set. svc may be
// nil — in that case every handler returns 501 not_implemented,
// matching the pre-Stage-5.7 placeholder. logger may also be nil;
// zap.NewNop is used in that case.
func NewAdminNotificationsHandlers(svc *notification.Service, logger *zap.Logger) *AdminNotificationsHandlers {
if logger == nil {
logger = zap.NewNop()
}
return &AdminNotificationsHandlers{svc: svc, logger: logger.Named("http.admin.notifications")}
}
// List handles GET /api/v1/admin/notifications.
func (h *AdminNotificationsHandlers) List() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("adminNotificationsList")
}
return func(c *gin.Context) {
page := parsePositiveQueryInt(c.Query("page"), 1)
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
ctx := c.Request.Context()
out, err := h.svc.AdminListNotifications(ctx, page, pageSize)
if err != nil {
respondNotificationError(c, h.logger, "admin notifications list", ctx, err)
return
}
c.JSON(http.StatusOK, notificationListToWire(out))
}
}
// Get handles GET /api/v1/admin/notifications/{notification_id}.
func (h *AdminNotificationsHandlers) Get() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("adminNotificationsGet")
}
return func(c *gin.Context) {
id, ok := parseNotificationIDParam(c)
if !ok {
return
}
ctx := c.Request.Context()
n, err := h.svc.AdminGetNotification(ctx, id)
if err != nil {
respondNotificationError(c, h.logger, "admin notifications get", ctx, err)
return
}
c.JSON(http.StatusOK, notificationToWire(n))
}
}
// ListDeadLetters handles GET /api/v1/admin/notifications/dead-letters.
func (h *AdminNotificationsHandlers) ListDeadLetters() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("adminNotificationsListDeadLetters")
}
return func(c *gin.Context) {
page := parsePositiveQueryInt(c.Query("page"), 1)
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
ctx := c.Request.Context()
out, err := h.svc.AdminListDeadLetters(ctx, page, pageSize)
if err != nil {
respondNotificationError(c, h.logger, "admin notifications list dead-letters", ctx, err)
return
}
c.JSON(http.StatusOK, notificationDeadLetterListToWire(out))
}
}
// ListMalformed handles GET /api/v1/admin/notifications/malformed.
func (h *AdminNotificationsHandlers) ListMalformed() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("adminNotificationsListMalformed")
}
return func(c *gin.Context) {
page := parsePositiveQueryInt(c.Query("page"), 1)
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
ctx := c.Request.Context()
out, err := h.svc.AdminListMalformed(ctx, page, pageSize)
if err != nil {
respondNotificationError(c, h.logger, "admin notifications list malformed", ctx, err)
return
}
c.JSON(http.StatusOK, notificationMalformedListToWire(out))
}
}
// parseNotificationIDParam reads `notification_id` from the path. On
// invalid input it writes the standard 400 envelope and returns
// (uuid.Nil, false).
func parseNotificationIDParam(c *gin.Context) (uuid.UUID, bool) {
parsed, err := uuid.Parse(c.Param("notification_id"))
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "notification_id must be a valid UUID")
return uuid.Nil, false
}
return parsed, true
}
// respondNotificationError translates the notification-domain sentinels
// to HTTP. Any other error is logged and surfaced as 500 internal_error.
func respondNotificationError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) {
switch {
case errors.Is(err, notification.ErrNotificationNotFound):
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "notification not found")
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
httperr.Abort(c, http.StatusServiceUnavailable, httperr.CodeServiceUnavailable, "request cancelled")
default:
logger.Error(op+" failed", zap.Error(err))
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "internal server error")
}
_ = ctx
}
// Wire DTOs mirror the OpenAPI schemas in `backend/openapi.yaml`.
type notificationWire struct {
NotificationID string `json:"notification_id"`
Kind string `json:"kind"`
IdempotencyKey string `json:"idempotency_key"`
UserID string `json:"user_id,omitempty"`
Payload map[string]any `json:"payload,omitempty"`
CreatedAt string `json:"created_at"`
}
type notificationListWire struct {
Items []notificationWire `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
}
type notificationDeadLetterWire struct {
DeadLetterID string `json:"dead_letter_id"`
NotificationID string `json:"notification_id"`
ArchivedAt string `json:"archived_at"`
Reason string `json:"reason,omitempty"`
}
type notificationDeadLetterListWire struct {
Items []notificationDeadLetterWire `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
}
type notificationMalformedWire struct {
ID string `json:"id"`
ReceivedAt string `json:"received_at"`
Payload map[string]any `json:"payload,omitempty"`
Reason string `json:"reason,omitempty"`
}
type notificationMalformedListWire struct {
Items []notificationMalformedWire `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
}
func notificationToWire(n notification.Notification) notificationWire {
out := notificationWire{
NotificationID: n.NotificationID.String(),
Kind: n.Kind,
IdempotencyKey: n.IdempotencyKey,
Payload: n.Payload,
CreatedAt: n.CreatedAt.UTC().Format(time.RFC3339Nano),
}
if n.UserID != nil {
out.UserID = n.UserID.String()
}
return out
}
func notificationListToWire(p notification.AdminListNotificationsPage) notificationListWire {
items := make([]notificationWire, 0, len(p.Items))
for _, n := range p.Items {
items = append(items, notificationToWire(n))
}
return notificationListWire{
Items: items,
Page: p.Page,
PageSize: p.PageSize,
Total: p.Total,
}
}
func notificationDeadLetterToWire(dl notification.DeadLetter) notificationDeadLetterWire {
return notificationDeadLetterWire{
DeadLetterID: dl.DeadLetterID.String(),
NotificationID: dl.NotificationID.String(),
ArchivedAt: dl.ArchivedAt.UTC().Format(time.RFC3339Nano),
Reason: dl.Reason,
}
}
func notificationDeadLetterListToWire(p notification.AdminListDeadLettersPage) notificationDeadLetterListWire {
items := make([]notificationDeadLetterWire, 0, len(p.Items))
for _, dl := range p.Items {
items = append(items, notificationDeadLetterToWire(dl))
}
return notificationDeadLetterListWire{
Items: items,
Page: p.Page,
PageSize: p.PageSize,
Total: p.Total,
}
}
func notificationMalformedToWire(m notification.MalformedIntent) notificationMalformedWire {
return notificationMalformedWire{
ID: m.ID.String(),
ReceivedAt: m.ReceivedAt.UTC().Format(time.RFC3339Nano),
Payload: m.Payload,
Reason: m.Reason,
}
}
func notificationMalformedListToWire(p notification.AdminListMalformedPage) notificationMalformedListWire {
items := make([]notificationMalformedWire, 0, len(p.Items))
for _, m := range p.Items {
items = append(items, notificationMalformedToWire(m))
}
return notificationMalformedListWire{
Items: items,
Page: p.Page,
PageSize: p.PageSize,
Total: p.Total,
}
}
@@ -0,0 +1,202 @@
package server
import (
"context"
"errors"
"net/http"
"galaxy/backend/internal/runtime"
"galaxy/backend/internal/server/handlers"
"galaxy/backend/internal/server/httperr"
"galaxy/backend/internal/telemetry"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// AdminRuntimesHandlers groups the admin-side runtime handlers under
// `/api/v1/admin/runtimes/*`. The implementation swaps the placeholder bodies
// for real `*runtime.Service` calls; tests that omit the service fall
// back to 501.
type AdminRuntimesHandlers struct {
svc *runtime.Service
logger *zap.Logger
}
// NewAdminRuntimesHandlers constructs the handler set. svc may be
// nil — placeholders are returned in that case.
func NewAdminRuntimesHandlers(svc *runtime.Service, logger *zap.Logger) *AdminRuntimesHandlers {
if logger == nil {
logger = zap.NewNop()
}
return &AdminRuntimesHandlers{svc: svc, logger: logger.Named("http.admin.runtimes")}
}
// Get handles GET /api/v1/admin/runtimes/{game_id}.
func (h *AdminRuntimesHandlers) Get() gin.HandlerFunc {
if h == nil || h.svc == nil {
return handlers.NotImplemented("adminRuntimesGet")
}
return func(c *gin.Context) {
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
ctx := c.Request.Context()
rec, err := h.svc.GetRuntime(ctx, gameID)
if err != nil {
respondRuntimeError(c, h.logger, "admin runtimes get", ctx, err)
return
}
c.JSON(http.StatusOK, runtimeRecordToWire(rec))
}
}
// Restart handles POST /api/v1/admin/runtimes/{game_id}/restart.
func (h *AdminRuntimesHandlers) Restart() gin.HandlerFunc {
if h == nil || h.svc == nil {
return handlers.NotImplemented("adminRuntimesRestart")
}
return func(c *gin.Context) {
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
ctx := c.Request.Context()
op, err := h.svc.AdminRestart(ctx, gameID)
if err != nil {
respondRuntimeError(c, h.logger, "admin runtimes restart", ctx, err)
return
}
c.JSON(http.StatusAccepted, runtimeOperationToWire(op))
}
}
// Patch handles POST /api/v1/admin/runtimes/{game_id}/patch.
func (h *AdminRuntimesHandlers) Patch() gin.HandlerFunc {
if h == nil || h.svc == nil {
return handlers.NotImplemented("adminRuntimesPatch")
}
return func(c *gin.Context) {
var req runtimePatchRequestWire
if err := c.ShouldBindJSON(&req); err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
return
}
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
ctx := c.Request.Context()
op, err := h.svc.AdminPatch(ctx, gameID, req.TargetVersion)
if err != nil {
respondRuntimeError(c, h.logger, "admin runtimes patch", ctx, err)
return
}
c.JSON(http.StatusAccepted, runtimeOperationToWire(op))
}
}
// ForceNextTurn handles POST /api/v1/admin/runtimes/{game_id}/force-next-turn.
func (h *AdminRuntimesHandlers) ForceNextTurn() gin.HandlerFunc {
if h == nil || h.svc == nil {
return handlers.NotImplemented("adminRuntimesForceNextTurn")
}
return func(c *gin.Context) {
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
ctx := c.Request.Context()
op, err := h.svc.AdminForceNextTurn(ctx, gameID)
if err != nil {
respondRuntimeError(c, h.logger, "admin runtimes force-next-turn", ctx, err)
return
}
c.JSON(http.StatusOK, runtimeOperationToWire(op))
}
}
func respondRuntimeError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) {
switch {
case errors.Is(err, runtime.ErrNotFound):
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "runtime record not found")
case errors.Is(err, runtime.ErrInvalidInput),
errors.Is(err, runtime.ErrPatchSemverIncompatible):
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error())
case errors.Is(err, runtime.ErrConflict),
errors.Is(err, runtime.ErrEngineVersionDisabled):
httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, err.Error())
case errors.Is(err, runtime.ErrJobQueueFull):
httperr.Abort(c, http.StatusServiceUnavailable, httperr.CodeServiceUnavailable, "runtime worker queue full, retry later")
default:
logger.Error(op+" failed",
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
)
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "internal error")
}
}
// runtimeRecordWire mirrors `RuntimeRecord` from openapi.yaml. The
// schema declares `additionalProperties: true`, so we serialise the
// minimal documented shape.
type runtimeRecordWire struct {
GameID string `json:"game_id"`
Status string `json:"status"`
CurrentContainerID string `json:"current_container_id,omitempty"`
ImageRef string `json:"image_ref,omitempty"`
StartedAt *string `json:"started_at,omitempty"`
LastObservedAt *string `json:"last_observed_at,omitempty"`
}
func runtimeRecordToWire(r runtime.RuntimeRecord) runtimeRecordWire {
out := runtimeRecordWire{
GameID: r.GameID.String(),
Status: r.Status,
CurrentContainerID: r.CurrentContainerID,
ImageRef: r.CurrentImageRef,
}
if r.StartedAt != nil {
s := r.StartedAt.UTC().Format(timestampLayout)
out.StartedAt = &s
}
if r.LastObservedAt != nil {
s := r.LastObservedAt.UTC().Format(timestampLayout)
out.LastObservedAt = &s
}
return out
}
// runtimeOperationWire mirrors `RuntimeOperation` from openapi.yaml.
type runtimeOperationWire struct {
OperationID string `json:"operation_id"`
GameID string `json:"game_id"`
Op string `json:"op"`
Status string `json:"status"`
StartedAt string `json:"started_at"`
FinishedAt *string `json:"finished_at,omitempty"`
Error string `json:"error,omitempty"`
}
func runtimeOperationToWire(op runtime.OperationLog) runtimeOperationWire {
out := runtimeOperationWire{
OperationID: op.OperationID.String(),
GameID: op.GameID.String(),
Op: op.Op,
Status: op.Status,
StartedAt: op.StartedAt.UTC().Format(timestampLayout),
}
if op.FinishedAt != nil {
s := op.FinishedAt.UTC().Format(timestampLayout)
out.FinishedAt = &s
}
if op.ErrorMessage != "" {
out.Error = op.ErrorMessage
}
return out
}
// runtimePatchRequestWire mirrors `RuntimePatchRequest`.
type runtimePatchRequestWire struct {
TargetVersion string `json:"target_version"`
}
@@ -0,0 +1,313 @@
package server
import (
"errors"
"net/http"
"strconv"
"galaxy/backend/internal/server/handlers"
"galaxy/backend/internal/server/httperr"
"galaxy/backend/internal/server/middleware/basicauth"
"galaxy/backend/internal/user"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// AdminUsersHandlers groups the admin-side user-management handlers
// under `/api/v1/admin/users/*`. The current implementation ships real implementations
// backed by `*user.Service`; tests that supply a nil service fall back
// to the Stage-3 placeholder body so the contract test continues to
// validate the OpenAPI envelope without booting a database.
type AdminUsersHandlers struct {
svc *user.Service
logger *zap.Logger
}
// NewAdminUsersHandlers constructs the handler set. svc may be nil — in
// that case every handler returns 501 not_implemented, matching the
// pre-Stage-5.2 placeholder. logger may also be nil; zap.NewNop is used
// in that case.
func NewAdminUsersHandlers(svc *user.Service, logger *zap.Logger) *AdminUsersHandlers {
if logger == nil {
logger = zap.NewNop()
}
return &AdminUsersHandlers{svc: svc, logger: logger.Named("http.admin.users")}
}
// List handles GET /api/v1/admin/users.
func (h *AdminUsersHandlers) List() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("adminUsersList")
}
return func(c *gin.Context) {
page := parsePositiveQueryInt(c.Query("page"), 1)
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
ctx := c.Request.Context()
result, err := h.svc.ListAccounts(ctx, page, pageSize)
if err != nil {
respondAccountError(c, h.logger, "admin users list", ctx, err)
return
}
c.JSON(http.StatusOK, accountListToWire(result))
}
}
// Get handles GET /api/v1/admin/users/{user_id}.
func (h *AdminUsersHandlers) Get() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("adminUsersGet")
}
return func(c *gin.Context) {
userID, ok := parseUserIDParam(c)
if !ok {
return
}
ctx := c.Request.Context()
account, err := h.svc.GetAccount(ctx, userID)
if err != nil {
respondAccountError(c, h.logger, "admin users get", ctx, err)
return
}
c.JSON(http.StatusOK, accountResponseToWire(account))
}
}
// AddSanction handles POST /api/v1/admin/users/{user_id}/sanctions.
func (h *AdminUsersHandlers) AddSanction() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("adminUsersAddSanction")
}
return func(c *gin.Context) {
userID, ok := parseUserIDParam(c)
if !ok {
return
}
var req adminUserSanctionRequestWire
if err := c.ShouldBindJSON(&req); err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
return
}
expiresAt, err := parseTimePtr(req.ExpiresAt)
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "expires_at must be RFC 3339")
return
}
ctx := c.Request.Context()
account, err := h.svc.ApplySanction(ctx, user.ApplySanctionInput{
UserID: userID,
SanctionCode: req.SanctionCode,
Scope: req.Scope,
ReasonCode: req.ReasonCode,
Actor: wireToActorRef(req.Actor, c),
ExpiresAt: expiresAt,
})
if err != nil {
respondAccountError(c, h.logger, "admin users add sanction", ctx, err)
return
}
c.JSON(http.StatusOK, accountResponseToWire(account))
}
}
// AddLimit handles POST /api/v1/admin/users/{user_id}/limits.
func (h *AdminUsersHandlers) AddLimit() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("adminUsersAddLimit")
}
return func(c *gin.Context) {
userID, ok := parseUserIDParam(c)
if !ok {
return
}
var req adminUserLimitRequestWire
if err := c.ShouldBindJSON(&req); err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
return
}
expiresAt, err := parseTimePtr(req.ExpiresAt)
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "expires_at must be RFC 3339")
return
}
ctx := c.Request.Context()
account, err := h.svc.ApplyLimit(ctx, user.ApplyLimitInput{
UserID: userID,
LimitCode: req.LimitCode,
Value: req.Value,
ReasonCode: req.ReasonCode,
Actor: wireToActorRef(req.Actor, c),
ExpiresAt: expiresAt,
})
if err != nil {
respondAccountError(c, h.logger, "admin users add limit", ctx, err)
return
}
c.JSON(http.StatusOK, accountResponseToWire(account))
}
}
// AddEntitlement handles POST /api/v1/admin/users/{user_id}/entitlements.
func (h *AdminUsersHandlers) AddEntitlement() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("adminUsersAddEntitlement")
}
return func(c *gin.Context) {
userID, ok := parseUserIDParam(c)
if !ok {
return
}
var req adminUserEntitlementRequestWire
if err := c.ShouldBindJSON(&req); err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
return
}
startsAt, err := parseTimePtr(req.StartsAt)
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "starts_at must be RFC 3339")
return
}
endsAt, err := parseTimePtr(req.EndsAt)
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "ends_at must be RFC 3339")
return
}
ctx := c.Request.Context()
account, err := h.svc.ApplyEntitlement(ctx, user.ApplyEntitlementInput{
UserID: userID,
Tier: req.Tier,
Source: req.Source,
Actor: wireToActorRef(req.Actor, c),
ReasonCode: req.ReasonCode,
StartsAt: startsAt,
EndsAt: endsAt,
})
if err != nil {
respondAccountError(c, h.logger, "admin users add entitlement", ctx, err)
return
}
c.JSON(http.StatusOK, accountResponseToWire(account))
}
}
// SoftDelete handles POST /api/v1/admin/users/{user_id}/soft-delete.
func (h *AdminUsersHandlers) SoftDelete() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("adminUsersSoftDelete")
}
return func(c *gin.Context) {
userID, ok := parseUserIDParam(c)
if !ok {
return
}
ctx := c.Request.Context()
username, _ := basicauth.UsernameFromContext(ctx)
actor := user.ActorRef{Type: "admin", ID: username}
if err := h.svc.SoftDelete(ctx, userID, actor); err != nil {
if errors.Is(err, user.ErrAccountNotFound) {
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "account not found")
return
}
// Cascade errors do not mask the canonical state — the
// account is soft-deleted in Postgres. Surface 204 with
// the error logged so caller UI proceeds.
h.logger.Warn("admin users soft-delete cascade returned error", zap.Error(err))
}
c.Status(http.StatusNoContent)
}
}
// parseUserIDParam reads `user_id` from the path. On invalid input it
// writes the standard 400 envelope and returns (uuid.Nil, false).
func parseUserIDParam(c *gin.Context) (uuid.UUID, bool) {
parsed, 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 uuid.Nil, false
}
return parsed, true
}
// parsePositiveQueryInt parses a non-negative integer query parameter.
// Empty / non-numeric values fall back to fallback.
func parsePositiveQueryInt(raw string, fallback int) int {
if raw == "" {
return fallback
}
parsed, err := strconv.Atoi(raw)
if err != nil || parsed <= 0 {
return fallback
}
return parsed
}
// wireToActorRef converts the wire-level ActorRef into the user-domain
// type. The basic-auth context plumbing supplies a fallback id when the
// client omits one, so admin actions always carry the operator
// identity.
func wireToActorRef(actor *actorRefWire, c *gin.Context) user.ActorRef {
if actor == nil {
username, _ := basicauth.UsernameFromContext(c.Request.Context())
return user.ActorRef{Type: "admin", ID: username}
}
out := user.ActorRef{Type: actor.Type, ID: actor.ID}
if out.ID == "" {
if username, ok := basicauth.UsernameFromContext(c.Request.Context()); ok {
out.ID = username
}
}
return out
}
// accountListToWire renders the AccountPage into the AdminUserList
// schema declared in openapi.yaml.
func accountListToWire(page user.AccountPage) adminUserListWire {
out := adminUserListWire{
Items: make([]accountWire, 0, len(page.Items)),
Page: page.Page,
PageSize: page.PageSize,
Total: page.Total,
}
for _, a := range page.Items {
out.Items = append(out.Items, accountToWire(a))
}
return out
}
// adminUserSanctionRequestWire mirrors `AdminUserSanctionRequest`.
type adminUserSanctionRequestWire struct {
SanctionCode string `json:"sanction_code"`
Scope string `json:"scope"`
ReasonCode string `json:"reason_code"`
Actor *actorRefWire `json:"actor"`
ExpiresAt *string `json:"expires_at,omitempty"`
}
// adminUserLimitRequestWire mirrors `AdminUserLimitRequest`.
type adminUserLimitRequestWire struct {
LimitCode string `json:"limit_code"`
Value int32 `json:"value"`
ReasonCode string `json:"reason_code"`
Actor *actorRefWire `json:"actor"`
ExpiresAt *string `json:"expires_at,omitempty"`
}
// adminUserEntitlementRequestWire mirrors `AdminUserEntitlementRequest`.
type adminUserEntitlementRequestWire struct {
Tier string `json:"tier"`
Source string `json:"source"`
Actor *actorRefWire `json:"actor"`
ReasonCode string `json:"reason_code,omitempty"`
StartsAt *string `json:"starts_at,omitempty"`
EndsAt *string `json:"ends_at,omitempty"`
}
// adminUserListWire mirrors `AdminUserList`.
type adminUserListWire struct {
Items []accountWire `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int `json:"total"`
}
@@ -0,0 +1,88 @@
package server
import (
"encoding/base64"
"net/mail"
"strings"
"galaxy/backend/internal/auth"
)
// ed25519PublicKeyLen is the fixed size of a raw Ed25519 public key. The
// OpenAPI contract documents `client_public_key` as a "Standard
// base64-encoded raw 32-byte Ed25519 public key"; the handler enforces
// the length after decode so the auth service always operates on the
// canonical shape.
const ed25519PublicKeyLen = 32
// deviceSessionPayload is the JSON body the internal session endpoints
// emit. It mirrors the `DeviceSession` schema in `openapi.yaml`.
type deviceSessionPayload struct {
DeviceSessionID string `json:"device_session_id"`
UserID string `json:"user_id"`
Status string `json:"status"`
ClientPublicKey string `json:"client_public_key,omitempty"`
CreatedAt string `json:"created_at"`
RevokedAt *string `json:"revoked_at,omitempty"`
LastSeenAt *string `json:"last_seen_at,omitempty"`
}
func deviceSessionToWire(s auth.Session) deviceSessionPayload {
out := deviceSessionPayload{
DeviceSessionID: s.DeviceSessionID.String(),
UserID: s.UserID.String(),
Status: s.Status,
CreatedAt: s.CreatedAt.UTC().Format("2006-01-02T15:04:05.000Z07:00"),
}
if len(s.ClientPublicKey) > 0 {
out.ClientPublicKey = base64.StdEncoding.EncodeToString(s.ClientPublicKey)
}
if s.RevokedAt != nil {
formatted := s.RevokedAt.UTC().Format("2006-01-02T15:04:05.000Z07:00")
out.RevokedAt = &formatted
}
if s.LastSeenAt != nil {
formatted := s.LastSeenAt.UTC().Format("2006-01-02T15:04:05.000Z07:00")
out.LastSeenAt = &formatted
}
return out
}
// validateEmail returns the trimmed value when raw parses as an
// addr-spec, or "" when raw is malformed. Auth normalises to lowercase
// internally; the handler only enforces the syntactic shape.
func validateEmail(raw string) string {
addr, err := mail.ParseAddress(strings.TrimSpace(raw))
if err != nil {
return ""
}
return addr.Address
}
// decodeClientPublicKey decodes the wire-format client_public_key into
// raw bytes and validates the length. Standard base64 (with padding) is
// the canonical encoding documented in `openapi.yaml`.
func decodeClientPublicKey(raw string) ([]byte, bool) {
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(raw))
if err != nil {
return nil, false
}
if len(decoded) != ed25519PublicKeyLen {
return nil, false
}
return decoded, true
}
// isDecimalCodeOfLength reports whether s is a string of exactly want
// ASCII digits.
func isDecimalCodeOfLength(s string, want int) bool {
if len(s) != want {
return false
}
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}
@@ -0,0 +1,119 @@
package server
import (
"errors"
"net/http"
"galaxy/backend/internal/auth"
"galaxy/backend/internal/server/handlers"
"galaxy/backend/internal/server/httperr"
"galaxy/backend/internal/telemetry"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// 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.
type InternalSessionsHandlers struct {
svc *auth.Service
logger *zap.Logger
}
// NewInternalSessionsHandlers constructs the handler set. svc may be
// nil — in that case every handler returns 501 not_implemented, matching
// the pre-Stage-5.1 placeholder. logger may also be nil; zap.NewNop is
// used in that case.
func NewInternalSessionsHandlers(svc *auth.Service, logger *zap.Logger) *InternalSessionsHandlers {
if logger == nil {
logger = zap.NewNop()
}
return &InternalSessionsHandlers{svc: svc, logger: logger.Named("http.internal.sessions")}
}
// Get handles GET /api/v1/internal/sessions/{device_session_id}.
func (h *InternalSessionsHandlers) Get() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("internalSessionsGet")
}
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.GetSession(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 get failed",
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
)
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error")
return
}
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),
})
}
}
@@ -0,0 +1,50 @@
package server
import (
"net/http"
"galaxy/backend/internal/server/handlers"
"galaxy/backend/internal/user"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// InternalUsersHandlers groups the gateway-only user-fetch handlers
// under `/api/v1/internal/users/*`. The current implementation ships real
// implementations backed by `*user.Service`; tests that supply a nil
// service fall back to the Stage-3 placeholder body.
type InternalUsersHandlers struct {
svc *user.Service
logger *zap.Logger
}
// NewInternalUsersHandlers constructs the handler set. svc may be
// nil — in that case every handler returns 501 not_implemented.
// logger may also be nil; zap.NewNop is used in that case.
func NewInternalUsersHandlers(svc *user.Service, logger *zap.Logger) *InternalUsersHandlers {
if logger == nil {
logger = zap.NewNop()
}
return &InternalUsersHandlers{svc: svc, logger: logger.Named("http.internal.users")}
}
// GetAccountInternal handles GET /api/v1/internal/users/{user_id}/account-internal.
func (h *InternalUsersHandlers) GetAccountInternal() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("internalUsersGetAccountInternal")
}
return func(c *gin.Context) {
userID, ok := parseUserIDParam(c)
if !ok {
return
}
ctx := c.Request.Context()
account, err := h.svc.GetAccount(ctx, userID)
if err != nil {
respondAccountError(c, h.logger, "internal users get account", ctx, err)
return
}
c.JSON(http.StatusOK, accountResponseToWire(account))
}
}
@@ -0,0 +1,139 @@
package server
import (
"errors"
"net/http"
"time"
"galaxy/backend/internal/auth"
"galaxy/backend/internal/server/clientip"
"galaxy/backend/internal/server/handlers"
"galaxy/backend/internal/server/httperr"
"galaxy/backend/internal/telemetry"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// PublicAuthHandlers groups the public unauthenticated auth handlers
// under `/api/v1/public/auth/*`. The current implementation ships the real challenge
// issuance and confirmation flows; tests that supply a nil *auth.Service
// fall back to the Stage-3 placeholder body so the contract test
// continues to validate the OpenAPI envelope without booting a database.
type PublicAuthHandlers struct {
svc *auth.Service
logger *zap.Logger
}
// NewPublicAuthHandlers constructs the handler set. svc may be nil — in
// that case every handler returns 501 not_implemented, matching the
// pre-Stage-5.1 placeholder. logger may also be nil; zap.NewNop is used
// in that case.
func NewPublicAuthHandlers(svc *auth.Service, logger *zap.Logger) *PublicAuthHandlers {
if logger == nil {
logger = zap.NewNop()
}
return &PublicAuthHandlers{svc: svc, logger: logger.Named("http.public.auth")}
}
// SendEmailCode handles POST /api/v1/public/auth/send-email-code.
func (h *PublicAuthHandlers) SendEmailCode() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("publicAuthSendEmailCode")
}
return func(c *gin.Context) {
var req struct {
Email string `json:"email"`
Locale string `json:"locale"`
}
if err := c.ShouldBindJSON(&req); err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
return
}
email := validateEmail(req.Email)
if email == "" {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "email is invalid")
return
}
ctx := c.Request.Context()
challengeID, err := h.svc.SendEmailCode(ctx, email, req.Locale, c.GetHeader("Accept-Language"), clientip.ExtractSourceIP(c))
if err != nil {
switch {
case errors.Is(err, auth.ErrEmailPermanentlyBlocked):
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "email is not allowed")
default:
h.logger.Error("send-email-code failed",
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
)
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error")
}
return
}
c.JSON(http.StatusOK, gin.H{"challenge_id": challengeID.String()})
}
}
// ConfirmEmailCode handles POST /api/v1/public/auth/confirm-email-code.
func (h *PublicAuthHandlers) ConfirmEmailCode() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("publicAuthConfirmEmailCode")
}
return func(c *gin.Context) {
var req struct {
ChallengeID string `json:"challenge_id"`
Code string `json:"code"`
ClientPublicKey string `json:"client_public_key"`
TimeZone string `json:"time_zone"`
}
if err := c.ShouldBindJSON(&req); err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
return
}
challengeID, err := uuid.Parse(req.ChallengeID)
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "challenge_id must be a valid UUID")
return
}
if !isDecimalCodeOfLength(req.Code, auth.CodeLength) {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "code must be a 6-digit decimal string")
return
}
clientPubKey, ok := decodeClientPublicKey(req.ClientPublicKey)
if !ok {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "client_public_key must be a base64-encoded 32-byte Ed25519 key")
return
}
if _, err := time.LoadLocation(req.TimeZone); err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "time_zone must be a valid IANA zone")
return
}
ctx := c.Request.Context()
session, err := h.svc.ConfirmEmailCode(ctx, auth.ConfirmInputs{
ChallengeID: challengeID,
Code: req.Code,
ClientPublicKey: clientPubKey,
TimeZone: req.TimeZone,
SourceIP: clientip.ExtractSourceIP(c),
})
if err != nil {
switch {
case errors.Is(err, auth.ErrChallengeNotFound):
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "challenge is not redeemable")
case errors.Is(err, auth.ErrCodeMismatch):
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")
default:
h.logger.Error("confirm-email-code failed",
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
)
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error")
}
return
}
c.JSON(http.StatusOK, gin.H{"device_session_id": session.DeviceSessionID.String()})
}
}
@@ -0,0 +1,154 @@
package server
import (
"errors"
"net/http"
"galaxy/backend/internal/server/handlers"
"galaxy/backend/internal/server/httperr"
"galaxy/backend/internal/server/middleware/userid"
"galaxy/backend/internal/telemetry"
"galaxy/backend/internal/user"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// UserAccountHandlers groups the handlers under `/api/v1/user/account/*`.
// The current implementation ships real implementations backed by `*user.Service`; tests
// that supply a nil service fall back to the Stage-3 placeholder body
// so the contract test continues to validate the OpenAPI envelope
// without booting a database.
type UserAccountHandlers struct {
svc *user.Service
logger *zap.Logger
}
// NewUserAccountHandlers constructs the handler set. svc may be nil —
// in that case every handler returns 501 not_implemented, matching the
// pre-Stage-5.2 placeholder. logger may also be nil; zap.NewNop is
// used in that case.
func NewUserAccountHandlers(svc *user.Service, logger *zap.Logger) *UserAccountHandlers {
if logger == nil {
logger = zap.NewNop()
}
return &UserAccountHandlers{svc: svc, logger: logger.Named("http.user.account")}
}
// Get handles GET /api/v1/user/account.
func (h *UserAccountHandlers) Get() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("userAccountGet")
}
return func(c *gin.Context) {
userID, 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()
account, err := h.svc.GetAccount(ctx, userID)
if err != nil {
respondAccountError(c, h.logger, "user account get", ctx, err)
return
}
c.JSON(http.StatusOK, accountResponseToWire(account))
}
}
// UpdateProfile handles PATCH /api/v1/user/account/profile.
func (h *UserAccountHandlers) UpdateProfile() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("userAccountUpdateProfile")
}
return func(c *gin.Context) {
userID, ok := userid.FromContext(c.Request.Context())
if !ok {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
return
}
var req updateProfileRequestWire
if err := c.ShouldBindJSON(&req); err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
return
}
ctx := c.Request.Context()
account, err := h.svc.UpdateProfile(ctx, userID, user.UpdateProfileInput{
DisplayName: req.DisplayName,
})
if err != nil {
respondAccountError(c, h.logger, "user account update profile", ctx, err)
return
}
c.JSON(http.StatusOK, accountResponseToWire(account))
}
}
// UpdateSettings handles PATCH /api/v1/user/account/settings.
func (h *UserAccountHandlers) UpdateSettings() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("userAccountUpdateSettings")
}
return func(c *gin.Context) {
userID, ok := userid.FromContext(c.Request.Context())
if !ok {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
return
}
var req updateSettingsRequestWire
if err := c.ShouldBindJSON(&req); err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
return
}
ctx := c.Request.Context()
account, err := h.svc.UpdateSettings(ctx, userID, user.UpdateSettingsInput{
PreferredLanguage: req.PreferredLanguage,
TimeZone: req.TimeZone,
})
if err != nil {
respondAccountError(c, h.logger, "user account update settings", ctx, err)
return
}
c.JSON(http.StatusOK, accountResponseToWire(account))
}
}
// Delete handles POST /api/v1/user/account/delete.
func (h *UserAccountHandlers) Delete() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("userAccountDelete")
}
return func(c *gin.Context) {
userID, 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()
actor := user.ActorRef{Type: "user", ID: userID.String()}
if err := h.svc.SoftDelete(ctx, userID, actor); err != nil {
if errors.Is(err, user.ErrAccountNotFound) {
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "account not found")
return
}
h.logger.Warn("user account soft-delete returned cascade errors",
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
)
// Cascade errors do not change the canonical state — the
// account is soft-deleted in Postgres. Surface 204 so the
// caller's UI proceeds to a logged-out state.
}
c.Status(http.StatusNoContent)
}
}
// updateProfileRequestWire mirrors `UpdateProfileRequest` from openapi.yaml.
type updateProfileRequestWire struct {
DisplayName *string `json:"display_name,omitempty"`
}
// updateSettingsRequestWire mirrors `UpdateSettingsRequest` from openapi.yaml.
type updateSettingsRequestWire struct {
PreferredLanguage *string `json:"preferred_language,omitempty"`
TimeZone *string `json:"time_zone,omitempty"`
}
@@ -0,0 +1,230 @@
package server
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"galaxy/backend/internal/engineclient"
"galaxy/backend/internal/runtime"
"galaxy/backend/internal/server/handlers"
"galaxy/backend/internal/server/httperr"
"galaxy/backend/internal/server/middleware/userid"
"galaxy/backend/internal/telemetry"
"galaxy/model/order"
gamerest "galaxy/model/rest"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// UserGamesHandlers groups the engine-proxy handlers under
// `/api/v1/user/games/{game_id}/*`. The wiring connects them through
// `engineclient` against running engine containers.
type UserGamesHandlers struct {
runtime *runtime.Service
engine *engineclient.Client
logger *zap.Logger
}
// NewUserGamesHandlers constructs the handler set. When runtime or
// engine is nil, every handler returns 501 so the contract test still
// passes against a partially-wired router.
func NewUserGamesHandlers(rt *runtime.Service, engine *engineclient.Client, logger *zap.Logger) *UserGamesHandlers {
if logger == nil {
logger = zap.NewNop()
}
return &UserGamesHandlers{runtime: rt, engine: engine, logger: logger.Named("http.user.games")}
}
// Commands handles POST /api/v1/user/games/{game_id}/commands.
func (h *UserGamesHandlers) Commands() gin.HandlerFunc {
if h == nil || h.runtime == nil || h.engine == nil {
return handlers.NotImplemented("userGamesCommands")
}
return func(c *gin.Context) {
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
userID, ok := userid.FromContext(c.Request.Context())
if !ok {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user id missing")
return
}
body, err := io.ReadAll(c.Request.Body)
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body could not be read")
return
}
ctx := c.Request.Context()
mapping, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID)
if err != nil {
respondGameProxyError(c, h.logger, "user games commands", ctx, err)
return
}
endpoint, err := h.runtime.EngineEndpoint(ctx, gameID)
if err != nil {
respondGameProxyError(c, h.logger, "user games commands", ctx, err)
return
}
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.ExecuteCommands(ctx, endpoint, payload)
if err != nil {
respondEngineProxyError(c, h.logger, "user games commands", ctx, resp, err)
return
}
c.Data(http.StatusOK, "application/json", resp)
}
}
// Orders handles POST /api/v1/user/games/{game_id}/orders.
func (h *UserGamesHandlers) Orders() gin.HandlerFunc {
if h == nil || h.runtime == nil || h.engine == nil {
return handlers.NotImplemented("userGamesOrders")
}
return func(c *gin.Context) {
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
userID, ok := userid.FromContext(c.Request.Context())
if !ok {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user id missing")
return
}
body, err := io.ReadAll(c.Request.Body)
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body could not be read")
return
}
ctx := c.Request.Context()
mapping, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID)
if err != nil {
respondGameProxyError(c, h.logger, "user games orders", ctx, err)
return
}
endpoint, err := h.runtime.EngineEndpoint(ctx, gameID)
if err != nil {
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
_ = order.Order{}
resp, err := h.engine.PutOrders(ctx, endpoint, body)
if err != nil {
respondEngineProxyError(c, h.logger, "user games orders", ctx, resp, err)
return
}
c.Data(http.StatusOK, "application/json", resp)
}
}
// Report handles GET /api/v1/user/games/{game_id}/reports/{turn}.
func (h *UserGamesHandlers) Report() gin.HandlerFunc {
if h == nil || h.runtime == nil || h.engine == nil {
return handlers.NotImplemented("userGamesReport")
}
return func(c *gin.Context) {
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
turnRaw := c.Param("turn")
turn, err := strconv.Atoi(turnRaw)
if err != nil || turn < 0 {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "turn must be a non-negative integer")
return
}
userID, ok := userid.FromContext(c.Request.Context())
if !ok {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user id missing")
return
}
ctx := c.Request.Context()
mapping, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID)
if err != nil {
respondGameProxyError(c, h.logger, "user games report", ctx, err)
return
}
endpoint, err := h.runtime.EngineEndpoint(ctx, gameID)
if err != nil {
respondGameProxyError(c, h.logger, "user games report", ctx, err)
return
}
body, err := h.engine.GetReport(ctx, endpoint, mapping.RaceName, turn)
if err != nil {
respondEngineProxyError(c, h.logger, "user games report", ctx, body, err)
return
}
c.Data(http.StatusOK, "application/json", body)
}
}
// rebindActor decodes a JSON object from raw, sets `actor` to
// raceName, and re-encodes. Backend never trusts the actor field
// supplied by the client (per ARCHITECTURE.md §9).
func rebindActor(raw []byte, raceName string) (json.RawMessage, error) {
if len(raw) == 0 {
// Empty body — synthesise a minimal envelope so the engine
// receives a well-formed request.
return json.Marshal(gamerest.Command{Actor: raceName})
}
var generic map[string]json.RawMessage
if err := json.Unmarshal(raw, &generic); err != nil {
return nil, err
}
actor, _ := json.Marshal(raceName)
generic["actor"] = actor
return json.Marshal(generic)
}
func respondGameProxyError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) {
switch {
case errors.Is(err, runtime.ErrNotFound):
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "no runtime mapping for this user/game")
case errors.Is(err, runtime.ErrConflict):
httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, err.Error())
default:
logger.Error(op+" failed",
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
)
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "internal error")
}
}
func respondEngineProxyError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, body []byte, err error) {
switch {
case errors.Is(err, engineclient.ErrEngineValidation):
if len(body) > 0 {
c.Data(http.StatusBadRequest, "application/json", body)
return
}
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error())
case errors.Is(err, engineclient.ErrEngineUnreachable):
httperr.Abort(c, http.StatusServiceUnavailable, httperr.CodeServiceUnavailable, "engine is unreachable")
case errors.Is(err, engineclient.ErrEngineProtocolViolation):
logger.Error(op+" engine protocol violation",
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
)
httperr.Abort(c, http.StatusBadGateway, httperr.CodeInternalError, "engine response was malformed")
default:
logger.Error(op+" failed",
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
)
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "internal error")
}
}
@@ -0,0 +1,197 @@
package server
import (
"context"
"errors"
"net/http"
"time"
"galaxy/backend/internal/server/httperr"
"galaxy/backend/internal/telemetry"
"galaxy/backend/internal/user"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// timestampLayout matches the format used by other backend handlers
// (The implementation deviceSession serialisation). UTC, millisecond precision.
const timestampLayout = "2006-01-02T15:04:05.000Z07:00"
// respondAccountError maps user-package sentinels to the standard
// JSON error envelope. Unknown errors land on a 500.
func respondAccountError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) {
switch {
case errors.Is(err, user.ErrAccountNotFound):
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "account not found")
case errors.Is(err, user.ErrInvalidInput),
errors.Is(err, user.ErrInvalidActor):
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error())
case errors.Is(err, user.ErrInvalidTier):
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "tier is not supported")
case errors.Is(err, user.ErrInvalidSanctionCode):
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "sanction_code is not supported")
default:
logger.Error(op+" failed",
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
)
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error")
}
}
// accountResponseToWire renders the Account aggregate into the
// `AccountResponse` shape declared in openapi.yaml.
func accountResponseToWire(account user.Account) accountResponseWire {
return accountResponseWire{Account: accountToWire(account)}
}
func accountToWire(account user.Account) accountWire {
out := accountWire{
UserID: account.UserID.String(),
Email: account.Email,
UserName: account.UserName,
DisplayName: account.DisplayName,
PreferredLanguage: account.PreferredLanguage,
TimeZone: account.TimeZone,
DeclaredCountry: account.DeclaredCountry,
Entitlement: entitlementSnapshotToWire(account.Entitlement),
ActiveSanctions: activeSanctionsToWire(account.ActiveSanctions),
ActiveLimits: activeLimitsToWire(account.ActiveLimits),
CreatedAt: account.CreatedAt.UTC().Format(timestampLayout),
UpdatedAt: account.UpdatedAt.UTC().Format(timestampLayout),
}
return out
}
func entitlementSnapshotToWire(snap user.EntitlementSnapshot) entitlementSnapshotWire {
out := entitlementSnapshotWire{
PlanCode: snap.Tier,
IsPaid: snap.IsPaid,
Source: snap.Source,
Actor: actorRefToWire(snap.Actor),
ReasonCode: snap.ReasonCode,
StartsAt: snap.StartsAt.UTC().Format(timestampLayout),
MaxRegisteredRaceNames: snap.MaxRegisteredRaceNames,
UpdatedAt: snap.UpdatedAt.UTC().Format(timestampLayout),
}
if snap.EndsAt != nil {
formatted := snap.EndsAt.UTC().Format(timestampLayout)
out.EndsAt = &formatted
}
return out
}
func activeSanctionsToWire(items []user.ActiveSanction) []activeSanctionWire {
out := make([]activeSanctionWire, 0, len(items))
for _, s := range items {
entry := activeSanctionWire{
SanctionCode: s.SanctionCode,
Scope: s.Scope,
ReasonCode: s.ReasonCode,
Actor: actorRefToWire(s.Actor),
AppliedAt: s.AppliedAt.UTC().Format(timestampLayout),
}
if s.ExpiresAt != nil {
formatted := s.ExpiresAt.UTC().Format(timestampLayout)
entry.ExpiresAt = &formatted
}
out = append(out, entry)
}
return out
}
func activeLimitsToWire(items []user.ActiveLimit) []activeLimitWire {
out := make([]activeLimitWire, 0, len(items))
for _, l := range items {
entry := activeLimitWire{
LimitCode: l.LimitCode,
Value: l.Value,
ReasonCode: l.ReasonCode,
Actor: actorRefToWire(l.Actor),
AppliedAt: l.AppliedAt.UTC().Format(timestampLayout),
}
if l.ExpiresAt != nil {
formatted := l.ExpiresAt.UTC().Format(timestampLayout)
entry.ExpiresAt = &formatted
}
out = append(out, entry)
}
return out
}
func actorRefToWire(actor user.ActorRef) actorRefWire {
return actorRefWire{Type: actor.Type, ID: actor.ID}
}
// parseTimePtr converts a wire timestamp pointer into a time.Time
// pointer. A nil or empty input yields nil. Invalid timestamps return
// an error that handlers map to ErrInvalidInput.
func parseTimePtr(raw *string) (*time.Time, error) {
if raw == nil {
return nil, nil
}
if *raw == "" {
return nil, nil
}
t, err := time.Parse(time.RFC3339Nano, *raw)
if err != nil {
return nil, err
}
return &t, nil
}
// accountResponseWire mirrors `AccountResponse` in openapi.yaml.
type accountResponseWire struct {
Account accountWire `json:"account"`
}
// accountWire mirrors `Account` in openapi.yaml.
type accountWire struct {
UserID string `json:"user_id"`
Email string `json:"email"`
UserName string `json:"user_name"`
DisplayName string `json:"display_name,omitempty"`
PreferredLanguage string `json:"preferred_language"`
TimeZone string `json:"time_zone"`
DeclaredCountry string `json:"declared_country,omitempty"`
Entitlement entitlementSnapshotWire `json:"entitlement"`
ActiveSanctions []activeSanctionWire `json:"active_sanctions"`
ActiveLimits []activeLimitWire `json:"active_limits"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type entitlementSnapshotWire struct {
PlanCode string `json:"plan_code"`
IsPaid bool `json:"is_paid"`
Source string `json:"source"`
Actor actorRefWire `json:"actor"`
ReasonCode string `json:"reason_code"`
StartsAt string `json:"starts_at"`
EndsAt *string `json:"ends_at,omitempty"`
MaxRegisteredRaceNames int32 `json:"max_registered_race_names"`
UpdatedAt string `json:"updated_at"`
}
type activeSanctionWire struct {
SanctionCode string `json:"sanction_code"`
Scope string `json:"scope"`
ReasonCode string `json:"reason_code"`
Actor actorRefWire `json:"actor"`
AppliedAt string `json:"applied_at"`
ExpiresAt *string `json:"expires_at,omitempty"`
}
type activeLimitWire struct {
LimitCode string `json:"limit_code"`
Value int32 `json:"value"`
ReasonCode string `json:"reason_code"`
Actor actorRefWire `json:"actor"`
AppliedAt string `json:"applied_at"`
ExpiresAt *string `json:"expires_at,omitempty"`
}
type actorRefWire struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
}
@@ -0,0 +1,128 @@
package server
import (
"net/http"
"galaxy/backend/internal/lobby"
"galaxy/backend/internal/server/handlers"
"galaxy/backend/internal/server/httperr"
"galaxy/backend/internal/server/middleware/userid"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// UserLobbyApplicationsHandlers groups the application-lifecycle handlers
// under `/api/v1/user/lobby/games/{game_id}/applications/*`. The implementation // ships real implementations backed by `*lobby.Service`.
type UserLobbyApplicationsHandlers struct {
svc *lobby.Service
logger *zap.Logger
}
// NewUserLobbyApplicationsHandlers constructs the handler set. svc may
// be nil — in that case every handler returns 501 not_implemented.
func NewUserLobbyApplicationsHandlers(svc *lobby.Service, logger *zap.Logger) *UserLobbyApplicationsHandlers {
if logger == nil {
logger = zap.NewNop()
}
return &UserLobbyApplicationsHandlers{svc: svc, logger: logger.Named("http.user.lobby.applications")}
}
// Submit handles POST /api/v1/user/lobby/games/{game_id}/applications.
func (h *UserLobbyApplicationsHandlers) Submit() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("userLobbyApplicationsSubmit")
}
return func(c *gin.Context) {
userID, ok := userid.FromContext(c.Request.Context())
if !ok {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
return
}
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
var req lobbyApplicationSubmitRequestWire
if err := c.ShouldBindJSON(&req); err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
return
}
ctx := c.Request.Context()
app, err := h.svc.SubmitApplication(ctx, lobby.SubmitApplicationInput{
GameID: gameID,
ApplicantUserID: userID,
RaceName: req.RaceName,
})
if err != nil {
respondLobbyError(c, h.logger, "user lobby applications submit", ctx, err)
return
}
c.JSON(http.StatusCreated, lobbyApplicationDetailToWire(app))
}
}
// Approve handles POST /api/v1/user/lobby/games/{game_id}/applications/{application_id}/approve.
func (h *UserLobbyApplicationsHandlers) Approve() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("userLobbyApplicationsApprove")
}
return func(c *gin.Context) {
userID, ok := userid.FromContext(c.Request.Context())
if !ok {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
return
}
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
applicationID, ok := parseApplicationIDParam(c)
if !ok {
return
}
ctx := c.Request.Context()
caller := userID
updated, err := h.svc.ApproveApplication(ctx, &caller, false, gameID, applicationID)
if err != nil {
respondLobbyError(c, h.logger, "user lobby applications approve", ctx, err)
return
}
c.JSON(http.StatusOK, lobbyApplicationDetailToWire(updated))
}
}
// Reject handles POST /api/v1/user/lobby/games/{game_id}/applications/{application_id}/reject.
func (h *UserLobbyApplicationsHandlers) Reject() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("userLobbyApplicationsReject")
}
return func(c *gin.Context) {
userID, ok := userid.FromContext(c.Request.Context())
if !ok {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
return
}
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
applicationID, ok := parseApplicationIDParam(c)
if !ok {
return
}
ctx := c.Request.Context()
caller := userID
updated, err := h.svc.RejectApplication(ctx, &caller, false, gameID, applicationID)
if err != nil {
respondLobbyError(c, h.logger, "user lobby applications reject", ctx, err)
return
}
c.JSON(http.StatusOK, lobbyApplicationDetailToWire(updated))
}
}
// lobbyApplicationSubmitRequestWire mirrors `LobbyApplicationSubmitRequest`.
type lobbyApplicationSubmitRequestWire struct {
RaceName string `json:"race_name"`
}
@@ -0,0 +1,306 @@
package server
import (
"context"
"net/http"
"time"
"galaxy/backend/internal/lobby"
"galaxy/backend/internal/server/handlers"
"galaxy/backend/internal/server/httperr"
"galaxy/backend/internal/server/middleware/userid"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// UserLobbyGamesHandlers groups the handlers under
// `/api/v1/user/lobby/games/*`. The current implementation ships real implementations
// backed by `*lobby.Service`; tests that supply a nil service fall back
// to the Stage-3 placeholder body so the contract test continues to
// validate the OpenAPI envelope without booting a database.
type UserLobbyGamesHandlers struct {
svc *lobby.Service
logger *zap.Logger
}
// NewUserLobbyGamesHandlers constructs the handler set. svc may be nil
// — in that case every handler returns 501 not_implemented.
func NewUserLobbyGamesHandlers(svc *lobby.Service, logger *zap.Logger) *UserLobbyGamesHandlers {
if logger == nil {
logger = zap.NewNop()
}
return &UserLobbyGamesHandlers{svc: svc, logger: logger.Named("http.user.lobby.games")}
}
func (h *UserLobbyGamesHandlers) callerUserID(c *gin.Context) (uuid.UUID, bool) {
userID, ok := userid.FromContext(c.Request.Context())
if !ok {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
return uuid.Nil, false
}
return userID, true
}
// List handles GET /api/v1/user/lobby/games.
func (h *UserLobbyGamesHandlers) List() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("userLobbyGamesList")
}
return func(c *gin.Context) {
page := parsePositiveQueryInt(c.Query("page"), 1)
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
ctx := c.Request.Context()
result, err := h.svc.ListPublicGames(ctx, page, pageSize)
if err != nil {
respondLobbyError(c, h.logger, "user lobby games list", ctx, err)
return
}
c.JSON(http.StatusOK, gameSummaryPageToWire(result))
}
}
// Create handles POST /api/v1/user/lobby/games.
func (h *UserLobbyGamesHandlers) Create() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("userLobbyGamesCreate")
}
return func(c *gin.Context) {
userID, ok := h.callerUserID(c)
if !ok {
return
}
var req lobbyGameCreateRequestWire
if err := c.ShouldBindJSON(&req); err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
return
}
if req.Visibility != lobby.VisibilityPrivate {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user-facing /lobby/games only creates private games; admins use /api/v1/admin/games for public")
return
}
enrollmentEndsAt, err := time.Parse(time.RFC3339Nano, req.EnrollmentEndsAt)
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "enrollment_ends_at must be RFC 3339")
return
}
ctx := c.Request.Context()
owner := userID
game, err := h.svc.CreateGame(ctx, lobby.CreateGameInput{
OwnerUserID: &owner,
Visibility: req.Visibility,
GameName: req.GameName,
Description: req.Description,
MinPlayers: req.MinPlayers,
MaxPlayers: req.MaxPlayers,
StartGapHours: req.StartGapHours,
StartGapPlayers: req.StartGapPlayers,
EnrollmentEndsAt: enrollmentEndsAt,
TurnSchedule: req.TurnSchedule,
TargetEngineVersion: req.TargetEngineVersion,
})
if err != nil {
respondLobbyError(c, h.logger, "user lobby games create", ctx, err)
return
}
c.JSON(http.StatusCreated, lobbyGameDetailToWire(game))
}
}
// Get handles GET /api/v1/user/lobby/games/{game_id}.
func (h *UserLobbyGamesHandlers) Get() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("userLobbyGamesGet")
}
return func(c *gin.Context) {
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
ctx := c.Request.Context()
game, err := h.svc.GetGame(ctx, gameID)
if err != nil {
respondLobbyError(c, h.logger, "user lobby games get", ctx, err)
return
}
c.JSON(http.StatusOK, lobbyGameDetailToWire(game))
}
}
// Update handles PATCH /api/v1/user/lobby/games/{game_id}.
func (h *UserLobbyGamesHandlers) Update() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("userLobbyGamesUpdate")
}
return func(c *gin.Context) {
userID, ok := h.callerUserID(c)
if !ok {
return
}
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
var req lobbyGameUpdateRequestWire
if err := c.ShouldBindJSON(&req); err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
return
}
ends, err := parseTimePtrField(req.EnrollmentEndsAt, "enrollment_ends_at")
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error())
return
}
ctx := c.Request.Context()
caller := userID
updated, err := h.svc.UpdateGame(ctx, &caller, false, gameID, lobby.UpdateGameInput{
GameName: req.GameName,
Description: req.Description,
EnrollmentEndsAt: ends,
TurnSchedule: req.TurnSchedule,
TargetEngineVersion: req.TargetEngineVersion,
MinPlayers: req.MinPlayers,
MaxPlayers: req.MaxPlayers,
StartGapHours: req.StartGapHours,
StartGapPlayers: req.StartGapPlayers,
})
if err != nil {
respondLobbyError(c, h.logger, "user lobby games update", ctx, err)
return
}
c.JSON(http.StatusOK, lobbyGameDetailToWire(updated))
}
}
// transitionHandler is the shared shape for owner-driven state-machine
// endpoints. fn captures the lobby Service method to invoke.
func (h *UserLobbyGamesHandlers) transitionHandler(opName string, successStatus int, fn func(context.Context, *lobby.Service, *uuid.UUID, uuid.UUID) (lobby.GameRecord, error)) gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented(opName)
}
return func(c *gin.Context) {
userID, ok := h.callerUserID(c)
if !ok {
return
}
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
ctx := c.Request.Context()
caller := userID
updated, err := fn(ctx, h.svc, &caller, gameID)
if err != nil {
respondLobbyError(c, h.logger, "user lobby games "+opName, ctx, err)
return
}
c.JSON(successStatus, lobbyGameStateChangeToWire(updated))
}
}
// OpenEnrollment handles POST /api/v1/user/lobby/games/{game_id}/open-enrollment.
func (h *UserLobbyGamesHandlers) OpenEnrollment() gin.HandlerFunc {
return h.transitionHandler("openEnrollment", http.StatusOK,
func(ctx context.Context, svc *lobby.Service, caller *uuid.UUID, gameID uuid.UUID) (lobby.GameRecord, error) {
return svc.OpenEnrollment(ctx, caller, false, gameID)
})
}
// ReadyToStart handles POST /api/v1/user/lobby/games/{game_id}/ready-to-start.
func (h *UserLobbyGamesHandlers) ReadyToStart() gin.HandlerFunc {
return h.transitionHandler("readyToStart", http.StatusOK,
func(ctx context.Context, svc *lobby.Service, caller *uuid.UUID, gameID uuid.UUID) (lobby.GameRecord, error) {
return svc.ReadyToStart(ctx, caller, false, gameID)
})
}
// Start handles POST /api/v1/user/lobby/games/{game_id}/start.
func (h *UserLobbyGamesHandlers) Start() gin.HandlerFunc {
return h.transitionHandler("start", http.StatusAccepted,
func(ctx context.Context, svc *lobby.Service, caller *uuid.UUID, gameID uuid.UUID) (lobby.GameRecord, error) {
return svc.Start(ctx, caller, false, gameID)
})
}
// Pause handles POST /api/v1/user/lobby/games/{game_id}/pause.
func (h *UserLobbyGamesHandlers) Pause() gin.HandlerFunc {
return h.transitionHandler("pause", http.StatusOK,
func(ctx context.Context, svc *lobby.Service, caller *uuid.UUID, gameID uuid.UUID) (lobby.GameRecord, error) {
return svc.Pause(ctx, caller, false, gameID)
})
}
// Resume handles POST /api/v1/user/lobby/games/{game_id}/resume.
func (h *UserLobbyGamesHandlers) Resume() gin.HandlerFunc {
return h.transitionHandler("resume", http.StatusOK,
func(ctx context.Context, svc *lobby.Service, caller *uuid.UUID, gameID uuid.UUID) (lobby.GameRecord, error) {
return svc.Resume(ctx, caller, false, gameID)
})
}
// Cancel handles POST /api/v1/user/lobby/games/{game_id}/cancel.
func (h *UserLobbyGamesHandlers) Cancel() gin.HandlerFunc {
return h.transitionHandler("cancel", http.StatusOK,
func(ctx context.Context, svc *lobby.Service, caller *uuid.UUID, gameID uuid.UUID) (lobby.GameRecord, error) {
return svc.Cancel(ctx, caller, false, gameID)
})
}
// RetryStart handles POST /api/v1/user/lobby/games/{game_id}/retry-start.
func (h *UserLobbyGamesHandlers) RetryStart() gin.HandlerFunc {
return h.transitionHandler("retryStart", http.StatusAccepted,
func(ctx context.Context, svc *lobby.Service, caller *uuid.UUID, gameID uuid.UUID) (lobby.GameRecord, error) {
return svc.RetryStart(ctx, caller, false, gameID)
})
}
// lobbyGameCreateRequestWire mirrors `LobbyGameCreateRequest`.
type lobbyGameCreateRequestWire struct {
GameName string `json:"game_name"`
Visibility string `json:"visibility"`
Description string `json:"description"`
MinPlayers int32 `json:"min_players"`
MaxPlayers int32 `json:"max_players"`
StartGapHours int32 `json:"start_gap_hours"`
StartGapPlayers int32 `json:"start_gap_players"`
EnrollmentEndsAt string `json:"enrollment_ends_at"`
TurnSchedule string `json:"turn_schedule"`
TargetEngineVersion string `json:"target_engine_version"`
}
// lobbyGameUpdateRequestWire mirrors `LobbyGameUpdateRequest`. Optional
// fields are pointers so the handler can distinguish "not supplied"
// from "empty string".
type lobbyGameUpdateRequestWire struct {
GameName *string `json:"game_name,omitempty"`
Description *string `json:"description,omitempty"`
EnrollmentEndsAt *string `json:"enrollment_ends_at,omitempty"`
TurnSchedule *string `json:"turn_schedule,omitempty"`
TargetEngineVersion *string `json:"target_engine_version,omitempty"`
MinPlayers *int32 `json:"min_players,omitempty"`
MaxPlayers *int32 `json:"max_players,omitempty"`
StartGapHours *int32 `json:"start_gap_hours,omitempty"`
StartGapPlayers *int32 `json:"start_gap_players,omitempty"`
}
// gameSummaryPageWire mirrors `GameSummaryPage`.
type gameSummaryPageWire struct {
Items []gameSummaryWire `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int `json:"total"`
}
func gameSummaryPageToWire(page lobby.GamePage) gameSummaryPageWire {
out := gameSummaryPageWire{
Items: make([]gameSummaryWire, 0, len(page.Items)),
Page: page.Page,
PageSize: page.PageSize,
Total: page.Total,
}
for _, g := range page.Items {
out.Items = append(out.Items, gameSummaryToWire(g))
}
return out
}
@@ -0,0 +1,318 @@
package server
import (
"context"
"errors"
"net/http"
"time"
"galaxy/backend/internal/lobby"
"galaxy/backend/internal/server/httperr"
"galaxy/backend/internal/telemetry"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// respondLobbyError maps lobby-package sentinel errors to the standard
// JSON error envelope. Unknown errors land on a 500.
func respondLobbyError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) {
switch {
case errors.Is(err, lobby.ErrInvalidInput):
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error())
case errors.Is(err, lobby.ErrNotFound):
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "resource was not found")
case errors.Is(err, lobby.ErrForbidden):
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, err.Error())
case errors.Is(err, lobby.ErrConflict),
errors.Is(err, lobby.ErrInvalidStatus),
errors.Is(err, lobby.ErrRaceNameTaken),
errors.Is(err, lobby.ErrEntitlementExceeded),
errors.Is(err, lobby.ErrPendingExpired):
httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, err.Error())
default:
logger.Error(op+" failed",
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
)
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error")
}
}
// parseGameIDParam reads `game_id` from the path. Writes 400 envelope on
// invalid input and returns false in that case.
func parseGameIDParam(c *gin.Context) (uuid.UUID, bool) {
parsed, err := uuid.Parse(c.Param("game_id"))
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "game_id must be a valid UUID")
return uuid.Nil, false
}
return parsed, true
}
func parseApplicationIDParam(c *gin.Context) (uuid.UUID, bool) {
parsed, err := uuid.Parse(c.Param("application_id"))
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "application_id must be a valid UUID")
return uuid.Nil, false
}
return parsed, true
}
func parseInviteIDParam(c *gin.Context) (uuid.UUID, bool) {
parsed, err := uuid.Parse(c.Param("invite_id"))
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "invite_id must be a valid UUID")
return uuid.Nil, false
}
return parsed, true
}
func parseMembershipIDParam(c *gin.Context) (uuid.UUID, bool) {
parsed, err := uuid.Parse(c.Param("membership_id"))
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "membership_id must be a valid UUID")
return uuid.Nil, false
}
return parsed, true
}
// gameSummaryWire mirrors `GameSummary` from openapi.yaml.
type gameSummaryWire struct {
GameID string `json:"game_id"`
GameName string `json:"game_name"`
GameType string `json:"game_type"`
Status string `json:"status"`
OwnerUserID *string `json:"owner_user_id,omitempty"`
MinPlayers int32 `json:"min_players"`
MaxPlayers int32 `json:"max_players"`
EnrollmentEndsAt string `json:"enrollment_ends_at"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// lobbyGameDetailWire mirrors `LobbyGameDetail` from openapi.yaml.
type lobbyGameDetailWire struct {
gameSummaryWire
Visibility string `json:"visibility"`
Description string `json:"description,omitempty"`
TurnSchedule string `json:"turn_schedule"`
TargetEngineVersion string `json:"target_engine_version"`
StartGapHours int32 `json:"start_gap_hours"`
StartGapPlayers int32 `json:"start_gap_players"`
CurrentTurn int32 `json:"current_turn"`
RuntimeStatus string `json:"runtime_status"`
EngineHealth string `json:"engine_health,omitempty"`
StartedAt *string `json:"started_at,omitempty"`
FinishedAt *string `json:"finished_at,omitempty"`
}
func gameSummaryToWire(g lobby.GameRecord) gameSummaryWire {
out := gameSummaryWire{
GameID: g.GameID.String(),
GameName: g.GameName,
GameType: g.Visibility,
Status: g.Status,
MinPlayers: g.MinPlayers,
MaxPlayers: g.MaxPlayers,
EnrollmentEndsAt: g.EnrollmentEndsAt.UTC().Format(timestampLayout),
CreatedAt: g.CreatedAt.UTC().Format(timestampLayout),
UpdatedAt: g.UpdatedAt.UTC().Format(timestampLayout),
}
if g.OwnerUserID != nil {
s := g.OwnerUserID.String()
out.OwnerUserID = &s
}
return out
}
func lobbyGameDetailToWire(g lobby.GameRecord) lobbyGameDetailWire {
out := lobbyGameDetailWire{
gameSummaryWire: gameSummaryToWire(g),
Visibility: g.Visibility,
Description: g.Description,
TurnSchedule: g.TurnSchedule,
TargetEngineVersion: g.TargetEngineVersion,
StartGapHours: g.StartGapHours,
StartGapPlayers: g.StartGapPlayers,
CurrentTurn: g.RuntimeSnapshot.CurrentTurn,
RuntimeStatus: g.RuntimeSnapshot.RuntimeStatus,
EngineHealth: g.RuntimeSnapshot.EngineHealth,
}
if g.StartedAt != nil {
s := g.StartedAt.UTC().Format(timestampLayout)
out.StartedAt = &s
}
if g.FinishedAt != nil {
s := g.FinishedAt.UTC().Format(timestampLayout)
out.FinishedAt = &s
}
return out
}
// lobbyGameStateChangeWire mirrors `LobbyGameStateChange`.
type lobbyGameStateChangeWire struct {
GameID string `json:"game_id"`
Status string `json:"status"`
RuntimeStatus string `json:"runtime_status,omitempty"`
}
func lobbyGameStateChangeToWire(g lobby.GameRecord) lobbyGameStateChangeWire {
return lobbyGameStateChangeWire{
GameID: g.GameID.String(),
Status: g.Status,
RuntimeStatus: g.RuntimeSnapshot.RuntimeStatus,
}
}
// lobbyApplicationDetailWire mirrors `LobbyApplicationDetail`.
type lobbyApplicationDetailWire struct {
ApplicationID string `json:"application_id"`
GameID string `json:"game_id"`
ApplicantUserID string `json:"applicant_user_id"`
RaceName string `json:"race_name"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
DecidedAt *string `json:"decided_at,omitempty"`
}
func lobbyApplicationDetailToWire(a lobby.Application) lobbyApplicationDetailWire {
out := lobbyApplicationDetailWire{
ApplicationID: a.ApplicationID.String(),
GameID: a.GameID.String(),
ApplicantUserID: a.ApplicantUserID.String(),
RaceName: a.RaceName,
Status: a.Status,
CreatedAt: a.CreatedAt.UTC().Format(timestampLayout),
}
if a.DecidedAt != nil {
s := a.DecidedAt.UTC().Format(timestampLayout)
out.DecidedAt = &s
}
return out
}
// lobbyInviteDetailWire mirrors `LobbyInviteDetail`.
type lobbyInviteDetailWire struct {
InviteID string `json:"invite_id"`
GameID string `json:"game_id"`
InviterUserID string `json:"inviter_user_id"`
InvitedUserID *string `json:"invited_user_id,omitempty"`
Code *string `json:"code,omitempty"`
RaceName string `json:"race_name"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"`
DecidedAt *string `json:"decided_at,omitempty"`
}
func lobbyInviteDetailToWire(i lobby.Invite) lobbyInviteDetailWire {
out := lobbyInviteDetailWire{
InviteID: i.InviteID.String(),
GameID: i.GameID.String(),
InviterUserID: i.InviterUserID.String(),
RaceName: i.RaceName,
Status: i.Status,
CreatedAt: i.CreatedAt.UTC().Format(timestampLayout),
ExpiresAt: i.ExpiresAt.UTC().Format(timestampLayout),
}
if i.InvitedUserID != nil {
s := i.InvitedUserID.String()
out.InvitedUserID = &s
}
if i.Code != "" {
c := i.Code
out.Code = &c
}
if i.DecidedAt != nil {
s := i.DecidedAt.UTC().Format(timestampLayout)
out.DecidedAt = &s
}
return out
}
// lobbyMembershipDetailWire mirrors `LobbyMembershipDetail`.
type lobbyMembershipDetailWire struct {
MembershipID string `json:"membership_id"`
GameID string `json:"game_id"`
UserID string `json:"user_id"`
RaceName string `json:"race_name"`
CanonicalKey string `json:"canonical_key"`
Status string `json:"status"`
JoinedAt string `json:"joined_at"`
RemovedAt *string `json:"removed_at,omitempty"`
}
func lobbyMembershipDetailToWire(m lobby.Membership) lobbyMembershipDetailWire {
out := lobbyMembershipDetailWire{
MembershipID: m.MembershipID.String(),
GameID: m.GameID.String(),
UserID: m.UserID.String(),
RaceName: m.RaceName,
CanonicalKey: m.CanonicalKey,
Status: m.Status,
JoinedAt: m.JoinedAt.UTC().Format(timestampLayout),
}
if m.RemovedAt != nil {
s := m.RemovedAt.UTC().Format(timestampLayout)
out.RemovedAt = &s
}
return out
}
// raceNameDetailWire mirrors `RaceNameDetail`.
type raceNameDetailWire struct {
Name string `json:"name"`
Canonical string `json:"canonical"`
Status string `json:"status"`
OwnerUserID string `json:"owner_user_id"`
GameID *string `json:"game_id,omitempty"`
SourceGameID *string `json:"source_game_id,omitempty"`
ReservedAt *string `json:"reserved_at,omitempty"`
ExpiresAt *string `json:"expires_at,omitempty"`
RegisteredAt *string `json:"registered_at,omitempty"`
}
func raceNameDetailToWire(e lobby.RaceNameEntry) raceNameDetailWire {
out := raceNameDetailWire{
Name: e.Name,
Canonical: string(e.Canonical),
Status: e.Status,
OwnerUserID: e.OwnerUserID.String(),
}
if e.GameID != (uuid.UUID{}) {
s := e.GameID.String()
out.GameID = &s
}
if e.SourceGameID != nil {
s := e.SourceGameID.String()
out.SourceGameID = &s
}
if e.ReservedAt != nil {
s := e.ReservedAt.UTC().Format(timestampLayout)
out.ReservedAt = &s
}
if e.ExpiresAt != nil {
s := e.ExpiresAt.UTC().Format(timestampLayout)
out.ExpiresAt = &s
}
if e.RegisteredAt != nil {
s := e.RegisteredAt.UTC().Format(timestampLayout)
out.RegisteredAt = &s
}
return out
}
// parseTimePtrField parses a wire timestamp pointer into a time.Time
// pointer. Empty / nil input yields nil. Invalid timestamps return an
// error.
func parseTimePtrField(raw *string, field string) (*time.Time, error) {
if raw == nil || *raw == "" {
return nil, nil
}
t, err := time.Parse(time.RFC3339Nano, *raw)
if err != nil {
return nil, errors.New(field + " must be RFC 3339")
}
return &t, nil
}
@@ -0,0 +1,180 @@
package server
import (
"net/http"
"galaxy/backend/internal/lobby"
"galaxy/backend/internal/server/handlers"
"galaxy/backend/internal/server/httperr"
"galaxy/backend/internal/server/middleware/userid"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// UserLobbyInvitesHandlers groups the invite-lifecycle handlers under
// `/api/v1/user/lobby/games/{game_id}/invites/*`. The current implementation ships real
// implementations backed by `*lobby.Service`.
type UserLobbyInvitesHandlers struct {
svc *lobby.Service
logger *zap.Logger
}
// NewUserLobbyInvitesHandlers constructs the handler set. svc may be
// nil — in that case every handler returns 501 not_implemented.
func NewUserLobbyInvitesHandlers(svc *lobby.Service, logger *zap.Logger) *UserLobbyInvitesHandlers {
if logger == nil {
logger = zap.NewNop()
}
return &UserLobbyInvitesHandlers{svc: svc, logger: logger.Named("http.user.lobby.invites")}
}
// Issue handles POST /api/v1/user/lobby/games/{game_id}/invites.
func (h *UserLobbyInvitesHandlers) Issue() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("userLobbyInvitesIssue")
}
return func(c *gin.Context) {
userID, ok := userid.FromContext(c.Request.Context())
if !ok {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
return
}
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
var req lobbyInviteIssueRequestWire
if err := c.ShouldBindJSON(&req); err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
return
}
var invitedID *uuid.UUID
if req.InvitedUserID != nil && *req.InvitedUserID != "" {
parsed, err := uuid.Parse(*req.InvitedUserID)
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "invited_user_id must be a valid UUID")
return
}
invitedID = &parsed
}
expires, err := parseTimePtrField(req.ExpiresAt, "expires_at")
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error())
return
}
ctx := c.Request.Context()
raceName := ""
if req.RaceName != nil {
raceName = *req.RaceName
}
invite, err := h.svc.IssueInvite(ctx, lobby.IssueInviteInput{
GameID: gameID,
InviterUserID: userID,
InvitedUserID: invitedID,
RaceName: raceName,
ExpiresAt: expires,
})
if err != nil {
respondLobbyError(c, h.logger, "user lobby invites issue", ctx, err)
return
}
c.JSON(http.StatusCreated, lobbyInviteDetailToWire(invite))
}
}
// Redeem handles POST /api/v1/user/lobby/games/{game_id}/invites/{invite_id}/redeem.
func (h *UserLobbyInvitesHandlers) Redeem() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("userLobbyInvitesRedeem")
}
return func(c *gin.Context) {
userID, ok := userid.FromContext(c.Request.Context())
if !ok {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
return
}
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
inviteID, ok := parseInviteIDParam(c)
if !ok {
return
}
ctx := c.Request.Context()
invite, err := h.svc.RedeemInvite(ctx, userID, gameID, inviteID)
if err != nil {
respondLobbyError(c, h.logger, "user lobby invites redeem", ctx, err)
return
}
c.JSON(http.StatusOK, lobbyInviteDetailToWire(invite))
}
}
// Decline handles POST /api/v1/user/lobby/games/{game_id}/invites/{invite_id}/decline.
func (h *UserLobbyInvitesHandlers) Decline() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("userLobbyInvitesDecline")
}
return func(c *gin.Context) {
userID, ok := userid.FromContext(c.Request.Context())
if !ok {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
return
}
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
inviteID, ok := parseInviteIDParam(c)
if !ok {
return
}
ctx := c.Request.Context()
invite, err := h.svc.DeclineInvite(ctx, userID, gameID, inviteID)
if err != nil {
respondLobbyError(c, h.logger, "user lobby invites decline", ctx, err)
return
}
c.JSON(http.StatusOK, lobbyInviteDetailToWire(invite))
}
}
// Revoke handles POST /api/v1/user/lobby/games/{game_id}/invites/{invite_id}/revoke.
func (h *UserLobbyInvitesHandlers) Revoke() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("userLobbyInvitesRevoke")
}
return func(c *gin.Context) {
userID, ok := userid.FromContext(c.Request.Context())
if !ok {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
return
}
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
inviteID, ok := parseInviteIDParam(c)
if !ok {
return
}
ctx := c.Request.Context()
caller := userID
invite, err := h.svc.RevokeInvite(ctx, &caller, false, gameID, inviteID)
if err != nil {
respondLobbyError(c, h.logger, "user lobby invites revoke", ctx, err)
return
}
c.JSON(http.StatusOK, lobbyInviteDetailToWire(invite))
}
}
// lobbyInviteIssueRequestWire mirrors `LobbyInviteIssueRequest`.
type lobbyInviteIssueRequestWire struct {
InvitedUserID *string `json:"invited_user_id,omitempty"`
RaceName *string `json:"race_name,omitempty"`
ExpiresAt *string `json:"expires_at,omitempty"`
}
@@ -0,0 +1,118 @@
package server
import (
"net/http"
"galaxy/backend/internal/lobby"
"galaxy/backend/internal/server/handlers"
"galaxy/backend/internal/server/httperr"
"galaxy/backend/internal/server/middleware/userid"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// UserLobbyMembershipsHandlers groups the membership-lifecycle handlers
// under `/api/v1/user/lobby/games/{game_id}/memberships/*`. The implementation // ships real implementations backed by `*lobby.Service`.
type UserLobbyMembershipsHandlers struct {
svc *lobby.Service
logger *zap.Logger
}
// NewUserLobbyMembershipsHandlers constructs the handler set. svc may
// be nil — in that case every handler returns 501 not_implemented.
func NewUserLobbyMembershipsHandlers(svc *lobby.Service, logger *zap.Logger) *UserLobbyMembershipsHandlers {
if logger == nil {
logger = zap.NewNop()
}
return &UserLobbyMembershipsHandlers{svc: svc, logger: logger.Named("http.user.lobby.memberships")}
}
// List handles GET /api/v1/user/lobby/games/{game_id}/memberships.
func (h *UserLobbyMembershipsHandlers) List() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("userLobbyMembershipsList")
}
return func(c *gin.Context) {
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
ctx := c.Request.Context()
items, err := h.svc.ListMembershipsForGame(ctx, gameID)
if err != nil {
respondLobbyError(c, h.logger, "user lobby memberships list", ctx, err)
return
}
out := lobbyMembershipListWire{Items: make([]lobbyMembershipDetailWire, 0, len(items))}
for _, m := range items {
out.Items = append(out.Items, lobbyMembershipDetailToWire(m))
}
c.JSON(http.StatusOK, out)
}
}
// Remove handles POST /api/v1/user/lobby/games/{game_id}/memberships/{membership_id}/remove.
func (h *UserLobbyMembershipsHandlers) Remove() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("userLobbyMembershipsRemove")
}
return func(c *gin.Context) {
userID, ok := userid.FromContext(c.Request.Context())
if !ok {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
return
}
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
membershipID, ok := parseMembershipIDParam(c)
if !ok {
return
}
ctx := c.Request.Context()
caller := userID
updated, err := h.svc.RemoveMembership(ctx, &caller, false, gameID, membershipID)
if err != nil {
respondLobbyError(c, h.logger, "user lobby memberships remove", ctx, err)
return
}
c.JSON(http.StatusOK, lobbyMembershipDetailToWire(updated))
}
}
// Block handles POST /api/v1/user/lobby/games/{game_id}/memberships/{membership_id}/block.
func (h *UserLobbyMembershipsHandlers) Block() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("userLobbyMembershipsBlock")
}
return func(c *gin.Context) {
userID, ok := userid.FromContext(c.Request.Context())
if !ok {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
return
}
gameID, ok := parseGameIDParam(c)
if !ok {
return
}
membershipID, ok := parseMembershipIDParam(c)
if !ok {
return
}
ctx := c.Request.Context()
caller := userID
updated, err := h.svc.BlockMembership(ctx, &caller, false, gameID, membershipID)
if err != nil {
respondLobbyError(c, h.logger, "user lobby memberships block", ctx, err)
return
}
c.JSON(http.StatusOK, lobbyMembershipDetailToWire(updated))
}
}
// lobbyMembershipListWire mirrors `LobbyMembershipList`.
type lobbyMembershipListWire struct {
Items []lobbyMembershipDetailWire `json:"items"`
}
@@ -0,0 +1,152 @@
package server
import (
"net/http"
"galaxy/backend/internal/lobby"
"galaxy/backend/internal/server/handlers"
"galaxy/backend/internal/server/httperr"
"galaxy/backend/internal/server/middleware/userid"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// UserLobbyMyHandlers groups the caller-scoped lobby read endpoints
// under `/api/v1/user/lobby/my/*`. The current implementation ships real implementations
// backed by `*lobby.Service`.
type UserLobbyMyHandlers struct {
svc *lobby.Service
logger *zap.Logger
}
// NewUserLobbyMyHandlers constructs the handler set. svc may be nil —
// in that case every handler returns 501 not_implemented.
func NewUserLobbyMyHandlers(svc *lobby.Service, logger *zap.Logger) *UserLobbyMyHandlers {
if logger == nil {
logger = zap.NewNop()
}
return &UserLobbyMyHandlers{svc: svc, logger: logger.Named("http.user.lobby.my")}
}
// Games handles GET /api/v1/user/lobby/my/games.
func (h *UserLobbyMyHandlers) Games() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("userLobbyMyGames")
}
return func(c *gin.Context) {
userID, 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()
games, err := h.svc.ListMyGames(ctx, userID)
if err != nil {
respondLobbyError(c, h.logger, "user lobby my games", ctx, err)
return
}
out := myGamesListResponseWire{Items: make([]gameSummaryWire, 0, len(games))}
for _, g := range games {
out.Items = append(out.Items, gameSummaryToWire(g))
}
c.JSON(http.StatusOK, out)
}
}
// Applications handles GET /api/v1/user/lobby/my/applications.
func (h *UserLobbyMyHandlers) Applications() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("userLobbyMyApplications")
}
return func(c *gin.Context) {
userID, 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()
items, err := h.svc.ListMyApplications(ctx, userID)
if err != nil {
respondLobbyError(c, h.logger, "user lobby my applications", ctx, err)
return
}
out := lobbyApplicationListWire{Items: make([]lobbyApplicationDetailWire, 0, len(items))}
for _, a := range items {
out.Items = append(out.Items, lobbyApplicationDetailToWire(a))
}
c.JSON(http.StatusOK, out)
}
}
// Invites handles GET /api/v1/user/lobby/my/invites.
func (h *UserLobbyMyHandlers) Invites() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("userLobbyMyInvites")
}
return func(c *gin.Context) {
userID, 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()
items, err := h.svc.ListMyInvites(ctx, userID)
if err != nil {
respondLobbyError(c, h.logger, "user lobby my invites", ctx, err)
return
}
out := lobbyInviteListWire{Items: make([]lobbyInviteDetailWire, 0, len(items))}
for _, i := range items {
out.Items = append(out.Items, lobbyInviteDetailToWire(i))
}
c.JSON(http.StatusOK, out)
}
}
// RaceNames handles GET /api/v1/user/lobby/my/race-names.
func (h *UserLobbyMyHandlers) RaceNames() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("userLobbyMyRaceNames")
}
return func(c *gin.Context) {
userID, 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()
items, err := h.svc.ListMyRaceNames(ctx, userID)
if err != nil {
respondLobbyError(c, h.logger, "user lobby my race-names", ctx, err)
return
}
out := raceNameListWire{Items: make([]raceNameDetailWire, 0, len(items))}
for _, e := range items {
out.Items = append(out.Items, raceNameDetailToWire(e))
}
c.JSON(http.StatusOK, out)
}
}
// Wire envelopes for caller-scoped lists.
// myGamesListResponseWire mirrors `MyGamesListResponse`.
type myGamesListResponseWire struct {
Items []gameSummaryWire `json:"items"`
}
// lobbyApplicationListWire mirrors `LobbyApplicationList`.
type lobbyApplicationListWire struct {
Items []lobbyApplicationDetailWire `json:"items"`
}
// lobbyInviteListWire mirrors `LobbyInviteList`.
type lobbyInviteListWire struct {
Items []lobbyInviteDetailWire `json:"items"`
}
// raceNameListWire mirrors `RaceNameList`.
type raceNameListWire struct {
Items []raceNameDetailWire `json:"items"`
}
@@ -0,0 +1,61 @@
package server
import (
"net/http"
"galaxy/backend/internal/lobby"
"galaxy/backend/internal/server/handlers"
"galaxy/backend/internal/server/httperr"
"galaxy/backend/internal/server/middleware/userid"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// UserLobbyRaceNamesHandlers groups the race-name-directory handlers
// under `/api/v1/user/lobby/race-names/*`. The current implementation ships real
// implementations backed by `*lobby.Service`.
type UserLobbyRaceNamesHandlers struct {
svc *lobby.Service
logger *zap.Logger
}
// NewUserLobbyRaceNamesHandlers constructs the handler set. svc may be
// nil — in that case every handler returns 501 not_implemented.
func NewUserLobbyRaceNamesHandlers(svc *lobby.Service, logger *zap.Logger) *UserLobbyRaceNamesHandlers {
if logger == nil {
logger = zap.NewNop()
}
return &UserLobbyRaceNamesHandlers{svc: svc, logger: logger.Named("http.user.lobby.race_names")}
}
// Register handles POST /api/v1/user/lobby/race-names/register.
func (h *UserLobbyRaceNamesHandlers) Register() gin.HandlerFunc {
if h.svc == nil {
return handlers.NotImplemented("userLobbyRaceNamesRegister")
}
return func(c *gin.Context) {
userID, ok := userid.FromContext(c.Request.Context())
if !ok {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
return
}
var req raceNameRegisterRequestWire
if err := c.ShouldBindJSON(&req); err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
return
}
ctx := c.Request.Context()
entry, err := h.svc.RegisterRaceName(ctx, userID, req.Name)
if err != nil {
respondLobbyError(c, h.logger, "user lobby race-names register", ctx, err)
return
}
c.JSON(http.StatusOK, raceNameDetailToWire(entry))
}
}
// raceNameRegisterRequestWire mirrors `RaceNameRegisterRequest`.
type raceNameRegisterRequestWire struct {
Name string `json:"name"`
}
@@ -0,0 +1,52 @@
// Package httperr defines the standard JSON error envelope shared by every
// backend HTTP middleware and handler.
//
// The envelope shape is fixed by `backend/openapi.yaml` (`ErrorResponse`) and
// must remain identical across the public, user, admin, and internal route
// groups so that callers can parse failures uniformly.
package httperr
import (
"github.com/gin-gonic/gin"
)
// Error code values that appear in the JSON envelope `error.code` field. They
// are documented in `backend/openapi.yaml` and form the closed set of stable
// machine-readable failure markers.
const (
CodeNotImplemented = "not_implemented"
CodeInvalidRequest = "invalid_request"
CodeUnauthorized = "unauthorized"
CodeForbidden = "forbidden"
CodeNotFound = "not_found"
CodeConflict = "conflict"
CodeMethodNotAllowed = "method_not_allowed"
CodeInternalError = "internal_error"
CodeServiceUnavailable = "service_unavailable"
)
// Body stores the inner `error` object of the standard envelope.
type Body struct {
// Code is the stable machine-readable failure marker.
Code string `json:"code"`
// Message is the human-readable client-safe failure description.
Message string `json:"message"`
}
// Response wraps Body in the documented `{"error":{...}}` shape.
type Response struct {
Error Body `json:"error"`
}
// Abort writes the standard JSON error envelope with statusCode and aborts the
// gin handler chain. It is the single helper every middleware and handler must
// use to emit a failure response.
func Abort(c *gin.Context, statusCode int, code, message string) {
c.AbortWithStatusJSON(statusCode, Response{
Error: Body{
Code: code,
Message: message,
},
})
}
@@ -0,0 +1,131 @@
// Package basicauth gates a route group behind HTTP Basic authentication.
//
// The middleware delegates the credential check to a Verifier.
// Production wires `*admin.Service` (Postgres-backed, bcrypt cost 12).
// The bundled StaticVerifier is a test utility — it accepts any
// non-empty username together with a fixed password so the contract
// test can exercise the admin route group without booting a database.
// Production wiring never references StaticVerifier.
package basicauth
import (
"context"
"crypto/subtle"
"net/http"
"strings"
"galaxy/backend/internal/server/httperr"
"github.com/gin-gonic/gin"
)
// DefaultRealm is the realm advertised on `WWW-Authenticate` for the admin
// surface.
const DefaultRealm = "galaxy-admin"
// usernameContextKey is the unexported context key used to expose the
// authenticated admin username to downstream handlers (e.g. for
// soft-delete audit trails). The unexported value type prevents
// accidental collisions with keys defined in unrelated packages.
type usernameContextKey struct{}
// Verifier validates a username/password pair. Implementations must run in
// constant time relative to the credential bytes.
type Verifier interface {
// Verify reports whether the supplied credentials are accepted. A non-nil
// error indicates an unexpected verifier failure, distinct from a clean
// rejection (false, nil).
Verify(ctx context.Context, username, password string) (bool, error)
}
// UsernameFromContext returns the authenticated admin username stored on
// ctx by Middleware. The boolean reports whether a value was found.
func UsernameFromContext(ctx context.Context) (string, bool) {
if ctx == nil {
return "", false
}
value, ok := ctx.Value(usernameContextKey{}).(string)
if !ok {
return "", false
}
return value, true
}
// WithUsername stores username on ctx under the package-private context
// key. Exposed for tests that need to build a context outside the
// middleware.
func WithUsername(ctx context.Context, username string) context.Context {
return context.WithValue(ctx, usernameContextKey{}, username)
}
// Middleware returns a gin middleware that enforces Basic authentication via
// verifier. realm is advertised on `WWW-Authenticate`. A nil verifier behaves
// as a deny-all verifier, suitable for the operating mode where the admin
// surface must remain mounted but inaccessible.
func Middleware(verifier Verifier, realm string) gin.HandlerFunc {
if realm == "" {
realm = DefaultRealm
}
challenge := `Basic realm="` + realm + `"`
return func(c *gin.Context) {
username, password, ok := c.Request.BasicAuth()
if !ok {
c.Header("WWW-Authenticate", challenge)
httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "basic authentication is required")
return
}
if verifier == nil {
c.Header("WWW-Authenticate", challenge)
httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "credentials were rejected")
return
}
accepted, err := verifier.Verify(c.Request.Context(), username, password)
if err != nil {
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "credential verification failed")
return
}
if !accepted {
c.Header("WWW-Authenticate", challenge)
httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "credentials were rejected")
return
}
c.Request = c.Request.WithContext(WithUsername(c.Request.Context(), username))
c.Next()
}
}
// StaticVerifier accepts any non-empty username together with a
// fixed shared password. It is a test-only utility: the OpenAPI
// contract test wires it to exercise the admin route group without
// booting a database. Production wiring uses the Postgres-backed
// `*backend/internal/admin.Service`.
type StaticVerifier struct {
// Password is the shared secret. An empty value disables the verifier
// (every request is rejected).
Password string
}
// NewStaticVerifier returns a StaticVerifier with the supplied password.
func NewStaticVerifier(password string) StaticVerifier {
return StaticVerifier{Password: password}
}
// Verify accepts any non-empty username together with the configured password.
// The password comparison runs in constant time. An empty configured password
// rejects every request.
func (v StaticVerifier) Verify(_ context.Context, username, password string) (bool, error) {
if strings.TrimSpace(username) == "" {
return false, nil
}
if v.Password == "" {
return false, nil
}
if subtle.ConstantTimeCompare([]byte(password), []byte(v.Password)) != 1 {
return false, nil
}
return true, nil
}
@@ -0,0 +1,58 @@
// Package geocounter exposes the gin middleware that records
// `(user_id, country)` counters for every authenticated user-surface
// request. The middleware sits one layer below `userid.Middleware` in
// the route chain: it relies on the parsed user id already being on
// the request context.
//
// The middleware never blocks: the underlying counter implementation
// looks up the country synchronously (mmap read) and dispatches the
// database upsert to a fire-and-forget goroutine. Errors from the
// asynchronous path are logged inside the geo service, never surfaced
// to the response.
package geocounter
import (
"context"
"galaxy/backend/internal/server/clientip"
"galaxy/backend/internal/server/middleware/userid"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Service is the narrow contract the middleware needs from the geo
// package. It is satisfied by `*geo.Service` directly; tests inject a
// recording stub. A nil Service is allowed and disables the
// middleware's side effect.
type Service interface {
IncrementCounterAsync(ctx context.Context, userID uuid.UUID, sourceIP string)
}
// Middleware returns a gin handler that, after the wrapped handler
// chain has run, dispatches an `IncrementCounterAsync` call for the
// authenticated user and the originating IP. svc may be nil, in which
// case the middleware is a no-op pass-through.
//
// The middleware reads the user id from the request context populated
// by `userid.Middleware`; routes that mount this middleware without
// `userid.Middleware` ahead of it will silently skip the increment
// because the user id is absent.
func Middleware(svc Service) gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
if svc == nil {
return
}
userID, ok := userid.FromContext(c.Request.Context())
if !ok || userID == uuid.Nil {
return
}
ip := clientip.ExtractSourceIP(c)
if ip == "" {
return
}
svc.IncrementCounterAsync(c.Request.Context(), userID, ip)
}
}
@@ -0,0 +1,164 @@
package geocounter_test
import (
"context"
"net/http"
"net/http/httptest"
"sync"
"testing"
"galaxy/backend/internal/server/middleware/geocounter"
"galaxy/backend/internal/server/middleware/userid"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type recordingSvc struct {
mu sync.Mutex
calls []recordedCall
}
type recordedCall struct {
UserID uuid.UUID
SourceIP string
}
func (r *recordingSvc) IncrementCounterAsync(_ context.Context, userID uuid.UUID, sourceIP string) {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = append(r.calls, recordedCall{UserID: userID, SourceIP: sourceIP})
}
func (r *recordingSvc) snapshot() []recordedCall {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]recordedCall, len(r.calls))
copy(out, r.calls)
return out
}
func newEngine(t *testing.T, svc geocounter.Service) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(userid.Middleware())
r.Use(geocounter.Middleware(svc))
r.GET("/probe", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
return r
}
func TestMiddlewareInvokesIncrementOnAuthenticatedRequest(t *testing.T) {
t.Parallel()
svc := &recordingSvc{}
r := newEngine(t, svc)
userID := uuid.New()
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
req.Header.Set(userid.Header, userID.String())
req.Header.Set("X-Forwarded-For", "203.0.113.5")
req.RemoteAddr = "10.0.0.1:1000"
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: want 200, got %d", rec.Code)
}
calls := svc.snapshot()
if len(calls) != 1 {
t.Fatalf("calls: want 1, got %+v", calls)
}
if calls[0].UserID != userID {
t.Errorf("user id: want %s, got %s", userID, calls[0].UserID)
}
if calls[0].SourceIP != "203.0.113.5" {
t.Errorf("source ip: want 203.0.113.5, got %q", calls[0].SourceIP)
}
}
func TestMiddlewareFallsBackToRemoteAddr(t *testing.T) {
t.Parallel()
svc := &recordingSvc{}
r := newEngine(t, svc)
userID := uuid.New()
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
req.Header.Set(userid.Header, userID.String())
req.RemoteAddr = "198.51.100.7:60000"
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
calls := svc.snapshot()
if len(calls) != 1 {
t.Fatalf("calls: want 1, got %+v", calls)
}
if calls[0].SourceIP != "198.51.100.7" {
t.Errorf("source ip: want 198.51.100.7, got %q", calls[0].SourceIP)
}
}
func TestMiddlewareSkipsWhenNoSourceIP(t *testing.T) {
t.Parallel()
svc := &recordingSvc{}
r := newEngine(t, svc)
userID := uuid.New()
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
req.Header.Set(userid.Header, userID.String())
req.RemoteAddr = ""
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if calls := svc.snapshot(); len(calls) != 0 {
t.Fatalf("calls: want 0, got %+v", calls)
}
}
func TestMiddlewareSkipsWithoutUserContext(t *testing.T) {
t.Parallel()
svc := &recordingSvc{}
gin.SetMode(gin.TestMode)
r := gin.New()
// No userid.Middleware on this chain.
r.Use(geocounter.Middleware(svc))
r.GET("/probe", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
req.RemoteAddr = "203.0.113.5:1000"
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if calls := svc.snapshot(); len(calls) != 0 {
t.Fatalf("calls: want 0, got %+v", calls)
}
}
func TestMiddlewareNilServiceIsPassThrough(t *testing.T) {
t.Parallel()
r := newEngine(t, nil)
userID := uuid.New()
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
req.Header.Set(userid.Header, userID.String())
req.RemoteAddr = "203.0.113.5:1000"
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status with nil service: want 200, got %d", rec.Code)
}
}
@@ -0,0 +1,44 @@
// Package logging emits a single info-level access log entry per HTTP request,
// enriched with the active OpenTelemetry trace fields and the resolved request
// id when present.
package logging
import (
"time"
"galaxy/backend/internal/server/middleware/requestid"
"galaxy/backend/internal/telemetry"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// Middleware returns the access-log gin middleware. The provided logger should
// already carry the per-process service-name field; the middleware adds the
// request method, matched route, status, latency, request id, and trace
// fields.
func Middleware(logger *zap.Logger) gin.HandlerFunc {
if logger == nil {
logger = zap.NewNop()
}
return func(c *gin.Context) {
start := time.Now()
c.Next()
duration := time.Since(start)
fields := make([]zap.Field, 0, 6)
fields = append(fields,
zap.String("method", c.Request.Method),
zap.String("path", c.FullPath()),
zap.Int("status", c.Writer.Status()),
zap.Duration("duration", duration),
)
if requestID, ok := requestid.FromGin(c); ok {
fields = append(fields, zap.String("request_id", requestID))
}
fields = append(fields, telemetry.TraceFieldsFromContext(c.Request.Context())...)
logger.Info("http request", fields...)
}
}
@@ -0,0 +1,110 @@
// Package metrics emits per-request OpenTelemetry counters and histograms
// scoped by route group.
//
// The metric names are fixed by `backend/README.md` §15:
//
// - http_requests_total{group, method, route, status}
// - http_request_duration_seconds{group, method, route, status}
//
// One Middleware instance per route group keeps the `group` attribute stable
// across requests while allowing the gin router to share the same Meter.
package metrics
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
// Group identifies the route family that emits the metric. The set is closed
// and matches the prefixes registered by router.New.
type Group string
const (
// GroupRoot covers `/healthz`, `/readyz`, and unmatched routes.
GroupRoot Group = "root"
// GroupProbes covers the readiness/liveness probes when reported separately
// from other root-level traffic.
GroupProbes Group = "probes"
// GroupPublic covers `/api/v1/public/*` endpoints.
GroupPublic Group = "public"
// GroupUser covers `/api/v1/user/*` endpoints.
GroupUser Group = "user"
// GroupAdmin covers `/api/v1/admin/*` endpoints.
GroupAdmin Group = "admin"
// GroupInternal covers `/api/v1/internal/*` endpoints.
GroupInternal Group = "internal"
)
// Instruments holds the shared metric instruments used by every Group-scoped
// middleware. The instruments are constructed once per Meter; the
// per-middleware closure binds them to the right `group` attribute.
type Instruments struct {
requestsTotal metric.Int64Counter
requestDuration metric.Float64Histogram
}
// NewInstruments builds the shared metric instruments from meter. A nil meter
// returns nil instruments and disables metric emission.
func NewInstruments(meter metric.Meter) (*Instruments, error) {
if meter == nil {
return nil, nil
}
requestsTotal, err := meter.Int64Counter(
"http_requests_total",
metric.WithDescription("Number of HTTP requests served by the backend, partitioned by route group, method, route, and response status."),
metric.WithUnit("1"),
)
if err != nil {
return nil, err
}
requestDuration, err := meter.Float64Histogram(
"http_request_duration_seconds",
metric.WithDescription("Duration of HTTP requests served by the backend, partitioned by route group, method, route, and response status."),
metric.WithUnit("s"),
)
if err != nil {
return nil, err
}
return &Instruments{
requestsTotal: requestsTotal,
requestDuration: requestDuration,
}, nil
}
// Middleware returns a gin middleware that records request counters and
// duration histograms with the `group` attribute fixed to group. A nil
// instruments value yields a no-op middleware so that metric emission is
// strictly opt-in.
func Middleware(instruments *Instruments, group Group) gin.HandlerFunc {
if instruments == nil {
return func(c *gin.Context) { c.Next() }
}
return func(c *gin.Context) {
start := time.Now()
c.Next()
duration := time.Since(start)
route := c.FullPath()
if route == "" {
route = "unmatched"
}
attrs := metric.WithAttributes(
attribute.String("group", string(group)),
attribute.String("method", c.Request.Method),
attribute.String("route", route),
attribute.String("status", strconv.Itoa(c.Writer.Status())),
)
instruments.requestsTotal.Add(c.Request.Context(), 1, attrs)
instruments.requestDuration.Record(c.Request.Context(), duration.Seconds(), attrs)
}
}
@@ -0,0 +1,38 @@
// Package panicrecovery converts unrecovered panics into a structured 500
// response and a single error-level log entry. It is wired exactly once at the
// top of the gin middleware chain.
package panicrecovery
import (
"net/http"
"galaxy/backend/internal/server/httperr"
"galaxy/backend/internal/server/middleware/requestid"
"galaxy/backend/internal/telemetry"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// Middleware returns a gin middleware that recovers from panics, logs the
// failure with trace fields, and writes the standard 500 envelope.
func Middleware(logger *zap.Logger) gin.HandlerFunc {
if logger == nil {
logger = zap.NewNop()
}
return gin.CustomRecovery(func(c *gin.Context, recovered any) {
fields := []zap.Field{
zap.String("method", c.Request.Method),
zap.String("path", c.FullPath()),
zap.Any("panic", recovered),
}
if requestID, ok := requestid.FromGin(c); ok {
fields = append(fields, zap.String("request_id", requestID))
}
fields = append(fields, telemetry.TraceFieldsFromContext(c.Request.Context())...)
logger.Error("http handler panicked", fields...)
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "internal server error")
})
}
@@ -0,0 +1,83 @@
// Package requestid carries a per-request identifier across the gin handler
// chain.
//
// The middleware reads the inbound `X-Request-ID` header, generates a UUIDv4
// when absent, stores the value on the gin context, and reflects it on the
// response. Downstream code retrieves the identifier through FromContext.
package requestid
import (
"context"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Header is the canonical case-correct header name carrying the request id.
const Header = "X-Request-ID"
// ginContextKey is the gin.Context key under which the resolved request id is
// stored. The value is a string. The key is exported in lowercase form so that
// it never collides with handler-level keys; consumers should prefer
// FromContext rather than reading the gin context directly.
const ginContextKey = "backend.request_id"
// requestIDContextKey is the unexported context.Context key used when the
// resolved request id is propagated outside gin (background goroutines,
// downstream client calls). The unexported value type prevents accidental
// collisions across packages.
type requestIDContextKey struct{}
// Middleware returns the gin middleware that resolves and propagates the
// request id.
func Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := strings.TrimSpace(c.GetHeader(Header))
if requestID == "" {
requestID = uuid.NewString()
}
c.Set(ginContextKey, requestID)
c.Writer.Header().Set(Header, requestID)
c.Request = c.Request.WithContext(WithValue(c.Request.Context(), requestID))
c.Next()
}
}
// FromContext returns the request id stored on ctx by Middleware. The boolean
// reports whether an id was found. Consumers must always check the boolean
// before using the returned string.
func FromContext(ctx context.Context) (string, bool) {
if ctx == nil {
return "", false
}
value, ok := ctx.Value(requestIDContextKey{}).(string)
if !ok || value == "" {
return "", false
}
return value, true
}
// FromGin returns the request id stored on the gin context by Middleware. The
// boolean reports whether an id was found.
func FromGin(c *gin.Context) (string, bool) {
if c == nil {
return "", false
}
raw, ok := c.Get(ginContextKey)
if !ok {
return "", false
}
value, ok := raw.(string)
if !ok || value == "" {
return "", false
}
return value, true
}
// WithValue stores requestID on ctx under the package-private context key.
// Exposed primarily for tests that build a context outside the middleware.
func WithValue(ctx context.Context, requestID string) context.Context {
return context.WithValue(ctx, requestIDContextKey{}, requestID)
}
@@ -0,0 +1,70 @@
// Package userid extracts the calling user identifier from the trusted
// `X-User-ID` header injected by gateway and exposes it through the request
// context.
//
// Backend trusts the header value because the network segment between gateway
// and backend is the trust boundary (see `ARCHITECTURE.md` §15). The
// middleware therefore only validates the syntactic shape (UUID) and rejects
// malformed or absent values with the standard `400 invalid_request` envelope.
package userid
import (
"context"
"net/http"
"strings"
"galaxy/backend/internal/server/httperr"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Header is the canonical case-correct header name carrying the trusted user
// id forwarded by gateway.
const Header = "X-User-ID"
// userIDContextKey is the unexported context key used to store the parsed
// user id. The unexported value type prevents accidental collisions with
// keys defined in unrelated packages.
type userIDContextKey struct{}
// Middleware returns the gin middleware that requires a syntactically valid
// `X-User-ID` header on every authenticated user request and stores the
// parsed UUID on the request context.
func Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
raw := strings.TrimSpace(c.GetHeader(Header))
if raw == "" {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, Header+" header is required")
return
}
userID, err := uuid.Parse(raw)
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, Header+" header must be a valid UUID")
return
}
c.Request = c.Request.WithContext(WithValue(c.Request.Context(), userID))
c.Next()
}
}
// FromContext returns the user id stored on ctx by Middleware. The boolean
// reports whether a value was found.
func FromContext(ctx context.Context) (uuid.UUID, bool) {
if ctx == nil {
return uuid.Nil, false
}
value, ok := ctx.Value(userIDContextKey{}).(uuid.UUID)
if !ok {
return uuid.Nil, false
}
return value, true
}
// WithValue stores userID on ctx under the package-private context key.
// Exposed for tests that need to build a context outside the middleware.
func WithValue(ctx context.Context, userID uuid.UUID) context.Context {
return context.WithValue(ctx, userIDContextKey{}, userID)
}
+26
View File
@@ -0,0 +1,26 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
)
// statusResponse is the body shape returned by both probes.
type statusResponse struct {
Status string `json:"status"`
}
func handleHealthz(c *gin.Context) {
c.JSON(http.StatusOK, statusResponse{Status: "ok"})
}
func handleReadyz(ready func() bool) gin.HandlerFunc {
return func(c *gin.Context) {
if ready != nil && !ready() {
c.JSON(http.StatusServiceUnavailable, statusResponse{Status: "starting"})
return
}
c.JSON(http.StatusOK, statusResponse{Status: "ready"})
}
}
+345
View File
@@ -0,0 +1,345 @@
// Package server hosts the backend HTTP listener and the route
// configuration that wires the documented `backend/openapi.yaml`
// contract against the per-domain handler sets.
//
// router.go is the single place where route groups, group-scoped
// middleware, and per-domain handlers are mounted. Domain handlers
// hold their own Service references; the routing layout is stable.
package server
import (
"net/http"
"sync"
"galaxy/backend/internal/server/httperr"
"galaxy/backend/internal/server/middleware/basicauth"
"galaxy/backend/internal/server/middleware/geocounter"
"galaxy/backend/internal/server/middleware/logging"
"galaxy/backend/internal/server/middleware/metrics"
"galaxy/backend/internal/server/middleware/panicrecovery"
"galaxy/backend/internal/server/middleware/requestid"
"galaxy/backend/internal/server/middleware/userid"
"galaxy/backend/internal/telemetry"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
"go.uber.org/zap"
)
const (
// otelServerName is the operation-name attribute attached to spans
// produced by otelgin.
otelServerName = "galaxy-backend"
// adminBasicAuthRealm is the realm advertised on `WWW-Authenticate`
// responses from the admin surface.
adminBasicAuthRealm = "galaxy-admin"
)
var configureGinModeOnce sync.Once
// RouterDependencies aggregates every collaborator required to build the
// backend HTTP handler chain.
//
// Logger, Telemetry, and Ready come from the process bootstrap.
// AdminVerifier gates the admin surface; production wires
// `*admin.Service`. The handler-set fields are allowed to be nil —
// NewRouter substitutes a freshly-constructed placeholder set so
// callers can supply only the slices they want to override.
type RouterDependencies struct {
Logger *zap.Logger
Telemetry *telemetry.Runtime
Ready func() bool
AdminVerifier basicauth.Verifier
// GeoCounter, when non-nil, is mounted as middleware on the
// `/api/v1/user/*` route group so that every authenticated request
// dispatches a fire-and-forget counter increment. A nil value
// leaves the route group untouched, which keeps existing tests
// that build the router without geo wiring working as before.
GeoCounter geocounter.Service
PublicAuth *PublicAuthHandlers
UserAccount *UserAccountHandlers
UserLobbyGames *UserLobbyGamesHandlers
UserLobbyApplications *UserLobbyApplicationsHandlers
UserLobbyInvites *UserLobbyInvitesHandlers
UserLobbyMemberships *UserLobbyMembershipsHandlers
UserLobbyMy *UserLobbyMyHandlers
UserLobbyRaceNames *UserLobbyRaceNamesHandlers
UserGames *UserGamesHandlers
AdminAdminAccounts *AdminAdminAccountsHandlers
AdminUsers *AdminUsersHandlers
AdminGames *AdminGamesHandlers
AdminRuntimes *AdminRuntimesHandlers
AdminEngineVersions *AdminEngineVersionsHandlers
AdminMail *AdminMailHandlers
AdminNotifications *AdminNotificationsHandlers
AdminGeo *AdminGeoHandlers
InternalSessions *InternalSessionsHandlers
InternalUsers *InternalUsersHandlers
}
// NewRouter constructs the backend gin engine wired with the documented
// middleware chain and every placeholder route from `backend/openapi.yaml`.
// The returned handler is safe to pass into Server.NewServer.
func NewRouter(deps RouterDependencies) (http.Handler, error) {
configureGinModeOnce.Do(func() {
gin.SetMode(gin.ReleaseMode)
})
if deps.Logger == nil {
deps.Logger = zap.NewNop()
}
deps = withDefaultHandlers(deps)
logger := deps.Logger.Named("http")
var instruments *metrics.Instruments
if deps.Telemetry != nil {
var err error
instruments, err = metrics.NewInstruments(deps.Telemetry.MeterProvider().Meter(otelServerName))
if err != nil {
return nil, err
}
}
router := gin.New()
router.HandleMethodNotAllowed = true
router.Use(requestid.Middleware())
router.Use(panicrecovery.Middleware(logger))
router.Use(otelgin.Middleware(otelServerName))
router.Use(logging.Middleware(logger))
router.GET("/healthz", metrics.Middleware(instruments, metrics.GroupProbes), handleHealthz)
router.GET("/readyz", metrics.Middleware(instruments, metrics.GroupProbes), handleReadyz(deps.Ready))
registerPublicRoutes(router, instruments, deps)
registerUserRoutes(router, instruments, deps)
registerAdminRoutes(router, instruments, deps)
registerInternalRoutes(router, instruments, deps)
router.NoMethod(func(c *gin.Context) {
if allow := allowedMethodsForPath(c.Request.URL.Path); allow != "" {
c.Header("Allow", allow)
}
httperr.Abort(c, http.StatusMethodNotAllowed, httperr.CodeMethodNotAllowed, "request method is not allowed for this route")
})
router.NoRoute(func(c *gin.Context) {
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "resource was not found")
})
return router, nil
}
func withDefaultHandlers(deps RouterDependencies) RouterDependencies {
if deps.PublicAuth == nil {
deps.PublicAuth = NewPublicAuthHandlers(nil, deps.Logger)
}
if deps.UserAccount == nil {
deps.UserAccount = NewUserAccountHandlers(nil, deps.Logger)
}
if deps.UserLobbyGames == nil {
deps.UserLobbyGames = NewUserLobbyGamesHandlers(nil, deps.Logger)
}
if deps.UserLobbyApplications == nil {
deps.UserLobbyApplications = NewUserLobbyApplicationsHandlers(nil, deps.Logger)
}
if deps.UserLobbyInvites == nil {
deps.UserLobbyInvites = NewUserLobbyInvitesHandlers(nil, deps.Logger)
}
if deps.UserLobbyMemberships == nil {
deps.UserLobbyMemberships = NewUserLobbyMembershipsHandlers(nil, deps.Logger)
}
if deps.UserLobbyMy == nil {
deps.UserLobbyMy = NewUserLobbyMyHandlers(nil, deps.Logger)
}
if deps.UserLobbyRaceNames == nil {
deps.UserLobbyRaceNames = NewUserLobbyRaceNamesHandlers(nil, deps.Logger)
}
if deps.UserGames == nil {
deps.UserGames = NewUserGamesHandlers(nil, nil, deps.Logger)
}
if deps.AdminAdminAccounts == nil {
deps.AdminAdminAccounts = NewAdminAdminAccountsHandlers(nil, deps.Logger)
}
if deps.AdminUsers == nil {
deps.AdminUsers = NewAdminUsersHandlers(nil, deps.Logger)
}
if deps.AdminGames == nil {
deps.AdminGames = NewAdminGamesHandlers(nil, deps.Logger)
}
if deps.AdminRuntimes == nil {
deps.AdminRuntimes = NewAdminRuntimesHandlers(nil, deps.Logger)
}
if deps.AdminEngineVersions == nil {
deps.AdminEngineVersions = NewAdminEngineVersionsHandlers(nil, deps.Logger)
}
if deps.AdminMail == nil {
deps.AdminMail = NewAdminMailHandlers(nil, deps.Logger)
}
if deps.AdminNotifications == nil {
deps.AdminNotifications = NewAdminNotificationsHandlers(nil, deps.Logger)
}
if deps.AdminGeo == nil {
deps.AdminGeo = NewAdminGeoHandlers(nil, deps.Logger)
}
if deps.InternalSessions == nil {
deps.InternalSessions = NewInternalSessionsHandlers(nil, deps.Logger)
}
if deps.InternalUsers == nil {
deps.InternalUsers = NewInternalUsersHandlers(nil, deps.Logger)
}
return deps
}
func registerPublicRoutes(router *gin.Engine, instruments *metrics.Instruments, deps RouterDependencies) {
group := router.Group("/api/v1/public")
group.Use(metrics.Middleware(instruments, metrics.GroupPublic))
auth := group.Group("/auth")
auth.POST("/send-email-code", deps.PublicAuth.SendEmailCode())
auth.POST("/confirm-email-code", deps.PublicAuth.ConfirmEmailCode())
}
func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, deps RouterDependencies) {
group := router.Group("/api/v1/user")
group.Use(metrics.Middleware(instruments, metrics.GroupUser))
group.Use(userid.Middleware())
if deps.GeoCounter != nil {
group.Use(geocounter.Middleware(deps.GeoCounter))
}
account := group.Group("/account")
account.GET("", deps.UserAccount.Get())
account.PATCH("/profile", deps.UserAccount.UpdateProfile())
account.PATCH("/settings", deps.UserAccount.UpdateSettings())
account.POST("/delete", deps.UserAccount.Delete())
lobbyGroup := group.Group("/lobby")
games := lobbyGroup.Group("/games")
games.GET("", deps.UserLobbyGames.List())
games.POST("", deps.UserLobbyGames.Create())
games.GET("/:game_id", deps.UserLobbyGames.Get())
games.PATCH("/:game_id", deps.UserLobbyGames.Update())
games.POST("/:game_id/open-enrollment", deps.UserLobbyGames.OpenEnrollment())
games.POST("/:game_id/ready-to-start", deps.UserLobbyGames.ReadyToStart())
games.POST("/:game_id/start", deps.UserLobbyGames.Start())
games.POST("/:game_id/pause", deps.UserLobbyGames.Pause())
games.POST("/:game_id/resume", deps.UserLobbyGames.Resume())
games.POST("/:game_id/cancel", deps.UserLobbyGames.Cancel())
games.POST("/:game_id/retry-start", deps.UserLobbyGames.RetryStart())
games.POST("/:game_id/applications", deps.UserLobbyApplications.Submit())
games.POST("/:game_id/applications/:application_id/approve", deps.UserLobbyApplications.Approve())
games.POST("/:game_id/applications/:application_id/reject", deps.UserLobbyApplications.Reject())
games.POST("/:game_id/invites", deps.UserLobbyInvites.Issue())
games.POST("/:game_id/invites/:invite_id/redeem", deps.UserLobbyInvites.Redeem())
games.POST("/:game_id/invites/:invite_id/decline", deps.UserLobbyInvites.Decline())
games.POST("/:game_id/invites/:invite_id/revoke", deps.UserLobbyInvites.Revoke())
games.GET("/:game_id/memberships", deps.UserLobbyMemberships.List())
games.POST("/:game_id/memberships/:membership_id/remove", deps.UserLobbyMemberships.Remove())
games.POST("/:game_id/memberships/:membership_id/block", deps.UserLobbyMemberships.Block())
my := lobbyGroup.Group("/my")
my.GET("/games", deps.UserLobbyMy.Games())
my.GET("/applications", deps.UserLobbyMy.Applications())
my.GET("/invites", deps.UserLobbyMy.Invites())
my.GET("/race-names", deps.UserLobbyMy.RaceNames())
raceNames := lobbyGroup.Group("/race-names")
raceNames.POST("/register", deps.UserLobbyRaceNames.Register())
userGames := group.Group("/games")
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())
}
func registerAdminRoutes(router *gin.Engine, instruments *metrics.Instruments, deps RouterDependencies) {
group := router.Group("/api/v1/admin")
group.Use(metrics.Middleware(instruments, metrics.GroupAdmin))
group.Use(basicauth.Middleware(deps.AdminVerifier, adminBasicAuthRealm))
adminAccounts := group.Group("/admin-accounts")
adminAccounts.GET("", deps.AdminAdminAccounts.List())
adminAccounts.POST("", deps.AdminAdminAccounts.Create())
adminAccounts.GET("/:username", deps.AdminAdminAccounts.Get())
adminAccounts.POST("/:username/disable", deps.AdminAdminAccounts.Disable())
adminAccounts.POST("/:username/enable", deps.AdminAdminAccounts.Enable())
adminAccounts.POST("/:username/reset-password", deps.AdminAdminAccounts.ResetPassword())
users := group.Group("/users")
users.GET("", deps.AdminUsers.List())
users.GET("/:user_id", deps.AdminUsers.Get())
users.POST("/:user_id/sanctions", deps.AdminUsers.AddSanction())
users.POST("/:user_id/limits", deps.AdminUsers.AddLimit())
users.POST("/:user_id/entitlements", deps.AdminUsers.AddEntitlement())
users.POST("/:user_id/soft-delete", deps.AdminUsers.SoftDelete())
games := group.Group("/games")
games.GET("", deps.AdminGames.List())
games.POST("", deps.AdminGames.Create())
games.GET("/:game_id", deps.AdminGames.Get())
games.POST("/:game_id/force-start", deps.AdminGames.ForceStart())
games.POST("/:game_id/force-stop", deps.AdminGames.ForceStop())
games.POST("/:game_id/ban-member", deps.AdminGames.BanMember())
runtimes := group.Group("/runtimes")
runtimes.GET("/:game_id", deps.AdminRuntimes.Get())
runtimes.POST("/:game_id/restart", deps.AdminRuntimes.Restart())
runtimes.POST("/:game_id/patch", deps.AdminRuntimes.Patch())
runtimes.POST("/:game_id/force-next-turn", deps.AdminRuntimes.ForceNextTurn())
engineVersions := group.Group("/engine-versions")
engineVersions.GET("", deps.AdminEngineVersions.List())
engineVersions.POST("", deps.AdminEngineVersions.Create())
engineVersions.PATCH("/:id", deps.AdminEngineVersions.Update())
engineVersions.POST("/:id/disable", deps.AdminEngineVersions.Disable())
mail := group.Group("/mail")
mail.GET("/deliveries", deps.AdminMail.ListDeliveries())
mail.GET("/deliveries/:delivery_id", deps.AdminMail.GetDelivery())
mail.GET("/deliveries/:delivery_id/attempts", deps.AdminMail.ListDeliveryAttempts())
mail.POST("/deliveries/:delivery_id/resend", deps.AdminMail.ResendDelivery())
mail.GET("/dead-letters", deps.AdminMail.ListDeadLetters())
notifications := group.Group("/notifications")
notifications.GET("", deps.AdminNotifications.List())
notifications.GET("/dead-letters", deps.AdminNotifications.ListDeadLetters())
notifications.GET("/malformed", deps.AdminNotifications.ListMalformed())
notifications.GET("/:notification_id", deps.AdminNotifications.Get())
geo := group.Group("/geo")
geo.GET("/users/:user_id/countries", deps.AdminGeo.ListUserCountries())
}
func registerInternalRoutes(router *gin.Engine, instruments *metrics.Instruments, deps RouterDependencies) {
group := router.Group("/api/v1/internal")
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())
}
// allowedMethodsForPath returns the comma-separated list of methods
// the gin router accepts on requestPath. Only the probe paths declare
// a non-empty list so NoMethod can advertise a useful `Allow` header
// on `/healthz` and `/readyz`. Other endpoints fall through to NoRoute.
func allowedMethodsForPath(requestPath string) string {
switch requestPath {
case "/healthz", "/readyz":
return http.MethodGet
default:
return ""
}
}
+124
View File
@@ -0,0 +1,124 @@
// Package server is documented in router.go. server.go owns the HTTP listener
// lifecycle: it binds the configured TCP listener, serves the supplied
// http.Handler, and shuts down within the configured budget.
package server
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"sync"
"time"
"galaxy/backend/internal/config"
"go.uber.org/zap"
)
// Server owns the HTTP listener exposed by the backend.
type Server struct {
cfg config.HTTPConfig
handler http.Handler
logger *zap.Logger
stateMu sync.RWMutex
server *http.Server
listener net.Listener
}
// NewServer constructs an HTTP server bound to cfg. handler is the prebuilt
// http.Handler returned by NewRouter. A nil logger is replaced with zap.NewNop.
func NewServer(cfg config.HTTPConfig, handler http.Handler, logger *zap.Logger) *Server {
if logger == nil {
logger = zap.NewNop()
}
if handler == nil {
handler = http.NotFoundHandler()
}
return &Server{
cfg: cfg,
handler: handler,
logger: logger.Named("http"),
}
}
// Run binds the listener and serves requests until Shutdown closes the server.
func (s *Server) Run(ctx context.Context) error {
if ctx == nil {
return errors.New("run backend HTTP server: nil context")
}
if err := ctx.Err(); err != nil {
return err
}
listener, err := net.Listen("tcp", s.cfg.Addr)
if err != nil {
return fmt.Errorf("run backend HTTP server: listen on %q: %w", s.cfg.Addr, err)
}
server := &http.Server{
Handler: s.handler,
ReadTimeout: s.cfg.ReadTimeout,
WriteTimeout: s.cfg.WriteTimeout,
IdleTimeout: s.cfg.ReadTimeout,
}
s.stateMu.Lock()
s.server = server
s.listener = listener
s.stateMu.Unlock()
s.logger.Info("backend HTTP server started", zap.String("addr", listener.Addr().String()))
defer func() {
s.stateMu.Lock()
s.server = nil
s.listener = nil
s.stateMu.Unlock()
}()
err = server.Serve(listener)
switch {
case err == nil:
return nil
case errors.Is(err, http.ErrServerClosed):
s.logger.Info("backend HTTP server stopped")
return nil
default:
return fmt.Errorf("run backend HTTP server: serve on %q: %w", s.cfg.Addr, err)
}
}
// Shutdown gracefully stops the HTTP server within ctx, applying the
// configured per-listener shutdown timeout when it is shorter.
func (s *Server) Shutdown(ctx context.Context) error {
if ctx == nil {
return errors.New("shutdown backend HTTP server: nil context")
}
s.stateMu.RLock()
server := s.server
s.stateMu.RUnlock()
if server == nil {
return nil
}
shutdownCtx, cancel := boundedContext(ctx, s.cfg.ShutdownTimeout)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("shutdown backend HTTP server: %w", err)
}
return nil
}
func boundedContext(parent context.Context, limit time.Duration) (context.Context, context.CancelFunc) {
if limit <= 0 {
return context.WithCancel(parent)
}
return context.WithTimeout(parent, limit)
}