From 65e3af7c7889c510fcb0b4728d22f0351df66900 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Thu, 17 Sep 2026 23:11:01 -0400 Subject: [PATCH] feat(rcon): add vanilla-safe custom gear builder --- .../src/app/admin/(console)/rcon/actions.ts | 9 +- .../admin/(console)/rcon/gear-actions.test.ts | 126 ++ .../app/admin/(console)/rcon/gear-actions.ts | 64 + apps/web/src/components/gear-builder.test.tsx | 142 ++ apps/web/src/components/gear-builder.tsx | 104 ++ apps/web/src/components/rcon-console.test.tsx | 7 + apps/web/src/components/rcon-console.tsx | 2 + apps/web/src/lib/gear-catalog.json | 1209 +++++++++++++++++ apps/web/src/lib/gear-runtime.test.ts | 36 + apps/web/src/lib/gear.test.ts | 37 + apps/web/src/lib/gear.ts | 37 + apps/web/src/lib/rcon-connection-revision.ts | 6 + docs/rcon.md | 24 + scripts/VerifyGear.java | 110 ++ 14 files changed, 1912 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/app/admin/(console)/rcon/gear-actions.test.ts create mode 100644 apps/web/src/app/admin/(console)/rcon/gear-actions.ts create mode 100644 apps/web/src/components/gear-builder.test.tsx create mode 100644 apps/web/src/components/gear-builder.tsx create mode 100644 apps/web/src/lib/gear-catalog.json create mode 100644 apps/web/src/lib/gear-runtime.test.ts create mode 100644 apps/web/src/lib/gear.test.ts create mode 100644 apps/web/src/lib/gear.ts create mode 100644 apps/web/src/lib/rcon-connection-revision.ts create mode 100644 scripts/VerifyGear.java diff --git a/apps/web/src/app/admin/(console)/rcon/actions.ts b/apps/web/src/app/admin/(console)/rcon/actions.ts index bbf7e53..9637c44 100644 --- a/apps/web/src/app/admin/(console)/rcon/actions.ts +++ b/apps/web/src/app/admin/(console)/rcon/actions.ts @@ -10,6 +10,7 @@ import { requireAdminSession } from "@/lib/auth/require-admin"; import { db } from "@/lib/database"; import { isUniqueConstraintViolation } from "@/lib/database-errors"; import { decryptRconPassword, encryptRconPassword, rconCommandDigest } from "@/lib/rcon-credentials"; +import { rconConnectionRevision } from "@/lib/rcon-connection-revision"; import { executeRcon, testRconConnection } from "@/lib/rcon-gateway"; import { recordAdminSubjectEvent } from "@/lib/audit"; 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 ? and(eq(rconServers.id, serverId), eq(rconServers.enabled, true)) : 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 }); if (!validated) return null; try { @@ -212,6 +213,7 @@ export async function testSavedRconServer(formData: FormData) { export async function executeRconCommand( _previous: RconCommandState, formData: FormData, + expectedConnectionRevision?: string, ): Promise { const admin = await requireAdminSession(); 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 }; const server = await savedConnection(serverId, true); 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); let commandDigest: string; try { diff --git a/apps/web/src/app/admin/(console)/rcon/gear-actions.test.ts b/apps/web/src/app/admin/(console)/rcon/gear-actions.test.ts new file mode 100644 index 0000000..743ca13 --- /dev/null +++ b/apps/web/src/app/admin/(console)/rcon/gear-actions.test.ts @@ -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 | 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, +})); +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/); + }); +}); diff --git a/apps/web/src/app/admin/(console)/rcon/gear-actions.ts b/apps/web/src/app/admin/(console)/rcon/gear-actions.ts new file mode 100644 index 0000000..f5f1593 --- /dev/null +++ b/apps/web/src/app/admin/(console)/rcon/gear-actions.ts @@ -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." }; } +} diff --git a/apps/web/src/components/gear-builder.test.tsx b/apps/web/src/components/gear-builder.test.tsx new file mode 100644 index 0000000..5a7ebf4 --- /dev/null +++ b/apps/web/src/components/gear-builder.test.tsx @@ -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(); await choosePlayer(); + fireEvent.click(screen.getByRole("button", { name: "Review delivery" })); await screen.findByRole("group", { name: "Confirm gear delivery" }); + view.rerender(); + 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(); + await act(async () => finish({ ok: true, players: ["LatePlayer"] })); + expect(screen.queryByRole("option", { name: "LatePlayer" })).toBeNull(); + view.rerender(); + expect((screen.getByRole("button", { name: "Refresh players" }) as HTMLButtonElement).disabled).toBe(true); + view.rerender(); + expect((screen.getByRole("button", { name: "Review delivery" }) as HTMLButtonElement).disabled).toBe(true); + view.unmount(); render(); + 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(); 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(); + 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(); 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(); 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(); 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 ' }); + 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 { + render(); 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(); + 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()); + }); +}); diff --git a/apps/web/src/components/gear-builder.tsx b/apps/web/src/components/gear-builder.tsx new file mode 100644 index 0000000..207ff9c --- /dev/null +++ b/apps/web/src/components/gear-builder.tsx @@ -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 ; +} +function Workshop({ server }: { server?: RconServerOption }) { + const [confirmation, setConfirmation] = useState(null); + const confirmationRef = useRef(null); + const revision = useRef(0); + useEffect(() => { confirmationRef.current?.focus(); }, [confirmation]); + function invalidate() { revision.current++; setConfirmation(null); } + const [players, setPlayers] = useState([]); + const [recipient, setRecipient] = useState(""); + const [item, setItem] = useState("minecraft:diamond_sword"); + const [name, setName] = useState(""); + const [lore, setLore] = useState(""); + const [enchantments, setEnchantments] = useState([]); + 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
+

01 / Recipient

+

Custom gear

+

{server?.name ?? "Select a server"} · Vanilla-safe · Minecraft 26.2 / Purpur 2618 only

+ + +
+
+ 02 / Specification + + +