feat(rcon): add vanilla-safe custom gear builder
CI / validate (push) Successful in 7m15s
Release / release (push) Successful in 12m13s

This commit is contained in:
dmg committed 2026-09-17 23:11:01 -04:00
1 parent 999c27c7f9
commit 65e3af7c78
14 files changed
+1912 -1

No files matched your search

@@ -10,6 +10,7 @@ import { requireAdminSession } from "@/lib/auth/require-admin";
import { db } from "@/lib/database"; import { db } from "@/lib/database";
import { isUniqueConstraintViolation } from "@/lib/database-errors"; import { isUniqueConstraintViolation } from "@/lib/database-errors";
import { decryptRconPassword, encryptRconPassword, rconCommandDigest } from "@/lib/rcon-credentials"; import { decryptRconPassword, encryptRconPassword, rconCommandDigest } from "@/lib/rcon-credentials";
import { rconConnectionRevision } from "@/lib/rcon-connection-revision";
import { executeRcon, testRconConnection } from "@/lib/rcon-gateway"; import { executeRcon, testRconConnection } from "@/lib/rcon-gateway";
import { recordAdminSubjectEvent } from "@/lib/audit"; import { recordAdminSubjectEvent } from "@/lib/audit";
import { validateRconCommand, validateRconConnection } from "@/lib/rcon-validation"; import { validateRconCommand, validateRconConnection } from "@/lib/rcon-validation";
@@ -182,7 +183,7 @@ async function savedConnection(serverId: string, requireEnabled: boolean) {
const [server] = await db.select().from(rconServers).where(requireEnabled const [server] = await db.select().from(rconServers).where(requireEnabled
? and(eq(rconServers.id, serverId), eq(rconServers.enabled, true)) ? and(eq(rconServers.id, serverId), eq(rconServers.enabled, true))
: eq(rconServers.id, serverId)).limit(1); : eq(rconServers.id, serverId)).limit(1);
if (!server) return null; if (!server || (requireEnabled && !server.enabled)) return null;
const validated = validateRconConnection({ ...server, password: "placeholder" }, { passwordRequired: true }); const validated = validateRconConnection({ ...server, password: "placeholder" }, { passwordRequired: true });
if (!validated) return null; if (!validated) return null;
try { try {
@@ -212,6 +213,7 @@ export async function testSavedRconServer(formData: FormData) {
export async function executeRconCommand( export async function executeRconCommand(
_previous: RconCommandState, _previous: RconCommandState,
formData: FormData, formData: FormData,
expectedConnectionRevision?: string,
): Promise<RconCommandState> { ): Promise<RconCommandState> {
const admin = await requireAdminSession(); const admin = await requireAdminSession();
const serverId = String(formData.get("serverId") ?? ""); const serverId = String(formData.get("serverId") ?? "");
@@ -219,6 +221,11 @@ export async function executeRconCommand(
if (!command) return { status: "error", message: "Enter one command of at most 1,024 bytes without control characters.", serverId }; if (!command) return { status: "error", message: "Enter one command of at most 1,024 bytes without control characters.", serverId };
const server = await savedConnection(serverId, true); const server = await savedConnection(serverId, true);
if (!server) return { status: "error", message: "That RCON connection is disabled or unavailable.", serverId }; if (!server) return { status: "error", message: "That RCON connection is disabled or unavailable.", serverId };
if (expectedConnectionRevision !== undefined) {
try {
if (rconConnectionRevision(server) !== expectedConnectionRevision) return { status: "error", message: "The RCON connection changed. Refresh and confirm again.", serverId };
} catch { return { status: "error", message: "RCON command auditing is not configured.", serverId }; }
}
const verb = command.split(/\s+/u, 1)[0]!.toLowerCase().slice(0, 64); const verb = command.split(/\s+/u, 1)[0]!.toLowerCase().slice(0, 64);
let commandDigest: string; let commandDigest: string;
try { try {
@@ -0,0 +1,126 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createElement } from "react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { GearBuilder } from "@/components/gear-builder";
afterEach(cleanup);
const state = vi.hoisted(() => ({
authorized: 0, denied: false, server: null as Record<string, unknown> | null,
beforeQuery: undefined as (() => void) | undefined,
commands: [] as string[], audit: [] as { type: string; data: unknown; options: unknown }[], auditFailure: "", beforeSend: undefined as ((command: string) => void) | undefined,
response: {} as Record<string, { ok: true; response: string } | { ok: false; reason: "busy" | "timeout" | "unavailable" }>,
}));
vi.mock("@/lib/auth/require-admin", () => ({ requireAdminSession: async () => { state.authorized++; if (state.denied) throw new Error("AUTH REDIRECT private"); return { email: "admin@example.test", name: "Admin" }; } }));
vi.mock("@/lib/database", () => ({ db: { select: () => { const chain = { from: () => chain, where: () => chain, limit: async () => { state.beforeQuery?.(); return state.server ? [state.server] : []; } }; return chain; } } }));
vi.mock("@/lib/audit", () => ({ recordAdminSubjectEvent: async (_admin: unknown, _subject: string, type: string, data: unknown, options: unknown) => { if (state.auditFailure && type.endsWith(state.auditFailure)) throw new Error("AUDIT private"); state.audit.push({ type, data, options }); } }));
vi.mock("@/lib/rcon-gateway", () => ({ executeRcon: async (_server: unknown, command: string) => { expect(state.audit.at(-1)?.type).toMatch(/requested$/); state.commands.push(command); state.beforeSend?.(command); return state.response[command] ?? { ok: true, response: "Given 1 [Diamond Sword] to Alex_2" }; }, testRconConnection: vi.fn() }));
vi.mock("next/headers", () => ({ headers: async () => new Headers() }));
vi.mock("next/navigation", () => ({ redirect: (path: string) => { throw new Error(path); } }));
import { encryptRconPassword } from "@/lib/rcon-credentials";
import { discoverGearPlayers, previewGear, giveGear } from "./gear-actions";
import { buildGearCommand } from "@/lib/gear";
const gear = { recipient: "Alex_2", item: "minecraft:diamond_sword", name: 'Reward "雪"', lore: ["first", "second"], enchantments: [{ id: "minecraft:sharpness", level: 5 }] };
const serverId = "11111111-1111-4111-8111-111111111111";
const version = "Current Purpur Version: 26.2-2618-5a85de0 (MC: 26.2)*\nYou are running the latest version";
describe("gear server action boundary", () => {
beforeEach(() => {
process.env.AUTH_SECRET = "local-test-secret-not-a-credential";
state.authorized = 0; state.denied = false; state.commands = []; state.audit = []; state.auditFailure = ""; state.beforeQuery = undefined; state.beforeSend = undefined;
state.server = { id: serverId, name: "Season 4", host: "season4.example.test", port: 25575, enabled: true, updatedAt: new Date("2026-01-01T00:00:00Z"), encryptedPassword: encryptRconPassword("test-password", serverId) };
state.response = { "bukkit:version": { ok: true, response: version }, "minecraft:list": { ok: true, response: "There are 2 of a max of 20 players online: Alex_2, Steve" } };
});
it("drives discovery, server preview, confirmation and audited delivery from the real UI through real actions", async () => {
render(createElement(GearBuilder, { server: { id: serverId, name: "Season 4", host: "season4.example.test", port: 25575, enabled: true } }));
fireEvent.click(screen.getByRole("button", { name: "Refresh players" }));
await screen.findByRole("option", { name: "Alex_2" });
fireEvent.change(screen.getByLabelText("Recipient"), { target: { value: "Alex_2" } });
fireEvent.change(screen.getByLabelText("Custom name"), { target: { value: 'Real "boundary" 雪' } });
fireEvent.click(screen.getByRole("button", { name: "Review delivery" }));
await screen.findByRole("group", { name: "Confirm gear delivery" });
const confirmed = (screen.getByLabelText("Exact give command") as HTMLTextAreaElement).value;
expect(state.commands).toEqual(["bukkit:version", "minecraft:list"]);
fireEvent.click(screen.getByRole("button", { name: "Confirm and give one item" }));
await screen.findByText(/Transport completed; game-command success is not confirmed/);
expect(state.commands).toEqual(["bukkit:version", "minecraft:list", "bukkit:version", "minecraft:list", confirmed]);
expect(state.audit.at(-2)?.data).toMatchObject({ command: confirmed });
expect(JSON.stringify(state.audit)).not.toContain("Given 1");
});
it.each(["recipient", "item", "name", "lore", "enchantments", "server", "command", "token"])("rejects changed confirmation %s before any command", async field => {
const result = await previewGear(serverId, gear); if (!result.ok) throw new Error(result.message);
const changed = { ...gear };
if (field === "recipient") changed.recipient = "Steve";
if (field === "item") changed.item = "minecraft:netherite_sword";
if (field === "name") changed.name = "Other";
if (field === "lore") changed.lore = ["Other"];
if (field === "enchantments") changed.enchantments = [];
if (field === "server") state.server!.host = "replacement.example.test";
if (field === "command") result.preview.command = "minecraft:give @a diamond 64";
if (field === "token") result.preview.confirmation = "fake";
expect(await giveGear(serverId, changed, result.preview)).toMatchObject({ ok: false });
expect(state.commands).toEqual([]);
});
it.each(["auth", "disabled", "missing", "audit", "version", "offline", "malformed"])("does not give when fresh %s check fails", async failure => {
const result = await previewGear(serverId, gear); if (!result.ok) throw new Error(result.message);
if (failure === "auth") state.denied = true;
if (failure === "disabled") state.server!.enabled = false;
if (failure === "missing") state.server = null;
if (failure === "audit") state.auditFailure = ".requested";
if (failure === "version") state.response["bukkit:version"] = { ok: true, response: "Current Purpur Version: 26.2-2619-other (MC: 26.2)*" };
if (failure === "offline") state.response["minecraft:list"] = { ok: true, response: "There are 0 of a max of 20 players online: " };
if (failure === "malformed") state.response["minecraft:list"] = { ok: true, response: "Steve, Alex_2" };
if (failure === "auth") await expect(giveGear(serverId, gear, result.preview)).rejects.toThrow("AUTH");
else expect(await giveGear(serverId, gear, result.preview)).toMatchObject({ ok: false });
expect(state.commands.some(c => c.startsWith("minecraft:give"))).toBe(false);
});
it.each(["authorization", "enabled", "audit"])("rechecks %s immediately before give after an otherwise successful online check", async gate => {
const result = await previewGear(serverId, gear); if (!result.ok) throw new Error(result.message);
state.beforeSend = command => { if (command === "minecraft:list") { if (gate === "authorization") state.denied = true; if (gate === "enabled") state.server!.enabled = false; if (gate === "audit") state.auditFailure = ".requested"; } };
expect(await giveGear(serverId, gear, result.preview)).toMatchObject({ ok: false });
expect(state.commands).toEqual(["bukkit:version", "minecraft:list"]);
});
it("rejects malformed structured input and unknown connection IDs without RCON", async () => {
for (const input of [null, { ...gear, recipient: "@a" }, { ...gear, name: "雪".repeat(500) }, { ...gear, enchantments: [{ id: "minecraft:sharpness", level: 255 }] }]) expect(await previewGear(serverId, input)).toMatchObject({ ok: false });
expect(await previewGear("not-an-id", gear)).toMatchObject({ ok: false });
expect(state.commands).toEqual([]); expect(state.audit).toEqual([]);
});
it.each(["version", "list"])("prevents endpoint replacement after %s from redirecting verified delivery", async phase => {
const result = await previewGear(serverId, gear); if (!result.ok) throw new Error(result.message);
state.beforeSend = command => { if (command === (phase === "version" ? "bukkit:version" : "minecraft:list")) state.server!.host = "replacement.example.test"; };
expect(await giveGear(serverId, gear, result.preview)).toMatchObject({ ok: false });
expect(state.commands.some(c => c.startsWith("minecraft:give"))).toBe(false);
});
it.each(["timeout", "unavailable", "busy"] as const)("reports %s honestly with no automatic retry", async reason => {
const result = await previewGear(serverId, gear); if (!result.ok) throw new Error(result.message);
state.response[result.preview.command] = { ok: false, reason };
const delivery = await giveGear(serverId, gear, result.preview);
expect(delivery).toMatchObject({ ok: false, message: expect.stringMatching(reason === "timeout" ? /may already have been given/ : /not retried/) });
expect(state.commands.filter(c => c.startsWith("minecraft:give"))).toHaveLength(1);
});
it("does not lose uncertain-delivery feedback when completion auditing fails", async () => {
const result = await previewGear(serverId, gear); if (!result.ok) throw new Error(result.message);
state.beforeSend = command => { if (command.startsWith("minecraft:give")) state.auditFailure = ".completed"; };
expect(await giveGear(serverId, gear, result.preview)).toMatchObject({ ok: false, message: expect.stringMatching(/may already have been given/) });
expect(state.commands.filter(c => c.startsWith("minecraft:give"))).toHaveLength(1);
});
it("previews server-generated structured gear without transport, then sends that exact confirmed command after fresh checks", async () => {
const result = await previewGear(serverId, gear);
expect(result.ok).toBe(true);
if (!result.ok) throw new Error(result.message);
expect(result.preview).toMatchObject({ serverId, serverName: "Season 4", command: buildGearCommand(gear) });
expect(state.commands).toEqual([]);
expect(await giveGear(serverId, gear, result.preview)).toMatchObject({ ok: true, message: expect.stringContaining("Transport completed") });
expect(state.commands).toEqual(["bukkit:version", "minecraft:list", result.preview.command]);
expect(state.authorized).toBeGreaterThanOrEqual(5);
expect(state.audit.at(-2)?.data).toMatchObject({ command: result.preview.command, name: "Season 4", serverId });
expect(JSON.stringify(state.audit)).not.toContain("Given 1");
});
it("discovers current players through independently audited guarded RCON, not portal data", async () => {
expect(await discoverGearPlayers(serverId)).toEqual({ ok: true, players: ["Alex_2", "Steve"] });
expect(state.commands).toEqual(["bukkit:version", "minecraft:list"]);
expect(state.authorized).toBeGreaterThanOrEqual(3);
expect(state.audit).toHaveLength(4);
expect(state.audit[0]?.options).toEqual(state.audit[1]?.options);
expect(JSON.stringify(state.audit)).not.toMatch(/test-password|There are 2|latest version/);
});
});
@@ -0,0 +1,64 @@
"use server";
import { requireAdminSession } from "@/lib/auth/require-admin";
import { buildGearCommand, parseOnlinePlayers, type GearInput } from "@/lib/gear";
import { rconServers } from "@minecraft-account-manager/database";
import { eq } from "drizzle-orm";
import { db } from "@/lib/database";
import { rconCommandDigest } from "@/lib/rcon-credentials";
import { rconConnectionRevision } from "@/lib/rcon-connection-revision";
import { executeRconCommand } from "./actions";
export type GearPreview = { serverId: string; serverName: string; command: string; confirmation: string; connectionRevision: string };
type Failure = { ok: false; message: string };
async function connection(serverId: string) {
if (typeof serverId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(serverId)) return null;
const [server] = await db.select().from(rconServers).where(eq(rconServers.id, serverId)).limit(1);
return server?.enabled ? server : null;
}
export async function previewGear(serverId: string, input: unknown): Promise<{ ok: true; preview: GearPreview } | Failure> {
const admin = await requireAdminSession();
let generated: string;
try { generated = buildGearCommand(input); } catch (error) { return { ok: false, message: error instanceof Error ? error.message : "Invalid gear fields." }; }
const server = await connection(serverId);
if (!server) return { ok: false, message: "That RCON connection is disabled or unavailable." };
try {
const connectionRevision = rconConnectionRevision(server);
const confirmation = rconCommandDigest(JSON.stringify(["gear-confirmation-v1", admin.email, connectionRevision, generated]));
return { ok: true, preview: { serverId, serverName: server.name, command: generated, confirmation, connectionRevision } };
} catch { return { ok: false, message: "RCON command auditing is not configured." }; }
}
export async function giveGear(serverId: string, input: unknown, preview: unknown): Promise<{ ok: true; message: string } | Failure> {
const fresh = await previewGear(serverId, input);
if (!fresh.ok) return fresh;
const confirmed = preview as GearPreview | null;
if (!confirmed || confirmed.serverId !== serverId || confirmed.serverName !== fresh.preview.serverName || confirmed.command !== fresh.preview.command || confirmed.confirmation !== fresh.preview.confirmation || confirmed.connectionRevision !== fresh.preview.connectionRevision) return { ok: false, message: "The target or item changed. Review and confirm a new preview." };
const online = await discover(serverId, fresh.preview.connectionRevision);
if (!online.ok) return online;
if (!online.players.includes((input as GearInput).recipient)) return { ok: false, message: "The selected player is no longer online. Refresh players and confirm again." };
try {
const result = await command(serverId, fresh.preview.command, fresh.preview.connectionRevision);
return result.status === "success" ? { ok: true, message: `Transport completed; game-command success is not confirmed. Server response:\n${result.message}` } : { ok: false, message: `${result.message} Delivery is not confirmed; the item may already have been given. The request was not retried. Check in-game before starting a new action.` };
} catch { return { ok: false, message: "Delivery or completion auditing could not be confirmed; the item may already have been given. The request was not retried. Check in-game before starting a new action." }; }
}
async function command(serverId: string, value: string, revision: string) {
const form = new FormData(); form.set("serverId", serverId); form.set("command", value);
return executeRconCommand({ status: "idle", message: "", serverId }, form, revision);
}
export async function discoverGearPlayers(serverId: string): Promise<{ ok: true; players: string[] } | Failure> {
await requireAdminSession();
const server = await connection(serverId);
if (!server) return { ok: false, message: "That RCON connection is disabled or unavailable." };
let revision: string;
try { revision = rconConnectionRevision(server); } catch { return { ok: false, message: "RCON command auditing is not configured." }; }
return discover(serverId, revision);
}
async function discover(serverId: string, revision: string): Promise<{ ok: true; players: string[] } | Failure> {
const version = await command(serverId, "bukkit:version", revision);
if (version.status !== "success") return { ok: false, message: version.message };
if (!/^Current Purpur Version: 26\.2-2618-5a85de0 \(MC: 26\.2\)\*?$/m.test(version.message.replace(/§[0-9a-fk-or]/gi, ""))) return { ok: false, message: "Gear requires verified Minecraft 26.2 / Purpur 2618. Unknown versions or a pending version reply are not supported; refresh manually when ready." };
const result = await command(serverId, "minecraft:list", revision);
if (result.status !== "success") return { ok: false, message: result.message };
try { return { ok: true, players: parseOnlinePlayers(result.message) }; }
catch { return { ok: false, message: "The server did not return a supported complete online-player list." }; }
}
@@ -0,0 +1,142 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildGearCommand, type GearInput } from "@/lib/gear";
const actions = vi.hoisted(() => ({ discover: vi.fn(), preview: vi.fn(), give: vi.fn() }));
vi.mock("@/app/admin/(console)/rcon/gear-actions", () => ({ discoverGearPlayers: actions.discover, previewGear: actions.preview, giveGear: actions.give }));
import { GearBuilder } from "./gear-builder";
const server = { id: "11111111-1111-4111-8111-111111111111", name: "Season 4", host: "season4.example.test", port: 25575, enabled: true };
afterEach(cleanup);
beforeEach(() => {
actions.discover.mockReset().mockResolvedValue({ ok: true, players: ["Alex_2", "Steve"] });
actions.preview.mockReset().mockImplementation(async (serverId: string, input: GearInput) => ({ ok: true, preview: { serverId, serverName: server.name, command: buildGearCommand(input), confirmation: "signed", connectionRevision: "revision" } }));
actions.give.mockReset().mockResolvedValue({ ok: true, message: "Transport completed; game-command success is not confirmed." });
});
async function choosePlayer() {
fireEvent.click(screen.getByRole("button", { name: "Refresh players" }));
await screen.findByRole("option", { name: "Alex_2" });
fireEvent.change(screen.getByLabelText("Recipient"), { target: { value: "Alex_2" } });
}
describe("Custom gear workshop", () => {
it("isolates players and confirmation across server/endpoint/enabled changes, late replies and remounts", async () => {
const storage = vi.spyOn(Storage.prototype, "setItem");
const view = render(<GearBuilder server={server} />); await choosePlayer();
fireEvent.click(screen.getByRole("button", { name: "Review delivery" })); await screen.findByRole("group", { name: "Confirm gear delivery" });
view.rerender(<GearBuilder server={{ ...server, host: "replacement.example.test" }} />);
expect(screen.queryByRole("group", { name: "Confirm gear delivery" })).toBeNull(); expect(screen.queryByRole("option", { name: "Alex_2" })).toBeNull();
let finish!: (value: unknown) => void; actions.discover.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }));
fireEvent.click(screen.getByRole("button", { name: "Refresh players" }));
view.rerender(<GearBuilder server={{ ...server, id: "22222222-2222-4222-8222-222222222222", name: "Creative" }} />);
await act(async () => finish({ ok: true, players: ["LatePlayer"] }));
expect(screen.queryByRole("option", { name: "LatePlayer" })).toBeNull();
view.rerender(<GearBuilder server={{ ...server, enabled: false }} />);
expect((screen.getByRole("button", { name: "Refresh players" }) as HTMLButtonElement).disabled).toBe(true);
view.rerender(<GearBuilder />);
expect((screen.getByRole("button", { name: "Review delivery" }) as HTMLButtonElement).disabled).toBe(true);
view.unmount(); render(<GearBuilder server={server} />);
expect(screen.queryByRole("option", { name: "Alex_2" })).toBeNull(); expect(storage).not.toHaveBeenCalled(); storage.mockRestore();
});
it("shows validation errors instead of permitting unsafe gear or byte overflow", async () => {
render(<GearBuilder server={server} />); await choosePlayer();
fireEvent.click(screen.getByRole("button", { name: "Add enchantment" }));
fireEvent.change(screen.getByLabelText("Level 1"), { target: { value: "255" } });
expect(screen.getByRole("alert").textContent).toMatch(/normal level/);
expect((screen.getByRole("button", { name: "Review delivery" }) as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Remove enchantment 1" }));
fireEvent.change(screen.getByLabelText("Custom name"), { target: { value: "雪".repeat(500) } });
expect(screen.getByRole("alert").textContent).toContain("1,024");
expect((screen.getByRole("button", { name: "Review delivery" }) as HTMLButtonElement).disabled).toBe(true);
});
it("fails safely on discovery, preview and give exceptions without leaking details or retrying", async () => {
render(<GearBuilder server={server} />);
actions.discover.mockRejectedValueOnce(new Error("private discovery"));
fireEvent.click(screen.getByRole("button", { name: "Refresh players" })); await waitFor(() => expect(screen.getByRole("status").textContent).toMatch(/discovery unavailable/));
await choosePlayer();
actions.preview.mockRejectedValueOnce(new Error("private preview")); fireEvent.click(screen.getByRole("button", { name: "Review delivery" }));
await waitFor(() => expect(screen.getByRole("status").textContent).toMatch(/Preview unavailable/));
fireEvent.click(screen.getByRole("button", { name: "Review delivery" })); await screen.findByRole("group", { name: "Confirm gear delivery" });
actions.give.mockRejectedValueOnce(new Error("private give")); fireEvent.click(screen.getByRole("button", { name: "Confirm and give one item" }));
await waitFor(() => expect(screen.getByRole("status").textContent).toMatch(/may already have been given/));
expect(document.body.textContent).not.toContain("private"); expect(actions.give).toHaveBeenCalledTimes(1);
});
it("requires a server-confirmed preview, warns about dropped items and auditing, and consumes confirmation before giving once", async () => {
render(<GearBuilder server={server} />); await choosePlayer();
fireEvent.click(screen.getByRole("button", { name: "Review delivery" }));
const confirmation = await screen.findByRole("group", { name: "Confirm gear delivery" });
expect(confirmation.textContent).toContain("Season 4"); expect(confirmation.textContent).toContain("Alex_2"); expect(confirmation.textContent).toContain("Diamond Sword");
expect(confirmation.textContent).toMatch(/may drop items/); expect(confirmation.textContent).toMatch(/names and lore.*audit/i);
expect(actions.give).not.toHaveBeenCalled();
let finish!: (value: unknown) => void; actions.give.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }));
const confirm = screen.getByRole("button", { name: "Confirm and give one item" });
fireEvent.click(confirm); fireEvent.click(confirm);
expect(actions.give).toHaveBeenCalledTimes(1);
expect((screen.getByRole("button", { name: "Review delivery" }) as HTMLButtonElement).disabled).toBe(true);
expect(screen.queryByRole("group", { name: "Confirm gear delivery" })).toBeNull();
await act(async () => finish({ ok: false, message: "The RCON request timed out. The item may already have been given. The request was not retried." }));
expect(screen.getByRole("status").textContent).toContain("may already have been given");
expect(actions.give).toHaveBeenCalledTimes(1);
expect(actions.give.mock.calls[0]?.[2].command).toBe((screen.getByLabelText("Exact give command") as HTMLTextAreaElement).value);
});
it.each(["recipient", "name", "lore", "item", "enchantment", "refresh", "cancel"])("invalidates confirmation after %s changes", async change => {
render(<GearBuilder server={server} />); await choosePlayer();
fireEvent.click(screen.getByRole("button", { name: "Review delivery" })); await screen.findByRole("group", { name: "Confirm gear delivery" });
if (change === "recipient") fireEvent.change(screen.getByLabelText("Recipient"), { target: { value: "Steve" } });
if (change === "name") fireEvent.change(screen.getByLabelText("Custom name"), { target: { value: "Other" } });
if (change === "lore") fireEvent.change(screen.getByLabelText("Lore — one line per row"), { target: { value: "Other" } });
if (change === "item") fireEvent.change(screen.getByLabelText("Item"), { target: { value: "minecraft:bow" } });
if (change === "enchantment") fireEvent.click(screen.getByRole("button", { name: "Add enchantment" }));
if (change === "refresh") fireEvent.click(screen.getByRole("button", { name: "Refresh players" }));
if (change === "cancel") fireEvent.click(screen.getByRole("button", { name: "Cancel confirmation" }));
expect(screen.queryByRole("group", { name: "Confirm gear delivery" })).toBeNull(); expect(actions.give).not.toHaveBeenCalled();
if (change === "refresh") await screen.findByRole("option", { name: "Alex_2" });
});
it("refuses a mismatched server preview and renders game responses as inert text", async () => {
render(<GearBuilder server={server} />); await choosePlayer();
actions.preview.mockResolvedValueOnce({ ok: true, preview: { serverId: server.id, command: "minecraft:give @a diamond 64" } });
fireEvent.click(screen.getByRole("button", { name: "Review delivery" }));
await waitFor(() => expect(screen.getByRole("status").textContent).toMatch(/preview changed/i));
expect(screen.queryByRole("group", { name: "Confirm gear delivery" })).toBeNull();
actions.give.mockResolvedValueOnce({ ok: true, message: 'Transport completed; game-command success is not confirmed. Unknown item <img src=x onerror="alert(1)">' });
fireEvent.click(screen.getByRole("button", { name: "Review delivery" })); await screen.findByRole("group", { name: "Confirm gear delivery" });
fireEvent.click(screen.getByRole("button", { name: "Confirm and give one item" }));
await waitFor(() => expect(screen.getByRole("status").textContent).toContain("Unknown item <img"));
expect(document.querySelector("img")).toBeNull();
});
it("builds weapons, tools and armor with plain text, ordered lore and editable enchantments in a copyable live preview", async () => {
render(<GearBuilder server={server} />); await choosePlayer();
fireEvent.change(screen.getByLabelText("Item"), { target: { value: "minecraft:diamond_sword" } });
fireEvent.change(screen.getByLabelText("Custom name"), { target: { value: 'Reward "雪"' } });
fireEvent.change(screen.getByLabelText("Lore — one line per row"), { target: { value: "first\n\nlast" } });
fireEvent.click(screen.getByRole("button", { name: "Add enchantment" }));
fireEvent.change(screen.getByLabelText("Enchantment 1"), { target: { value: "minecraft:sharpness" } });
fireEvent.change(screen.getByLabelText("Level 1"), { target: { value: "5" } });
fireEvent.click(screen.getByRole("button", { name: "Add enchantment" }));
fireEvent.change(screen.getByLabelText("Enchantment 2"), { target: { value: "minecraft:unbreaking" } });
fireEvent.change(screen.getByLabelText("Level 2"), { target: { value: "3" } });
const command = buildGearCommand({ recipient: "Alex_2", item: "minecraft:diamond_sword", name: 'Reward "雪"', lore: ["first", "", "last"], enchantments: [{ id: "minecraft:sharpness", level: 5 }, { id: "minecraft:unbreaking", level: 3 }] });
expect((screen.getByLabelText("Exact give command") as HTMLTextAreaElement).value).toBe(command);
expect(screen.getByLabelText("Item summary").textContent).toContain("1 × Diamond Sword → Alex_2");
expect(screen.getByLabelText("Item summary").textContent).toContain("Sharpness 5");
const copy = vi.fn().mockResolvedValue(undefined); Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: copy } });
fireEvent.click(screen.getByRole("button", { name: "Copy command" })); await waitFor(() => expect(copy).toHaveBeenCalledWith(command));
fireEvent.click(screen.getByRole("button", { name: "Remove enchantment 1" }));
expect(screen.queryByLabelText("Enchantment 2")).toBeNull();
for (const item of ["minecraft:netherite_pickaxe", "minecraft:diamond_boots"]) {
fireEvent.change(screen.getByLabelText("Item"), { target: { value: item } });
expect((screen.getByLabelText("Exact give command") as HTMLTextAreaElement).value).toContain(item);
expect(screen.queryByLabelText("Enchantment 1")).toBeNull();
}
expect(actions.give).not.toHaveBeenCalled(); expect(actions.preview).not.toHaveBeenCalled();
});
it("discovers players for the selected enabled server and immediately invalidates selection on refresh", async () => {
render(<GearBuilder server={server} />);
expect(screen.queryByRole("option", { name: "Alex_2" })).toBeNull();
await choosePlayer();
expect(actions.discover).toHaveBeenCalledWith(server.id);
expect((screen.getByLabelText("Recipient") as HTMLSelectElement).value).toBe("Alex_2");
actions.discover.mockResolvedValueOnce({ ok: true, players: ["Steve"] });
fireEvent.click(screen.getByRole("button", { name: "Refresh players" }));
expect((screen.getByLabelText("Recipient") as HTMLSelectElement).value).toBe("");
await waitFor(() => expect(screen.queryByRole("option", { name: "Alex_2" })).toBeNull());
});
});
+104
View File
@@ -0,0 +1,104 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { discoverGearPlayers, previewGear, giveGear, type GearPreview } from "@/app/admin/(console)/rcon/gear-actions";
import { buildGearCommand, gearCatalog, type GearInput } from "@/lib/gear";
import type { RconServerOption } from "./rcon-console";
const field = "mt-2 block w-full border border-line bg-canvas px-3 py-2 font-mono text-xs outline-none focus:border-accent disabled:opacity-50";
function label(id: string) { return id.replace("minecraft:", "").split("_").map(word => word[0]?.toUpperCase() + word.slice(1)).join(" "); }
const button = "border border-ink px-3 py-2 font-mono text-[10px] font-bold uppercase tracking-wider hover:bg-canvas disabled:opacity-50";
export function GearBuilder({ server }: { server?: RconServerOption }) {
return <Workshop key={JSON.stringify(server)} server={server} />;
}
function Workshop({ server }: { server?: RconServerOption }) {
const [confirmation, setConfirmation] = useState<GearPreview | null>(null);
const confirmationRef = useRef<HTMLDivElement>(null);
const revision = useRef(0);
useEffect(() => { confirmationRef.current?.focus(); }, [confirmation]);
function invalidate() { revision.current++; setConfirmation(null); }
const [players, setPlayers] = useState<string[]>([]);
const [recipient, setRecipient] = useState("");
const [item, setItem] = useState("minecraft:diamond_sword");
const [name, setName] = useState("");
const [lore, setLore] = useState("");
const [enchantments, setEnchantments] = useState<GearInput["enchantments"]>([]);
const input: GearInput = { recipient, item, name, lore: lore ? lore.split("\n") : [], enchantments };
let command = ""; let validation = "";
try { command = buildGearCommand(input); } catch (error) { validation = error instanceof Error ? error.message : "Invalid gear fields."; }
const available = gearCatalog.enchantments.filter(e => e.items.includes(item));
const nextEnchantment = available.find(e => !enchantments.some(current => current.id === e.id || e.conflicts.includes(current.id)));
const [pending, setPending] = useState(false);
const [message, setMessage] = useState("");
const busy = useRef(false);
async function review() {
if (!server?.enabled || !command || busy.current) return;
invalidate(); const current = revision.current;
busy.current = true; setPending(true); setMessage("");
try {
const result = await previewGear(server.id, input);
if (current !== revision.current) return;
if (!result.ok) setMessage(result.message);
else if (result.preview.command !== command || result.preview.serverId !== server.id || result.preview.serverName !== server.name) setMessage("The preview changed. Refresh the connection and review again.");
else setConfirmation(result.preview);
} catch { setMessage("Preview unavailable. Check your administrator session and try again manually."); }
finally { busy.current = false; setPending(false); }
}
async function give() {
if (!server?.enabled || !confirmation || confirmation.command !== command || busy.current) return;
const confirmed = confirmation; invalidate(); busy.current = true; setPending(true); setMessage("");
try { const result = await giveGear(server.id, input, confirmed); setMessage(result.message); }
catch { setMessage("Delivery is not confirmed; the item may already have been given. The request was not retried. Check in-game before starting a new action."); }
finally { busy.current = false; setPending(false); }
}
async function refresh() {
if (!server?.enabled || busy.current) return;
invalidate(); busy.current = true; setPending(true); setPlayers([]); setRecipient(""); setMessage("");
try {
const result = await discoverGearPlayers(server.id);
if (result.ok) { setPlayers(result.players); setMessage(`${result.players.length} players online · checked via RCON`); }
else setMessage(result.message);
} catch { setMessage("Player discovery unavailable. Refresh manually when ready."); }
finally { busy.current = false; setPending(false); }
}
return <section aria-label="Custom gear" className="border-t-2 border-ink bg-panel p-5">
<p className="font-mono text-[10px] uppercase tracking-widest text-accent">01 / Recipient</p>
<h2 className="mt-2 font-display text-3xl font-black uppercase">Custom gear</h2>
<p className="my-4 text-xs text-muted">{server?.name ?? "Select a server"} · Vanilla-safe · Minecraft 26.2 / Purpur 2618 only</p>
<button className={button} disabled={!server?.enabled || pending} onClick={() => void refresh()} type="button">Refresh players</button>
<label className="mt-4 block text-xs">Recipient<select aria-label="Recipient" className={field} disabled={!server?.enabled || pending} value={recipient} onChange={e => { invalidate(); setRecipient(e.target.value); }}><option value="">Select an online player</option>{players.map(p => <option key={p} value={p}>{p}</option>)}</select></label>
<div className="mt-6 grid gap-6 lg:grid-cols-[1fr_1fr]">
<fieldset className="space-y-4" disabled={!server?.enabled || pending} onChange={invalidate}>
<legend className="mb-4 font-mono text-[10px] uppercase tracking-widest text-accent">02 / Specification</legend>
<label className="block text-xs">Item<select aria-label="Item" className={field} value={item} onChange={e => { setItem(e.target.value); setEnchantments([]); }}>{gearCatalog.items.map(id => <option key={id} value={id}>{label(id)}</option>)}</select></label>
<label className="block text-xs">Custom name<input className={field} maxLength={1024} value={name} onChange={e => setName(e.target.value)} /></label>
<label className="block text-xs">Lore — one line per row<textarea className={field} maxLength={4096} rows={4} value={lore} onChange={e => setLore(e.target.value)} /></label>
<p className="text-xs text-muted">Plain text only. Up to 32 lore lines, in order. The entire command must fit 1,024 UTF-8 bytes.</p>
{enchantments.map((entry, index) => <div key={index} className="flex items-end gap-2">
<label className="min-w-0 flex-1 text-xs">Enchantment {index + 1}<select aria-label={`Enchantment ${index + 1}`} className={field} value={entry.id} onChange={e => setEnchantments(current => current.map((value, i) => i === index ? { id: e.target.value, level: 1 } : value))}>{available.map(e => <option key={e.id} value={e.id}>{label(e.id)}</option>)}</select></label>
<label className="w-20 text-xs">Level {index + 1}<input className={field} type="number" min={1} max={available.find(e => e.id === entry.id)?.maxLevel} value={entry.level} onChange={e => setEnchantments(current => current.map((value, i) => i === index ? { ...value, level: Number(e.target.value) } : value))} /></label>
<button aria-label={`Remove enchantment ${index + 1}`} className={button} onClick={() => { invalidate(); setEnchantments(current => current.filter((_, i) => i !== index)); }} type="button">Remove</button>
</div>)}
<button className={button} disabled={!nextEnchantment} onClick={() => { invalidate(); if (nextEnchantment) setEnchantments(current => [...current, { id: nextEnchantment.id, level: 1 }]); }} type="button">Add enchantment</button>
<p className="text-xs text-muted">Vanilla-safe only: item compatibility, normal maximum levels, no conflicts. No unsafe override.</p>
</fieldset>
<div className="border-l-2 border-accent bg-canvas p-4">
<p className="font-mono text-[10px] uppercase tracking-widest text-accent">03 / Delivery preview</p>
<div aria-label="Item summary" className="my-4 space-y-2 text-sm"><p className="font-bold">1 × {label(item)} → {recipient || "Select recipient"}</p>{name && <p>{name}</p>}<ol className="whitespace-pre-wrap text-xs">{input.lore.map((line, i) => <li key={i}>{line || "(blank line)"}</li>)}</ol><ul className="text-xs">{enchantments.map((e, i) => <li key={i}>{label(e.id)} {e.level}</li>)}</ul></div>
<label className="block text-xs">Exact give command<textarea className={`${field} min-h-36 break-all`} readOnly value={command} /></label>
{validation && <p role="alert" className="mt-3 text-xs text-accent">{validation}</p>}
<button className={`${button} mt-3`} disabled={!command} type="button" onClick={() => { void (async () => { try { await navigator.clipboard.writeText(command); setMessage("Command copied."); } catch { setMessage("Copy unavailable. Select the command text to copy manually."); } })(); }}>Copy command</button>
<button className={`${button} ml-2 mt-3 bg-ink text-canvas hover:text-ink`} disabled={!command || !server?.enabled || pending} onClick={() => void review()} type="button">Review delivery</button>
<p className="mt-4 text-xs leading-5 text-muted">One item per action. Names and lore are stored in the command audit. RCON transport completion is not proof of game-command success.</p>
</div>
</div>
{confirmation && <div aria-label="Confirm gear delivery" className="mt-6 border-2 border-accent bg-canvas p-5 outline-none" ref={confirmationRef} role="group" tabIndex={-1}>
<h3 className="font-display text-2xl font-black uppercase">Confirm one-item delivery</h3>
<p className="mt-3 font-mono text-sm">{confirmation.serverName} · {recipient} · 1 × {label(item)}</p>
<pre className="my-4 whitespace-pre-wrap break-all font-mono text-xs">{confirmation.command}</pre>
<p className="text-sm font-bold">Vanilla /give may drop items when inventory space is insufficient. This does not replace equipment or guarantee inventory placement.</p>
<p className="mt-2 text-xs">Custom names and lore are retained in the command audit. The player may disconnect before delivery. A timeout may mean the item was already given; do not retry without checking in-game.</p>
<div className="mt-4 flex flex-wrap gap-3"><button className={`${button} bg-ink text-canvas hover:text-ink`} disabled={pending} onClick={() => void give()} type="button">Confirm and give one item</button><button className={button} onClick={invalidate} type="button">Cancel confirmation</button></div>
</div>}
<p role="status" className="mt-3 whitespace-pre-wrap break-words text-xs">{pending ? "RCON operation in progress…" : message}</p>
</section>;
}
@@ -21,6 +21,7 @@ vi.mock("@/app/admin/(console)/rcon/actions", () => ({
updateRconServer: vi.fn(), updateRconServer: vi.fn(),
})); }));
vi.mock("@/app/admin/(console)/rcon/gear-actions", () => ({ discoverGearPlayers: vi.fn(), previewGear: vi.fn(), giveGear: vi.fn() }));
import { RconConsole } from "./rcon-console"; import { RconConsole } from "./rcon-console";
afterEach(() => cleanup()); afterEach(() => cleanup());
@@ -55,6 +56,12 @@ describe("RconConsole", () => {
}); });
}); });
it("includes the custom gear workshop under the same selected RCON connection", () => {
render(<RconConsole servers={[server, creative]} />);
expect(screen.getByLabelText("Custom gear").textContent).toContain("Season 4");
fireEvent.change(screen.getByLabelText("Server"), { target: { value: creative.id } });
expect(screen.getByLabelText("Custom gear").textContent).toContain("Creative");
});
it("refreshes server help and accepts suggestions without submitting or polluting recall", async () => { it("refreshes server help and accepts suggestions without submitting or polluting recall", async () => {
render(<RconConsole servers={[server]} />); render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" })); fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
+2
View File
@@ -12,6 +12,7 @@ import {
import { AdminModalForm } from "@/components/admin-modal-form"; import { AdminModalForm } from "@/components/admin-modal-form";
import { rconSuggestions, type RconSuggestion } from "@/lib/rcon-help"; import { rconSuggestions, type RconSuggestion } from "@/lib/rcon-help";
import { useRconSession } from "./use-rcon-session"; import { useRconSession } from "./use-rcon-session";
import { GearBuilder } from "./gear-builder";
const MAX_COMMAND_HISTORY = 50; const MAX_COMMAND_HISTORY = 50;
const MAX_TRANSCRIPT_EXCHANGES = 50; const MAX_TRANSCRIPT_EXCHANGES = 50;
@@ -301,6 +302,7 @@ export function RconConsole({
/> />
<button className="border border-ink bg-ink px-4 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-canvas disabled:cursor-not-allowed disabled:opacity-50" disabled={!selected?.enabled || pending} type="submit">{pending ? "Running…" : "Enter ↵"}</button> <button className="border border-ink bg-ink px-4 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-canvas disabled:cursor-not-allowed disabled:opacity-50" disabled={!selected?.enabled || pending} type="submit">{pending ? "Running…" : "Enter ↵"}</button>
</form> </form>
<GearBuilder server={selected} />
</section> </section>
); );
} }
File diff suppressed because it is too large. Load diff
+36
View File
@@ -0,0 +1,36 @@
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { expect, it } from "vitest";
import { buildGearCommand, gearCatalog } from "./gear";
// Explicit offline check; no downloads, server startup, world creation, or RCON traffic.
it.skipIf(!process.env.GEAR_RUNTIME)("verifies catalog, full give grammar and decoded components against cached Purpur 26.2/2618", () => {
const runtime = resolve(process.env.GEAR_RUNTIME!);
const java = process.env.GEAR_JAVA_HOME!;
const hash = (file: string) => createHash("sha256").update(readFileSync(file)).digest("hex");
expect(hash(join(runtime, "purpur-26.2-2618.jar"))).toBe("4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31");
expect(hash(join(runtime, "versions/26.2/purpur-26.2.jar"))).toBe("cd2f999ef27605bedd05d59b0f7491a2bf38dd12d9624659edffa36c39958aa4");
const jars = ["versions", "libraries"].flatMap(dir => readdirSync(join(runtime, dir), { recursive: true, encoding: "utf8" }).filter(file => file.endsWith(".jar")).map(file => join(runtime, dir, file)));
const work = mkdtempSync(join(tmpdir(), "gear-runtime-"));
const classpath = jars.join(process.platform === "win32" ? ";" : ":");
const cases: object[] = [];
for (const item of gearCatalog.items) {
const base = { recipient: "Alex_2", item, name: '雪 "quoted" \\ blade 😀', lore: ["first", "", "<script>alert(1)</script>", '{"click_event":{}}', "last 'line'"], enchantments: [] };
cases.push({ command: buildGearCommand(base), valid: true, name: base.name, lore: base.lore, enchantments: {} });
for (const enchantment of gearCatalog.enchantments.filter(e => e.items.includes(item))) {
cases.push({ command: buildGearCommand({ ...base, enchantments: [{ id: enchantment.id, level: enchantment.maxLevel }] }), valid: true, name: base.name, lore: base.lore, enchantments: { [enchantment.id]: enchantment.maxLevel } });
}
}
cases.push({ command: buildGearCommand({ recipient: "Alex_2", item: "minecraft:diamond_sword", name: "", lore: [], enchantments: [{ id: "minecraft:sharpness", level: 5 }, { id: "minecraft:unbreaking", level: 3 }] }), valid: true, enchantments: { "minecraft:sharpness": 5, "minecraft:unbreaking": 3 } });
cases.push({ command: 'minecraft:give Alex_2 minecraft:diamond_sword{display:{Name:"legacy"}} 1', valid: false });
cases.push({ command: 'minecraft:give Alex_2 minecraft:diamond_sword[minecraft:enchantments={levels:{"minecraft:sharpness":5}}] 1', valid: false });
writeFileSync(join(work, "cases.json"), JSON.stringify(cases));
execFileSync(join(java, "bin/javac"), ["-cp", classpath, "-d", work, resolve("../../scripts/VerifyGear.java")], { timeout: 60_000 });
const output = execFileSync(join(java, "bin/java"), ["-Xmx1G", "-cp", `${work}:${classpath}`, "VerifyGear", join(work, "catalog.json"), join(work, "cases.json")], { cwd: work, timeout: 90_000, encoding: "utf8" });
expect(output).toContain(`Verified ${cases.length} give grammar/component cases`);
expect(JSON.parse(readFileSync(join(work, "catalog.json"), "utf8"))).toEqual(gearCatalog);
console.info(`Runtime verified ${cases.length} cases, 78 items, 43 enchantment rules; artifacts: ${work}`);
}, 180_000);
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { buildGearCommand, parseOnlinePlayers, type GearInput } from "./gear";
export const sword: GearInput = { recipient: "Alex_2", item: "minecraft:diamond_sword", name: "", lore: [], enchantments: [] };
describe("vanilla-safe gear commands", () => {
it("supports runtime gear and multiple normal compatible enchantments only", () => {
expect(buildGearCommand({ ...sword, enchantments: [{ id: "minecraft:unbreaking", level: 3 }, { id: "minecraft:sharpness", level: 5 }] })).toContain('minecraft:enchantments={"minecraft:sharpness":5,"minecraft:unbreaking":3}');
expect(buildGearCommand({ ...sword, item: "minecraft:netherite_pickaxe", enchantments: [{ id: "minecraft:efficiency", level: 5 }] })).toContain("minecraft:netherite_pickaxe[");
expect(buildGearCommand({ ...sword, item: "minecraft:diamond_boots", enchantments: [{ id: "minecraft:protection", level: 4 }] })).toContain("minecraft:diamond_boots[");
for (const enchantments of [[{ id: "minecraft:sharpness", level: 6 }], [{ id: "minecraft:sharpness", level: 0 }], [{ id: "minecraft:sharpness", level: 1.5 }], [{ id: "minecraft:sharpness", level: "5" }], [{ id: "minecraft:efficiency", level: 1 }], [{ id: "minecraft:unknown", level: 1 }], [{ id: "minecraft:sharpness", level: 1 }, { id: "minecraft:smite", level: 1 }], [{ id: "minecraft:sharpness", level: 1 }, { id: "minecraft:sharpness", level: 1 }], null]) expect(() => buildGearCommand({ ...sword, enchantments })).toThrow();
});
it("discovers only a complete bounded vanilla online list, never guessed accounts or selectors", () => {
expect(parseOnlinePlayers("§eThere are 2 of a max of 20 players online: Alex_2, Steve")).toEqual(["Alex_2", "Steve"]);
expect(parseOnlinePlayers("There are 0 of a max of 20 players online: ")).toEqual([]);
for (const value of ["Alex", "There are 2 of a max of 20 players online: Alex", "There are 1 of a max of 20 players online: @a", "There are 2 of a max of 20 players online: Alex, Alex", "There are 1 of a max of 0 players online: Alex", "There are 1 of a max of 20 players online: Alex\ngive @a diamond"]) expect(() => parseOnlinePlayers(value)).toThrow();
});
it("encodes plain text name and ordered lore as literal components, rejecting controls and byte overflow", () => {
const command = buildGearCommand({ ...sword, name: 'A "quoted" \\ blade 雪', lore: ['first', '', 'third "line"'] });
expect(command).toBe('minecraft:give Alex_2 minecraft:diamond_sword[minecraft:custom_name={text:"A \\"quoted\\" \\\\ blade 雪"},minecraft:lore=[{text:"first"},{text:""},{text:"third \\"line\\""}]] 1');
for (const input of [{ ...sword, name: "a\nb" }, { ...sword, lore: ["a\u0000b"] }, { ...sword, lore: Array(33).fill("") }, { ...sword, name: "雪".repeat(500) }, { ...sword, name: "\ud800" }, null]) expect(() => buildGearCommand(input)).toThrow();
const overhead = Buffer.byteLength(buildGearCommand({ ...sword, name: "x" })) - 1;
expect(Buffer.byteLength(buildGearCommand({ ...sword, name: "x".repeat(1024 - overhead) }))).toBe(1024);
expect(() => buildGearCommand({ ...sword, name: "x".repeat(1025 - overhead) })).toThrow(/1,024/);
});
it("rejects sparse or non-string lore rather than silently dropping lines", () => {
for (const lore of [Array(1), [undefined], [null], [1]]) expect(() => buildGearCommand({ ...sword, lore })).toThrow();
});
it("rejects line-breaking recipients even at the end of a syntactically valid name", () => {
for (const recipient of ["Alex\n", "Alex\r", "Alex\u2028", "Alex\u2029"]) expect(() => buildGearCommand({ ...sword, recipient })).toThrow();
});
it("gives exactly one supported item to a literal Java player", () => {
expect(buildGearCommand(sword)).toBe("minecraft:give Alex_2 minecraft:diamond_sword 1");
for (const input of [{ ...sword, item: "minecraft:command_block" }, { ...sword, recipient: "@a" }, { ...sword, recipient: "Alex 64" }, { ...sword, item: "minecraft:diamond_sword 64" }]) {
expect(() => buildGearCommand(input)).toThrow();
}
});
});
+37
View File
@@ -0,0 +1,37 @@
import catalog from "./gear-catalog.json";
export const gearCatalog = catalog;
export type GearInput = { recipient: string; item: string; name: string; lore: string[]; enchantments: { id: string; level: number }[] };
export function parseOnlinePlayers(response: string): string[] {
const match = response.length <= 65536 && /^There are (\d{1,4}) of a max of (\d{1,6}) players online: ?([^\r\n]*)$/.exec(response.replace(/§[0-9a-fk-or]/gi, "").trim());
if (!match) throw new Error("The server did not return a supported complete online-player list.");
const players = match[3] ? match[3].split(", ") : [];
if (players.length > 1000 || players.length !== Number(match[1]) || players.length > Number(match[2]) || new Set(players.map(p => p.toLowerCase())).size !== players.length || !players.every(p => PLAYER.test(p))) throw new Error("The online-player list is invalid or incomplete.");
return players;
}
const PLAYER = /^[A-Za-z0-9_]{1,16}$/;
const CONTROL = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u;
function plain(value: unknown): value is string {
return typeof value === "string" && value.length <= 1024 && !CONTROL.test(value) && !/[\uD800-\uDFFF]/u.test(value);
}
function text(value: string) { return `{text:${JSON.stringify(value)}}`; }
export function buildGearCommand(input: unknown): string {
if (!input || typeof input !== "object") throw new Error("Invalid gear fields.");
const gear = input as GearInput;
if (typeof gear.recipient !== "string" || !PLAYER.test(gear.recipient) || !catalog.items.includes(gear.item)) throw new Error("Choose a supported item and online Java player.");
if (!plain(gear.name) || !Array.isArray(gear.lore) || gear.lore.length > 32 || !Array.from(gear.lore).every(plain)) throw new Error("Use plain text without control characters, at most 32 lore lines.");
if (!Array.isArray(gear.enchantments) || gear.enchantments.length > catalog.enchantments.length) throw new Error("Invalid enchantments.");
const seen = new Set<string>();
for (const entry of gear.enchantments) {
const rule = entry && catalog.enchantments.find(rule => rule.id === entry.id);
if (!rule || !rule.items.includes(gear.item) || !Number.isInteger(entry.level) || entry.level < 1 || entry.level > rule.maxLevel || seen.has(entry.id) || rule.conflicts.some(id => gear.enchantments.some(e => e !== entry && e?.id === id))) throw new Error("Enchantments must be compatible with the item and each other, within normal level limits.");
seen.add(entry.id);
}
const components: string[] = [];
if (gear.name) components.push(`minecraft:custom_name=${text(gear.name)}`);
if (gear.lore.length) components.push(`minecraft:lore=[${gear.lore.map(text).join(",")}]`);
if (gear.enchantments.length) components.push(`minecraft:enchantments={${[...gear.enchantments].sort((a, b) => a.id.localeCompare(b.id)).map(e => `${JSON.stringify(e.id)}:${e.level}`).join(",")}}`);
const command = `minecraft:give ${gear.recipient} ${gear.item}${components.length ? `[${components.join(",")}]` : ""} 1`;
if (new TextEncoder().encode(command).length > 1024) throw new Error("Command exceeds the 1,024 UTF-8-byte RCON limit.");
return command;
}
@@ -0,0 +1,6 @@
import { rconCommandDigest } from "./rcon-credentials";
/** Opaque identity of the endpoint/configuration; never expose the credential envelope. */
export function rconConnectionRevision(server: { id: string; name: string; host: string; port: number; enabled: boolean; updatedAt: Date; encryptedPassword: string }) {
return rconCommandDigest(JSON.stringify(["rcon-connection-revision-v1", server.id, server.name, server.host, server.port, server.enabled, server.updatedAt, server.encryptedPassword]));
}
+24
View File
@@ -15,6 +15,30 @@ Select an enabled server and choose **Refresh commands**. The portal reads `help
No additional Minecraft plugin, database migration, secret, or deployment setting is required for autocomplete. No additional Minecraft plugin, database migration, secret, or deployment setting is required for autocomplete.
## Custom gear — vanilla-safe only
The **Custom gear** workshop below the terminal uses the same selected connection. Select an enabled server, **Refresh players**, choose a current Java player, then choose a weapon, tool or armor item. Portal registrations and historical logins are never evidence that someone is online. Refresh, server changes, endpoint edits and reload discard the recipient and any confirmation.
- Support is deliberately limited to **Minecraft 26.2 / Purpur 2618 (`5a85de0`)**, using the bundled vanilla registry data. Every discovery/delivery checks `bukkit:version`; an unknown version is rejected. Purpur may initially answer only “Checking version”; refresh manually after it finishes. No automatic retries or version guesses. Plugins/datapacks that replace vanilla behavior are not certified by this catalog.
- The catalog contains **78** gear items and **43** enchantment definitions, extracted from the supported runtime's loaded registries. Only item-compatible enchantments at normal levels are accepted, with no duplicates or conflicting combinations. There is **no unsafe mode**. Changing the base item clears enchantments so incompatible selections cannot carry over silently.
- Names and up to **32 ordered lore lines** are plain text. Empty interior/trailing lore rows are preserved. No rich text, click events, arbitrary components, selectors or command editing. The exact command is updated immediately and can be copied. The **1,024 UTF-8-byte** bound includes escaping and every component; overflow is rejected, not truncated.
- **Review delivery** obtains a server-generated preview. **Confirm and give one item** sends only that exact command. Every target/item edit invalidates confirmation, and confirmation is consumed before sending. The server independently regenerates and validates fields; its HMAC confirmation binds the administrator, command and saved connection revision. Each version/list/give rechecks authorization and enabled state and uses the existing audit-before-send transport. A changed endpoint cannot redirect an already reviewed give.
- Vanilla `/give` **may drop items if the inventory is full**. This is not equipment replacement or a guarantee of inventory placement. A player can disconnect after the online check. RCON transport completion is **not confirmed game-command success**: read the safely displayed server response.
- A timeout, lost response or completion-audit failure **may mean the item was already given**. The UI blocks repeat submission while pending and never automatically retries. Check in-game before deliberately starting a new action. Confirmation is not an exactly-once receipt or durable idempotency mechanism.
- **Names and lore are retained in command audit history** with the administrator and server identity. Responses and credentials are not persisted; browser state is page-memory only. Discovery/version checks themselves are audited. There is no new schema, secret, plugin or deployment setting.
### Reproducing the offline compatibility check
The checked-in catalog is `apps/web/src/lib/gear-catalog.json`. An opt-in test uses an **existing** extracted Purpur runtime and Java 25; it does not install/download tooling, start a server, create a world, or send RCON. It checks launcher and patched-runtime SHA-256, reloads vanilla registry data, compares every catalog entry, parses give grammar via the actual Bukkit vanilla-command adapter and Brigadier, then decodes ItemStacks to assert literal names/lore and enchantment levels. Legacy NBT and the obsolete enchantment `levels` wrapper are negative controls.
```bash
GEAR_RUNTIME=/absolute/path/to/cached/runtime \
GEAR_JAVA_HOME=/absolute/path/to/existing/java-25 \
npm test --workspace @minecraft-account-manager/web -- src/lib/gear-runtime.test.ts
```
Expected layout: `purpur-26.2-2618.jar`, `versions/26.2/purpur-26.2.jar`, and `libraries/**/*.jar`. The ordinary suite skips this explicit check when `GEAR_RUNTIME` is absent; do not describe that skip as fresh runtime verification. Keep the exact parser check and catalog comparison when changing support. A newer version requires new verification, not simply relaxing the version gate. Live item appearance, full-inventory and disconnect behavior remain separately approved post-deployment follow-ups.
## Application configuration ## Application configuration
Administrators may configure any syntactically valid DNS hostname and TCP port without deployment-managed endpoint configuration. IP literals, trailing-dot hostnames, and malformed DNS names are rejected whenever a connection is saved, tested, or used. Administrators may configure any syntactically valid DNS hostname and TCP port without deployment-managed endpoint configuration. IP literals, trailing-dot hostnames, and malformed DNS names are rejected whenever a connection is saved, tested, or used.
+110
View File
@@ -0,0 +1,110 @@
import java.nio.file.*;
import java.util.*;
import java.util.stream.Stream;
import com.google.gson.*;
import com.mojang.brigadier.StringReader;
import net.minecraft.SharedConstants;
import net.minecraft.commands.Commands;
import net.minecraft.commands.arguments.item.ItemParser;
import net.minecraft.core.*;
import net.minecraft.core.registries.*;
import net.minecraft.resources.*;
import net.minecraft.server.*;
import net.minecraft.server.packs.PackType;
import net.minecraft.server.packs.repository.ServerPacksSource;
import net.minecraft.server.packs.resources.MultiPackResourceManager;
import net.minecraft.server.permissions.LevelBasedPermissionSet;
import net.minecraft.tags.TagLoader;
import net.minecraft.util.Util;
import net.minecraft.world.flag.FeatureFlags;
import net.minecraft.world.item.enchantment.Enchantment;
import net.minecraft.world.level.*;
import org.bukkit.craftbukkit.CraftRegistry;
/** Offline verification only: loads cached vanilla registries, never starts a server or sends commands. */
public class VerifyGear {
public static void main(String[] args) throws Exception {
SharedConstants.tryDetectVersion();
if (!SharedConstants.getCurrentVersion().name().equals("26.2")) throw new AssertionError("Expected Minecraft 26.2");
Bootstrap.bootStrap();
var flags = FeatureFlags.VANILLA_SET;
var packs = ServerPacksSource.createVanillaTrustedRepository();
MinecraftServer.configurePackRepository(packs, new WorldDataConfiguration(new DataPackConfig(
FeatureFlags.REGISTRY.toNames(flags).stream().map(Identifier::getPath).toList(), List.of()), flags), true, false);
try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, packs.openAllSelected())) {
LayeredRegistryAccess<RegistryLayer> layers = RegistryLayer.createRegistryAccess();
List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC));
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLDGEN), tags);
RegistryAccess.Frozen worldgen = RegistryDataLoader.load(resources, lookups, RegistryDataLoader.WORLDGEN_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.WORLDGEN, worldgen);
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources, Stream.concat(lookups.stream(), worldgen.listRegistries()).toList(), RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions);
Class.forName(org.bukkit.Registry.class.getName());
var datapack = ReloadableServerResources.loadResources(resources, layers, tags, flags,
Commands.CommandSelection.DEDICATED, LevelBasedPermissionSet.ALL_PERMISSIONS, Util.backgroundExecutor(), Runnable::run).join();
datapack.updateComponentsAndStaticRegistryTags();
var registries = layers.compositeAccess().freeze();
CraftRegistry.setMinecraftRegistry(registries);
var items = BuiltInRegistries.ITEM.keySet().stream().filter(id -> id.getNamespace().equals("minecraft") &&
(id.getPath().matches("(wooden|stone|copper|iron|golden|diamond|netherite)_(sword|axe|pickaxe|shovel|hoe|spear)") ||
id.getPath().matches("(leather|copper|chainmail|iron|golden|diamond|netherite)_(helmet|chestplate|leggings|boots)") ||
Set.of("bow", "crossbow", "trident", "mace", "fishing_rod", "shears", "shield", "turtle_helmet").contains(id.getPath())))
.sorted(Comparator.comparing(Identifier::toString)).toList();
var enchantments = registries.lookupOrThrow(Registries.ENCHANTMENT);
var catalog = new JsonObject();
catalog.addProperty("version", "26.2"); catalog.addProperty("purpurBuild", 2618);
var itemList = new JsonArray(); items.forEach(id -> itemList.add(id.toString())); catalog.add("items", itemList);
var enchantmentList = new JsonArray();
enchantments.listElements().sorted(Comparator.comparing(h -> h.key().identifier().toString())).forEach(holder -> {
var value = holder.value(); var entry = new JsonObject();
entry.addProperty("id", holder.key().identifier().toString()); entry.addProperty("maxLevel", value.getMaxLevel());
var supported = new JsonArray(); items.stream().filter(id -> value.isSupportedItem(new net.minecraft.world.item.ItemStack(BuiltInRegistries.ITEM.getValue(id)))).forEach(id -> supported.add(id.toString()));
entry.add("items", supported);
var conflicts = new JsonArray(); enchantments.listElements().filter(other -> !Enchantment.areCompatible(holder, other)).map(h -> h.key().identifier().toString()).sorted().forEach(conflicts::add);
entry.add("conflicts", conflicts); enchantmentList.add(entry);
});
catalog.add("enchantments", enchantmentList);
Files.writeString(Path.of(args[0]), new GsonBuilder().setPrettyPrinting().create().toJson(catalog) + "\n");
if (args.length > 1) {
var parser = new ItemParser(registries);
var wrapper = new org.bukkit.craftbukkit.command.VanillaCommandWrapper(datapack.getCommands().getDispatcher().getRoot().getChild("give"));
var toDispatcher = wrapper.getClass().getDeclaredMethod("toDispatcher", String[].class, String.class);
toDispatcher.setAccessible(true);
var cases = JsonParser.parseString(Files.readString(Path.of(args[1]))).getAsJsonArray();
for (var element : cases) {
var test = element.getAsJsonObject(); var command = test.get("command").getAsString();
boolean valid = false;
try {
// Bukkit maps minecraft:give to this vanilla wrapper; use its real argument
// reconstruction rather than inventing the command dispatched to Brigadier.
if (!command.startsWith("minecraft:give ")) throw new AssertionError("Expected literal minecraft:give");
var arguments = command.substring("minecraft:give ".length()).split(" ");
var dispatched = (String) toDispatcher.invoke(wrapper, arguments, wrapper.getName());
var reader = new StringReader(dispatched);
var parsed = datapack.getCommands().getDispatcher().parse(reader, Commands.createCompilationContext(LevelBasedPermissionSet.ALL_PERMISSIONS));
Commands.validateParseResults(parsed);
if (parsed.getContext().getCommand() == null || parsed.getReader().canRead()) throw new AssertionError("Incomplete give parse");
// Decode components into an actual ItemStack, not just a syntactic SNBT parse.
var itemReader = new StringReader(command.substring(command.indexOf(' ', command.indexOf(' ') + 1) + 1));
var item = parser.parse(itemReader).createItemStack(1);
if (item.getCount() != 1) throw new AssertionError("Count mismatch");
if (test.has("name") && !item.getHoverName().getString().equals(test.get("name").getAsString())) throw new AssertionError("Plain text name mismatch");
if (test.has("lore")) {
var lore = item.get(net.minecraft.core.component.DataComponents.LORE);
var actual = new JsonArray(); if (lore != null) lore.lines().forEach(line -> actual.add(line.getString()));
if (!actual.equals(test.get("lore"))) throw new AssertionError("Lore mismatch: " + actual);
}
if (test.has("enchantments")) {
var actual = new JsonObject(); item.getEnchantments().entrySet().forEach(e -> actual.addProperty(e.getKey().unwrapKey().orElseThrow().identifier().toString(), e.getIntValue()));
if (!actual.equals(test.get("enchantments"))) throw new AssertionError("Enchantment mismatch: " + actual);
}
valid = true;
} catch (com.mojang.brigadier.exceptions.CommandSyntaxException expected) { /* invalid fixture */ }
if (valid != test.get("valid").getAsBoolean()) throw new AssertionError("Unexpected grammar result: " + command);
}
System.out.println("Verified " + cases.size() + " give grammar/component cases against Minecraft 26.2 / Purpur 2618");
}
}
System.exit(0);
}
}