From 9ea35304e34829b54d1bfb618bae529f4c939e4e Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Thu, 16 Jul 2026 13:27:02 +0900 Subject: [PATCH] fix(setup): source the console host from the panel hostname, not op.console The owner setup URL and the limbo login link were built from the admin host (op.console., with an op.console.localhost fallback) and a hardcoded console., so an operator who set a custom panel_hostname got an unreachable setup link and a wrong login target. Thread the resolved panel host (defaultPanelHostname) through performSetupMCBind, the MC-bind TUI, and the login system-server env (new FELIS_PANEL_HOSTNAME); the limbo plugin prefers it and keeps console. only as the fallback for an older operator whose env predates it. This also matters for security: the only wired WebAuthn verifier is scoped to the panel host, so passkey enrollment must land on the panel face, never op.console. While here, the limbo login handler checks link status before minting a bind code: an already-linked player is sent straight to the lobby instead of being shown a useless code. --- cmd/felis/breakglass.go | 13 ++++--- cmd/felis/breakglass_test.go | 20 +++++------ cmd/felis/setup.go | 2 +- cmd/felis/systemservers.go | 35 +++++++++++-------- cmd/felis/systemservers_test.go | 24 +++++++------ cmd/felis/tui_mc_bind.go | 8 ++--- cmd/felis/tui_menu_test.go | 2 +- cmd/felis/tui_root.go | 2 +- internal/api/handlers_setup.go | 2 +- .../lolicon/felis/limbo/FelisLimboPlugin.java | 22 ++++++++++-- 10 files changed, 78 insertions(+), 52 deletions(-) diff --git a/cmd/felis/breakglass.go b/cmd/felis/breakglass.go index ce7c869..194f959 100644 --- a/cmd/felis/breakglass.go +++ b/cmd/felis/breakglass.go @@ -388,14 +388,17 @@ func newSetupToken() (raw, hash string, err error) { // binds their Minecraft account via a one-time link code the login gate handed // them in-game, the bound user is promoted to role='admin' (passwordless Owner), // local auth is enabled, and a one-time setup URL is minted for the first web -// login where the Owner verifies email / enrolls a passkey. adminHostname is the -// op.console host the URL points at; osUser is recorded as the accountable actor. +// login where the Owner verifies email / enrolls a passkey. panelHostname is the +// panel host the URL points at: the wizard enrolls the passkey, and the only wired +// WebAuthn verifier (cmd/felis/api.go) is scoped to the panel host, so the +// ceremony's origin MUST be the panel face — op.console has no verifier wired and +// cannot enroll at all. osUser is recorded as the accountable actor. // // Local auth is as load-bearing here as it is in break-glass, and for a sharper // reason: an MC-bound Owner has no password AND no email, so the setup token is // their ONLY door. CompleteOwnerSetup therefore commits the identity bind, auth // toggle, and token together; any failed write leaves the link code retryable. -func performSetupMCBind(ctx context.Context, s ownerStore, code, adminHostname, osUser string) (breakGlassOutcome, error) { +func performSetupMCBind(ctx context.Context, s ownerStore, code, panelHostname, osUser string) (breakGlassOutcome, error) { code = strings.TrimSpace(strings.ToUpper(code)) if code == "" { return breakGlassOutcome{}, errors.New("link code is required") @@ -420,9 +423,9 @@ func performSetupMCBind(ctx context.Context, s ownerStore, code, adminHostname, ownerIdentity: mcUUID, auditErr: auditSetupMCBind(ctx, s, osUser, mcUUID, authSource), } - host := strings.TrimSpace(adminHostname) + host := strings.TrimSpace(panelHostname) if host == "" { - host = "op.console.localhost" + host = "console.localhost" } out.setupTokenURL = "https://" + host + "/setup?token=" + raw return out, nil diff --git a/cmd/felis/breakglass_test.go b/cmd/felis/breakglass_test.go index ddb81f8..f59cefc 100644 --- a/cmd/felis/breakglass_test.go +++ b/cmd/felis/breakglass_test.go @@ -650,7 +650,7 @@ func TestPerformSetupMCBind(t *testing.T) { t.Run("binds the owner and mints a setup URL whose token hash is what is stored", func(t *testing.T) { f := &fakeOwnerStore{redeemUserID: "usr-owner-1", redeemMCUUID: "mc-uuid-1", redeemAuthSource: "mojang"} - out, err := performSetupMCBind(ctx, f, " abc-123 ", "op.console.example.com", "deploybot") + out, err := performSetupMCBind(ctx, f, " abc-123 ", "console.example.com", "deploybot") if err != nil { t.Fatalf("performSetupMCBind: %v", err) } @@ -672,7 +672,7 @@ func TestPerformSetupMCBind(t *testing.T) { if out.ownerIdentity != "mc-uuid-1" { t.Errorf("owner identity = %q, want the verified Minecraft UUID", out.ownerIdentity) } - const prefix = "https://op.console.example.com/setup?token=" + const prefix = "https://console.example.com/setup?token=" if !strings.HasPrefix(out.setupTokenURL, prefix) { t.Fatalf("setup URL = %q, want prefix %q", out.setupTokenURL, prefix) } @@ -711,7 +711,7 @@ func TestPerformSetupMCBind(t *testing.T) { t.Run("an empty link code mints nothing", func(t *testing.T) { f := &fakeOwnerStore{} - if _, err := performSetupMCBind(ctx, f, " ", "op.console.example.com", "root"); err == nil { + if _, err := performSetupMCBind(ctx, f, " ", "console.example.com", "root"); err == nil { t.Fatal("want error for an empty link code") } if len(f.redeems) != 0 || len(f.tokens) != 0 { @@ -724,7 +724,7 @@ func TestPerformSetupMCBind(t *testing.T) { t.Run("a link-code redemption failure mints no token", func(t *testing.T) { f := &fakeOwnerStore{redeemErr: errors.New("code expired")} - if _, err := performSetupMCBind(ctx, f, "abc-123", "op.console.example.com", "root"); err == nil { + if _, err := performSetupMCBind(ctx, f, "abc-123", "console.example.com", "root"); err == nil { t.Fatal("want error when the link code cannot be redeemed") } if len(f.tokens) != 0 { @@ -737,7 +737,7 @@ func TestPerformSetupMCBind(t *testing.T) { t.Run("a local-auth failure fails the bind rather than minting an unredeemable URL", func(t *testing.T) { f := &fakeOwnerStore{redeemUserID: "usr-owner-1", setErr: errors.New("db down")} - if _, err := performSetupMCBind(ctx, f, "abc-123", "op.console.example.com", "root"); err == nil { + if _, err := performSetupMCBind(ctx, f, "abc-123", "console.example.com", "root"); err == nil { t.Fatal("want error when local auth cannot be enabled") } if len(f.redeems) != 0 || len(f.tokens) != 0 { @@ -747,7 +747,7 @@ func TestPerformSetupMCBind(t *testing.T) { t.Run("a token-store failure rolls the bind back", func(t *testing.T) { f := &fakeOwnerStore{redeemUserID: "usr-owner-1", createTokenErr: errors.New("db down")} - if _, err := performSetupMCBind(ctx, f, "abc-123", "op.console.example.com", "root"); err == nil { + if _, err := performSetupMCBind(ctx, f, "abc-123", "console.example.com", "root"); err == nil { t.Fatal("want error when the setup token cannot be stored") } if len(f.redeems) != 0 { @@ -763,7 +763,7 @@ func TestPerformSetupMCBind(t *testing.T) { t.Run("an audit failure does not cost the operator their install", func(t *testing.T) { f := &fakeOwnerStore{redeemUserID: "usr-owner-1", auditErr: errors.New("audit sink down")} - out, err := performSetupMCBind(ctx, f, "abc-123", "op.console.example.com", "root") + out, err := performSetupMCBind(ctx, f, "abc-123", "console.example.com", "root") if err != nil { t.Fatalf("an audit failure must not fail the bind: %v", err) } @@ -778,14 +778,14 @@ func TestPerformSetupMCBind(t *testing.T) { } }) - t.Run("defaults the op.console host when adminHostname is empty", func(t *testing.T) { + t.Run("defaults the console host when panelHostname is empty", func(t *testing.T) { f := &fakeOwnerStore{redeemUserID: "usr-owner-1"} out, err := performSetupMCBind(ctx, f, "abc-123", " ", "root") if err != nil { t.Fatalf("performSetupMCBind: %v", err) } - if !strings.HasPrefix(out.setupTokenURL, "https://op.console.localhost/setup?token=") { - t.Errorf("setup URL = %q, want the op.console.localhost default host", out.setupTokenURL) + if !strings.HasPrefix(out.setupTokenURL, "https://console.localhost/setup?token=") { + t.Errorf("setup URL = %q, want the console.localhost default host", out.setupTokenURL) } }) } diff --git a/cmd/felis/setup.go b/cmd/felis/setup.go index 4a31d71..fe71602 100644 --- a/cmd/felis/setup.go +++ b/cmd/felis/setup.go @@ -239,7 +239,7 @@ func provisionSystemServers(ctx context.Context, cfg *config.Config, out io.Writ ensureSecretReplica(ctx, cl, controlNS, cfg.K8s.Namespace, naming.ForwardingSecretName, naming.ForwardingSecretKey, "forwarding-secret"), } - outcomes := ensureSystemServers(ctx, cl, cfg.K8s.Namespace, cfg.Velocity.LoginImage, cfg.Velocity.LobbyImage, apiBaseURL, cfg.Server.RootDomain) + outcomes := ensureSystemServers(ctx, cl, cfg.K8s.Namespace, cfg.Velocity.LoginImage, cfg.Velocity.LobbyImage, apiBaseURL, cfg.Server.RootDomain, defaultPanelHostname(cfg.Server.RootDomain, cfg.Auth.PanelHostname)) outcomes = append(secretOutcomes, outcomes...) fmt.Fprintln(out, "\nfelis setup: login/lobby system servers (always-on, reaper-exempt):") for _, o := range outcomes { diff --git a/cmd/felis/systemservers.go b/cmd/felis/systemservers.go index f39d13f..276e63e 100644 --- a/cmd/felis/systemservers.go +++ b/cmd/felis/systemservers.go @@ -63,16 +63,17 @@ const felisLimboHealthPort int32 = 8080 // The felis-limbo login plugin reads its deployment configuration from these // environment variables (env wins over its felis-link.properties template). The -// non-secret three are baked into the login pod's Spec.Env here at provision time +// non-secret four are baked into the login pod's Spec.Env here at provision time // (they derive from the deployment: the internal API URL, the root domain, the -// lobby server name); the service-token secret is injected separately by the -// operator via secretKeyRef. Without the token the plugin fail-safes to -// readiness-only, so a login pod that has the URL/domain but not yet the token is -// safe (it simply does not authenticate) rather than broken. +// resolved panel host, the lobby server name); the service-token secret is injected +// separately by the operator via secretKeyRef. Without the token the plugin +// fail-safes to readiness-only, so a login pod that has the URL/domain but not yet +// the token is safe (it simply does not authenticate) rather than broken. const ( - envAPIBaseURL = "FELIS_API_BASE_URL" - envRootDomain = "FELIS_ROOT_DOMAIN" - envLobbyServer = "FELIS_LOBBY_SERVER" + envAPIBaseURL = "FELIS_API_BASE_URL" + envRootDomain = "FELIS_ROOT_DOMAIN" + envPanelHostname = "FELIS_PANEL_HOSTNAME" + envLobbyServer = "FELIS_LOBBY_SERVER" ) // buildSystemServer constructs an always-on, reaper-exempt MinecraftServer from @@ -153,11 +154,14 @@ func buildSystemServer(in systemServerSpec, namespace string) (*v1alpha1.Minecra // only safe fallback, so it carries no fallback of its own: if it is down the // proxy refuses the connection rather than routing onward past authentication. // -// apiBaseURL is the felis-api internal face the login plugin authenticates to and -// rootDomain builds the console URL the plugin links players at; both are baked in -// as plain env. The service token is NOT passed here — the operator injects it via -// secretKeyRef so the credential never lands in the CRD. -func loginSystemServer(image, namespace, apiBaseURL, rootDomain string) (*v1alpha1.MinecraftServer, error) { +// apiBaseURL is the felis-api internal face the login plugin authenticates to; +// panelHostname is the resolved console/panel host the plugin links players at (the +// single source of truth for that host — see defaultPanelHostname), and rootDomain +// is kept for the plugin's own console. fallback when the panel env is absent +// (an older operator). All three are baked in as plain env. The service token is NOT +// passed here — the operator injects it via secretKeyRef so the credential never +// lands in the CRD. +func loginSystemServer(image, namespace, apiBaseURL, rootDomain, panelHostname string) (*v1alpha1.MinecraftServer, error) { return buildSystemServer(systemServerSpec{ name: naming.SystemLoginServer, subdomain: naming.SystemLoginServer, @@ -170,6 +174,7 @@ func loginSystemServer(image, namespace, apiBaseURL, rootDomain string) (*v1alph env: []v1alpha1.EnvVar{ {Name: envAPIBaseURL, Value: apiBaseURL}, {Name: envRootDomain, Value: rootDomain}, + {Name: envPanelHostname, Value: panelHostname}, {Name: envLobbyServer, Value: naming.SystemLobbyServer}, }, }, namespace) @@ -233,7 +238,7 @@ type systemServerOutcome struct { // service is created. It never deletes or overwrites. The caller supplies the // K8s client and namespace; this function performs no signal-handler or client // setup of its own. -func ensureSystemServers(ctx context.Context, cl client.Client, namespace, loginImage, lobbyImage, apiBaseURL, rootDomain string) []systemServerOutcome { +func ensureSystemServers(ctx context.Context, cl client.Client, namespace, loginImage, lobbyImage, apiBaseURL, rootDomain, panelHostname string) []systemServerOutcome { type plan struct { name string image string @@ -241,7 +246,7 @@ func ensureSystemServers(ctx context.Context, cl client.Client, namespace, login } plans := []plan{ {name: naming.SystemLoginServer, image: loginImage, build: func(image, ns string) (*v1alpha1.MinecraftServer, error) { - return loginSystemServer(image, ns, apiBaseURL, rootDomain) + return loginSystemServer(image, ns, apiBaseURL, rootDomain, panelHostname) }}, {name: naming.SystemLobbyServer, image: lobbyImage, build: lobbySystemServer}, } diff --git a/cmd/felis/systemservers_test.go b/cmd/felis/systemservers_test.go index 5d44d3c..1c23584 100644 --- a/cmd/felis/systemservers_test.go +++ b/cmd/felis/systemservers_test.go @@ -56,7 +56,7 @@ func TestBuildSystemServerShape(t *testing.T) { // fallback of its own; the lobby falls back to login. Neither may fall back to // the lobby — that would route a player past authentication. func TestSystemServerFallbackPolicy(t *testing.T) { - login, err := loginSystemServer("reg/limbo:1", "minecraft", "http://felis-api.felis.svc.cluster.local:8081", "mc.example.net") + login, err := loginSystemServer("reg/limbo:1", "minecraft", "http://felis-api.felis.svc.cluster.local:8081", "mc.example.net", "console.mc.example.net") if err != nil { t.Fatalf("loginSystemServer: %v", err) } @@ -107,11 +107,12 @@ func TestBuildSystemServerRejectsBadInput(t *testing.T) { } // The login gate needs its deployment config as plain env: the internal API URL, -// the root domain, and the lobby name — but NEVER the service token (that is -// injected by the operator via secretKeyRef, never a literal in the CRD). +// the root domain, the resolved panel host, and the lobby name — but NEVER the +// service token (that is injected by the operator via secretKeyRef, never a literal +// in the CRD). func TestLoginSystemServerEnv(t *testing.T) { login, err := loginSystemServer("reg/limbo:1", "minecraft", - "http://felis-api.felis.svc.cluster.local:8081", "mc.example.net") + "http://felis-api.felis.svc.cluster.local:8081", "mc.example.net", "console.mc.example.net") if err != nil { t.Fatalf("loginSystemServer: %v", err) } @@ -120,9 +121,10 @@ func TestLoginSystemServerEnv(t *testing.T) { got[e.Name] = e.Value } want := map[string]string{ - "FELIS_API_BASE_URL": "http://felis-api.felis.svc.cluster.local:8081", - "FELIS_ROOT_DOMAIN": "mc.example.net", - "FELIS_LOBBY_SERVER": naming.SystemLobbyServer, + "FELIS_API_BASE_URL": "http://felis-api.felis.svc.cluster.local:8081", + "FELIS_ROOT_DOMAIN": "mc.example.net", + "FELIS_PANEL_HOSTNAME": "console.mc.example.net", + "FELIS_LOBBY_SERVER": naming.SystemLobbyServer, } for k, v := range want { if got[k] != v { @@ -352,7 +354,7 @@ func TestEnsureSystemServersIdempotent(t *testing.T) { cl := fake.NewClientBuilder().WithScheme(scheme).Build() ctx := context.Background() - first := ensureSystemServers(ctx, cl, "minecraft", "reg/limbo:1", "reg/lobby:1", "http://felis-api.felis.svc.cluster.local:8081", "mc.example.net") + first := ensureSystemServers(ctx, cl, "minecraft", "reg/limbo:1", "reg/lobby:1", "http://felis-api.felis.svc.cluster.local:8081", "mc.example.net", "console.mc.example.net") if len(first) != 2 { t.Fatalf("first run outcomes = %d, want 2", len(first)) } @@ -378,7 +380,7 @@ func TestEnsureSystemServersIdempotent(t *testing.T) { } // Re-run: both already exist → skipped, nothing created, no error. - second := ensureSystemServers(ctx, cl, "minecraft", "reg/limbo:1", "reg/lobby:1", "http://felis-api.felis.svc.cluster.local:8081", "mc.example.net") + second := ensureSystemServers(ctx, cl, "minecraft", "reg/limbo:1", "reg/lobby:1", "http://felis-api.felis.svc.cluster.local:8081", "mc.example.net", "console.mc.example.net") for _, o := range second { if o.err != nil { t.Fatalf("%s: unexpected error on re-run: %v", o.name, o.err) @@ -405,7 +407,7 @@ func TestEnsureSystemServersRejectsLegacyLoginNameCollision(t *testing.T) { out := ensureSystemServers( context.Background(), cl, "minecraft", "reg/limbo:1", "", - "http://felis-api.felis.svc.cluster.local:8081", "mc.example.net", + "http://felis-api.felis.svc.cluster.local:8081", "mc.example.net", "console.mc.example.net", ) if len(out) != 2 { t.Fatalf("outcomes = %d, want 2", len(out)) @@ -425,7 +427,7 @@ func TestEnsureSystemServersSkipsUnsetImage(t *testing.T) { ctx := context.Background() // login image set, lobby image empty → login created, lobby skipped. - out := ensureSystemServers(ctx, cl, "minecraft", "reg/limbo:1", "", "http://felis-api.felis.svc.cluster.local:8081", "mc.example.net") + out := ensureSystemServers(ctx, cl, "minecraft", "reg/limbo:1", "", "http://felis-api.felis.svc.cluster.local:8081", "mc.example.net", "console.mc.example.net") byName := map[string]systemServerOutcome{} for _, o := range out { byName[o.name] = o diff --git a/cmd/felis/tui_mc_bind.go b/cmd/felis/tui_mc_bind.go index 07963fe..d2731eb 100644 --- a/cmd/felis/tui_mc_bind.go +++ b/cmd/felis/tui_mc_bind.go @@ -18,7 +18,7 @@ import ( type mcBindModel struct { ctx context.Context store ownerStore - adminHost string + panelHost string osUser string step mcBindStep @@ -47,14 +47,14 @@ type mcBindMsg struct { err error } -func newMCBindModel(ctx context.Context, store ownerStore, adminHost, osUser string) *mcBindModel { +func newMCBindModel(ctx context.Context, store ownerStore, panelHost, osUser string) *mcBindModel { sp := spinner.New() sp.Spinner = spinner.Dot sp.Style = tuiLabel m := &mcBindModel{ ctx: ctx, store: store, - adminHost: adminHost, + panelHost: panelHost, osUser: osUser, sp: sp, step: mcBindForm, @@ -156,7 +156,7 @@ func (m *mcBindModel) onFormComplete() (tea.Model, tea.Cmd) { m.working = "Binding Minecraft account…" code := strings.TrimSpace(strings.ToUpper(m.linkCode)) return m, tea.Batch(m.sp.Tick, func() tea.Msg { - out, err := performSetupMCBind(m.ctx, m.store, code, m.adminHost, m.osUser) + out, err := performSetupMCBind(m.ctx, m.store, code, m.panelHost, m.osUser) return mcBindMsg{outcome: out, err: err} }) } diff --git a/cmd/felis/tui_menu_test.go b/cmd/felis/tui_menu_test.go index a2bbcfd..1337770 100644 --- a/cmd/felis/tui_menu_test.go +++ b/cmd/felis/tui_menu_test.go @@ -185,7 +185,7 @@ func TestOwnerResultCmdCarriesIsOperator(t *testing.T) { } func TestMCBindCarriesAuditWarning(t *testing.T) { - m := newMCBindModel(context.Background(), &fakeOwnerStore{}, "op.console.example.com", "root") + m := newMCBindModel(context.Background(), &fakeOwnerStore{}, "console.example.com", "root") next, _ := m.Update(mcBindMsg{outcome: breakGlassOutcome{ ownerIdentity: "mc-uuid-1", setupTokenURL: "https://op.console.example.com/setup?token=t0ken", diff --git a/cmd/felis/tui_root.go b/cmd/felis/tui_root.go index 478e9d0..29f4b9d 100644 --- a/cmd/felis/tui_root.go +++ b/cmd/felis/tui_root.go @@ -211,7 +211,7 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.stage = stageOwner if m.mode == consoleModeSetup { - return m.adopt(newMCBindModel(m.ctx, m.store, m.adminHost, m.osUser)) + return m.adopt(newMCBindModel(m.ctx, m.store, defaultPanelHostname(m.rootDomain, m.panelHost), m.osUser)) } return m.adopt(newOwnerModel(m.ctx, m.store, m.osUser, false)) diff --git a/internal/api/handlers_setup.go b/internal/api/handlers_setup.go index 27970c3..64879d2 100644 --- a/internal/api/handlers_setup.go +++ b/internal/api/handlers_setup.go @@ -11,7 +11,7 @@ import ( // Setup-token redemption (spec §B setup bootstrap). The `felis setup` MC-bind // flow mints a one-time token and prints a URL like: // -// https://op.console./setup?token= +// https://console./setup?token= // // The Owner opens that URL in a browser; the SPA reads the token from the query // string and POSTs it here. This handler consumes the token (single-use, hashed diff --git a/plugins/limbo/src/main/java/best/lolicon/felis/limbo/FelisLimboPlugin.java b/plugins/limbo/src/main/java/best/lolicon/felis/limbo/FelisLimboPlugin.java index 0e66324..1b9d7c8 100644 --- a/plugins/limbo/src/main/java/best/lolicon/felis/limbo/FelisLimboPlugin.java +++ b/plugins/limbo/src/main/java/best/lolicon/felis/limbo/FelisLimboPlugin.java @@ -196,14 +196,22 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener { return; } + // The console host the player links at. Prefer the resolved FELIS_PANEL_HOSTNAME + // the provisioner bakes in (single source of truth — it honours a custom + // panel_hostname); fall back to console. only for an older operator whose + // env predates it. With neither set there is no link to build, so login stays off. + String panelHost = trimmed(System.getenv("FELIS_PANEL_HOSTNAME")); String rootDomain = trimmed(System.getenv("FELIS_ROOT_DOMAIN")); - if (rootDomain == null) { - LOG.warning("FelisLimbo: FELIS_ROOT_DOMAIN unset — cannot build the console login link"); + if (panelHost == null && rootDomain != null) { + panelHost = "console." + rootDomain; + } + if (panelHost == null) { + LOG.warning("FelisLimbo: neither FELIS_PANEL_HOSTNAME nor FELIS_ROOT_DOMAIN set — cannot build the console login link"); loginEnabled = false; return; } - this.consoleUrl = "https://console." + rootDomain; + this.consoleUrl = "https://" + panelHost; String lobby = trimmed(System.getenv("FELIS_LOBBY_SERVER")); this.lobbyServer = lobby != null ? lobby : "lobby"; this.timeoutMillis = loginTimeoutSeconds() * 1000L; @@ -229,6 +237,14 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener { disconnectOnMain(id, "该用户名已被回收保护 / This username is under reclaim protection. Contact staff."); return; } + // Check registration before minting: an already-linked player needs no + // bind code, so send them straight to the lobby instead of flashing a + // useless code. Only unlinked players get one. The on-demand /link + // command (proxy + lobby) stays the door to a fresh web session. + if (apiClient.linkStatus(id)) { + getServer().getScheduler().runTask(this, () -> transferToLobby(id)); + return; + } LinkCode code = linkClient.requestCode(id); getServer().getScheduler().runTask(this, () -> presentAndPoll(id, code)); } catch (LinkException e) {