feat(api): add public Bind-Code onboarding for the player console

Adds POST /api/v1/auth/bind, the one public pre-account entrypoint of the
player console (console.<root_domain>). An account-less player redeems the
one-time Bind Code minted in the in-game Login Lobby; in a single step the
platform creates a role=user player, links it to the verified in-game UUID,
and mints a host-only felis_session. Login is thus not forced at the edge
while operations stay app-authenticated.

The operator console (op.console.<root_domain>) is unaffected and stays
behind Zero Trust: a code whose UUID resolves to a staff (role=admin)
account is refused with 403 (ErrPlayerBindForbidden) without consuming the
code, so the public door provably never yields an admin principal — the
session it mints carries ViaAdminAccess=false and is host-only to console,
never sent to op.console.

Repo layer: new RedeemPlayerBindCode on the Repo interface, implemented on
PGRepo (single tx: resolve code, create-or-fetch the player, consume) and
the test fake. The returning-player branch is idempotent and is a deliberate
standing "log in via the game" door, not just first-time onboarding.

Honest labeling:
- ORACLE-VERIFIED (Go): account/session logic — role=user, refuse-staff,
  idempotent create-or-fetch, single-use code, and the op.console redline
  (player session rejected on admin routes). Covered by handlers_onboard_test
  and the OpenAPI parity gate.
- INTEGRATION-dependent: the endpoint's security rests on the Bind Code having
  been minted against an online-mode-Yggdrasil-authenticated UUID, a
  precondition that lives in velocity/Java and is not verifiable from this
  repo (CODE-ONLY). The Go layer proves the logic, not that identity guarantee.
- No app-level attempt cap: rate-limiting is deferred to the edge as for the
  public /auth/login; the ~1e12 keyspace, single use and short TTL make a
  blind app-level cap non-critical.
This commit is contained in:
flyemoji committed 2026-07-01 18:00:13 +09:00
1 parent c01f133cd8
commit fe2ece08cc
8 files changed
+585

No files matched your search

+8
View File
@@ -227,6 +227,14 @@ func (a *API) externalAPIRoutes() []apiRoute {
{Method: "POST", Pattern: "/api/v1/auth/login", Public: true, h: a.handleLogin},
{Method: "POST", Pattern: "/api/v1/auth/logout", Public: true, h: a.handleLogout},
{Method: "POST", Pattern: "/api/v1/auth/change-password", AllowDuringPasswordChange: true, h: a.handleChangePassword},
// Player-console bootstrap (console-tier access model): the account-less
// player's door into console.<root_domain>. Public — like login there is no prior
// principal — and session-minting, but the artifact it consumes is a one-time
// Bind Code minted internal-face against an online-mode-verified UUID, so
// possession already proves a Minecraft identity. A code whose UUID belongs to
// staff is refused (403) so this never yields an admin session; op.console stays
// behind Zero Trust (handlers_onboard.go).
{Method: "POST", Pattern: "/api/v1/auth/bind", Public: true, h: a.handleBindRedeem},
// App-auth tier: operations on your own servers (spec §14).
{Method: "POST", Pattern: "/api/v1/servers/{name}/wake", h: a.handleWake},
+35
View File
@@ -199,6 +199,41 @@ func (f *fakeRepo) VerifyLinkCode(_ context.Context, userID, code string, now ti
return rec.mcUUID, rec.authSource, nil
}
// RedeemPlayerBindCode mirrors PGRepo.RedeemPlayerBindCode: it create-or-fetches a
// player keyed on the code's verified mc_uuid. The staff map stands in for the single
// users table, so a newly created role='user' player is stored there (uuid-derived
// username) and resolves through SessionUser/UserByID just like the PG JOIN. An
// already-linked admin UUID is refused without consuming the code; an already-linked
// player is fetched idempotently.
//
// Contract gap vs PG (benign): on an orphan link (mc_uuid linked but its users row
// gone) the fake resolves no role and falls through to the idempotent return, minting
// a session for a ghost id, whereas PG's account_links⋈users JOIN would find no row,
// take the insert branch and 500 on the UNIQUE(mc_uuid) clash. The account_links.user_id
// FK makes an orphan link unreachable in production, so this divergence is untestable
// rather than a real behavioral difference.
func (f *fakeRepo) RedeemPlayerBindCode(_ context.Context, newUserID, code string, now time.Time) (string, string, string, error) {
rec, ok := f.linkCodes[code]
if !ok || !rec.expiresAt.After(now) {
return "", "", "", ErrLinkCodeInvalid
}
if existing, ok := f.links[rec.mcUUID]; ok {
for _, u := range f.staff { // resolve the linked identity to check its role
if u.ID == existing && u.Role != "user" {
return "", "", "", ErrPlayerBindForbidden // staff must use op.console; do not consume
}
}
delete(f.linkCodes, code)
return existing, rec.mcUUID, rec.authSource, nil
}
f.staff[rec.mcUUID] = &StaffUser{ID: newUserID, Username: rec.mcUUID, Role: "user"}
f.links[rec.mcUUID] = newUserID
f.linkAuthSource[rec.mcUUID] = rec.authSource
f.linked[newUserID] = true
delete(f.linkCodes, code)
return newUserID, rec.mcUUID, rec.authSource, nil
}
// CreateEmailOTP / VerifyEmailOTP mirror PGRepo's contract so the hermetic tests
// exercise the same semantics the integration impl honors: a fresh code supersedes
// the prior live one for (user, purpose), expiry and the attempt cap are checked
+7
View File
@@ -42,6 +42,13 @@ var (
// finish endpoint exists; the ceremony state is gone (never begun, already
// consumed, or expired) — so handlers map it to 400, not 404.
ErrPasskeyChallengeInvalid = errors.New("passkey challenge invalid or expired")
// ErrPlayerBindForbidden means a public Bind-Code redemption resolved to a STAFF
// account (role=admin), which the player-console bootstrap refuses (console-tier
// access model). Operators authenticate at op.console behind Zero Trust, never via
// the account-less console.<root_domain> door, so the public bootstrap provably
// never mints a session for an admin identity. It is distinct from ErrConflict so
// the handler answers 403 (wrong door) rather than 409 (already linked).
ErrPlayerBindForbidden = errors.New("bind code belongs to a staff account")
)
// apiError is a handler-level error carrying an HTTP status and a stable,
+131
View File
@@ -0,0 +1,131 @@
package api
import (
"crypto/rand"
"encoding/hex"
"errors"
"net/http"
"strings"
)
// Player-console onboarding bootstrap (console-tier access model; spec §10, §B). This
// is the ONE public, pre-account entrypoint of the player console (console.<root_domain>):
// an account-less player redeems the one-time Bind Code they generated in the in-game
// Login Lobby, and in a single step the platform creates their player account
// (role=user), binds it to their verified in-game UUID, and mints a player session.
// From there the ordinary app-tier onboarding endpoints (email-OTP, passkey) work off
// the resulting principal like any other — the session model is role-agnostic, so a
// player session is just an opaque felis_session over a role=user row.
//
// Why this may be Public while /account/link/verify may not: a Bind Code is minted
// INTERNAL-face only (handleCreateLinkCode), with a short TTL, single use, and a 32^8
// keyspace, so the web can never originate one (account_link_codes has no user_id) —
// no code, no account. This endpoint MATERIALLY elevates the code's authority: where
// /account/link/verify bound a UUID to an already-authenticated principal, this makes
// the code alone create an account and mint a session. That is only safe if the code
// was minted against a UUID an online-mode Yggdrasil actually authenticated — a
// precondition that lives in velocity/Java (CODE-ONLY, not verifiable from this repo).
// So the safety here is INTEGRATION-dependent on that upstream online-mode guarantee;
// the Go layer proves only the account/session logic (role, refuse-staff, idempotent),
// never the identity guarantee itself.
//
// No app-level attempt cap is enforced here (unlike the email-OTP flow, whose 1e6
// keyspace demanded one): the code's ~1e12 keyspace, single use and short TTL make
// blind brute force non-viable, and rate-limiting is deferred to the edge exactly as
// for the public /auth/login. The idempotent returning-player branch (a UUID already
// linked to a role=user player is fetched, not re-created) is a DELIBERATE standing
// "log in via the game" door, not merely first-time onboarding: control of the
// in-game identity is the root of trust, so re-minting a code always re-grants a
// session even after email/passkey are bound. "登录并非强制,但没登录什么都干不了".
//
// op.console stays behind Zero Trust. A code whose UUID belongs to STAFF (role=admin)
// is refused here (ErrPlayerBindForbidden → 403), so the public bootstrap provably
// never mints a session for an admin identity — the sole tier it yields is a role=user
// player session, host-only to console.<root_domain> (never sent to op.console) and
// carrying ViaAdminAccess=false. "op.console 必须得 Auth".
// newUserID returns an opaque random user id (128 bits, hex), matching the shape of
// the ids break-glass mints for staff rows.
func newUserID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return hex.EncodeToString(b[:]), nil
}
// bindRedeemRequest is the console bootstrap body: the Bind Code the player was shown
// in the Login Lobby.
type bindRedeemRequest struct {
Code string `json:"code"`
}
// handleBindRedeem redeems a Bind Code into a player account + session (Public). It is
// the account-less player's only door into console.<root_domain>: no prior principal,
// no Zero Trust in front (unlike op.console). Like handleLogin it is a cookie-minting
// public route, so it requires local sessions to be enabled and a JSON content type
// (the cross-site-forgery guard) and mints the same host-only felis_session cookie.
// The code is trimmed and uppercased so a player who typed it with stray spaces or in
// lowercase still matches, mirroring handleLinkVerify.
func (a *API) handleBindRedeem(w http.ResponseWriter, r *http.Request) {
// The minted session is a felis_session cookie, honored only when local sessions
// are enabled (SessionAuth). Minting one while they are off would hand back a dead
// cookie, so refuse loudly and consistently with handleLogin. This couples the
// player bootstrap to the same toggle that gates op.console local login; a future
// deployment wanting player cookies without local admin login would decouple them
// in SessionAuth — out of scope here (KNOWN coupling).
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
"session login is disabled"))
return
}
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
var body bindRedeemRequest
if err := decodeJSON(w, r, &body); err != nil {
writeError(w, r, err)
return
}
code := strings.ToUpper(strings.TrimSpace(body.Code))
if code == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "code is required"))
return
}
uid, err := newUserID()
if err != nil {
writeError(w, r, err)
return
}
userID, mcUUID, authSource, err := a.Repo.RedeemPlayerBindCode(r.Context(), uid, code, a.now())
switch {
case errors.Is(err, ErrLinkCodeInvalid):
writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "bind code is invalid or expired"))
return
case errors.Is(err, ErrPlayerBindForbidden):
writeError(w, r, newError(http.StatusForbidden, "staff_account",
"that Minecraft account belongs to staff; sign in at the operator console"))
return
case err != nil:
writeError(w, r, err)
return
}
token, err := newSessionToken()
if err != nil {
writeError(w, r, err)
return
}
expires := a.now().Add(sessionTTL)
if err := a.Repo.CreateSession(r.Context(), hashCookie(token), userID, expires); err != nil {
writeError(w, r, err)
return
}
setSessionCookie(w, token, expires)
a.audit(r, userID, "account.bind_redeem", "")
writeJSON(w, http.StatusOK, map[string]any{
"user_id": userID, "linked": true, "mc_uuid": mcUUID, "auth_source": authSource,
})
}
+261
View File
@@ -0,0 +1,261 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// Player-console onboarding bootstrap tests (console-tier access model). The load-
// bearing cases: a Bind Code alone bootstraps a role=user player + session on the
// public console door (no prior principal, no Zero Trust); a code whose UUID belongs
// to staff is refused so the public door never mints an admin session; and — the
// invariant that keeps "op.console 必须得 Auth" true after making console.<root> public —
// a redeemed player session is rejected on every Admin route and carries
// ViaAdminAccess=false.
const bindTestUUID = "11111111-1111-1111-1111-111111111111"
// seedBindAPI returns an API with local sessions enabled and its external face wired
// to the real SessionAuth, so a cookie minted by /auth/bind is actually honored on
// the follow-up authenticated requests (the whole point of the bootstrap).
func seedBindAPI(t *testing.T) (*API, *fakeRepo) {
t.Helper()
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
api := newTestAPI(repo, newFakeCluster())
api.External = SessionAuth{Repo: repo, RootDomain: testRoot, Now: api.now}
return api, repo
}
// mintBindCode stores a live Bind Code for uuid/authSource, mirroring the internal
// mint (handleCreateLinkCode → CreateLinkCode).
func mintBindCode(t *testing.T, api *API, repo *fakeRepo, code, uuid, authSource string) {
t.Helper()
if err := repo.CreateLinkCode(t.Context(), code, uuid, authSource, api.now().Add(linkCodeTTL)); err != nil {
t.Fatalf("mint bind code: %v", err)
}
}
// sessionCookieValue returns the raw felis_session cookie value set on w, or fails.
func sessionCookieValue(t *testing.T, w *httptest.ResponseRecorder) string {
t.Helper()
for _, c := range w.Result().Cookies() {
if c.Name == sessionCookieName && c.Value != "" {
return c.Value
}
}
t.Fatalf("no %s cookie set (%s)", sessionCookieName, w.Body.String())
return ""
}
// TestBindRedeemBootstrapsPlayer is the happy path: an account-less player redeems a
// Bind Code and, in one step, gets a role=user account, an account_links binding, and
// a live session — which then resolves through the external face on /me.
func TestBindRedeemBootstrapsPlayer(t *testing.T) {
api, repo := seedBindAPI(t)
mintBindCode(t, api, repo, "ABCD2345", bindTestUUID, authSourceMojang)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", `{"code":"ABCD2345"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
var got map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("body not JSON: %v (%s)", err, w.Body.String())
}
userID, _ := got["user_id"].(string)
if userID == "" || got["linked"] != true || got["mc_uuid"] != bindTestUUID || got["auth_source"] != authSourceMojang {
t.Fatalf("body = %v, want a user_id, linked=true, mc_uuid+auth_source echoed", got)
}
// A fresh role=user player row was created and bound; the code was consumed.
if u := repo.staff[bindTestUUID]; u == nil || u.Role != "user" || u.PasswordHash != "" || u.ID != userID {
t.Fatalf("created row = %+v, want role=user, NULL hash, id=%s", u, userID)
}
if repo.links[bindTestUUID] != userID {
t.Fatalf("account_links[%s] = %q, want %q", bindTestUUID, repo.links[bindTestUUID], userID)
}
if _, live := repo.linkCodes["ABCD2345"]; live {
t.Fatal("the bind code must be consumed on success")
}
// The minted session must resolve through the external face: /me returns the
// player identity, role=user, is_admin=false.
token := sessionCookieValue(t, w)
me := do(api.ExternalHandler(), "GET", "/api/v1/me", "",
map[string]string{"Cookie": sessionCookieName + "=" + token})
if me.Code != http.StatusOK {
t.Fatalf("/me code = %d, want 200 (%s)", me.Code, me.Body.String())
}
var meBody map[string]any
if err := json.Unmarshal(me.Body.Bytes(), &meBody); err != nil {
t.Fatalf("/me body not JSON: %v", err)
}
if meBody["role"] != "user" || meBody["is_admin"] != false || meBody["user_id"] != userID {
t.Fatalf("/me = %v, want role=user is_admin=false user_id=%s", meBody, userID)
}
}
// TestBindRedeemIdempotentReturningPlayer proves a second redemption of the SAME UUID
// logs the same player back in (idempotent "log in via the game") rather than forking
// a duplicate account.
func TestBindRedeemIdempotentReturningPlayer(t *testing.T) {
api, repo := seedBindAPI(t)
mintBindCode(t, api, repo, "FIRST234", bindTestUUID, authSourceMojang)
w1 := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", `{"code":"FIRST234"}`, jsonHeader)
if w1.Code != http.StatusOK {
t.Fatalf("first redeem code = %d, want 200 (%s)", w1.Code, w1.Body.String())
}
var b1 map[string]any
_ = json.Unmarshal(w1.Body.Bytes(), &b1)
mintBindCode(t, api, repo, "SECOND34", bindTestUUID, authSourceThirdParty)
w2 := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", `{"code":"SECOND34"}`, jsonHeader)
if w2.Code != http.StatusOK {
t.Fatalf("second redeem code = %d, want 200 (%s)", w2.Code, w2.Body.String())
}
var b2 map[string]any
_ = json.Unmarshal(w2.Body.Bytes(), &b2)
if b1["user_id"] != b2["user_id"] {
t.Fatalf("returning player got a new account: %v then %v", b1["user_id"], b2["user_id"])
}
}
// TestBindRedeemRefusesStaffAccount is the op.console red line at the data layer: a
// Bind Code whose UUID belongs to a STAFF account (role=admin) is refused 403, and the
// code is NOT consumed and no session is minted. The public console door therefore can
// never yield a session for an admin identity.
func TestBindRedeemRefusesStaffAccount(t *testing.T) {
api, repo := seedBindAPI(t)
// An admin already linked to this UUID (e.g. an Operator on the thirdparty Yggdrasil).
repo.staff["op"] = &StaffUser{ID: "admin1", Username: "op", Role: "admin"}
repo.links[bindTestUUID] = "admin1"
mintBindCode(t, api, repo, "STAFF234", bindTestUUID, authSourceThirdParty)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", `{"code":"STAFF234"}`, jsonHeader)
if w.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403 (%s)", w.Code, w.Body.String())
}
if code := decodeErr(t, w); code != "staff_account" {
t.Fatalf("error code = %q, want staff_account", code)
}
if len(w.Result().Cookies()) != 0 {
t.Fatal("no session cookie may be set when a staff UUID is refused")
}
if _, live := repo.linkCodes["STAFF234"]; !live {
t.Fatal("a refused staff redemption must NOT consume the code")
}
}
// TestPlayerSessionRejectedOnAdminRoutes is the invariant that lets console.<root> be
// public without weakening op.console: a session minted by the public bootstrap is a
// role=user session, so every Admin route rejects it (403) and it carries
// ViaAdminAccess=false even on the operator host.
func TestPlayerSessionRejectedOnAdminRoutes(t *testing.T) {
api, repo := seedBindAPI(t)
mintBindCode(t, api, repo, "PLAYER34", bindTestUUID, authSourceMojang)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", `{"code":"PLAYER34"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("redeem code = %d, want 200 (%s)", w.Code, w.Body.String())
}
token := sessionCookieValue(t, w)
// An Admin route (POST /api/v1/servers), requested on the OPERATOR host with the
// player cookie, is rejected before the handler: the player is role=user, so
// IsAdmin() is false regardless of host.
adm := do(api.ExternalHandler(), "POST", "https://op.console.mc.example.net/api/v1/servers", `{}`,
map[string]string{"Cookie": sessionCookieName + "=" + token})
if adm.Code != http.StatusForbidden {
t.Fatalf("player on Admin route: code = %d, want 403 (%s)", adm.Code, adm.Body.String())
}
// Directly: SessionAuth resolves the player on the operator host WITHOUT admin
// access. A role=user can never satisfy ViaAdminAccess (it is admin-AND-host), so
// the public bootstrap provably yields no admin principal.
auth := SessionAuth{Repo: repo, RootDomain: testRoot, Now: api.now}
r := httptest.NewRequest("GET", "https://op.console.mc.example.net/api/v1/me", nil)
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
p, err := auth.Authenticate(r)
if err != nil {
t.Fatalf("Authenticate: %v", err)
}
if p.Role != "user" || p.ViaAdminAccess || p.IsAdmin() {
t.Fatalf("player principal = %+v, want role=user ViaAdminAccess=false IsAdmin=false", p)
}
}
func TestBindRedeemInvalidCode(t *testing.T) {
api, _ := seedBindAPI(t)
for _, tc := range []struct{ name, body string }{
{"unknown code", `{"code":"NOPE2345"}`},
{"empty code", `{"code":""}`},
} {
t.Run(tc.name, func(t *testing.T) {
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", tc.body, jsonHeader)
if w.Code != http.StatusBadRequest {
t.Fatalf("code = %d, want 400 (%s)", w.Code, w.Body.String())
}
if len(w.Result().Cookies()) != 0 {
t.Fatal("no session cookie may be set on an invalid redeem")
}
})
}
}
// TestBindRedeemExpiredCode proves expiry is enforced against the API clock: a code
// past its TTL is invalid_code, never a silent bootstrap.
func TestBindRedeemExpiredCode(t *testing.T) {
api, repo := seedBindAPI(t)
repo.linkCodes["OLD23456"] = fakeLinkCode{
mcUUID: bindTestUUID, authSource: authSourceMojang,
expiresAt: api.now().Add(-time.Minute), // already expired
}
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", `{"code":"OLD23456"}`, jsonHeader)
if w.Code != http.StatusBadRequest {
t.Fatalf("code = %d, want 400 (%s)", w.Code, w.Body.String())
}
if code := decodeErr(t, w); code != "invalid_code" {
t.Fatalf("error code = %q, want invalid_code", code)
}
}
// TestBindRedeemLocalAuthDisabled proves the bootstrap refuses to mint a session that
// SessionAuth would not honor: with local sessions off it returns 403, never a dead
// cookie, mirroring handleLogin.
func TestBindRedeemLocalAuthDisabled(t *testing.T) {
repo := newFakeRepo() // local_auth_enabled never set → fail closed
api := newTestAPI(repo, newFakeCluster())
mintBindCode(t, api, repo, "DEAD2345", bindTestUUID, authSourceMojang)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", `{"code":"DEAD2345"}`, jsonHeader)
if w.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403 (%s)", w.Code, w.Body.String())
}
if code := decodeErr(t, w); code != "local_auth_disabled" {
t.Fatalf("error code = %q, want local_auth_disabled", code)
}
if len(w.Result().Cookies()) != 0 {
t.Fatal("no session cookie may be minted while local auth is disabled")
}
}
// TestBindRedeemContentTypeGuard pins the cross-site-forgery guard: a body whose
// Content-Type an HTML form could emit is rejected 415 before any redemption, so a
// forged off-origin POST cannot bootstrap an account.
func TestBindRedeemContentTypeGuard(t *testing.T) {
api, repo := seedBindAPI(t)
mintBindCode(t, api, repo, "FORM2345", bindTestUUID, authSourceMojang)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", `{"code":"FORM2345"}`,
map[string]string{"Content-Type": "application/x-www-form-urlencoded"})
if w.Code != http.StatusUnsupportedMediaType {
t.Fatalf("code = %d, want 415 (%s)", w.Code, w.Body.String())
}
if _, live := repo.linkCodes["FORM2345"]; !live {
t.Fatal("a content-type-rejected redeem must not consume the code")
}
}
+64
View File
@@ -125,6 +125,70 @@ func (p *PGRepo) VerifyLinkCode(ctx context.Context, userID, code string, now ti
return mcUUID, authSource, nil
}
// RedeemPlayerBindCode redeems a Bind Code into a player account + link in one
// transaction (console-tier access model). It mirrors VerifyLinkCode's structure —
// strict expiry against the passed clock, the durable-link write, and the DELETE
// that consumes the code — but creates or fetches the user instead of requiring one.
// See the Repo interface for the full contract. Like VerifyLinkCode a concurrent
// racer that passed the SELECT loses to the UNIQUE(mc_uuid)/UNIQUE(username) guard
// (a 500), acceptable for this integration-only path; the primary idempotency is the
// mc_uuid-keyed fetch below.
func (p *PGRepo) RedeemPlayerBindCode(ctx context.Context, newUserID, code string, now time.Time) (string, string, string, error) {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return "", "", "", err
}
defer tx.Rollback() //nolint:errcheck // no-op after commit
var mcUUID, authSource string
switch err := tx.QueryRowContext(ctx,
`SELECT mc_uuid, auth_source FROM account_link_codes WHERE code = $1 AND expires_at > $2`,
code, now).Scan(&mcUUID, &authSource); {
case errors.Is(err, sql.ErrNoRows):
return "", "", "", ErrLinkCodeInvalid
case err != nil:
return "", "", "", err
}
// Create-or-fetch keyed on the verified UUID. An already-linked role='user' player
// is fetched (idempotent "log in via the game"); a role='admin' STAFF account is
// refused (op.console only) BEFORE any consume, so the code survives; an unlinked
// UUID births a fresh role='user' player with a uuid-derived unique username.
userID := newUserID
var existingRole string
switch err := tx.QueryRowContext(ctx,
`SELECT u.id, u.role::text FROM account_links al JOIN users u ON u.id = al.user_id WHERE al.mc_uuid = $1`,
mcUUID).Scan(&userID, &existingRole); {
case errors.Is(err, sql.ErrNoRows):
if _, err := tx.ExecContext(ctx,
`INSERT INTO users (id, username, role) VALUES ($1, $2, 'user')`,
newUserID, mcUUID); err != nil {
return "", "", "", fmt.Errorf("create player: %w", err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO account_links (user_id, mc_uuid, auth_source) VALUES ($1, $2, $3)`,
newUserID, mcUUID, authSource); err != nil {
return "", "", "", fmt.Errorf("write account link: %w", err)
}
userID = newUserID
case err != nil:
return "", "", "", err
default:
if existingRole != "user" {
return "", "", "", ErrPlayerBindForbidden // staff must use op.console
}
}
if _, err := tx.ExecContext(ctx,
`DELETE FROM account_link_codes WHERE code = $1`, code); err != nil {
return "", "", "", fmt.Errorf("consume link code: %w", err)
}
if err := tx.Commit(); err != nil {
return "", "", "", err
}
return userID, mcUUID, authSource, nil
}
// QuotaAvailable treats a missing quota row or a NULL max_servers as unlimited;
// otherwise it compares the live owned-server count against the cap (spec §9.3).
func (p *PGRepo) QuotaAvailable(ctx context.Context, userID string) (bool, error) {
+23
View File
@@ -149,6 +149,29 @@ type Repo interface {
// re-verifying the same (user, uuid) pair is idempotent and refreshes the stored
// authSource. now is the API clock so expiry is testable.
VerifyLinkCode(ctx context.Context, userID, code string, now time.Time) (mcUUID, authSource string, err error)
// RedeemPlayerBindCode is the account-less player-console bootstrap (console-tier
// access model): it redeems a one-time Bind Code into a PLAYER account + link in
// one atomic step, so a first-time player with no Felis account can create one
// from the console.<root_domain> door. Unlike VerifyLinkCode it takes NO prior
// user — it creates or fetches one, keyed on the verified mc_uuid the code carries:
//
// - code missing/expired → ErrLinkCodeInvalid (does not consume it);
// - the uuid is not yet linked → create a role='user' player row with id
// newUserID (NULL password_hash, username derived from the uuid so it is unique
// and deterministic), write the account_links binding, consume the code, and
// return newUserID;
// - the uuid is already linked to a role='user' player → return THAT user
// (idempotent "log in via the game"), consuming the code;
// - the uuid is linked to a role='admin' STAFF account → ErrPlayerBindForbidden
// WITHOUT consuming the code (operators use op.console behind Zero Trust; the
// public bootstrap never mints a session for an admin identity).
//
// Safe as an unauthenticated entrypoint because a Bind Code is minted internal-face
// only (CreateLinkCode), against an online-mode-verified UUID, short-TTL and
// single-use — possession already proves control of a Minecraft identity. now is
// the API clock so expiry is testable. It returns the effective userID plus the
// bound mc_uuid and authSource (for the response + audit).
RedeemPlayerBindCode(ctx context.Context, newUserID, code string, now time.Time) (userID, mcUUID, authSource string, err error)
// QuotaAvailable reports whether the user is under their max_servers quota
// (spec §9.3 step ②, evaluated before provisioning).
QuotaAvailable(ctx context.Context, userID string) (bool, error)