From c1aa38bac1e0cf92d29d23274da126012e8b3467 Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Mon, 6 Jul 2026 23:50:57 +0900 Subject: [PATCH] =?UTF-8?q?feat(velocity):=20add=20/felis=20migrate=20to?= =?UTF-8?q?=20open=20an=20account=20migration=20(=C2=A7B3=20inherit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the in-game /felis migrate command that a player runs to open an account migration, the entry point for handing their owned servers to another account (§B3 inherit, scenario A). The command posts the player's Mojang-verified UUID to the existing handleMigrateStart backend, which puts the account into migrate mode; the player then finishes on the web console (prove identity, name the receiving account, redeem a one-time code). Mirrors the existing /felis claim path: requires a real player past login limbo, acts on the caller's account rather than the current server, expects 201 Created affirming started=true (a 201 without it is a contract breach, not a refusal), and maps the handler refusals (404 not_linked, 409 account_retired) to player-facing guidance. On success it points the player at https://console., derived from config, never hardcoded. Compile-verified against velocity-api:3.3.0-SNAPSHOT via the podman gradle toolchain. Closes the code-only gap named in handlers_account_migrate.go. --- .../lolicon/felis/link/FelisApiClient.java | 23 +++++++ .../felis/velocity/FelisVelocityPlugin.java | 60 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java b/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java index 6da6d50..9eb1c3f 100644 --- a/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java +++ b/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java @@ -200,6 +200,29 @@ public final class FelisApiClient { } } + /** + * migrateStart opens an account migration for the player who ran {@code /felis + * migrate} in-game (spec §B3 inherit, scenario A). It is the internal face of that + * command: Velocity has already established the caller's Mojang-verified UUID, so the + * initiator is trustworthy, and this POST puts that UUID's linked account into migrate + * mode (state {@code initiated}). Only the migration is opened here — the sensitive + * proof (web step-up, naming the receiving account, redeeming a code) happens + * afterwards on the console. {@code POST /api/v1/internal/account/migrate/start} with + * the verified UUID; expects 201. An unlinked UUID has no account to migrate (404 + * {@code not_linked}); a retired or already-migrating account cannot re-initiate (409 + * {@code account_retired}). Both surface as branchable {@link LinkException}s; a 201 + * that does not affirm {@code started:true} is a contract breach, not a refusal. + */ + public void migrateStart(UUID mcUuid) throws LinkException { + Objects.requireNonNull(mcUuid, "mcUuid"); + String body = "{\"mc_uuid\":\"" + mcUuid + "\"}"; + Map res = postObject("/api/v1/internal/account/migrate/start", body, 201); + Object started = res.get("started"); + if (!(started instanceof Boolean) || !((Boolean) started)) { + throw new LinkException(201, "bad_response", "migrate start returned 201 without started=true"); + } + } + // ---- transport ---- private Map getObject(String path, int expect) throws LinkException { diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java index 5a6c04d..962d91f 100644 --- a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java @@ -268,6 +268,11 @@ public final class FelisVelocityPlugin { doClaim(ctx.getSource()); return Command.SINGLE_SUCCESS; })) + .then(BrigadierCommand.literalArgumentBuilder("migrate") + .executes(ctx -> { + doMigrate(ctx.getSource()); + return Command.SINGLE_SUCCESS; + })) .then(BrigadierCommand.literalArgumentBuilder("web") .executes(ctx -> { sendWebInfo(ctx.getSource()); @@ -353,6 +358,7 @@ public final class FelisVelocityPlugin { helpLine(source, "/felis server", "the felis servers this proxy knows"); helpLine(source, "/felis go ", "start a server and move you in when it's ready"); helpLine(source, "/felis claim", "take ownership of the server you're on"); + helpLine(source, "/felis migrate", "move your servers to another account"); helpLine(source, "/felis web", "where the web consoles live"); helpLine(source, "/felis web op approve ", "approve a pending operator sign-in"); } @@ -443,6 +449,44 @@ public final class FelisVelocityPlugin { }); } + // doMigrate opens an account migration for the calling player (spec §B3 inherit): it + // hands their owned servers to another account. Identity-bound (acts on the caller's + // verified UUID) and out-of-limbo like claim, but server-independent — it touches the + // account, not the server the player stands on, so there is no registry/current-server + // check. The command only OPENS the migration; the player finishes it on the web + // console (prove it's them, name the receiving account, redeem a code), so on success + // we point them there. + private void doMigrate(CommandSource source) { + Player player = requirePlayer(source); + if (player == null || !ensureOutOfLimbo(player)) { + return; + } + if (!routingActive) { + player.sendMessage(routingDisabled()); + return; + } + UUID uuid = player.getUniqueId(); + String who = player.getUsername(); + player.sendMessage(Component.text("Starting account migration…", NamedTextColor.GRAY)); + async(() -> { + try { + apiClient.migrateStart(uuid); + String root = config.rootDomain(); + player.sendMessage(Component.text( + "Migration started — finish it on the web console:", NamedTextColor.GREEN)); + player.sendMessage(Component.text( + " " + (root == null ? "the players' web console" : "https://console." + root), + NamedTextColor.WHITE)); + player.sendMessage(Component.text( + "You'll confirm it's you, name the account to receive your servers, then get a code.", + NamedTextColor.GRAY)); + logger.info("Felis: account migration started in-game by {} ({})", who, uuid); + } catch (LinkException e) { + player.sendMessage(Component.text(migrateError(e), NamedTextColor.RED)); + } + }); + } + private void sendWebInfo(CommandSource source) { if (!gateInfo(source)) { return; @@ -529,6 +573,22 @@ public final class FelisVelocityPlugin { } } + // migrateError maps the felis-api migrate-start refusals (spec §B3) to player-safe + // text. A 404 means the caller's UUID isn't linked to any account to migrate; a 409 + // means the linked account can't start one (already migrated, or retired). + private static String migrateError(LinkException e) { + switch (e.statusCode()) { + case 404: + return "Link your account on the web console before migrating."; + case 409: + return "This account can't start a migration (already migrated or retired)."; + case 0: + return "Felis is temporarily unavailable — please try again."; + default: + return "Couldn't start the migration right now. Please try again."; + } + } + // opApproveError maps the internal approve refusals to player-safe text. A 403 is // the API's own admin re-check (defence in depth over the in-game gate); a 404 // means no live pending request carries that code.