Loading docs/openapi.yaml +8 −0 Changes for docs/openapi.yaml: 8 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -1422,6 +1422,8 @@ paths: additionalProperties: type: string enum: [retiring, start_failed, owner, wake, owner_only, allowlist] '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' Loading Loading @@ -1505,6 +1507,8 @@ paths: required: [linked] properties: linked: { type: boolean } '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' Loading Loading @@ -1637,6 +1641,8 @@ paths: required: [blacklisted] properties: blacklisted: { type: boolean } '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' Loading Loading @@ -4684,6 +4690,8 @@ paths: properties: ok: { type: boolean, const: true } mc_uuid: { type: string, format: uuid } '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': Loading internal/api/handlers_account.go +33 −8 Changes for internal/api/handlers_account.go: 33 added lines, 8 removed lines. Original line number Diff line number Diff line Loading @@ -6,6 +6,8 @@ import ( "net/http" "strings" "time" "github.com/google/uuid" ) // Account-linking endpoints (spec §10). The flow is forced by the Loading Loading @@ -51,6 +53,28 @@ func validAuthSource(s string) bool { return s == authSourceMojang || s == authSourceThirdParty } // errBadMCUUID answers an mc_uuid that is not a UUID. Every mc_uuid column is // Postgres's uuid type, which refuses such text with 22P02, and that reached the // caller as a 500. var errBadMCUUID = newError(http.StatusBadRequest, "bad_mc_uuid", "mc_uuid must be a UUID, such as 069a79f4-44e9-4726-a5be-fca90e38aaf5") // parseMCUUID reads an mc_uuid from a request: surrounding spaces trimmed, empty // → 400 bad_request "mc_uuid is required", not a UUID → errBadMCUUID. It returns // the canonical lowercase hyphenated form, the text Postgres gives back for the // column, so what a handler stores, echoes and compares is one spelling. func parseMCUUID(s string) (string, error) { s = strings.TrimSpace(s) if s == "" { return "", newError(http.StatusBadRequest, "bad_request", "mc_uuid is required") } id, err := uuid.Parse(s) if err != nil { return "", errBadMCUUID } return id.String(), nil } // deriveAuthSource infers the auth source from the UUID's version nibble when // the minting backend omitted auth_source. Felis-nano rewrites every // third-party profile to a name-based UUIDv3 under its namespace before it ever Loading Loading @@ -104,8 +128,9 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) { writeError(w, r, err) return } if req.MCUUID == "" { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required")) mcUUID, err := parseMCUUID(req.MCUUID) if err != nil { writeError(w, r, err) return } // Default an omitted source from the UUID's version nibble (v3 = felis-nano Loading @@ -114,7 +139,7 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) { // stored value the panel will later mislabel. authSource := req.AuthSource if authSource == "" { authSource = deriveAuthSource(req.MCUUID) authSource = deriveAuthSource(mcUUID) } if !validAuthSource(authSource) { writeError(w, r, newError(http.StatusBadRequest, "bad_request", Loading @@ -127,7 +152,7 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) { return } expiresAt := a.now().Add(linkCodeTTL) if err := a.Repo.CreateLinkCode(r.Context(), code, req.MCUUID, authSource, expiresAt); err != nil { if err := a.Repo.CreateLinkCode(r.Context(), code, mcUUID, authSource, expiresAt); err != nil { writeError(w, r, err) return } Loading Loading @@ -171,12 +196,12 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) { // handlers_player_reclaim.go keeps CODE-ONLY (reclaimed_by_user_id stays NULL on // the verifiable path); this endpoint reports link completion only, not that choice. func (a *API) handleLinkStatus(w http.ResponseWriter, r *http.Request) { mcUUID := r.PathValue("mc_uuid") if mcUUID == "" { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required")) mcUUID, err := parseMCUUID(r.PathValue("mc_uuid")) if err != nil { writeError(w, r, err) return } _, err := a.Repo.UserByMCUUID(r.Context(), mcUUID) _, err = a.Repo.UserByMCUUID(r.Context(), mcUUID) switch { case errors.Is(err, ErrNotFound): // Not linked yet. For the poller this is simply "keep waiting": velocity Loading internal/api/handlers_account_migrate.go +3 −3 Changes for internal/api/handlers_account_migrate.go: 3 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -111,9 +111,9 @@ func (a *API) handleMigrateStart(w http.ResponseWriter, r *http.Request) { writeError(w, r, err) return } mcUUID := strings.TrimSpace(req.MCUUID) if mcUUID == "" { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required")) mcUUID, err := parseMCUUID(req.MCUUID) if err != nil { writeError(w, r, err) return } sourceUserID, err := a.Repo.UserByMCUUID(r.Context(), mcUUID) Loading internal/api/handlers_allowlist.go +1 −1 Changes for internal/api/handlers_allowlist.go: 1 added line, 1 removed line. Original line number Diff line number Diff line Loading @@ -66,7 +66,7 @@ func (a *API) handleAllowlistSetWake(w http.ResponseWriter, r *http.Request) { } id, err := uuid.Parse(r.PathValue("uuid")) if err != nil { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "invalid Minecraft UUID")) writeError(w, r, errBadMCUUID) return } var req allowlistWakeRequest Loading internal/api/handlers_internal.go +15 −12 Changes for internal/api/handlers_internal.go: 15 added lines, 12 removed lines. Original line number Diff line number Diff line Loading @@ -83,11 +83,12 @@ func (a *API) handleJoinEvent(w http.ResponseWriter, r *http.Request) { writeError(w, r, err) return } if req.MCUUID == "" { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required")) mcUUID, err := parseMCUUID(req.MCUUID) if err != nil { writeError(w, r, err) return } if err := a.Repo.RecordJoin(r.Context(), name, req.MCUUID); err != nil { if err := a.Repo.RecordJoin(r.Context(), name, mcUUID); err != nil { a.writeLookupError(w, r, err) return } Loading Loading @@ -120,8 +121,9 @@ func (a *API) handleInternalWake(w http.ResponseWriter, r *http.Request) { writeError(w, r, err) return } if req.MCUUID == "" { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required")) mcUUID, err := parseMCUUID(req.MCUUID) if err != nil { writeError(w, r, err) return } Loading @@ -148,7 +150,7 @@ func (a *API) handleInternalWake(w http.ResponseWriter, r *http.Request) { return } if err := a.authorizeWakeByUUID(r.Context(), req.MCUUID, info, rec); err != nil { if err := a.authorizeWakeByUUID(r.Context(), mcUUID, info, rec); err != nil { writeError(w, r, err) return } Loading Loading @@ -235,8 +237,9 @@ func (a *API) handleInternalClaim(w http.ResponseWriter, r *http.Request) { writeError(w, r, err) return } if req.MCUUID == "" { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required")) mcUUID, err := parseMCUUID(req.MCUUID) if err != nil { writeError(w, r, err) return } Loading @@ -245,7 +248,7 @@ func (a *API) handleInternalClaim(w http.ResponseWriter, r *http.Request) { // no separate IsLinked check (mirrors the external claim's order, link → quota // → write). ErrNotFound here is "claimer not linked" (412), never "server // missing" — that distinction is the claim call's, below. userID, err := a.Repo.UserByMCUUID(r.Context(), req.MCUUID) userID, err := a.Repo.UserByMCUUID(r.Context(), mcUUID) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, r, newError(http.StatusPreconditionFailed, "not_linked", Loading Loading @@ -364,9 +367,9 @@ const ( // since a retry gets past those. Retiring comes first: nobody may start such a // server, so it is the reason a stranger is shown too. func (a *API) handleInternalMenuAccess(w http.ResponseWriter, r *http.Request) { mcUUID := r.PathValue("mc_uuid") if mcUUID == "" { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required")) mcUUID, err := parseMCUUID(r.PathValue("mc_uuid")) if err != nil { writeError(w, r, err) return } infos, err := a.Cluster.ListServers(r.Context()) Loading Loading
docs/openapi.yaml +8 −0 Changes for docs/openapi.yaml: 8 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -1422,6 +1422,8 @@ paths: additionalProperties: type: string enum: [retiring, start_failed, owner, wake, owner_only, allowlist] '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' Loading Loading @@ -1505,6 +1507,8 @@ paths: required: [linked] properties: linked: { type: boolean } '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' Loading Loading @@ -1637,6 +1641,8 @@ paths: required: [blacklisted] properties: blacklisted: { type: boolean } '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' Loading Loading @@ -4684,6 +4690,8 @@ paths: properties: ok: { type: boolean, const: true } mc_uuid: { type: string, format: uuid } '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': Loading
internal/api/handlers_account.go +33 −8 Changes for internal/api/handlers_account.go: 33 added lines, 8 removed lines. Original line number Diff line number Diff line Loading @@ -6,6 +6,8 @@ import ( "net/http" "strings" "time" "github.com/google/uuid" ) // Account-linking endpoints (spec §10). The flow is forced by the Loading Loading @@ -51,6 +53,28 @@ func validAuthSource(s string) bool { return s == authSourceMojang || s == authSourceThirdParty } // errBadMCUUID answers an mc_uuid that is not a UUID. Every mc_uuid column is // Postgres's uuid type, which refuses such text with 22P02, and that reached the // caller as a 500. var errBadMCUUID = newError(http.StatusBadRequest, "bad_mc_uuid", "mc_uuid must be a UUID, such as 069a79f4-44e9-4726-a5be-fca90e38aaf5") // parseMCUUID reads an mc_uuid from a request: surrounding spaces trimmed, empty // → 400 bad_request "mc_uuid is required", not a UUID → errBadMCUUID. It returns // the canonical lowercase hyphenated form, the text Postgres gives back for the // column, so what a handler stores, echoes and compares is one spelling. func parseMCUUID(s string) (string, error) { s = strings.TrimSpace(s) if s == "" { return "", newError(http.StatusBadRequest, "bad_request", "mc_uuid is required") } id, err := uuid.Parse(s) if err != nil { return "", errBadMCUUID } return id.String(), nil } // deriveAuthSource infers the auth source from the UUID's version nibble when // the minting backend omitted auth_source. Felis-nano rewrites every // third-party profile to a name-based UUIDv3 under its namespace before it ever Loading Loading @@ -104,8 +128,9 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) { writeError(w, r, err) return } if req.MCUUID == "" { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required")) mcUUID, err := parseMCUUID(req.MCUUID) if err != nil { writeError(w, r, err) return } // Default an omitted source from the UUID's version nibble (v3 = felis-nano Loading @@ -114,7 +139,7 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) { // stored value the panel will later mislabel. authSource := req.AuthSource if authSource == "" { authSource = deriveAuthSource(req.MCUUID) authSource = deriveAuthSource(mcUUID) } if !validAuthSource(authSource) { writeError(w, r, newError(http.StatusBadRequest, "bad_request", Loading @@ -127,7 +152,7 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) { return } expiresAt := a.now().Add(linkCodeTTL) if err := a.Repo.CreateLinkCode(r.Context(), code, req.MCUUID, authSource, expiresAt); err != nil { if err := a.Repo.CreateLinkCode(r.Context(), code, mcUUID, authSource, expiresAt); err != nil { writeError(w, r, err) return } Loading Loading @@ -171,12 +196,12 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) { // handlers_player_reclaim.go keeps CODE-ONLY (reclaimed_by_user_id stays NULL on // the verifiable path); this endpoint reports link completion only, not that choice. func (a *API) handleLinkStatus(w http.ResponseWriter, r *http.Request) { mcUUID := r.PathValue("mc_uuid") if mcUUID == "" { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required")) mcUUID, err := parseMCUUID(r.PathValue("mc_uuid")) if err != nil { writeError(w, r, err) return } _, err := a.Repo.UserByMCUUID(r.Context(), mcUUID) _, err = a.Repo.UserByMCUUID(r.Context(), mcUUID) switch { case errors.Is(err, ErrNotFound): // Not linked yet. For the poller this is simply "keep waiting": velocity Loading
internal/api/handlers_account_migrate.go +3 −3 Changes for internal/api/handlers_account_migrate.go: 3 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -111,9 +111,9 @@ func (a *API) handleMigrateStart(w http.ResponseWriter, r *http.Request) { writeError(w, r, err) return } mcUUID := strings.TrimSpace(req.MCUUID) if mcUUID == "" { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required")) mcUUID, err := parseMCUUID(req.MCUUID) if err != nil { writeError(w, r, err) return } sourceUserID, err := a.Repo.UserByMCUUID(r.Context(), mcUUID) Loading
internal/api/handlers_allowlist.go +1 −1 Changes for internal/api/handlers_allowlist.go: 1 added line, 1 removed line. Original line number Diff line number Diff line Loading @@ -66,7 +66,7 @@ func (a *API) handleAllowlistSetWake(w http.ResponseWriter, r *http.Request) { } id, err := uuid.Parse(r.PathValue("uuid")) if err != nil { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "invalid Minecraft UUID")) writeError(w, r, errBadMCUUID) return } var req allowlistWakeRequest Loading
internal/api/handlers_internal.go +15 −12 Changes for internal/api/handlers_internal.go: 15 added lines, 12 removed lines. Original line number Diff line number Diff line Loading @@ -83,11 +83,12 @@ func (a *API) handleJoinEvent(w http.ResponseWriter, r *http.Request) { writeError(w, r, err) return } if req.MCUUID == "" { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required")) mcUUID, err := parseMCUUID(req.MCUUID) if err != nil { writeError(w, r, err) return } if err := a.Repo.RecordJoin(r.Context(), name, req.MCUUID); err != nil { if err := a.Repo.RecordJoin(r.Context(), name, mcUUID); err != nil { a.writeLookupError(w, r, err) return } Loading Loading @@ -120,8 +121,9 @@ func (a *API) handleInternalWake(w http.ResponseWriter, r *http.Request) { writeError(w, r, err) return } if req.MCUUID == "" { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required")) mcUUID, err := parseMCUUID(req.MCUUID) if err != nil { writeError(w, r, err) return } Loading @@ -148,7 +150,7 @@ func (a *API) handleInternalWake(w http.ResponseWriter, r *http.Request) { return } if err := a.authorizeWakeByUUID(r.Context(), req.MCUUID, info, rec); err != nil { if err := a.authorizeWakeByUUID(r.Context(), mcUUID, info, rec); err != nil { writeError(w, r, err) return } Loading Loading @@ -235,8 +237,9 @@ func (a *API) handleInternalClaim(w http.ResponseWriter, r *http.Request) { writeError(w, r, err) return } if req.MCUUID == "" { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required")) mcUUID, err := parseMCUUID(req.MCUUID) if err != nil { writeError(w, r, err) return } Loading @@ -245,7 +248,7 @@ func (a *API) handleInternalClaim(w http.ResponseWriter, r *http.Request) { // no separate IsLinked check (mirrors the external claim's order, link → quota // → write). ErrNotFound here is "claimer not linked" (412), never "server // missing" — that distinction is the claim call's, below. userID, err := a.Repo.UserByMCUUID(r.Context(), req.MCUUID) userID, err := a.Repo.UserByMCUUID(r.Context(), mcUUID) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, r, newError(http.StatusPreconditionFailed, "not_linked", Loading Loading @@ -364,9 +367,9 @@ const ( // since a retry gets past those. Retiring comes first: nobody may start such a // server, so it is the reason a stranger is shown too. func (a *API) handleInternalMenuAccess(w http.ResponseWriter, r *http.Request) { mcUUID := r.PathValue("mc_uuid") if mcUUID == "" { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required")) mcUUID, err := parseMCUUID(r.PathValue("mc_uuid")) if err != nil { writeError(w, r, err) return } infos, err := a.Cluster.ListServers(r.Context()) Loading