Unverified Commit c1aa38ba authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

feat(velocity): add /felis migrate to open an account migration (§B3 inherit)

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.<root_domain>, 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.
parent c2ee21ae
Loading
Loading
Loading
Loading
+23 −0
Changes for plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java: 23 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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 {
+60 −0
Changes for plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java: 60 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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 <server>", "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 <code>", "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.