feat(api): reclaim squatted usernames for Mojang-priority players (spec §B3)

When the configured third-party Yggdrasil and the official Mojang service
issue the same username under different UUIDs, the non-genuine squatter is
displaced in favour of the real Mojang owner (正版优先). This adds the
Go-verifiable data layer of that flow on the internal (velocity) face.

- migration 0006: username_blacklist (barred squatter UUIDs) and
  player_data_holds (the displaced account's 30-day data stash), both keyed
  by mc_uuid so the genuine Mojang player — identical username, different
  UUID — is never caught by the bar.
- POST /api/v1/internal/player/reclaim bars the squatter UUID and stashes
  its data in one transaction (all-or-nothing). It is idempotent on a
  retried callback and returns the hold's effective expiry — the first
  reclaim's window, never a fresh now()+30d — so the rejected player is told
  the truth about how long their data is kept.
- GET /api/v1/internal/player/blacklist/{mc_uuid} is the login-gate check
  velocity calls to reject a barred squatter before admitting them.

Scope: velocity collision-routing, the limbo prompt, the authlib
dual-backend and the data-inherit flow are code-only (Java plus a QR-bound
device session a row cannot express) and are not part of this slice. Unit
tests cover the handlers and the in-memory repo contract; the Postgres SQL
path is exercised by integration only.
This commit is contained in:
flyemoji committed 2026-06-27 13:41:19 +09:00
1 parent 1f8b9bb5d0
commit a29571de39
8 files changed
+611

No files matched your search

+37
View File
@@ -54,6 +54,24 @@ type fakeRepo struct {
// player email OTPs (spec §B2). Keyed by row id; the verify path scans for the
// newest live (user, purpose) just as the PG query does.
otps map[string]*fakeEmailOTP
// username-collision reclaim (spec §B3). blacklist mirrors username_blacklist
// (mc_uuid -> barred), holds mirrors player_data_holds keyed by the held
// (squatter) mc_uuid — both keyed by UUID, matching the PG UNIQUE(mc_uuid)
// idempotency. They are written together by ReclaimUsername so the fake encodes
// the same all-or-nothing contract the PG transaction enforces.
blacklist map[string]bool
holds map[string]fakeDataHold
}
// fakeDataHold mirrors a player_data_holds row at the granularity the verifiable
// (write-only) layer exercises: which name/data was stashed for the squatter UUID
// and when the 30-day window ends. reclaimed_by_user_id/reclaimed_at have no fake
// fields — the inherit flow that would set them is CODE-ONLY (deferred).
type fakeDataHold struct {
id string
username string
dataRef string
expiresAt time.Time
}
// fakeEmailOTP mirrors an email_otps row: only the code hash is held (never the
@@ -107,6 +125,8 @@ func newFakeRepo() *fakeRepo {
sessions: map[string]*fakeSession{},
settings: map[string][]byte{},
otps: map[string]*fakeEmailOTP{},
blacklist: map[string]bool{},
holds: map[string]fakeDataHold{},
}
}
@@ -210,6 +230,23 @@ func (f *fakeRepo) UserByMCUUID(_ context.Context, uuid string) (string, error)
}
return "", ErrNotFound
}
// ReclaimUsername mirrors PGRepo.ReclaimUsername: it bars the squatter UUID and
// stashes the data hold together (the all-or-nothing PG transaction), keyed by
// mc_uuid so a repeat reclaim of an already-barred UUID is an idempotent no-op
// (ON CONFLICT (mc_uuid) DO NOTHING on both tables) — the first reclaim wins and
// a duplicate neither errors nor overwrites the stored hold.
func (f *fakeRepo) ReclaimUsername(_ context.Context, id, squatterUUID, username, dataRef string, expiresAt time.Time) (time.Time, error) {
if h, ok := f.holds[squatterUUID]; ok { // already stashed — idempotent no-op; keep & report the first window
return h.expiresAt, nil
}
f.blacklist[squatterUUID] = true
f.holds[squatterUUID] = fakeDataHold{id: id, username: username, dataRef: dataRef, expiresAt: expiresAt}
return expiresAt, nil
}
func (f *fakeRepo) IsUsernameBlacklisted(_ context.Context, mcUUID string) (bool, error) {
return f.blacklist[mcUUID], nil
}
func (f *fakeRepo) ClaimServer(_ context.Context, n, u string) (bool, error) {
ok, present := f.claimOK[n]
if !present {