diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 0cb1ef7..af63a6f 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -668,6 +668,37 @@ paths: '401': $ref: '#/components/responses/Unauthorized' + /api/v1/internal/account/link/status/{mc_uuid}: + get: + tags: [account-internal] + operationId: linkStatus + summary: Poll whether an in-game UUID has finished linking — the QR scan-to-login completion check (spec §B3). + description: > + Internal-only, read-only. After a new player scans the QR-encoded link code + and the web verify writes the durable account_links row, velocity polls this + for the UUID it minted against and admits the player on linked:true, binding + the in-game session to user_id. Keyed by the verified UUID (not the scanned + code), so it consumes nothing and is safe to poll repeatedly; an unlinked or + never-seen UUID returns linked:false, and user_id is present only when linked. + x-felis-face: [internal] + x-felis-tier: service + security: [{ serviceToken: [] }] + parameters: + - { name: mc_uuid, in: path, required: true, schema: { type: string, format: uuid } } + responses: + '200': + description: Link-completion status; user_id is present only when linked. + content: + application/json: + schema: + type: object + required: [linked] + properties: + linked: { type: boolean } + user_id: { type: string } + '401': + $ref: '#/components/responses/Unauthorized' + /api/v1/internal/player/reclaim: post: tags: [account-internal] diff --git a/internal/api/api.go b/internal/api/api.go index 76dc92f..60e48ef 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -172,6 +172,12 @@ func (a *API) internalAPIRoutes() []apiRoute { // verified UUID. Internal-only — the code is born from an online-mode UUID the // web never holds (account_link_codes has no user_id column). {Method: "POST", Pattern: "/api/v1/internal/account/link/code", h: a.handleCreateLinkCode}, + // QR scan-to-login completion poll (spec §B3 player game-login). After the player + // scans the QR-encoded code and the web verify writes the durable link, velocity + // polls this for the UUID it minted against and admits on {linked:true}. Read-only + // and keyed by the verified UUID (not the scanned code), so it consumes nothing + // and is safe to poll repeatedly. + {Method: "GET", Pattern: "/api/v1/internal/account/link/status/{mc_uuid}", h: a.handleLinkStatus}, // Username-collision reclaim (spec §B3): velocity records a Mojang-priority // reclaim (bar the squatter UUID + stash its data for 30 days) and gates the // limbo login by checking whether a connecting UUID was barred. Internal-only — diff --git a/internal/api/handlers_account.go b/internal/api/handlers_account.go index 01f0eac..b3e5e96 100644 --- a/internal/api/handlers_account.go +++ b/internal/api/handlers_account.go @@ -116,6 +116,52 @@ func (a *API) handleCreateLinkCode(w http.ResponseWriter, r *http.Request) { }) } +// handleLinkStatus reports whether an in-game UUID has finished linking yet — the +// completion poll of the QR scan-to-login flow (spec §B3 player game-login; memory +// player-login-yggdrasil). It is internal-face and read-only, the device-code +// "poll for completion" step that turns the typed-code link into a scan: +// +// new player joins → velocity mints a code (handleCreateLinkCode) and renders it +// as a QR → player scans it on a phone already signed in to console. +// → that web session's verify (handleLinkVerify) writes the durable account_links +// row bound to THAT user → velocity polls HERE for the same UUID it minted against +// → on {linked:true} it admits the player, binding the in-game session to user_id +// with no reconnect — the whole point of scanning over typing. +// +// The poll is keyed by the verified mc_uuid velocity already holds, not by the +// scanned code, so it is a pure idempotent read of the durable link (UserByMCUUID): +// there is no transient device-session row, nothing is consumed, and a velocity +// restart re-polls safely. The secret is the short-TTL code the player scans, never +// this public UUID, so the read carries no guessing surface and needs no attempt +// cap — the internal face already gates it to service callers. +// +// CODE-ONLY (Java/Velocity, not represented here): rendering the code as a QR, the +// limbo collision routing, and admitting the polled player into the main server. +// KNOWN-LIMITATION: the reclaim disambiguation a scan can surface — "start fresh" +// vs "inherit the 30-day-held data" — is the data-inherit choice that +// handlers_player_reclaim.go keeps CODE-ONLY (reclaimed_by_user_id stays NULL on +// the verifiable path); this endpoint reports link completion only, not that choice. +func (a *API) handleLinkStatus(w http.ResponseWriter, r *http.Request) { + mcUUID := r.PathValue("mc_uuid") + if mcUUID == "" { + writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required")) + return + } + userID, err := a.Repo.UserByMCUUID(r.Context(), mcUUID) + switch { + case errors.Is(err, ErrNotFound): + // Not linked yet. For the poller this is simply "keep waiting": velocity + // polls until its own code TTL lapses. A never-seen UUID is indistinguishable + // from a not-yet-scanned one, and deliberately so — both mean "do not admit". + writeJSON(w, http.StatusOK, map[string]any{"linked": false}) + return + case err != nil: + writeError(w, r, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"linked": true, "user_id": userID}) +} + // linkVerifyRequest is the panel verify-code body (spec §10): the logged-in user // submits the code they were shown in-game. type linkVerifyRequest struct { diff --git a/internal/api/handlers_qr_login_test.go b/internal/api/handlers_qr_login_test.go new file mode 100644 index 0000000..d928a28 --- /dev/null +++ b/internal/api/handlers_qr_login_test.go @@ -0,0 +1,124 @@ +package api + +import ( + "net/http" + "testing" +) + +// statusPath builds the internal link-status poll path for a UUID. +func statusPath(mcUUID string) string { + return "/api/v1/internal/account/link/status/" + mcUUID +} + +// TestQRLoginCompletionPollVertical walks the QR scan-to-login flow end to end and +// proves its load-bearing invariant: the internal completion poll reports the link +// only after the WEB verify writes it, and reports it bound to the exact Principal +// that verified — never to a UUID the poll itself could name. velocity mints and +// polls on the internal face (it holds no web Principal); the durable bind is born +// on the external face from a logged-in user. That split is the whole security +// model of the scan, so the test drives both faces of one API. +func TestQRLoginCompletionPollVertical(t *testing.T) { + const mcUUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + user := &Principal{UserID: "u-scan", Email: "scan@example.net", Role: "user"} + + repo := newFakeRepo() + api := newTestAPI(repo, newFakeCluster()) + api.External = staticExternal{p: user} + ih := api.InternalHandler() + eh := api.ExternalHandler() + + // Before the player scans, velocity is already polling the UUID it minted + // against. The link does not exist yet, so the poll says "keep waiting" — a + // plain 200 linked:false, NOT an error, and with no user_id to leak. + w := do(ih, "GET", statusPath(mcUUID), "", nil) + if w.Code != http.StatusOK { + t.Fatalf("pre-scan poll: code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + if b := acctBody(t, w); b["linked"] != false { + t.Fatalf("pre-scan poll body = %v, want linked:false", b) + } else if _, ok := b["user_id"]; ok { + t.Fatalf("pre-scan poll leaked user_id: %v", b) + } + + // The QR encodes a one-time code velocity mints in-game (internal face). + w = do(ih, "POST", "/api/v1/internal/account/link/code", `{"mc_uuid":"`+mcUUID+`"}`, nil) + if w.Code != http.StatusCreated { + t.Fatalf("mint code: code = %d, want 201 (%s)", w.Code, w.Body.String()) + } + code, _ := acctBody(t, w)["code"].(string) + + // The player scans it on a phone already signed in to the panel: that web + // session's verify writes the durable link, bound to THAT Principal (external). + w = do(eh, "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()) + } + + // Now the poll flips: velocity sees linked:true and the user_id it must bind the + // in-game session to — and that user_id is the verifier's, the only identity the + // poll could ever return, since the poll cannot mint a link of its own. + w = do(ih, "GET", statusPath(mcUUID), "", nil) + if w.Code != http.StatusOK { + t.Fatalf("post-verify poll: code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + b := acctBody(t, w) + if b["linked"] != true { + t.Fatalf("post-verify poll body = %v, want linked:true", b) + } + if got := b["user_id"]; got != user.UserID { + t.Fatalf("post-verify poll user_id = %v, want %q (the verifier's id)", got, user.UserID) + } +} + +// TestQRLoginStatusUnknownUUID pins that a UUID the platform has never linked is +// reported as not-linked, not as an error. velocity polls public online-mode UUIDs; +// an unknown one means "do not admit yet", indistinguishable by design from a code +// that has simply not been scanned. +func TestQRLoginStatusUnknownUUID(t *testing.T) { + api := newTestAPI(newFakeRepo(), newFakeCluster()) + w := do(api.InternalHandler(), "GET", statusPath("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), "", nil) + if w.Code != http.StatusOK { + t.Fatalf("unknown-uuid poll: code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + if b := acctBody(t, w); b["linked"] != false { + t.Fatalf("unknown-uuid poll body = %v, want linked:false", b) + } +} + +// TestQRLoginStatusIdempotent re-polls a linked UUID and expects the identical +// answer: the poll is a pure read of the durable link, so a velocity that restarts +// mid-handshake and re-polls must never get a different verdict or consume the link. +func TestQRLoginStatusIdempotent(t *testing.T) { + const mcUUID = "cccccccc-cccc-cccc-cccc-cccccccccccc" + repo := newFakeRepo() + repo.links[mcUUID] = "u-held" // already linked + api := newTestAPI(repo, newFakeCluster()) + ih := api.InternalHandler() + + for i := 0; i < 2; i++ { + w := do(ih, "GET", statusPath(mcUUID), "", nil) + if w.Code != http.StatusOK { + t.Fatalf("poll %d: code = %d, want 200 (%s)", i, w.Code, w.Body.String()) + } + b := acctBody(t, w) + if b["linked"] != true || b["user_id"] != "u-held" { + t.Fatalf("poll %d body = %v, want linked:true user_id:u-held", i, b) + } + } + // The read must not have disturbed the durable link. + if repo.links[mcUUID] != "u-held" { + t.Errorf("poll consumed the link: links[%s] = %q, want u-held", mcUUID, repo.links[mcUUID]) + } +} + +// TestQRLoginStatusFaceSeparation enforces that the poll is internal-only. It +// reads who a UUID is linked to — a fact the public web face must not be able to +// fish out by UUID — so crossing onto the external face must 404, not answer. +func TestQRLoginStatusFaceSeparation(t *testing.T) { + user := &Principal{UserID: "u1", Email: "u1@example.net", Role: "user"} + api := newTestAPI(newFakeRepo(), newFakeCluster()) + api.External = staticExternal{p: user} + if w := do(api.ExternalHandler(), "GET", statusPath("dddddddd-dddd-dddd-dddd-dddddddddddd"), "", nil); w.Code != http.StatusNotFound { + t.Errorf("status endpoint on external face: code = %d, want 404", w.Code) + } +}