Files
flyemoji a29571de39 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.
2026-06-27 13:41:19 +09:00

53 lines
3.6 KiB
SQL

-- Phase B3 player game-login: username-collision reclaim (Mojang-priority 正版优先).
--
-- The configured third-party Yggdrasil and the official Mojang service can both
-- mint the SAME username under DIFFERENT UUIDs. Velocity detects the collision,
-- routes the player to a limbo login server, and asks whether they own both
-- accounts. When they do NOT — i.e. the connecting player is NOT the genuine
-- Mojang owner — the genuine Mojang account reclaims the name: the squatter is
-- rejected and barred, and their world/player data is stashed for 30 days so a
-- new account can inherit it.
--
-- Velocity collision-routing, the limbo prompt, the authlib dual-backend, and the
-- data-inherit flow are CODE-ONLY (Java + a QR-bound device session a Postgres
-- row cannot express). THIS migration is the Go-verifiable data layer those
-- callbacks read and write: the reclaim event bars the squatter UUID and records
-- the hold; the login gate reads the blacklist to reject that UUID.
-- username_blacklist bars a non-genuine UUID from connecting after a reclaim. It
-- is keyed by mc_uuid, NEVER by the contested username: the genuine Mojang player
-- shares that name under a DIFFERENT UUID and must always pass. The block is on
-- the squatting identity, not the name — this is the load-bearing safety property
-- (正版优先 must never harm the genuine Mojang player).
CREATE TABLE username_blacklist (
mc_uuid uuid PRIMARY KEY, -- the barred (non-Mojang) UUID
username text NOT NULL, -- the contested name, for display/audit only
reason text NOT NULL DEFAULT 'mojang_priority_reclaim',
blacklisted_at timestamptz NOT NULL DEFAULT now()
);
-- player_data_holds stashes the reclaimed (squatter) account's world/player data
-- so a new account can inherit it within the 30-day window (spec §B3 "数据将会被
-- 暂存 30 天"). The verifiable layer is WRITE-ONLY here: the reclaim event inserts
-- a row (held_at..expires_at, the opaque data_ref handle); the inherit/claim flow
-- that stamps reclaimed_by_user_id/reclaimed_at lives in the CODE-ONLY device-
-- session layer — a new account proves entitlement to an OLD uuid's data via its
-- QR-bound session, a primitive the Postgres layer cannot express, so those two
-- columns stay NULL throughout the verifiable path. UNIQUE (mc_uuid) makes a
-- repeated reclaim of an already-barred UUID idempotent (it should never recur —
-- the login gate rejects that UUID before it can reach reclaim again).
CREATE TABLE player_data_holds (
id text PRIMARY KEY, -- opaque row id (crypto-random hex)
mc_uuid uuid NOT NULL UNIQUE, -- the held (squatter) UUID
username text NOT NULL, -- the name at reclaim time
data_ref text, -- opaque archiver handle, server-side only (cf. world_backups.backup_ref); NULL until/unless archived
held_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL, -- held_at + 30d, set by the API clock (one authoritative clock)
reclaimed_by_user_id text REFERENCES users(id), -- NULL until a new account inherits the data (CODE-ONLY inherit flow)
reclaimed_at timestamptz -- NULL until inherited
);
-- The deferred inherit flow lists active holds by username (a reclaimed name's
-- new owner inheriting the old data), so index that lookup ahead of it.
CREATE INDEX player_data_holds_username_idx ON player_data_holds (username);