Compare commits

..
3 Commits
Author SHA1 Message Date
dmg 65e3af7c78 feat(rcon): add vanilla-safe custom gear builder
CI / validate (push) Successful in 7m15s
Release / release (push) Successful in 12m13s
2026-09-17 23:11:01 -04:00
dmg 999c27c7f9 feat(rcon): discover commands and autocomplete server help
CI / validate (push) Successful in 8m20s
Release / release (push) Successful in 11m23s
2026-09-14 15:52:56 -04:00
dmg eb8ef18688 fix(suggestions): preserve one-item archived pagination
CI / validate (push) Successful in 8m20s
Release / release (push) Successful in 10m52s
2026-09-10 16:00:02 -04:00
22 changed files with 2713 additions and 23 deletions

No files matched your search

@@ -1,7 +1,15 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// @vitest-environment jsdom
import { createElement } from "react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RconConsole } from "@/components/rcon-console";
const actionState = vi.hoisted(() => ({
authorized: 0,
authFailure: false,
helpResponses: {} as Record<string, string>,
requestedBeforeSend: [] as boolean[],
selected: [] as unknown[],
transactionSelected: [] as unknown[],
updates: [] as Record<string, unknown>[],
@@ -17,6 +25,7 @@ const actionState = vi.hoisted(() => ({
vi.mock("@/lib/auth/require-admin", () => ({
requireAdminSession: async () => {
actionState.authorized += 1;
if (actionState.authFailure) throw new Error("REDIRECT:/admin/login");
return { email: "admin@example.test", name: "Admin" };
},
}));
@@ -73,8 +82,9 @@ vi.mock("@/lib/rcon-credentials", () => ({
vi.mock("@/lib/rcon-gateway", () => ({
executeRcon: async (connection: Record<string, unknown>, command: string) => {
actionState.requestedBeforeSend.push(actionState.audits.at(-1)?.type.endsWith(".requested") === true);
actionState.executions.push({ connection, command });
return actionState.gatewayResult;
return command in actionState.helpResponses ? { ok: true, response: actionState.helpResponses[command] } : actionState.gatewayResult;
},
testRconConnection: vi.fn(),
}));
@@ -115,8 +125,12 @@ const savedServer = {
};
describe("RCON server actions", () => {
afterEach(() => cleanup());
beforeEach(() => {
actionState.authorized = 0;
actionState.authFailure = false;
actionState.helpResponses = {};
actionState.requestedBeforeSend = [];
actionState.selected = [];
actionState.transactionSelected = [];
actionState.updates = [];
@@ -127,6 +141,55 @@ describe("RCON server actions", () => {
actionState.gatewayResult = { ok: true, response: "private response" };
});
it("routes UI discovery and usage through independently authorized, audit-before-send RCON actions", async () => {
actionState.selected = [savedServer];
actionState.helpResponses = {
help: "Help: Index (1/2)\n/leaf: Private protection description",
"help 2": "Help: Index (2/2)\n/tyrant: Tyrant features",
"help tyrant": "Usage: /tyrant <menu|status>",
};
render(createElement(RconConsole, { servers: [savedServer] }));
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "tyrant " } });
await screen.findByLabelText("Command usage");
expect(actionState.executions.map((entry) => entry.command)).toEqual(["help", "help 2", "help tyrant"]);
expect(actionState.authorized).toBe(3);
expect(actionState.requestedBeforeSend).toEqual([true, true, true]);
expect(actionState.audits).toHaveLength(6);
for (let index = 0; index < 6; index += 2) {
expect(actionState.audits[index]?.admin).toEqual({ email: "admin@example.test", name: "Admin" });
expect(actionState.audits[index]?.correlationId).toBe(actionState.audits[index + 1]?.correlationId);
}
const audit = JSON.stringify(actionState.audits);
expect(audit).not.toContain("Private protection description");
expect(audit).not.toContain("/tyrant <menu|status>");
expect(audit).not.toContain("decrypted-password");
});
it.each(["authorization", "enabled-connection", "audit"])("does not send discovery requests when %s fails", async (failure) => {
actionState.selected = failure === "enabled-connection" ? [] : [savedServer];
actionState.authFailure = failure === "authorization";
actionState.auditFailure = failure === "audit";
render(createElement(RconConsole, { servers: [savedServer] }));
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("0 commands discovered · incomplete; refresh to retry");
expect(actionState.authorized).toBe(1);
expect(actionState.executions).toEqual([]);
expect(document.body.textContent).not.toContain("REDIRECT:");
});
it.each(["busy", "timeout", "unavailable"] as const)("preserves safe %s gateway outcomes for UI discovery", async (reason) => {
actionState.selected = [savedServer];
actionState.gatewayResult = { ok: false, reason };
render(createElement(RconConsole, { servers: [savedServer] }));
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("0 commands discovered · incomplete; refresh to retry");
expect(actionState.executions).toHaveLength(1);
expect(actionState.audits[1]?.data).toEqual(expect.objectContaining({ success: false, reason }));
expect((screen.getByLabelText("Command") as HTMLInputElement).disabled).toBe(false);
});
it("independently authorizes every exported operation before accepting input", async () => {
await expect(createRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=invalid-connection");
await expect(updateRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=invalid-connection");
@@ -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<RconCommandState> {
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 {
@@ -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." }; }
}
@@ -20,7 +20,17 @@ beforeEach(() => {
vi.stubGlobal("fetch", fetcher);
fetcher.mockImplementation(async (url: string) => {
if (url.endsWith(`/channels/${forum}`)) return Response.json({ id: forum, guild_id: guild, type: 15, available_tags: [] });
if (url.includes("/threads/archived/public")) return Response.json({ threads: [thread], has_more: true });
if (url.includes("/threads/archived/public")) {
const params = new URL(url).searchParams;
if (Number(params.get("limit")) < 2) return Response.json({ code: 50035, message: "Invalid Form Body", errors: { limit: { _errors: [{ code: "NUMBER_TYPE_MIN", message: "int value should be greater than or equal to 2." }] } } }, { status: 400 });
const newest = { ...thread, thread_metadata: { ...thread.thread_metadata, archived: true } };
const older = { ...newest, id: "100000000000000008", thread_metadata: { ...newest.thread_metadata, archive_timestamp: "2026-09-10T00:00:00.123455+00:00" } };
if (params.has("before")) {
expect(params.get("before")).toBe("2026-09-10T00:00:00.123456Z");
return Response.json({ threads: [older], has_more: false });
}
return Response.json({ threads: [newest, older], has_more: false });
}
if (url.includes("/threads/active")) return Response.json({ threads: [thread] });
if (url.endsWith(`/channels/${id}`)) return Response.json(thread);
if (url.endsWith(`/messages/${id}`)) return Response.json(message);
@@ -48,7 +58,16 @@ it("documents the nullable deleted starter and precise archived cursor", async (
expect((await response.json()).originalPost).toBeNull();
const archived = await list.GET(new Request("https://portal.example/api/suggestions?status=archived&limit=1"));
await assertResponse("/api/suggestions", "get", archived);
expect((await archived.json()).nextCursor).toBe("2026-09-10T00:00:00.123456Z");
expect(archived.status).toBe(200);
const first = await archived.json();
expect(first.items.map((item: { id: string }) => item.id)).toEqual([id]);
expect(first.nextCursor).toBe("2026-09-10T00:00:00.123456Z");
const terminal = await list.GET(new Request(`https://portal.example/api/suggestions?status=archived&limit=1&cursor=${encodeURIComponent(first.nextCursor)}`));
expect(terminal.status).toBe(200);
await assertResponse("/api/suggestions", "get", terminal);
const last = await terminal.json();
expect(last.items.map((item: { id: string }) => item.id)).toEqual(["100000000000000008"]);
expect(last.nextCursor).toBeNull();
});
it.each(routes)("documents Retry-After on $path upstream rate limits", async ({ path, route }) => {
fetcher.mockImplementation(async () => Response.json({ retry_after: 2.1 }, { status: 429 }));
@@ -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>;
}
+193 -2
View File
@@ -1,8 +1,8 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const actionMocks = vi.hoisted(() => ({
execute: vi.fn(async (_previous: unknown, formData: FormData) => ({
@@ -21,6 +21,7 @@ vi.mock("@/app/admin/(console)/rcon/actions", () => ({
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";
afterEach(() => cleanup());
@@ -41,6 +42,196 @@ const creative = {
};
describe("RconConsole", () => {
beforeEach(() => {
actionMocks.execute.mockClear();
actionMocks.execute.mockImplementation(async (_previous, formData) => {
const command = String(formData.get("command") ?? "");
const serverId = String(formData.get("serverId") ?? "");
const message = command === "help"
? "§eHelp: Index (1/2)\n§6Leaf: §fAll commands for Leaf\n§6/leaf: §fProtection"
: command === "help 2" ? "Help: Index (2/2)\n/tyrant: Tyrant features"
: command === "help tyrant" ? "Usage: /tyrant <menu|status|armor <helmet|boots>>"
: `Executed ${command}`;
return { status: "success", message, serverId };
});
});
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 () => {
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyr" } });
expect(screen.getByRole("option", { name: /tyrant Tyrant features/ })).toBeTruthy();
expect(fireEvent.keyDown(input, { key: "Enter" })).toBe(false);
expect(input.value).toBe("tyrant ");
expect(screen.queryByRole("listbox")).toBeNull();
expect(actionMocks.execute.mock.calls.map(([, data]) => data.get("command"))).toEqual(["help", "help 2"]);
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(input.value).toBe("tyrant ");
expect(screen.getByLabelText("Terminal transcript").textContent).not.toContain("$ help");
});
it("fetches and caches usage on demand and offers contextual literal arguments", async () => {
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyrant " } });
await screen.findByLabelText("Command usage");
expect(screen.getByRole("option", { name: "menu From server help" })).toBeTruthy();
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(fireEvent.keyDown(input, { key: "Tab" })).toBe(false);
expect(input.value).toBe("tyrant status ");
fireEvent.change(input, { target: { value: "tyrant armor " } });
fireEvent.click(screen.getByRole("option", { name: "helmet From server help" }));
expect(input.value).toBe("tyrant armor helmet ");
expect(document.activeElement).toBe(input);
expect(actionMocks.execute.mock.calls.map(([, data]) => data.get("command"))).toEqual(["help", "help 2", "help tyrant"]);
});
it("reports incomplete help safely and leaves unknown manual commands usable", async () => {
actionMocks.execute.mockRejectedValueOnce(new Error("private transport detail"));
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("0 commands discovered · incomplete; refresh to retry");
expect(document.body.textContent).not.toContain("private transport detail");
const input = screen.getByLabelText("Command") as HTMLInputElement;
expect(input.disabled).toBe(false);
fireEvent.change(input, { target: { value: "custom-plugin arbitrary value" } });
fireEvent.submit(input.form!);
await screen.findByText("Executed custom-plugin arbitrary value");
});
it("isolates caches by server, invalidates edited endpoints, and discards help on remount", async () => {
const storage = vi.spyOn(Storage.prototype, "setItem");
const view = render(<RconConsole servers={[server, creative]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "tyrant " } });
await screen.findByLabelText("Command usage");
fireEvent.change(screen.getByLabelText("Server"), { target: { value: creative.id } });
let input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyr" } });
expect(screen.queryByRole("listbox")).toBeNull();
expect(screen.queryByLabelText("Command usage")).toBeNull();
fireEvent.change(screen.getByLabelText("Server"), { target: { value: server.id } });
input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyrant " } });
expect(screen.getByLabelText("Command usage")).toBeTruthy();
expect(actionMocks.execute).toHaveBeenCalledTimes(3);
view.rerender(<RconConsole servers={[{ ...server, host: "replacement.example.com" }, creative]} />);
expect(screen.queryByLabelText("Command usage")).toBeNull();
expect(screen.queryByRole("listbox")).toBeNull();
view.unmount();
render(<RconConsole servers={[server]} />);
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "tyr" } });
expect(screen.queryByRole("listbox")).toBeNull();
expect(storage).not.toHaveBeenCalled();
storage.mockRestore();
});
it("dismisses suggestions for history recall, restores drafts, and submits only intentionally", async () => {
render(<RconConsole servers={[server]} />);
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "list" } });
fireEvent.submit(input.form!);
await screen.findByText("Executed list");
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
fireEvent.change(input, { target: { value: "tyr" } });
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(input.value).toBe("tyr");
fireEvent.keyDown(input, { key: "Escape" });
expect(input.getAttribute("aria-expanded")).toBe("false");
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(input.value).toBe("list");
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(input.value).toBe("tyr");
fireEvent.submit(input.form!);
await screen.findByText("Executed tyr");
expect(actionMocks.execute.mock.calls.map(([, data]) => data.get("command"))).toEqual(["list", "help", "help 2", "tyr"]);
});
it("does not offer end-of-command replacement while editing in the middle", async () => {
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyr" } });
input.setSelectionRange(1, 1);
fireEvent.select(input);
expect(screen.queryByRole("listbox")).toBeNull();
expect(fireEvent.keyDown(input, { key: "Tab" })).toBe(true);
expect(input.value).toBe("tyr");
});
it("stops pagination on server change and never publishes a late result to the new selection", async () => {
let resolve!: (value: { status: "success"; message: string; serverId: string }) => void;
actionMocks.execute.mockImplementationOnce(() => new Promise((done) => { resolve = done; }));
render(<RconConsole servers={[server, creative]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
fireEvent.change(screen.getByLabelText("Server"), { target: { value: creative.id } });
await act(async () => resolve({ status: "success", message: "Help: Index (1/9)\n/secretcmd: Private", serverId: server.id }));
expect(actionMocks.execute).toHaveBeenCalledTimes(1);
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "secret" } });
expect(screen.queryByRole("listbox")).toBeNull();
expect(screen.getByText("Refresh commands to discover server help")).toBeTruthy();
});
it("lets manual execution interrupt discovery and waits for the in-flight help request", async () => {
let resolve!: (value: { status: "success"; message: string; serverId: string }) => void;
actionMocks.execute.mockImplementationOnce(() => new Promise((done) => { resolve = done; }));
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "list" } });
fireEvent.submit(input.form!);
expect(actionMocks.execute).toHaveBeenCalledTimes(1);
await act(async () => resolve({ status: "success", message: "Help: Index (1/9)\n/leaf: Protection", serverId: server.id }));
await screen.findByText("Executed list");
expect(actionMocks.execute.mock.calls.map(([, data]) => data.get("command"))).toEqual(["help", "list"]);
expect(input.disabled).toBe(false);
});
it("caches safe usage failure without retries until refresh and keeps manual entry available", async () => {
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
actionMocks.execute.mockRejectedValueOnce(new Error("private usage detail"));
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyrant " } });
await screen.findByText("Usage unavailable. Refresh commands to retry.");
fireEvent.change(input, { target: { value: "tyrant armor " } });
expect(actionMocks.execute).toHaveBeenCalledTimes(3);
expect(document.body.textContent).not.toContain("private usage detail");
expect(input.disabled).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("/tyrant <menu|status|armor <helmet|boots>>");
expect(actionMocks.execute.mock.calls.map(([, data]) => data.get("command"))).toEqual(["help", "help 2", "help tyrant", "help", "help 2", "help tyrant"]);
});
it("renders server help as inert text and never refreshes missing or disabled connections", async () => {
const view = render(<RconConsole servers={[]} />);
expect((screen.getByRole("button", { name: "Refresh commands" }) as HTMLButtonElement).disabled).toBe(true);
view.rerender(<RconConsole servers={[{ ...server, enabled: false }]} />);
expect((screen.getByRole("button", { name: "Refresh commands" }) as HTMLButtonElement).disabled).toBe(true);
expect(actionMocks.execute).not.toHaveBeenCalled();
view.rerender(<RconConsole servers={[server]} />);
actionMocks.execute.mockResolvedValueOnce({ status: "success", message: 'Help: Index (1/1)\n/leaf: <img src=x onerror="alert(1)">', serverId: server.id });
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("1 commands discovered · best-effort server help");
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "lea" } });
expect(screen.getByText('<img src=x onerror="alert(1)">')).toBeTruthy();
expect(document.querySelector("img")).toBeNull();
});
it("renders one wide terminal workspace with connection controls and modal forms", () => {
const markup = renderToStaticMarkup(<RconConsole servers={[server]} />);
expect(markup).toContain('aria-label="RCON terminal"');
+82 -7
View File
@@ -1,19 +1,19 @@
"use client";
import Link from "next/link";
import { useActionState, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
createRconServer,
deleteRconServer,
executeRconCommand,
setRconServerEnabled,
testSavedRconServer,
type RconCommandState,
updateRconServer,
} from "@/app/admin/(console)/rcon/actions";
import { AdminModalForm } from "@/components/admin-modal-form";
import { rconSuggestions, type RconSuggestion } from "@/lib/rcon-help";
import { useRconSession } from "./use-rcon-session";
import { GearBuilder } from "./gear-builder";
const initialState: RconCommandState = { status: "idle", message: "", serverId: "" };
const MAX_COMMAND_HISTORY = 50;
const MAX_TRANSCRIPT_EXCHANGES = 50;
@@ -46,8 +46,9 @@ export function RconConsole({
servers: RconServerOption[];
}) {
const [selectedId, setSelectedId] = useState(servers[0]?.id ?? "");
const [state, action, pending] = useActionState(executeRconCommand, initialState);
const [command, setCommand] = useState("");
const [suggestionsOpen, setSuggestionsOpen] = useState(false);
const [activeSuggestion, setActiveSuggestion] = useState(0);
const [history, setHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
const [transcript, setTranscript] = useState<TranscriptExchange[]>([]);
@@ -57,11 +58,27 @@ export function RconConsole({
const pendingExchangeIdRef = useRef<number | null>(null);
const transcriptRef = useRef<HTMLDivElement>(null);
const selected = servers.find((server) => server.id === selectedId) ?? servers[0];
const help = useRconSession(selected, command);
const { state, action, pending } = help;
const suggestions = selected?.enabled && !pending && suggestionsOpen
? rconSuggestions(command, help.catalog?.commands ?? [], help.usage?.text) : [];
const suggestionIndex = Math.min(activeSuggestion, Math.max(0, suggestions.length - 1));
function acceptSuggestion(suggestion: RconSuggestion) {
setCommand(suggestion.value);
setSuggestionsOpen(false);
setHistoryIndex(null);
inputRef.current?.focus();
}
useEffect(() => {
inputRef.current?.focus();
}, [selectedId]);
useEffect(() => {
if (suggestions.length) document.getElementById(`rcon-suggestion-${suggestionIndex}`)?.scrollIntoView?.({ block: "nearest" });
}, [suggestionIndex, suggestions.length]);
useEffect(() => {
if (!pending && state.status !== "idle") inputRef.current?.focus();
}, [pending, state.status]);
@@ -101,6 +118,8 @@ export function RconConsole({
}
function rememberSubmittedCommand() {
help.stopHelp();
setSuggestionsOpen(false);
const submitted = command.trim();
if (!submitted || !selected) return;
const exchangeId = ++nextExchangeIdRef.current;
@@ -133,7 +152,11 @@ export function RconConsole({
<select
className="max-w-full border border-line bg-panel px-3 py-2 font-mono text-xs font-bold normal-case outline-none focus:border-accent"
id="rcon-console-server"
onChange={(event) => setSelectedId(event.target.value)}
onChange={(event) => {
help.stopHelp();
setSuggestionsOpen(false);
setSelectedId(event.target.value);
}}
value={selected?.id}
>
{servers.map((server) => <option key={server.id} value={server.id}>{server.name}{server.enabled ? "" : " — disabled"}</option>)}
@@ -200,11 +223,39 @@ export function RconConsole({
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-line bg-canvas px-4 py-2 font-mono text-[10px] text-muted">
<p aria-live="polite" role="status">{help.loading ? "Reading server help…" : help.message}</p>
<button className="border border-line px-3 py-2 font-bold uppercase tracking-wider hover:border-ink disabled:opacity-50" disabled={!selected?.enabled || pending || help.loading} onClick={() => void help.refresh()} type="button">Refresh commands</button>
</div>
{help.usage && (
<div aria-label="Command usage" className="border-t border-line bg-panel px-4 py-3 font-mono text-xs" id="rcon-usage">
<p className="mb-1 text-[9px] uppercase tracking-wider text-muted">Server usage hint</p>
<pre className="whitespace-pre-wrap break-words font-mono text-xs">{help.usage.text ?? (help.usage.failed ? "Usage unavailable. Refresh commands to retry." : "No usage supplied by server help.")}</pre>
</div>
)}
{suggestions.length > 0 && (
<div className="border-t border-line bg-panel px-4 py-3 font-mono text-xs">
<p className="mb-2 text-[9px] uppercase tracking-wider text-muted" id="rcon-suggestion-hint">↑↓ Select · Tab / Enter Insert · Esc Dismiss</p>
<ul aria-label="Command suggestions" className="max-h-48 overflow-y-auto" id="rcon-suggestions" role="listbox">
{suggestions.map((suggestion, index) => (
<li aria-selected={index === suggestionIndex} className={`flex cursor-pointer flex-wrap gap-x-4 gap-y-1 border-l-2 px-3 py-2 ${index === suggestionIndex ? "border-accent bg-canvas text-ink" : "border-transparent text-muted"}`} id={`rcon-suggestion-${index}`} key={suggestion.value} onClick={() => acceptSuggestion(suggestion)} onMouseDown={(event) => event.preventDefault()} role="option">
<span className="font-bold">{suggestion.label}</span>{" "}<span className="break-words">{suggestion.description}</span>
</li>
))}
</ul>
</div>
)}
<form action={action} className="flex items-center gap-3 border-t-2 border-ink bg-canvas p-3" onSubmit={rememberSubmittedCommand}>
<input name="serverId" type="hidden" value={selected?.id ?? ""} />
<span aria-hidden="true" className="font-mono text-lg font-black text-accent">$</span>
<label className="sr-only" htmlFor="rcon-command">Command</label>
<input
aria-activedescendant={suggestions.length ? `rcon-suggestion-${suggestionIndex}` : undefined}
aria-autocomplete="list"
aria-controls={suggestions.length ? "rcon-suggestions" : undefined}
aria-describedby={[suggestions.length ? "rcon-suggestion-hint" : "", help.usage ? "rcon-usage" : ""].filter(Boolean).join(" ") || undefined}
aria-expanded={suggestions.length > 0}
role="combobox"
autoComplete="off"
autoFocus
className="min-w-0 flex-1 bg-transparent px-1 py-2 font-mono text-sm outline-none placeholder:text-muted focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
@@ -213,8 +264,31 @@ export function RconConsole({
key={selected?.id ?? "no-server"}
maxLength={1024}
name="command"
onChange={(event) => setCommand(event.target.value)}
onSelect={(event) => {
const input = event.currentTarget;
if (input.selectionStart !== input.value.length || input.selectionEnd !== input.value.length) setSuggestionsOpen(false);
}}
onBlur={() => setSuggestionsOpen(false)}
onChange={(event) => {
setCommand(event.target.value);
setSuggestionsOpen(true);
setActiveSuggestion(0);
}}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) return;
if (event.key === "Escape") { setSuggestionsOpen(false); return; }
if (suggestions.length) {
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
setActiveSuggestion((suggestionIndex + (event.key === "ArrowUp" ? -1 : 1) + suggestions.length) % suggestions.length);
return;
}
if ((event.key === "Tab" && !event.shiftKey) || event.key === "Enter") {
event.preventDefault();
acceptSuggestion(suggestions[suggestionIndex]!);
return;
}
}
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
navigateHistory(event.key === "ArrowUp" ? "older" : "newer");
@@ -228,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>
</form>
<GearBuilder server={selected} />
</section>
);
}
@@ -0,0 +1,97 @@
"use client";
import { useActionState, useCallback, useEffect, useRef, useState } from "react";
import { executeRconCommand, type RconCommandState } from "@/app/admin/(console)/rcon/actions";
import { discoverRconCommands, parseRconHelp, type RconHelpCatalog } from "@/lib/rcon-help";
import type { RconServerOption } from "./rcon-console";
const initialState: RconCommandState = { status: "idle", message: "", serverId: "" };
type UsageResult = { text: string | null; failed: boolean };
type HelpCache = RconHelpCatalog & { usage: Map<string, UsageResult> };
export function useRconSession(server: RconServerOption | undefined, command: string) {
// Endpoint edits/enable changes invalidate the old cache, not just selection changes.
const key = server ? JSON.stringify([server.id, server.host, server.port, server.enabled]) : "";
const [cache, setCache] = useState(new Map<string, HelpCache>());
const [loading, setLoading] = useState(false);
const operationRef = useRef<AbortController | null>(null);
const flightRef = useRef<Promise<string> | null>(null);
function stopHelp() { operationRef.current?.abort(); }
useEffect(() => () => { operationRef.current?.abort(); }, [key]);
const [state, action, pending] = useActionState(async (previous: RconCommandState, formData: FormData) => {
// Do not race the page's help lookup against a manual command for the gateway lock.
stopHelp();
await flightRef.current?.catch(() => undefined);
return executeRconCommand(previous, formData);
}, initialState);
const serverId = server?.id ?? "";
const requestHelp = useCallback((helpCommand: string): Promise<string> => {
const data = new FormData();
data.set("serverId", serverId);
data.set("command", helpCommand);
const flight = executeRconCommand(initialState, data).then((result) => {
if (result.status !== "success") throw new Error("Help request unavailable");
return result.message;
});
flightRef.current = flight;
return flight;
}, [serverId]);
async function refresh() {
if (!server?.enabled || pending || operationRef.current) return;
const controller = new AbortController();
operationRef.current = controller;
setLoading(true);
try {
const result = await discoverRconCommands(requestHelp, controller.signal);
if (!controller.signal.aborted) {
setCache((current) => new Map(current).set(key, { ...result, usage: new Map() }));
}
} finally {
operationRef.current = null;
setLoading(false);
}
}
const catalog = cache.get(key);
const typedVerb = command.match(/^\/?([a-z0-9_.:-]+)\s/i)?.[1];
const verb = catalog?.commands.find((entry) => entry.name.toLowerCase() === typedVerb?.toLowerCase())?.name;
const usage = verb ? catalog?.usage.get(verb) : undefined;
const enabled = server?.enabled ?? false;
useEffect(() => {
if (!enabled || !verb || usage || pending || loading) return;
const timer = setTimeout(async () => {
if (operationRef.current) return;
const controller = new AbortController();
operationRef.current = controller;
setLoading(true);
let result: UsageResult;
try {
result = { text: parseRconHelp(await requestHelp(`help ${verb}`)).usage, failed: false };
} catch {
result = { text: null, failed: true };
}
if (!controller.signal.aborted) {
setCache((current) => {
const entry = current.get(key);
if (!entry) return current;
return new Map(current).set(key, { ...entry, usage: new Map(entry.usage).set(verb, result) });
});
}
operationRef.current = null;
setLoading(false);
}, 350);
return () => clearTimeout(timer);
}, [enabled, key, verb, usage, pending, loading, requestHelp]);
const message = catalog
? catalog.incomplete
? `${catalog.commands.length} commands discovered · incomplete; refresh to retry`
: `${catalog.commands.length} commands discovered · best-effort server help`
: "Refresh commands to discover server help";
return { state, action, pending, catalog, usage, loading, refresh, stopHelp, message };
}
+81 -4
View File
@@ -8,6 +8,9 @@ const thread = { id: threadId, guild_id: guildId, parent_id: forumId, type: 11,
function setup(responses: Record<string, unknown>) {
const fetcher = vi.fn<typeof fetch>(async (input) => {
const path = String(input).replace("https://discord.com/api/v10", "");
if (path.includes("/threads/archived/public") && Number(new URL(String(input)).searchParams.get("limit")) < 2) {
return Response.json({ code: 50035, message: "Invalid Form Body", errors: { limit: { _errors: [{ code: "NUMBER_TYPE_MIN", message: "int value should be greater than or equal to 2." }] } } }, { status: 400 });
}
if (!(path in responses)) throw new Error(`Unexpected path: ${path}`);
const value = responses[path];
return value instanceof Response ? value : Response.json(value);
@@ -19,20 +22,94 @@ it("reads archived forum pages using Discord's archive timestamp cursor", async
const cursor = "2026-01-01T00:00:00Z";
const { client } = setup({
[`/channels/${forumId}`]: forum,
[`/channels/${forumId}/threads/archived/public?limit=1`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archived: true } }], has_more: true },
[`/channels/${forumId}/threads/archived/public?limit=1&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false },
[`/channels/${forumId}/threads/archived/public?limit=2`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archived: true } }], has_more: true },
[`/channels/${forumId}/threads/archived/public?limit=2&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false },
});
expect(await client.list({ status: "archived", limit: 1 })).toMatchObject({ items: [{ archived: true }], nextCursor: cursor });
expect(await client.list({ status: "archived", limit: 1, cursor })).toEqual({ items: [], nextCursor: null });
});
it.each([true, false])("returns one archived post without skipping buffered posts when has_more=%s", async (hasMore) => {
const cursor = "2026-01-01T00:00:00.123456Z";
const newest = { ...thread, thread_metadata: { ...thread.thread_metadata, archived: true, archive_timestamp: "2026-01-01T00:00:00.123456+00:00" } };
const older = { ...newest, id: "100000000000000008", thread_metadata: { ...newest.thread_metadata, archive_timestamp: "2026-01-01T00:00:00.123455+00:00" } };
const { client } = setup({
[`/channels/${forumId}`]: forum,
[`/channels/${forumId}/threads/archived/public?limit=2`]: { threads: [newest, older], has_more: hasMore },
[`/channels/${forumId}/threads/archived/public?limit=2&before=${encodeURIComponent(cursor)}`]: { threads: [older], has_more: false },
});
const first = await client.list({ status: "archived", limit: 1 });
expect(first.items.map(({ id }) => id)).toEqual([newest.id]);
expect(first.nextCursor).toBe(cursor);
const last = await client.list({ status: "archived", limit: 1, cursor: first.nextCursor! });
expect(last.items.map(({ id }) => id)).toEqual([older.id]);
expect(last.nextCursor).toBeNull();
});
it.each([1, 2, 25, 100])("preserves archive request bounds and terminal pages for limit=%s", async (limit) => {
const { client } = setup({
[`/channels/${forumId}`]: forum,
[`/channels/${forumId}/threads/archived/public?limit=${Math.max(2, limit)}`]: { threads: [thread], has_more: false },
});
const result = await client.list({ status: "archived", limit });
expect(result.items.map(({ id }) => id)).toEqual([threadId]);
expect(result.nextCursor).toBeNull();
});
it.each([true, false])("does not invent a cursor for an empty archive response with has_more=%s", async (hasMore) => {
const { client } = setup({
[`/channels/${forumId}`]: forum,
[`/channels/${forumId}/threads/archived/public?limit=2`]: { threads: [], has_more: hasMore },
});
expect(await client.list({ status: "archived", limit: 1 })).toEqual({ items: [], nextCursor: null });
});
it.each([
{ parent_id: "100000000000000099" },
{ guild_id: "100000000000000099" },
{ type: 12 },
])("filters unrelated archives before slicing and choosing a continuation: %j", async (outside) => {
const cursor = "2026-01-01T00:00:00Z";
const unrelated = { ...thread, ...outside, id: "100000000000000007", thread_metadata: { ...thread.thread_metadata, archive_timestamp: "2025-12-31T00:00:00Z" } };
const newerUnrelated = { ...unrelated, thread_metadata: { ...unrelated.thread_metadata, archive_timestamp: "2026-01-02T00:00:00Z" } };
for (const threads of [[newerUnrelated, thread], [thread, unrelated]]) {
for (const hasMore of [false, true]) {
const { client } = setup({
[`/channels/${forumId}`]: forum,
[`/channels/${forumId}/threads/archived/public?limit=2`]: { threads, has_more: hasMore },
[`/channels/${forumId}/threads/archived/public?limit=2&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false },
});
const result = await client.list({ status: "archived", limit: 1 });
expect(result.items.map(({ id }) => id)).toEqual([threadId]);
expect(result.nextCursor).toBe(hasMore ? cursor : null);
if (result.nextCursor) expect(await client.list({ status: "archived", limit: 1, cursor: result.nextCursor })).toEqual({ items: [], nextCursor: null });
}
}
});
it.each([true, false])("preserves progress for a fully filtered archive page with has_more=%s", async (hasMore) => {
const cursor = "2026-01-01T00:00:00Z";
const { client } = setup({
[`/channels/${forumId}`]: forum,
[`/channels/${forumId}/threads/archived/public?limit=2`]: { threads: [{ ...thread, parent_id: "100000000000000099" }], has_more: hasMore },
[`/channels/${forumId}/threads/archived/public?limit=2&before=${encodeURIComponent(cursor)}`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archive_timestamp: "2025-12-31T00:00:00Z" } }], has_more: false },
});
const result = await client.list({ status: "archived", limit: 1 });
expect(result).toEqual({ items: [], nextCursor: hasMore ? cursor : null });
if (result.nextCursor) {
const next = await client.list({ status: "archived", limit: 1, cursor: result.nextCursor });
expect(next.items.map(({ id }) => id)).toEqual([threadId]);
expect(next.nextCursor).toBeNull();
}
});
it("normalizes offset archive timestamps without losing cursor precision", async () => {
const raw = "2026-01-01T00:00:00.123456+00:00";
const cursor = "2026-01-01T00:00:00.123456Z";
const { client } = setup({
[`/channels/${forumId}`]: forum,
[`/channels/${forumId}/threads/archived/public?limit=1`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archived: true, archive_timestamp: raw } }], has_more: true },
[`/channels/${forumId}/threads/archived/public?limit=1&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false },
[`/channels/${forumId}/threads/archived/public?limit=2`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archived: true, archive_timestamp: raw } }], has_more: true },
[`/channels/${forumId}/threads/archived/public?limit=2&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false },
});
const first = await client.list({ status: "archived", limit: 1 });
expect(first.nextCursor).toBe(cursor);
+8 -3
View File
@@ -148,10 +148,15 @@ export function createSuggestionsClient(options: { token: string; guildId: strin
const forum = await getForum();
if (status === "archived") {
const before = query.cursor ? `&before=${encodeURIComponent(query.cursor)}` : "";
const data = await get<{ threads: Thread[]; has_more: boolean }>(`/channels/${forumId}/threads/archived/public?limit=${limit}${before}`);
const data = await get<{ threads: Thread[]; has_more: boolean }>(`/channels/${forumId}/threads/archived/public?limit=${Math.max(2, limit)}${before}`);
const threads = data.threads.filter(belongs);
const page = threads.slice(0, limit);
// Resume after the last returned post, not the extra post fetched for
// Discord's minimum limit. Fully filtered pages must still advance.
const last = page.at(-1) ?? data.threads.at(-1);
return {
items: data.threads.filter(belongs).map((thread) => summary(thread, forum)),
nextCursor: data.has_more && data.threads.length ? archiveCursor(data.threads.at(-1)!.thread_metadata.archive_timestamp) : null,
items: page.map((thread) => summary(thread, forum)),
nextCursor: (data.has_more || threads.length > limit) && last ? archiveCursor(last.thread_metadata.archive_timestamp) : null,
};
}
const data = await get<{ threads: Thread[] }>(`/guilds/${guildId}/threads/active`);
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]));
}
+121
View File
@@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest";
import { discoverRconCommands, MAX_HELP_COMMANDS, MAX_HELP_PAGES, parseRconHelp, rconSuggestions } from "./rcon-help";
describe("RCON help discovery", () => {
it("walks help pages sequentially and deduplicates commands", async () => {
const requests: string[] = [];
const result = await discoverRconCommands(async (command) => {
requests.push(command);
return command === "help"
? "Help: Index (1/2)\nLeaf: All commands for Leaf\n/leaf: Protection"
: "Help: Index (2/2)\n/leaf: Protection\n/tyrant: Tyrant features";
});
expect(requests).toEqual(["help", "help 2"]);
expect(result).toEqual({ commands: [
{ name: "leaf", description: "Protection" },
{ name: "tyrant", description: "Tyrant features" },
], incomplete: false });
});
it.each([
"Unrecognized help format",
"Help: Index (0/2)",
"Help: Index (1/0)",
"Help: Index (1/9007199254740992)",
])("stops on malformed pagination: %s", async (output) => {
let calls = 0;
const result = await discoverRconCommands(async () => { calls++; return output; });
expect(calls).toBe(1);
expect(result.incomplete).toBe(true);
});
it("stops on repeated pages, transport failure, or cancellation without losing earlier commands", async () => {
const first = "Help: Index (1/3)\n/leaf: Protection";
let calls = 0;
const repeated = await discoverRconCommands(async () => { calls++; return first; });
expect(calls).toBe(2);
expect(repeated).toEqual({ commands: [{ name: "leaf", description: "Protection" }], incomplete: true });
calls = 0;
const failed = await discoverRconCommands(async () => {
if (calls++) throw new Error("private transport detail");
return first;
});
expect(failed).toEqual(repeated);
const controller = new AbortController();
const cancelled = await discoverRconCommands(async () => { controller.abort(); return first; }, controller.signal);
expect(cancelled).toEqual({ commands: [], incomplete: true });
});
it("bounds cached command count independently of pagination", async () => {
let calls = 0;
const result = await discoverRconCommands(async () => {
calls++;
return `Help: Index (${calls}/64)\n` + Array.from({ length: 100 }, (_, index) => `/c${calls}_${index}: A command`).join("\n");
});
expect(result.commands).toHaveLength(MAX_HELP_COMMANDS);
expect(result.incomplete).toBe(true);
expect(calls).toBeLessThan(MAX_HELP_PAGES);
});
it("caps excessive pagination and reports partial results", async () => {
let calls = 0;
const result = await discoverRconCommands(async () => `Help: Index (${++calls}/99999)\n/c${calls}: A command`);
expect(calls).toBe(MAX_HELP_PAGES);
expect(result.commands).toHaveLength(MAX_HELP_PAGES);
expect(result.incomplete).toBe(true);
});
});
describe("RCON suggestions and usage", () => {
it("filters discovered names without requiring a slash and preserves a typed slash", () => {
const commands = [{ name: "tyrant", description: "Tyrant features" }, { name: "leaf", description: "Protection" }];
expect(rconSuggestions("tyr", commands)).toEqual([{ value: "tyrant ", label: "tyrant", description: "Tyrant features" }]);
expect(rconSuggestions("/tyr", commands)[0]?.value).toBe("/tyrant ");
expect(rconSuggestions("unknown", commands)).toEqual([]);
expect(rconSuggestions("", commands)).toEqual([]);
});
it("reads wrapped Tyrant usage and offers only context-correct literal branches", () => {
const { usage } = parseRconHelp(`§e--------- §fHelp: §r/tyrant §e-------------------------------
§6Description: §fView and use Spigot Tyrant game features.
§f§6Usage: §f/tyrant <menu|status|choices|buy|armor
§f<helmet|chestplate|leggings|boots>|gear
§f<axe|pickaxe|sword|hoe|shovel>|assign|item|intelligence
§f|optout|optin|relinquish confirm>`);
expect(usage).toContain("/tyrant <menu|status");
expect(usage).not.toContain("§");
const commands = [{ name: "tyrant", description: "Tyrant features" }];
expect(rconSuggestions("tyrant ", commands, usage).map((item) => item.label)).toEqual([
"menu", "status", "choices", "buy", "armor", "gear", "assign", "item", "intelligence", "optout", "optin", "relinquish",
]);
expect(rconSuggestions("tyrant armor he", commands, usage).map((item) => item.value)).toEqual(["tyrant armor helmet "]);
expect(rconSuggestions("tyrant relinquish ", commands, usage).map((item) => item.label)).toEqual(["confirm"]);
});
it.each([
"/leaf <player>", "/leaf [player]", "/leaf <a|b", "/other <a|b>",
"/leaf <a||b>", "/leaf <a|b>>", "/leaf <a|b> ".repeat(300),
"/leaf " + "<a|b> ".repeat(10),
])("does not invent literals for opaque or malformed syntax: %s", (usage) => {
expect(rconSuggestions("leaf ", [{ name: "leaf", description: "Protection" }], usage)).toEqual([]);
});
});
describe("RCON help parsing", () => {
it("discovers slash commands, not help categories, from formatted paginated output", () => {
expect(parseRconHelp(`§e--------- §fHelp: §rIndex (1/31) §e--------------------------
§7Use /help [n] to get page n of help.
§7§6Aliases: §fLists command aliases
§f§6Leaf: §fAll commands for Leaf
§f§6/leaf: §fControl or administer Leaf protection.
§f§6/minecraft:help: §fProvides help
§f§6/not a command: §fIgnore this
`)).toEqual({
commands: [
{ name: "leaf", description: "Control or administer Leaf protection." },
{ name: "minecraft:help", description: "Provides help" },
],
page: 1, pages: 31, usage: null,
});
});
});
+127
View File
@@ -0,0 +1,127 @@
export type RconHelpCommand = { name: string; description: string };
export type RconHelpPage = {
commands: RconHelpCommand[];
page: number | null;
pages: number | null;
usage: string | null;
};
export type RconHelpCatalog = { commands: RconHelpCommand[]; incomplete: boolean };
export const MAX_HELP_PAGES = 64;
export const MAX_HELP_COMMANDS = 2_048;
export async function discoverRconCommands(
request: (command: string) => Promise<string>,
signal?: AbortSignal,
): Promise<RconHelpCatalog> {
const commands = new Map<string, RconHelpCommand>();
let total: number | null = null;
for (let page = 1; page <= MAX_HELP_PAGES; page += 1) {
if (signal?.aborted) break;
let parsed: RconHelpPage;
try {
parsed = parseRconHelp(await request(page === 1 ? "help" : `help ${page}`));
} catch {
break;
}
if (signal?.aborted) break;
for (const command of parsed.commands) {
if (commands.size >= MAX_HELP_COMMANDS) return { commands: [...commands.values()], incomplete: true };
commands.set(command.name.toLowerCase(), command);
}
if (total === null) total = parsed.pages;
if (parsed.page !== page || parsed.pages !== total || !Number.isSafeInteger(total) || total! < page) break;
if (page === total) return { commands: [...commands.values()], incomplete: false };
}
return { commands: [...commands.values()], incomplete: true };
}
export type RconSuggestion = { value: string; label: string; description: string };
// Only expand a small, balanced literal/alternative grammar. Single angle-bracket
// values are placeholders, not literals; unsupported syntax remains a usage hint.
function usagePaths(pattern: string): Array<Array<string | null>> {
if (pattern.length > 4_096 || /[^a-z0-9_.:<>|\s-]/i.test(pattern)) return [];
const tokens = pattern.match(/[a-z0-9_.:-]+|[<>|]/gi) ?? [];
if (tokens.length > 256) return [];
let cursor = 0;
function expression(depth: number): Array<Array<string | null>> {
if (depth > 8) throw new Error("Complex usage");
const alternatives: Array<Array<string | null>> = [];
let sequence: Array<Array<string | null>> = [[]];
let hasAlternatives = false;
while (cursor < tokens.length && tokens[cursor] !== ">") {
const token = tokens[cursor++]!;
if (token === "|") {
if (sequence.some((path) => !path.length)) throw new Error("Empty alternative");
alternatives.push(...sequence);
sequence = [[]];
hasAlternatives = true;
continue;
}
const values = token === "<" ? expression(depth + 1) : [[token]];
if (token === "<" && tokens[cursor++] !== ">") throw new Error("Unbalanced usage");
if (sequence.length * values.length + alternatives.length > 256) throw new Error("Complex usage");
sequence = sequence.flatMap((prefix) => values.map((suffix) => [...prefix, ...suffix]));
}
if (sequence.some((path) => !path.length)) throw new Error("Empty alternative");
alternatives.push(...sequence);
return depth > 0 && !hasAlternatives ? [[null]] : alternatives;
}
try {
const paths = expression(0);
return cursor === tokens.length ? paths : [];
} catch {
return [];
}
}
export function rconSuggestions(input: string, commands: RconHelpCommand[], usage?: string | null): RconSuggestion[] {
if (!input || input.length > 1_024) return [];
const slash = input.startsWith("/") ? "/" : "";
const parts = input.replace(/^\//, "").split(/\s+/);
const verb = parts[0]!;
if (parts.length === 1) {
return commands.filter((command) => command.name.toLowerCase().startsWith(verb.toLowerCase()))
.slice(0, 20).map((command) => ({ value: `${slash}${command.name} `, label: command.name, description: command.description }));
}
if (!commands.some((command) => command.name.toLowerCase() === verb.toLowerCase()) || !usage) return [];
const match = usage.match(/^\/?([a-z0-9_.:-]+)\s+([\s\S]+)$/i);
if (!match || match[1]!.toLowerCase() !== verb.toLowerCase()) return [];
const entered = parts.slice(1, -1);
const prefix = parts.at(-1)!;
const literals = new Set<string>();
for (const path of usagePaths(match[2]!)) {
if (!entered.every((value, index) => path[index] === value)) continue;
const next = path[entered.length];
if (next && next.startsWith(prefix)) literals.add(next);
}
const base = input.slice(0, input.length - prefix.length);
return [...literals].slice(0, 20).map((literal) => ({ value: `${base}${literal} `, label: literal, description: "From server help" }));
}
export function parseRconHelp(output: string): RconHelpPage {
const text = output.slice(0, 65_536).replace(/§[0-9a-fk-orx]/gi, "");
const pagination = text.match(/^.*Help:.*\((\d+)\/(\d+)\).*$/m);
const commands: RconHelpCommand[] = [];
for (const line of text.split(/\r?\n/)) {
const entry = line.trim().match(/^\/([a-z0-9_.:-]{1,128}):\s+(.+)$/i);
if (entry) commands.push({ name: entry[1]!, description: entry[2]!.slice(0, 512) });
}
const lines = text.split(/\r?\n/).map((line) => line.trim());
const usageStart = lines.findIndex((line) => /^Usage:\s*/i.test(line));
const usageLines: string[] = [];
if (usageStart !== -1) {
usageLines.push(lines[usageStart]!.replace(/^Usage:\s*/i, ""));
for (const line of lines.slice(usageStart + 1)) {
if (!line || /^[a-z][a-z ]+:\s|^-{3}/i.test(line)) break;
usageLines.push(line);
}
}
return {
commands,
page: pagination ? Number(pagination[1]) : null,
pages: pagination ? Number(pagination[2]) : null,
usage: usageLines.length ? usageLines.join("\n").slice(0, 4_096) : null,
};
}
+1 -1
View File
@@ -29,7 +29,7 @@ The bot must have **View Channel** and **Read Message History** for the forum an
| `/api/suggestions/:id` | Suggestion metadata and `originalPost`; `null` when the starter message was deleted. |
| `/api/suggestions/:id/messages?limit=25` | Discussion messages, newest first, including the starter if reached. |
Lists return `{ items, nextCursor }`. Pass `nextCursor` back as the URL-encoded `cursor` parameter with the same status. Limits are integers from 1 to 100. Active cursors are thread IDs; archived cursors are UTC archive timestamps normalized to `Z` while preserving fractional precision; message cursors are message IDs. Treat cursors as opaque. Messages may yield a final empty page because Discord does not provide a `has_more` flag for messages. Active threads are fetched via the guild active-threads endpoint, filtered to the forum, sorted, and paginated locally. Archives are paginated by Discord. This is a live view, not a consistent snapshot: threads can move between active and archived lists.
Lists return `{ items, nextCursor }`. Pass `nextCursor` back as the URL-encoded `cursor` parameter with the same status. Limits are integers from 1 to 100. Active cursors are thread IDs; archived cursors are UTC archive timestamps normalized to `Z` while preserving fractional precision; message cursors are message IDs. Treat cursors as opaque. Messages may yield a final empty page because Discord does not provide a `has_more` flag for messages. Active threads are fetched via the guild active-threads endpoint, filtered to the forum, sorted, and paginated locally. Archives are paginated by Discord. For `limit=1`, the portal requests Discord's minimum of two posts but returns at most one; the cursor follows the last returned post so the extra post remains available on the next page, even when Discord reports no further upstream pages. This is a live view, not a consistent snapshot: threads can move between active and archived lists.
Suggestion fields: `id`, `title`, `authorId`, `createdAt`, `archived`, `locked`, `tags`, `messageCount`, `discordUrl`. Discord's message count is approximate, not a vote count. Detail messages include `id`, `author` (`id`, `name`), `content`, `createdAt`, `editedAt`, `reactions` (`emoji`, `count`), and `discordUrl`. Reactions remain reaction counts, not interpreted votes. Attachments, embeds, and rendered Discord Markdown are not mirrored; use Discord links for the original presentation.
+38 -1
View File
@@ -2,6 +2,43 @@
The administrator RCON console proxies commands through the Next.js server runtime. Browsers never receive RCON credentials and never open RCON sockets.
## Command discovery and autocomplete
Select an enabled server and choose **Refresh commands**. The portal reads `help`, then its numbered pages, stripping Minecraft formatting codes and collecting slash-prefixed command entries and descriptions. Plugin/category headings are not executable suggestions. Discovery is best-effort: plugins can omit commands, customize help, or use unsupported formatting. Unknown commands remain freely enterable.
- Refresh is sequential and capped at **64 pages** and **2,048 commands**. Inconsistent pagination, limits, cancellation, or failed requests produce incomplete results rather than a complete-coverage claim.
- Type a command prefix to open suggestions. **Arrow Up/Down** selects; **Tab/Enter** inserts without executing; **Escape** dismisses. Mouse selection also inserts. With suggestions closed, Arrow Up/Down recalls submitted commands and restores the unsent draft. Enter then submits normally; the explicit submit button remains available.
- After a discovered command followed by whitespace is entered, a **350 ms debounce** fetches `help <command>` once for usage hints. Wrapped usage remains readable. Balanced, bounded literal alternatives can suggest arguments, including nested alternatives; placeholders, optional/unsupported syntax, and overly complex usage are hints only, never invented values. This is not native Minecraft tab completion or live player-name completion.
- Commands, descriptions, and usage lookup results (including failures) stay in **page memory**, separately keyed by server ID, endpoint, and enabled state. Reload clears them; endpoint edits invalidate the matching cache. Refresh clears cached usage so failed lookups can be retried. Browsing already cached suggestions sends no RCON requests.
- Switching servers or submitting a manual command stops further discovery pages. An already-sent help request cannot be recalled; manual execution waits for that request to finish rather than racing it for the connection lock. Discovery and usage requests do not populate the console transcript or Arrow-key recall.
- Help requests use the same authorized, audited, bounded server-side command path as manual commands. Each request records a command audit event; responses and suggestion caches are not persisted. Help text is rendered as inert text, never HTML. Errors leave manual command entry available.
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
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.
@@ -23,7 +60,7 @@ The password entered in the administrator connection form must match the server
- Each web process allows one operation per connection and at most eight RCON operations total. Size replica counts with that aggregate ceiling in mind.
- Each complete connect-and-response operation times out after five seconds and tears down the socket; cleanup is independently capped at one second.
- Responses are sanitized and limited to 64 KiB.
- Up to 50 command/response exchanges remain in a chronological page-memory transcript, and up to 50 submitted commands support Arrow Up/Arrow Down recall. Both are discarded on reload; commands and responses are never written to browser storage, application persistence, or logs. Audit events contain the command verb and a domain-separated HMAC digest.
- Up to 50 command/response exchanges remain in a chronological page-memory transcript, and up to 50 submitted commands support Arrow Up/Arrow Down recall. Both are discarded on reload; commands and responses are never written to browser storage or application logs, and responses are not persisted. Audit events persist the complete submitted command, acting administrator and server identity, command verb, a domain-separated HMAC digest, and a correlated safe outcome. Persistent audit-backed command history is separate from page-memory recall.
- Connection passwords are never selected by page queries or returned to the browser.
RCON is plaintext TCP. Internal deployments should use network policy; external connections should use private routing, a VPN, or an encrypted tunnel rather than direct public exposure.
+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);
}
}