feat(api): add QR scan-login completion poll on the internal face

QR scan-to-login is a device-code grant where the QR encodes the existing
short-lived account-link code (spec §B3 player game-login). velocity mints a
code in-game, renders it as a QR, the player scans it on a phone already signed
in to the panel, and that web session's verify writes the durable account_links
row bound to that user. The only new verifiable surface that flow needs is the
completion poll velocity calls to learn the link landed and admit the player.

Add GET /api/v1/internal/account/link/status/{mc_uuid}: a read-only, internal
handleLinkStatus keyed by the verified mc_uuid velocity already holds. It reuses
the existing UserByMCUUID, so it adds no migration and no mutation to the
load-bearing VerifyLinkCode; ErrNotFound maps to {linked:false} (pending /
not-yet-scanned), a hit to {linked:true, user_id}. Keying on the public UUID and
not the scanned code means the read carries no guessing surface and needs no
attempt cap — the internal face already gates it to service callers, and the poll
consumes nothing so a velocity restart re-polls safely.

QR render, limbo collision routing, in-game admit, and the reclaim
inherit-disambiguation stay CODE-ONLY (Java/Velocity) and are labeled as such;
this endpoint reports link completion only.

Document the route in openapi.yaml (x-felis-face internal, x-felis-tier service)
so the parity gate holds, and cover it with a hermetic vertical that proves the
poll reflects the durable link only after the external verify and binds the
verifier's id, plus unknown-uuid, idempotency, and internal-only face separation.
This commit is contained in:
flyemoji committed 2026-06-30 04:08:01 +09:00
1 parent 28c3eee361
commit 116595f3ee
4 files changed
+207

No files matched your search

+31
View File
@@ -668,6 +668,37 @@ paths:
'401': '401':
$ref: '#/components/responses/Unauthorized' $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: /api/v1/internal/player/reclaim:
post: post:
tags: [account-internal] tags: [account-internal]
+6
View File
@@ -172,6 +172,12 @@ func (a *API) internalAPIRoutes() []apiRoute {
// verified UUID. Internal-only — the code is born from an online-mode UUID the // 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). // web never holds (account_link_codes has no user_id column).
{Method: "POST", Pattern: "/api/v1/internal/account/link/code", h: a.handleCreateLinkCode}, {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 // Username-collision reclaim (spec §B3): velocity records a Mojang-priority
// reclaim (bar the squatter UUID + stash its data for 30 days) and gates the // 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 — // limbo login by checking whether a connecting UUID was barred. Internal-only —
+46
View File
@@ -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.<root_domain>
// → 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 // linkVerifyRequest is the panel verify-code body (spec §10): the logged-in user
// submits the code they were shown in-game. // submits the code they were shown in-game.
type linkVerifyRequest struct { type linkVerifyRequest struct {
+124
View File
@@ -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: "[email protected]", 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: "[email protected]", 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)
}
}