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:
7 files changed
+172
-41
No files matched your search
+14
-1
@@ -643,6 +643,15 @@ paths:
|
||||
required: [mc_uuid]
|
||||
properties:
|
||||
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:
|
||||
'201':
|
||||
description: Code minted.
|
||||
@@ -1427,10 +1436,14 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [linked, mc_uuid]
|
||||
required: [linked, mc_uuid, auth_source]
|
||||
properties:
|
||||
linked: { type: boolean, const: true }
|
||||
mc_uuid: { type: string }
|
||||
auth_source:
|
||||
type: string
|
||||
enum: [mojang, thirdparty]
|
||||
description: The source captured at mint, copied onto the durable link.
|
||||
'400':
|
||||
description: Invalid or expired code.
|
||||
content:
|
||||
|
||||
@@ -39,6 +39,9 @@ type fakeRepo struct {
|
||||
// account linking (spec §10)
|
||||
linkCodes map[string]fakeLinkCode // code -> pending binding
|
||||
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.
|
||||
backups []fakeBackup
|
||||
// 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.
|
||||
type fakeLinkCode struct {
|
||||
mcUUID string
|
||||
authSource string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
@@ -98,6 +102,7 @@ func newFakeRepo() *fakeRepo {
|
||||
claimOK: map[string]bool{},
|
||||
seeded: map[string]bool{}, aliases: map[string]string{},
|
||||
linkCodes: map[string]fakeLinkCode{}, links: map[string]string{},
|
||||
linkAuthSource: map[string]string{},
|
||||
staff: map[string]*StaffUser{},
|
||||
sessions: map[string]*fakeSession{},
|
||||
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) 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 {
|
||||
f.linkCodes[code] = fakeLinkCode{mcUUID: mcUUID, expiresAt: expiresAt}
|
||||
func (f *fakeRepo) CreateLinkCode(_ context.Context, code, mcUUID, authSource string, expiresAt time.Time) error {
|
||||
f.linkCodes[code] = fakeLinkCode{mcUUID: mcUUID, authSource: authSource, expiresAt: expiresAt}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyLinkCode mirrors PGRepo.VerifyLinkCode exactly so the hermetic tests
|
||||
// exercise the same contract the integration impl honors: strict expiry against
|
||||
// the passed clock, a different-user UUID → ErrConflict WITHOUT consuming the
|
||||
// code, same (user, uuid) idempotent, and the code consumed only on success.
|
||||
func (f *fakeRepo) VerifyLinkCode(_ context.Context, userID, code string, now time.Time) (string, error) {
|
||||
// code, same (user, uuid) idempotent, the code's auth_source copied onto the link
|
||||
// (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]
|
||||
if !ok || !rec.expiresAt.After(now) {
|
||||
return "", ErrLinkCodeInvalid
|
||||
return "", "", ErrLinkCodeInvalid
|
||||
}
|
||||
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.linkAuthSource[rec.mcUUID] = rec.authSource // copy/refresh, mirrors DO UPDATE
|
||||
f.linked[userID] = true
|
||||
delete(f.linkCodes, code)
|
||||
return rec.mcUUID, nil
|
||||
return rec.mcUUID, rec.authSource, nil
|
||||
}
|
||||
|
||||
// CreateEmailOTP / VerifyEmailOTP mirror PGRepo's contract so the hermetic tests
|
||||
|
||||
@@ -34,8 +34,23 @@ const (
|
||||
// linkCodeLen is the symbol count: a 32^8 ≈ 1.1e12 keyspace, far beyond brute
|
||||
// force inside the TTL.
|
||||
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.
|
||||
func newLinkCode() (string, error) {
|
||||
buf := make([]byte, linkCodeLen)
|
||||
@@ -49,9 +64,13 @@ func newLinkCode() (string, error) {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
MCUUID string `json:"mc_uuid"`
|
||||
AuthSource string `json:"auth_source"`
|
||||
}
|
||||
|
||||
// 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"))
|
||||
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()
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
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)
|
||||
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"))
|
||||
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 {
|
||||
case errors.Is(err, ErrLinkCodeInvalid):
|
||||
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
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -57,8 +57,8 @@ func TestAccountLinkVertical(t *testing.T) {
|
||||
if w.Code != http.StatusOK {
|
||||
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 {
|
||||
t.Fatalf("verify body = %v, want linked:true mc_uuid:%s", b, 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 auth_source:%s", b, mcUUID, authSourceMojang)
|
||||
}
|
||||
// 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]" {
|
||||
@@ -107,6 +107,10 @@ func TestCreateLinkCode(t *testing.T) {
|
||||
if 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) {
|
||||
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
|
||||
@@ -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
|
||||
// link state and instructions, and never mints (it has no UUID to mint against).
|
||||
func TestLinkStart(t *testing.T) {
|
||||
|
||||
+23
-18
@@ -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
|
||||
// the crypto/rand value is astronomically unlikely but surfaces as a plain
|
||||
// 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,
|
||||
`INSERT INTO account_link_codes (code, mc_uuid, expires_at) VALUES ($1, $2, $3)`,
|
||||
code, mcUUID, expiresAt)
|
||||
`INSERT INTO account_link_codes (code, mc_uuid, auth_source, expires_at) VALUES ($1, $2, $3, $4)`,
|
||||
code, mcUUID, authSource, expiresAt)
|
||||
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
|
||||
// against a concurrent racer that passed the SELECT; that loses to a 500, which
|
||||
// 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)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", "", err
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op after commit
|
||||
|
||||
var mcUUID string
|
||||
var mcUUID, authSource string
|
||||
switch err := tx.QueryRowContext(ctx,
|
||||
`SELECT mc_uuid FROM account_link_codes WHERE code = $1 AND expires_at > $2`,
|
||||
code, now).Scan(&mcUUID); {
|
||||
`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
|
||||
return "", "", ErrLinkCodeInvalid
|
||||
case err != nil:
|
||||
return "", err
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// 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):
|
||||
// not yet linked — fall through to insert
|
||||
case err != nil:
|
||||
return "", err
|
||||
return "", "", err
|
||||
default:
|
||||
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,
|
||||
`INSERT INTO account_links (user_id, mc_uuid) VALUES ($1, $2)
|
||||
ON CONFLICT (user_id, mc_uuid) DO NOTHING`, userID, mcUUID); err != nil {
|
||||
return "", fmt.Errorf("write account link: %w", err)
|
||||
`INSERT INTO account_links (user_id, mc_uuid, auth_source) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id, mc_uuid) DO UPDATE SET auth_source = EXCLUDED.auth_source`,
|
||||
userID, mcUUID, authSource); err != nil {
|
||||
return "", "", fmt.Errorf("write account link: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`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 {
|
||||
return "", err
|
||||
return "", "", err
|
||||
}
|
||||
return mcUUID, nil
|
||||
return mcUUID, authSource, nil
|
||||
}
|
||||
|
||||
// QuotaAvailable treats a missing quota row or a NULL max_servers as unlimited;
|
||||
|
||||
+12
-9
@@ -114,18 +114,21 @@ type Repo interface {
|
||||
IsLinked(ctx context.Context, userID string) (bool, error)
|
||||
// CreateLinkCode mints a one-time account-link code for an in-game player
|
||||
// (spec §10: 游戏内 /link → 生成一次性码). The code is born knowing only the
|
||||
// verified mc_uuid (online-mode=true established it); a web user binds it to
|
||||
// their user_id later via VerifyLinkCode. This is internal-face only — the web
|
||||
// has no verified UUID to mint against (the account_link_codes schema has no
|
||||
// user_id column, which forces the in-game origin).
|
||||
CreateLinkCode(ctx context.Context, code, mcUUID string, expiresAt time.Time) error
|
||||
// verified mc_uuid (online-mode=true established it) and the authSource that
|
||||
// established it (mojang|thirdparty, spec §10 dual-Yggdrasil); a web user binds
|
||||
// it to their user_id later via VerifyLinkCode. This is internal-face only — the
|
||||
// web has no verified UUID to mint against (the account_link_codes schema has no
|
||||
// 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
|
||||
// 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;
|
||||
// re-verifying the same (user, uuid) pair is idempotent. now is the API clock so
|
||||
// expiry is testable.
|
||||
VerifyLinkCode(ctx context.Context, userID, code string, now time.Time) (mcUUID string, err error)
|
||||
// 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)
|
||||
// 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)
|
||||
|
||||
@@ -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';
|
||||
Reference in new issue
Block a user