feat(api): record account-link auth source (mojang|thirdparty)

Capture which Yggdrasil authenticated an in-game UUID when a link code is
minted (spec §10 dual-Yggdrasil) and copy it onto the durable account_links
row at verify. The value originates in-game — the web verify side never sees
the authentication — so it threads through account_link_codes, mirroring how
mc_uuid (not user_id) lives on a code.

- migration 0005: add link_auth_source enum + auth_source column on both
  account_link_codes and account_links; DEFAULT 'mojang' backfills existing
  rows and sets the Mojang-priority default for a mint that omits the field
- mint validates an explicit auth_source (unknown value -> 400); verify
  surfaces it in the 200 body and refreshes it on idempotent re-verify
This commit is contained in:
flyemoji committed 2026-06-27 13:01:19 +09:00
1 parent dbe34a175f
commit 1f8b9bb5d0
7 files changed
+172 -41

No files matched your search

+14 -1
View File
@@ -643,6 +643,15 @@ paths:
required: [mc_uuid] required: [mc_uuid]
properties: properties:
mc_uuid: { type: string } mc_uuid: { type: string }
auth_source:
type: string
enum: [mojang, thirdparty]
default: mojang
description: >
Which Yggdrasil authenticated the in-game UUID (spec §10
dual-Yggdrasil). Optional; an omitted value defaults to the
Mojang-priority source. Captured here because only the in-game
side sees the authentication; it is copied onto the link at verify.
responses: responses:
'201': '201':
description: Code minted. description: Code minted.
@@ -1427,10 +1436,14 @@ paths:
application/json: application/json:
schema: schema:
type: object type: object
required: [linked, mc_uuid] required: [linked, mc_uuid, auth_source]
properties: properties:
linked: { type: boolean, const: true } linked: { type: boolean, const: true }
mc_uuid: { type: string } mc_uuid: { type: string }
auth_source:
type: string
enum: [mojang, thirdparty]
description: The source captured at mint, copied onto the durable link.
'400': '400':
description: Invalid or expired code. description: Invalid or expired code.
content: content:
+14 -7
View File
@@ -39,6 +39,9 @@ type fakeRepo struct {
// account linking (spec §10) // account linking (spec §10)
linkCodes map[string]fakeLinkCode // code -> pending binding linkCodes map[string]fakeLinkCode // code -> pending binding
links map[string]string // mc_uuid -> user_id (mirrors UNIQUE(mc_uuid)) links map[string]string // mc_uuid -> user_id (mirrors UNIQUE(mc_uuid))
// linkAuthSource mirrors account_links.auth_source (mc_uuid -> mojang|thirdparty),
// the value copied from the consumed code at verify (migration 0005).
linkAuthSource map[string]string
// world backups (spec §7, §22). A nil slice lists empty. // world backups (spec §7, §22). A nil slice lists empty.
backups []fakeBackup backups []fakeBackup
// local-password auth (spec §B). staff is keyed by username (the login key); // local-password auth (spec §B). staff is keyed by username (the login key);
@@ -86,6 +89,7 @@ type fakeBackup struct {
// fakeLinkCode mirrors an account_link_codes row. // fakeLinkCode mirrors an account_link_codes row.
type fakeLinkCode struct { type fakeLinkCode struct {
mcUUID string mcUUID string
authSource string
expiresAt time.Time expiresAt time.Time
} }
@@ -98,6 +102,7 @@ func newFakeRepo() *fakeRepo {
claimOK: map[string]bool{}, claimOK: map[string]bool{},
seeded: map[string]bool{}, aliases: map[string]string{}, seeded: map[string]bool{}, aliases: map[string]string{},
linkCodes: map[string]fakeLinkCode{}, links: map[string]string{}, linkCodes: map[string]fakeLinkCode{}, links: map[string]string{},
linkAuthSource: map[string]string{},
staff: map[string]*StaffUser{}, staff: map[string]*StaffUser{},
sessions: map[string]*fakeSession{}, sessions: map[string]*fakeSession{},
settings: map[string][]byte{}, settings: map[string][]byte{},
@@ -119,27 +124,29 @@ func (f *fakeRepo) ServerByName(_ context.Context, n string) (*ServerRecord, err
} }
func (f *fakeRepo) IsLinked(_ context.Context, u string) (bool, error) { return f.linked[u], nil } func (f *fakeRepo) IsLinked(_ context.Context, u string) (bool, error) { return f.linked[u], nil }
func (f *fakeRepo) QuotaAvailable(_ context.Context, u string) (bool, error) { return f.quota[u], nil } func (f *fakeRepo) QuotaAvailable(_ context.Context, u string) (bool, error) { return f.quota[u], nil }
func (f *fakeRepo) CreateLinkCode(_ context.Context, code, mcUUID string, expiresAt time.Time) error { func (f *fakeRepo) CreateLinkCode(_ context.Context, code, mcUUID, authSource string, expiresAt time.Time) error {
f.linkCodes[code] = fakeLinkCode{mcUUID: mcUUID, expiresAt: expiresAt} f.linkCodes[code] = fakeLinkCode{mcUUID: mcUUID, authSource: authSource, expiresAt: expiresAt}
return nil return nil
} }
// VerifyLinkCode mirrors PGRepo.VerifyLinkCode exactly so the hermetic tests // VerifyLinkCode mirrors PGRepo.VerifyLinkCode exactly so the hermetic tests
// exercise the same contract the integration impl honors: strict expiry against // exercise the same contract the integration impl honors: strict expiry against
// the passed clock, a different-user UUID → ErrConflict WITHOUT consuming the // the passed clock, a different-user UUID → ErrConflict WITHOUT consuming the
// code, same (user, uuid) idempotent, and the code consumed only on success. // code, same (user, uuid) idempotent, the code's auth_source copied onto the link
func (f *fakeRepo) VerifyLinkCode(_ context.Context, userID, code string, now time.Time) (string, error) { // (and refreshed on re-verify), and the code consumed only on success.
func (f *fakeRepo) VerifyLinkCode(_ context.Context, userID, code string, now time.Time) (string, string, error) {
rec, ok := f.linkCodes[code] rec, ok := f.linkCodes[code]
if !ok || !rec.expiresAt.After(now) { if !ok || !rec.expiresAt.After(now) {
return "", ErrLinkCodeInvalid return "", "", ErrLinkCodeInvalid
} }
if existing, ok := f.links[rec.mcUUID]; ok && existing != userID { if existing, ok := f.links[rec.mcUUID]; ok && existing != userID {
return "", ErrConflict // do not consume another user's pending code return "", "", ErrConflict // do not consume another user's pending code
} }
f.links[rec.mcUUID] = userID f.links[rec.mcUUID] = userID
f.linkAuthSource[rec.mcUUID] = rec.authSource // copy/refresh, mirrors DO UPDATE
f.linked[userID] = true f.linked[userID] = true
delete(f.linkCodes, code) delete(f.linkCodes, code)
return rec.mcUUID, nil return rec.mcUUID, rec.authSource, nil
} }
// CreateEmailOTP / VerifyEmailOTP mirror PGRepo's contract so the hermetic tests // CreateEmailOTP / VerifyEmailOTP mirror PGRepo's contract so the hermetic tests
+37 -4
View File
@@ -34,8 +34,23 @@ const (
// linkCodeLen is the symbol count: a 32^8 ≈ 1.1e12 keyspace, far beyond brute // linkCodeLen is the symbol count: a 32^8 ≈ 1.1e12 keyspace, far beyond brute
// force inside the TTL. // force inside the TTL.
linkCodeLen = 8 linkCodeLen = 8
// authSource records which Yggdrasil established the in-game UUID when a code
// was minted (spec §10, dual-Yggdrasil): the official Mojang service, or a
// configured thirdparty. It is captured at mint (the only place that knows it)
// and copied onto the durable link at verify; the web side never sees the
// authentication. These mirror the link_auth_source enum (migration 0005).
authSourceMojang = "mojang"
authSourceThirdParty = "thirdparty"
) )
// validAuthSource reports whether s is a recognised link_auth_source value. An
// empty string is NOT valid here — handleCreateLinkCode defaults it before this
// check, so a non-empty value reaching validation must be one we can store.
func validAuthSource(s string) bool {
return s == authSourceMojang || s == authSourceThirdParty
}
// newLinkCode returns a cryptographically random, unambiguous link code. // newLinkCode returns a cryptographically random, unambiguous link code.
func newLinkCode() (string, error) { func newLinkCode() (string, error) {
buf := make([]byte, linkCodeLen) buf := make([]byte, linkCodeLen)
@@ -49,9 +64,13 @@ func newLinkCode() (string, error) {
} }
// createLinkCodeRequest is the in-game /link callback body (spec §10): the // createLinkCodeRequest is the in-game /link callback body (spec §10): the
// backend reports the verified UUID of the player who ran the command. // backend reports the verified UUID of the player who ran the command, plus how
// that UUID was authenticated (auth_source). auth_source is optional — an older
// backend that omits it falls back to the Mojang-priority default — but a value
// that IS sent must be one we can store.
type createLinkCodeRequest struct { type createLinkCodeRequest struct {
MCUUID string `json:"mc_uuid"` MCUUID string `json:"mc_uuid"`
AuthSource string `json:"auth_source"`
} }
// handleCreateLinkCode mints a one-time link code for a verified in-game UUID // handleCreateLinkCode mints a one-time link code for a verified in-game UUID
@@ -69,13 +88,25 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required")) writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required"))
return return
} }
// Default an omitted source to Mojang (spec §10 priority) but reject an
// unrecognised one — a typo'd source must not silently land as a stored value
// the panel will later mislabel.
authSource := req.AuthSource
if authSource == "" {
authSource = authSourceMojang
}
if !validAuthSource(authSource) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
"auth_source must be %q or %q", authSourceMojang, authSourceThirdParty))
return
}
code, err := newLinkCode() code, err := newLinkCode()
if err != nil { if err != nil {
writeError(w, r, err) writeError(w, r, err)
return return
} }
expiresAt := a.now().Add(linkCodeTTL) expiresAt := a.now().Add(linkCodeTTL)
if err := a.Repo.CreateLinkCode(r.Context(), code, req.MCUUID, expiresAt); err != nil { if err := a.Repo.CreateLinkCode(r.Context(), code, req.MCUUID, authSource, expiresAt); err != nil {
writeError(w, r, err) writeError(w, r, err)
return return
} }
@@ -110,7 +141,7 @@ func (a *API) handleLinkVerify(w http.ResponseWriter, r *http.Request) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "code is required")) writeError(w, r, newError(http.StatusBadRequest, "bad_request", "code is required"))
return return
} }
mcUUID, err := a.Repo.VerifyLinkCode(r.Context(), p.UserID, code, a.now()) mcUUID, authSource, err := a.Repo.VerifyLinkCode(r.Context(), p.UserID, code, a.now())
switch { switch {
case errors.Is(err, ErrLinkCodeInvalid): case errors.Is(err, ErrLinkCodeInvalid):
writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "link code is invalid or expired")) writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "link code is invalid or expired"))
@@ -124,7 +155,9 @@ func (a *API) handleLinkVerify(w http.ResponseWriter, r *http.Request) {
return return
} }
a.audit(r, p.Email, "account.link", "") a.audit(r, p.Email, "account.link", "")
writeJSON(w, http.StatusOK, map[string]any{"linked": true, "mc_uuid": mcUUID}) writeJSON(w, http.StatusOK, map[string]any{
"linked": true, "mc_uuid": mcUUID, "auth_source": authSource,
})
} }
// handleLinkStart reports the caller's link status and how to link (spec §10, // handleLinkStart reports the caller's link status and how to link (spec §10,
+55 -2
View File
@@ -57,8 +57,8 @@ func TestAccountLinkVertical(t *testing.T) {
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Fatalf("verify: code = %d, want 200 (%s)", w.Code, w.Body.String()) t.Fatalf("verify: code = %d, want 200 (%s)", w.Code, w.Body.String())
} }
if b := acctBody(t, w); b["linked"] != true || b["mc_uuid"] != mcUUID { if b := acctBody(t, w); b["linked"] != true || b["mc_uuid"] != mcUUID || b["auth_source"] != authSourceMojang {
t.Fatalf("verify body = %v, want linked:true mc_uuid:%s", b, mcUUID) t.Fatalf("verify body = %v, want linked:true mc_uuid:%s auth_source:%s", b, mcUUID, authSourceMojang)
} }
// The link is audited as account.link by the principal's Access email. // The link is audited as account.link by the principal's Access email.
if n := len(repo.audits); n != 1 || repo.audits[0].Action != "account.link" || repo.audits[0].Actor != "[email protected]" { if n := len(repo.audits); n != 1 || repo.audits[0].Action != "account.link" || repo.audits[0].Actor != "[email protected]" {
@@ -107,6 +107,10 @@ func TestCreateLinkCode(t *testing.T) {
if rec.mcUUID != mcUUID { if rec.mcUUID != mcUUID {
t.Errorf("stored mc_uuid = %q, want %q", rec.mcUUID, mcUUID) t.Errorf("stored mc_uuid = %q, want %q", rec.mcUUID, mcUUID)
} }
// An omitted auth_source defaults to the Mojang-priority source.
if rec.authSource != authSourceMojang {
t.Errorf("default authSource = %q, want %q", rec.authSource, authSourceMojang)
}
if want := api.now().Add(linkCodeTTL); !rec.expiresAt.Equal(want) { if want := api.now().Add(linkCodeTTL); !rec.expiresAt.Equal(want) {
t.Errorf("expiresAt = %v, want %v", rec.expiresAt, want) t.Errorf("expiresAt = %v, want %v", rec.expiresAt, want)
} }
@@ -116,6 +120,24 @@ func TestCreateLinkCode(t *testing.T) {
} }
} }
}) })
t.Run("explicit thirdparty is stored", func(t *testing.T) {
body := `{"mc_uuid":"` + mcUUID + `","auth_source":"` + authSourceThirdParty + `"}`
w := do(ih, "POST", "/api/v1/internal/account/link/code", body, nil)
if w.Code != http.StatusCreated {
t.Fatalf("code = %d, want 201 (%s)", w.Code, w.Body.String())
}
code, _ := acctBody(t, w)["code"].(string)
if rec := repo.linkCodes[code]; rec.authSource != authSourceThirdParty {
t.Errorf("stored authSource = %q, want %q", rec.authSource, authSourceThirdParty)
}
})
t.Run("unrecognised auth_source -> 400", func(t *testing.T) {
body := `{"mc_uuid":"` + mcUUID + `","auth_source":"litebans"}`
w := do(ih, "POST", "/api/v1/internal/account/link/code", body, nil)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" {
t.Fatalf("code = %d body %s, want 400 bad_request", w.Code, w.Body.String())
}
})
} }
// TestLinkVerifyRejections is the verify failure matrix. The expired case seeds a // TestLinkVerifyRejections is the verify failure matrix. The expired case seeds a
@@ -206,6 +228,37 @@ func TestLinkVerifyIdempotent(t *testing.T) {
} }
} }
// TestLinkAuthSourcePropagates proves auth_source survives the whole §10 flow: a
// thirdparty source captured in-game at mint reaches the durable link and the
// verify response — the value the web side can never originate itself.
func TestLinkAuthSourcePropagates(t *testing.T) {
const mcUUID = "55555555-5555-5555-5555-555555555555"
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
repo := newFakeRepo()
api := newTestAPI(repo, newFakeCluster())
api.External = staticExternal{p: user}
// Mint in-game with the thirdparty Yggdrasil source.
body := `{"mc_uuid":"` + mcUUID + `","auth_source":"` + authSourceThirdParty + `"}`
w := do(api.InternalHandler(), "POST", "/api/v1/internal/account/link/code", body, nil)
if w.Code != http.StatusCreated {
t.Fatalf("mint: code = %d, want 201 (%s)", w.Code, w.Body.String())
}
code, _ := acctBody(t, w)["code"].(string)
// Verify on the web: the response and the stored link must both carry thirdparty.
w = do(api.ExternalHandler(), "POST", "/api/v1/account/link/verify", `{"code":"`+code+`"}`, nil)
if w.Code != http.StatusOK {
t.Fatalf("verify: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if got := acctBody(t, w)["auth_source"]; got != authSourceThirdParty {
t.Errorf("verify body auth_source = %v, want %q", got, authSourceThirdParty)
}
if got := repo.linkAuthSource[mcUUID]; got != authSourceThirdParty {
t.Errorf("stored link auth_source = %q, want %q", got, authSourceThirdParty)
}
}
// TestLinkStart pins the status endpoint handleClaim's 412 points at: it reports // TestLinkStart pins the status endpoint handleClaim's 412 points at: it reports
// link state and instructions, and never mints (it has no UUID to mint against). // link state and instructions, and never mints (it has no UUID to mint against).
func TestLinkStart(t *testing.T) { func TestLinkStart(t *testing.T) {
+23 -18
View File
@@ -58,10 +58,10 @@ func (p *PGRepo) IsLinked(ctx context.Context, userID string) (bool, error) {
// driven by one authoritative clock. The code is a PRIMARY KEY; a collision on // driven by one authoritative clock. The code is a PRIMARY KEY; a collision on
// the crypto/rand value is astronomically unlikely but surfaces as a plain // the crypto/rand value is astronomically unlikely but surfaces as a plain
// driver error (the caller can retry) rather than being masked here. // driver error (the caller can retry) rather than being masked here.
func (p *PGRepo) CreateLinkCode(ctx context.Context, code, mcUUID string, expiresAt time.Time) error { func (p *PGRepo) CreateLinkCode(ctx context.Context, code, mcUUID, authSource string, expiresAt time.Time) error {
_, err := p.db.ExecContext(ctx, _, err := p.db.ExecContext(ctx,
`INSERT INTO account_link_codes (code, mc_uuid, expires_at) VALUES ($1, $2, $3)`, `INSERT INTO account_link_codes (code, mc_uuid, auth_source, expires_at) VALUES ($1, $2, $3, $4)`,
code, mcUUID, expiresAt) code, mcUUID, authSource, expiresAt)
return err return err
} }
@@ -73,21 +73,21 @@ func (p *PGRepo) CreateLinkCode(ctx context.Context, code, mcUUID string, expire
// owner's pending code. The UNIQUE(mc_uuid) constraint is the last-resort guard // owner's pending code. The UNIQUE(mc_uuid) constraint is the last-resort guard
// against a concurrent racer that passed the SELECT; that loses to a 500, which // against a concurrent racer that passed the SELECT; that loses to a 500, which
// is acceptable for this integration-only path. // is acceptable for this integration-only path.
func (p *PGRepo) VerifyLinkCode(ctx context.Context, userID, code string, now time.Time) (string, error) { func (p *PGRepo) VerifyLinkCode(ctx context.Context, userID, code string, now time.Time) (string, string, error) {
tx, err := p.db.BeginTx(ctx, nil) tx, err := p.db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return "", err return "", "", err
} }
defer tx.Rollback() //nolint:errcheck // no-op after commit defer tx.Rollback() //nolint:errcheck // no-op after commit
var mcUUID string var mcUUID, authSource string
switch err := tx.QueryRowContext(ctx, switch err := tx.QueryRowContext(ctx,
`SELECT mc_uuid FROM account_link_codes WHERE code = $1 AND expires_at > $2`, `SELECT mc_uuid, auth_source FROM account_link_codes WHERE code = $1 AND expires_at > $2`,
code, now).Scan(&mcUUID); { code, now).Scan(&mcUUID, &authSource); {
case errors.Is(err, sql.ErrNoRows): case errors.Is(err, sql.ErrNoRows):
return "", ErrLinkCodeInvalid return "", "", ErrLinkCodeInvalid
case err != nil: case err != nil:
return "", err return "", "", err
} }
// If this UUID is already linked, only the same user may re-verify (idempotent); // If this UUID is already linked, only the same user may re-verify (idempotent);
@@ -98,26 +98,31 @@ func (p *PGRepo) VerifyLinkCode(ctx context.Context, userID, code string, now ti
case errors.Is(err, sql.ErrNoRows): case errors.Is(err, sql.ErrNoRows):
// not yet linked — fall through to insert // not yet linked — fall through to insert
case err != nil: case err != nil:
return "", err return "", "", err
default: default:
if existingUser != userID { if existingUser != userID {
return "", ErrConflict return "", "", ErrConflict
} }
} }
// Copy the code's auth_source onto the durable link. On the idempotent
// re-verify path DO UPDATE refreshes it (a player who re-linked via a different
// Yggdrasil this time gets the latest source stored), keeping the persisted
// value equal to the one returned to the caller.
if _, err := tx.ExecContext(ctx, if _, err := tx.ExecContext(ctx,
`INSERT INTO account_links (user_id, mc_uuid) VALUES ($1, $2) `INSERT INTO account_links (user_id, mc_uuid, auth_source) VALUES ($1, $2, $3)
ON CONFLICT (user_id, mc_uuid) DO NOTHING`, userID, mcUUID); err != nil { ON CONFLICT (user_id, mc_uuid) DO UPDATE SET auth_source = EXCLUDED.auth_source`,
return "", fmt.Errorf("write account link: %w", err) userID, mcUUID, authSource); err != nil {
return "", "", fmt.Errorf("write account link: %w", err)
} }
if _, err := tx.ExecContext(ctx, if _, err := tx.ExecContext(ctx,
`DELETE FROM account_link_codes WHERE code = $1`, code); err != nil { `DELETE FROM account_link_codes WHERE code = $1`, code); err != nil {
return "", fmt.Errorf("consume link code: %w", err) return "", "", fmt.Errorf("consume link code: %w", err)
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return "", err return "", "", err
} }
return mcUUID, nil return mcUUID, authSource, nil
} }
// QuotaAvailable treats a missing quota row or a NULL max_servers as unlimited; // QuotaAvailable treats a missing quota row or a NULL max_servers as unlimited;
+12 -9
View File
@@ -114,18 +114,21 @@ type Repo interface {
IsLinked(ctx context.Context, userID string) (bool, error) IsLinked(ctx context.Context, userID string) (bool, error)
// CreateLinkCode mints a one-time account-link code for an in-game player // CreateLinkCode mints a one-time account-link code for an in-game player
// (spec §10: 游戏内 /link → 生成一次性码). The code is born knowing only the // (spec §10: 游戏内 /link → 生成一次性码). The code is born knowing only the
// verified mc_uuid (online-mode=true established it); a web user binds it to // verified mc_uuid (online-mode=true established it) and the authSource that
// their user_id later via VerifyLinkCode. This is internal-face only — the web // established it (mojang|thirdparty, spec §10 dual-Yggdrasil); a web user binds
// has no verified UUID to mint against (the account_link_codes schema has no // it to their user_id later via VerifyLinkCode. This is internal-face only — the
// user_id column, which forces the in-game origin). // web has no verified UUID to mint against (the account_link_codes schema has no
CreateLinkCode(ctx context.Context, code, mcUUID string, expiresAt time.Time) error // user_id column, which forces the in-game origin), nor does it see the
// authentication, which is why authSource also originates here.
CreateLinkCode(ctx context.Context, code, mcUUID, authSource string, expiresAt time.Time) error
// VerifyLinkCode consumes a non-expired code for the logged-in user and writes // VerifyLinkCode consumes a non-expired code for the logged-in user and writes
// the account_links binding, atomically (spec §10: 网页 verify 填码 → 写 // the account_links binding, atomically (spec §10: 网页 verify 填码 → 写
// account_links). It returns the bound mc_uuid. A missing or expired code → // account_links). It returns the bound mc_uuid and the authSource captured at
// mint (copied from the code onto the durable link). A missing or expired code →
// ErrLinkCodeInvalid; a uuid already linked to a *different* user → ErrConflict; // ErrLinkCodeInvalid; a uuid already linked to a *different* user → ErrConflict;
// re-verifying the same (user, uuid) pair is idempotent. now is the API clock so // re-verifying the same (user, uuid) pair is idempotent and refreshes the stored
// expiry is testable. // authSource. now is the API clock so expiry is testable.
VerifyLinkCode(ctx context.Context, userID, code string, now time.Time) (mcUUID string, err error) VerifyLinkCode(ctx context.Context, userID, code string, now time.Time) (mcUUID, authSource string, err error)
// QuotaAvailable reports whether the user is under their max_servers quota // QuotaAvailable reports whether the user is under their max_servers quota
// (spec §9.3 step ②, evaluated before provisioning). // (spec §9.3 step ②, evaluated before provisioning).
QuotaAvailable(ctx context.Context, userID string) (bool, error) QuotaAvailable(ctx context.Context, userID string) (bool, error)
@@ -0,0 +1,17 @@
-- Player onboarding (spec §10, dual-Yggdrasil): record HOW the in-game identity
-- authenticated when a link code was minted — 'mojang' (Mojang/official Yggdrasil,
-- the priority source) or 'thirdparty' (a configured alternate Yggdrasil). The
-- value is known only in-game at the moment online-mode auth established the UUID,
-- so it is captured on the code at mint time and copied onto the durable link at
-- verify. The web verify side never sees the authentication and cannot originate
-- it — the same constraint that puts mc_uuid (not user_id) on a code.
--
-- DEFAULT 'mojang' backfills any code/link rows that predate this column and gives
-- a Mojang-priority default for a mint that omits the field; the mint path supplies
-- it explicitly going forward.
CREATE TYPE link_auth_source AS ENUM ('mojang','thirdparty');
ALTER TABLE account_link_codes
ADD COLUMN auth_source link_auth_source NOT NULL DEFAULT 'mojang';
ALTER TABLE account_links
ADD COLUMN auth_source link_auth_source NOT NULL DEFAULT 'mojang';