minecraft-toolkit 0.1.3 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  <!-- /automd -->
10
10
 
11
- Lightweight Mojang player utilities (profile, skin, UUID) for Node, Vite, and edge projects.
11
+ Lightweight Minecraft API + infrastructure toolkit: player profiles & textures, Java/Bedrock server status probes, and Votifier (v1/v2) clients that run in Node, Vite, and edge runtimes.
12
12
 
13
13
  > This toolkit wraps Mojang APIs. Rate limits and availability still apply. Write endpoints (name change, skin upload) are not yet included.
14
14
 
@@ -81,9 +81,10 @@ import {
81
81
 
82
82
  isValidUsername("26bz"); // true
83
83
  uuidWithDashes("069a79f444e94726a5befca90e38aaf5");
84
- const skinUrl = getSkinURL(await fetchPlayerProfile("26bz"));
84
+ const profile = await fetchPlayerProfile("26bz");
85
+ const skinUrl = getSkinURL(profile);
85
86
  const hash = extractTextureHash(skinUrl);
86
- const model = getSkinModel(skinUrl); // "slim" | "classic"
87
+ const model = getSkinModel(profile); // "slim" | "default"
87
88
  ```
88
89
 
89
90
  ## Skin Metadata & Color Sampling
@@ -139,18 +140,60 @@ import {
139
140
  fetchBedrockServerStatus,
140
141
  } from "minecraft-toolkit";
141
142
 
142
- const javaStatus = await fetchJavaServerStatus({ host: "mc.hypixel.net", port: 25565 });
143
- const bedrockStatus = await fetchBedrockServerStatus({ host: "play.example.net", port: 19132 });
143
+ const javaStatus = await fetchJavaServerStatus("mc.hypixel.net", { port: 25565 });
144
+ const bedrockStatus = await fetchBedrockServerStatus("play.example.net", { port: 19132 });
144
145
 
145
146
  // fetchServerStatus picks the right probe based on the `edition` field
146
- const autoStatus = await fetchServerStatus({ host: "my.realm.net", edition: "bedrock" });
147
+ const autoStatus = await fetchServerStatus("my.realm.net", { edition: "bedrock" });
147
148
 
148
- console.log(javaStatus.players.online, bedrockStatus.motd.text);
149
+ console.log(javaStatus.players.online, bedrockStatus.motd);
149
150
  ```
150
151
 
151
152
  Both helpers normalize MOTD text, favicon/Base64 icons, latency, and version info. Errors surface as
152
153
  `MinecraftToolkitError` with contextual status codes.
153
154
 
155
+ ### Server Icon Helper
156
+
157
+ ```ts
158
+ import { fetchServerIcon } from "minecraft-toolkit";
159
+
160
+ const icon = await fetchServerIcon("play.example.net");
161
+ console.log(icon.base64); // "iVBOR..."
162
+ console.log(icon.byteLength); // raw PNG size in bytes
163
+ ```
164
+
165
+ The helper reuses the Java status ping to extract the favicon, returning:
166
+
167
+ - `dataUri`: ready-to-render `data:image/png;base64,...`
168
+ - `base64`: raw Base64 payload
169
+ - `buffer` + `byteLength` for further processing (e.g., resizing, hashing)
170
+
171
+ If the server doesn’t expose an icon, it throws `MinecraftToolkitError` (404).
172
+
173
+ ## Votifier Client (Java)
174
+
175
+ Send vote notifications to classic Votifier v1 (RSA public key) and NuVotifier v2 (token/HMAC) servers without re-implementing either protocol.
176
+
177
+ ```ts
178
+ import { sendVotifierVote } from "minecraft-toolkit";
179
+
180
+ const result = await sendVotifierVote({
181
+ host: "votifier.myserver.net",
182
+ port: 8192, // defaults to 8192 if omitted
183
+ publicKey: process.env.VOTIFIER_PUBLIC_KEY, // v1 servers
184
+ serviceName: "MyTopList",
185
+ username: "26bz",
186
+ address: "198.51.100.42",
187
+ token: listingSiteConfig.token, // v2 servers (optional)
188
+ protocol: "auto", // let the handshake decide between v1/v2
189
+ });
190
+
191
+ console.log(result.acknowledged, result.version, result.protocol);
192
+ ```
193
+
194
+ - Provide either a legacy RSA public key (for protocol v1) **or** a NuVotifier token (protocol v2). Server listing sites typically store each server's token and pass it here; `protocol: "auto"` will select the right flow based on the handshake.
195
+ - `timestamp` accepts a `Date` or millisecond value (default: `Date.now()`). All failures bubble as `MinecraftToolkitError`.
196
+
154
197
  ## Minecraft Formatting Renderer
155
198
 
156
199
  Convert legacy `§` or `&` codes into safe HTML fragments or CSS class spans.
package/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { H3 } from "h3";
2
+ import type { Buffer } from "node:buffer";
2
3
 
3
4
  export interface SkinTexture {
4
5
  url: string;
@@ -70,7 +71,7 @@ export function fetchSkinMetadata(
70
71
  sampleRegion?: { x?: number; y?: number; width?: number; height?: number };
71
72
  },
72
73
  ): Promise<SkinMetadataResult>;
73
- export function fetchSkinDominantColor(
74
+ export function computeSkinDominantColor(
74
75
  url: string,
75
76
  region?: { x?: number; y?: number; width?: number; height?: number },
76
77
  ): Promise<string | null>;
@@ -135,6 +136,7 @@ export interface BedrockServerStatusOptions {
135
136
 
136
137
  export interface ServerStatusOptions extends JavaServerStatusOptions {
137
138
  edition?: ServerEdition;
139
+ /** @deprecated Use `edition` instead. */
138
140
  type?: ServerEdition;
139
141
  }
140
142
 
@@ -182,7 +184,19 @@ export interface BedrockServerStatus {
182
184
 
183
185
  export type ServerStatus = JavaServerStatus | BedrockServerStatus;
184
186
 
185
- export function fetchServerStatus(address: string, options?: ServerStatusOptions): Promise<ServerStatus>;
187
+ export interface ServerIconResult {
188
+ host: string;
189
+ port: number;
190
+ dataUri: string;
191
+ base64: string;
192
+ buffer: Buffer;
193
+ byteLength: number;
194
+ }
195
+
196
+ export function fetchServerStatus(
197
+ address: string,
198
+ options?: ServerStatusOptions,
199
+ ): Promise<ServerStatus>;
186
200
  export function fetchJavaServerStatus(
187
201
  address: string,
188
202
  options?: JavaServerStatusOptions,
@@ -191,13 +205,47 @@ export function fetchBedrockServerStatus(
191
205
  address: string,
192
206
  options?: BedrockServerStatusOptions,
193
207
  ): Promise<BedrockServerStatus>;
208
+ export function fetchServerIcon(
209
+ address: string,
210
+ options?: JavaServerStatusOptions,
211
+ ): Promise<ServerIconResult>;
212
+
213
+ export interface VotifierVoteOptions {
214
+ host: string;
215
+ port?: number;
216
+ publicKey: string;
217
+ serviceName: string;
218
+ username: string;
219
+ address: string;
220
+ timestamp?: number | Date;
221
+ timeoutMs?: number;
222
+ token?: string;
223
+ protocol?: "auto" | "v1" | "v2";
224
+ }
225
+
226
+ export interface VotifierVoteResult {
227
+ acknowledged: boolean;
228
+ version: string | null;
229
+ protocol: "v1" | "v2";
230
+ }
231
+
232
+ export function sendVotifierVote(options: VotifierVoteOptions): Promise<VotifierVoteResult>;
194
233
 
195
234
  export interface PlayerHandlers {
196
- profileHandler: any;
197
- skinHandler: any;
198
- summaryHandler: any;
199
- uuidHandler: any;
200
- resolverHandler: any;
235
+ profileHandler: import("h3").EventHandler;
236
+ skinHandler: import("h3").EventHandler;
237
+ summaryHandler: import("h3").EventHandler;
238
+ uuidHandler: import("h3").EventHandler;
239
+ resolverHandler: import("h3").EventHandler;
240
+ nameHistoryHandler: import("h3").EventHandler;
241
+ existsHandler: import("h3").EventHandler;
242
+ batchHandler: import("h3").EventHandler;
243
+ nameChangeInfoHandler: import("h3").EventHandler;
244
+ nameAvailabilityHandler: import("h3").EventHandler;
245
+ giftCodeValidationHandler: import("h3").EventHandler;
246
+ blockedServersHandler: import("h3").EventHandler;
247
+ serverStatusHandler: import("h3").EventHandler;
248
+ serverIconHandler: import("h3").EventHandler;
201
249
  }
202
250
 
203
251
  export function createPlayerHandlers(): PlayerHandlers;
package/index.js CHANGED
@@ -10,7 +10,13 @@ export {
10
10
  hasSkinChanged,
11
11
  } from "./src/player/profile/index.js";
12
12
  export { fetchSkinMetadata, computeSkinDominantColor } from "./src/player/skin.js";
13
- export { isValidUsername } from "./src/player/identity/index.js";
13
+ export {
14
+ isValidUsername,
15
+ isUUID,
16
+ normalizeUUID,
17
+ uuidWithDashes,
18
+ uuidWithoutDashes,
19
+ } from "./src/player/identity/index.js";
14
20
  export { getSkinURL, getCapeURL, getSkinModel, extractTextureHash } from "./src/player/textures.js";
15
21
  export { resolvePlayer } from "./src/player/resolve.js";
16
22
  export {
@@ -33,3 +39,5 @@ export {
33
39
  convertPrefix,
34
40
  getMaps,
35
41
  } from "./src/utils/formatting.js";
42
+ export { sendVotifierVote } from "./src/server/votifier/index.js";
43
+ export { fetchServerIcon } from "./src/server/icon.js";
package/package.json CHANGED
@@ -1,17 +1,21 @@
1
1
  {
2
2
  "name": "minecraft-toolkit",
3
- "version": "0.1.3",
4
- "description": "Developer toolkit for working with Mojang Minecraft player data, skins, and utilities.",
3
+ "version": "1.0.0",
4
+ "description": "Minecraft toolkit for Mojang player data, server status probes, and Votifier v1/v2 clients.",
5
5
  "keywords": [
6
6
  "minecraft",
7
7
  "minecraft-api",
8
8
  "minecraft-player",
9
+ "minecraft-server",
9
10
  "minecraft-skins",
11
+ "minecraft-status",
10
12
  "minecraft-tools",
11
13
  "minecraft-utils",
12
14
  "minecraft-uuid",
15
+ "minecraft-votifier",
13
16
  "mojang",
14
- "mojang-api"
17
+ "mojang-api",
18
+ "votifier"
15
19
  ],
16
20
  "homepage": "https://github.com/26bz/minecraft-toolkit",
17
21
  "bugs": {
@@ -43,17 +47,6 @@
43
47
  "default": "./index.js"
44
48
  }
45
49
  },
46
- "scripts": {
47
- "test": "vitest",
48
- "test:watch": "vitest watch",
49
- "test:coverage": "vitest run --coverage",
50
- "lint": "oxlint",
51
- "lint:fix": "oxlint --fix",
52
- "fmt": "oxfmt",
53
- "fmt:check": "oxfmt --check",
54
- "prepare": "husky install",
55
- "docs:sync": "automd"
56
- },
57
50
  "dependencies": {
58
51
  "h3": "2.0.1-rc.14",
59
52
  "pngjs": "^7.0.0"
@@ -72,5 +65,14 @@
72
65
  "engines": {
73
66
  "node": ">=18"
74
67
  },
75
- "packageManager": "pnpm@10.30.2"
76
- }
68
+ "scripts": {
69
+ "test": "vitest",
70
+ "test:watch": "vitest watch",
71
+ "test:coverage": "vitest run --coverage",
72
+ "lint": "oxlint",
73
+ "lint:fix": "oxlint --fix",
74
+ "fmt": "oxfmt",
75
+ "fmt:check": "oxfmt --check",
76
+ "docs:sync": "automd"
77
+ }
78
+ }
package/src/constants.js CHANGED
@@ -11,6 +11,7 @@ export const PACKAGE_METADATA = {
11
11
 
12
12
  export const DEFAULT_JAVA_PORT = 25565;
13
13
  export const DEFAULT_BEDROCK_PORT = 19132;
14
+ export const DEFAULT_VOTIFIER_PORT = 8192;
14
15
  export const DEFAULT_CACHE_TTL_SECONDS = 30;
15
16
  export const DEFAULT_TIMEOUT_MS = 5000;
16
17
  export const DEFAULT_PROTOCOL_VERSION = 760; // Minecraft 1.20.4
package/src/errors.js CHANGED
@@ -1,8 +1,7 @@
1
1
  export class MinecraftToolkitError extends Error {
2
2
  constructor(message, { statusCode = 500, cause } = {}) {
3
- super(message);
3
+ super(message, { cause });
4
4
  this.name = "MinecraftToolkitError";
5
5
  this.statusCode = statusCode;
6
- this.cause = cause;
7
6
  }
8
7
  }
package/src/h3/routes.js CHANGED
@@ -1,4 +1,12 @@
1
- import { H3, defineHandler, definePlugin, readBody, getQuery } from "h3";
1
+ import {
2
+ H3,
3
+ defineHandler,
4
+ definePlugin,
5
+ readBody,
6
+ getQuery,
7
+ getRouterParam,
8
+ getRequestHeader,
9
+ } from "h3";
2
10
  import { MinecraftToolkitError } from "../errors.js";
3
11
  import {
4
12
  fetchPlayerProfile,
@@ -17,10 +25,11 @@ import {
17
25
  fetchBlockedServers,
18
26
  } from "../player/account/index.js";
19
27
  import { fetchServerStatus } from "../server/status.js";
28
+ import { fetchServerIcon } from "../server/icon.js";
20
29
  import { normalizeAddress, validatePort } from "../utils/validation.js";
21
30
 
22
- function requireParam(event, key, message) {
23
- const value = event.context.params?.[key];
31
+ function requireRouterParam(event, key, message) {
32
+ const value = getRouterParam(event, key);
24
33
  if (!value) {
25
34
  throw new MinecraftToolkitError(message, { statusCode: 400 });
26
35
  }
@@ -29,37 +38,37 @@ function requireParam(event, key, message) {
29
38
 
30
39
  export function createPlayerHandlers() {
31
40
  const profileHandler = defineHandler(async (event) => {
32
- const username = requireParam(event, "username", "Username parameter is required");
41
+ const username = requireRouterParam(event, "username", "Username parameter is required");
33
42
  return fetchPlayerProfile(username);
34
43
  });
35
44
 
36
45
  const skinHandler = defineHandler(async (event) => {
37
- const username = requireParam(event, "username", "Username parameter is required");
46
+ const username = requireRouterParam(event, "username", "Username parameter is required");
38
47
  return fetchPlayerSkin(username);
39
48
  });
40
49
 
41
50
  const summaryHandler = defineHandler(async (event) => {
42
- const username = requireParam(event, "username", "Username parameter is required");
51
+ const username = requireRouterParam(event, "username", "Username parameter is required");
43
52
  return fetchPlayerSummary(username);
44
53
  });
45
54
 
46
55
  const uuidHandler = defineHandler(async (event) => {
47
- const username = requireParam(event, "username", "Username parameter is required");
56
+ const username = requireRouterParam(event, "username", "Username parameter is required");
48
57
  return fetchPlayerUUID(username);
49
58
  });
50
59
 
51
60
  const resolverHandler = defineHandler(async (event) => {
52
- const input = requireParam(event, "input", "Username or UUID parameter is required");
61
+ const input = requireRouterParam(event, "input", "Username or UUID parameter is required");
53
62
  return resolvePlayer(input);
54
63
  });
55
64
 
56
65
  const nameHistoryHandler = defineHandler(async (event) => {
57
- const uuid = requireParam(event, "uuid", "UUID parameter is required");
66
+ const uuid = requireRouterParam(event, "uuid", "UUID parameter is required");
58
67
  return fetchNameHistory(uuid);
59
68
  });
60
69
 
61
70
  const existsHandler = defineHandler(async (event) => {
62
- const username = requireParam(event, "username", "Username parameter is required");
71
+ const username = requireRouterParam(event, "username", "Username parameter is required");
63
72
  const exists = await playerExists(username);
64
73
  return { username, exists };
65
74
  });
@@ -82,7 +91,7 @@ export function createPlayerHandlers() {
82
91
  });
83
92
 
84
93
  const nameAvailabilityHandler = defineHandler(async (event) => {
85
- const name = requireParam(event, "name", "Name parameter is required");
94
+ const name = requireRouterParam(event, "name", "Name parameter is required");
86
95
  const token = requireAccessToken(event);
87
96
  return checkNameAvailability(name, token);
88
97
  });
@@ -100,9 +109,31 @@ export function createPlayerHandlers() {
100
109
 
101
110
  const blockedServersHandler = defineHandler(async () => fetchBlockedServers());
102
111
 
112
+ const serverIconHandler = defineHandler(async (event) => {
113
+ const address = normalizeAddress(
114
+ requireRouterParam(event, "address", "Server address parameter is required"),
115
+ );
116
+ const query = getQuery(event);
117
+ const port = typeof query.port === "string" ? validatePort(query.port) : undefined;
118
+ const timeoutMs =
119
+ typeof query.timeoutMs === "string" ? Number.parseInt(query.timeoutMs, 10) : undefined;
120
+ const protocolVersion =
121
+ typeof query.protocolVersion === "string"
122
+ ? Number.parseInt(query.protocolVersion, 10)
123
+ : undefined;
124
+
125
+ const icon = await fetchServerIcon(address, {
126
+ port,
127
+ timeoutMs,
128
+ protocolVersion,
129
+ });
130
+
131
+ return icon.buffer;
132
+ });
133
+
103
134
  const serverStatusHandler = defineHandler(async (event) => {
104
135
  const address = normalizeAddress(
105
- requireParam(event, "address", "Server address parameter is required"),
136
+ requireRouterParam(event, "address", "Server address parameter is required"),
106
137
  );
107
138
  const query = getQuery(event);
108
139
  const edition = typeof query.edition === "string" ? query.edition : undefined;
@@ -136,6 +167,7 @@ export function createPlayerHandlers() {
136
167
  giftCodeValidationHandler,
137
168
  blockedServersHandler,
138
169
  serverStatusHandler,
170
+ serverIconHandler,
139
171
  };
140
172
  }
141
173
 
@@ -195,6 +227,10 @@ export function createPlayerApp(options = {}) {
195
227
  meta: { category: "server", resource: "status" },
196
228
  });
197
229
 
230
+ app.get("/server/:address/icon", handlers.serverIconHandler, {
231
+ meta: { category: "server", resource: "icon" },
232
+ });
233
+
198
234
  return { app, handlers };
199
235
  }
200
236
 
@@ -204,7 +240,7 @@ export const playerPlugin = definePlugin((app) => {
204
240
  });
205
241
 
206
242
  function requireAccessToken(event) {
207
- const header = event.req?.headers?.get?.("authorization") ?? "";
243
+ const header = getRequestHeader(event, "authorization") ?? "";
208
244
  if (!header.toLowerCase().startsWith("bearer ")) {
209
245
  throw new MinecraftToolkitError("Authorization header with Bearer token is required", {
210
246
  statusCode: 401,
@@ -1,5 +1,5 @@
1
1
  import { isUUID, normalizeUUID, uuidWithDashes } from "./identity/index.js";
2
- import { fetchPlayerProfile, fetchPlayerUUID, fetchUsernameByUUID } from "./profile/index.js";
2
+ import { fetchPlayerProfile, fetchUsernameByUUID } from "./profile/index.js";
3
3
 
4
4
  export async function resolvePlayer(input) {
5
5
  if (typeof input !== "string" || input.trim().length === 0) {
@@ -21,9 +21,8 @@ export async function resolvePlayer(input) {
21
21
  }
22
22
 
23
23
  const profile = await fetchPlayerProfile(raw);
24
- const { id } = await fetchPlayerUUID(raw);
25
24
  return {
26
- id: uuidWithDashes(id),
25
+ id: uuidWithDashes(profile.id),
27
26
  name: profile.name,
28
27
  skin: profile.skin ?? null,
29
28
  cape: profile.cape ?? null,
@@ -0,0 +1,28 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { fetchJavaServerStatus } from "./status.js";
3
+ import { MinecraftToolkitError } from "../errors.js";
4
+
5
+ export async function fetchServerIcon(address, options = {}) {
6
+ const status = await fetchJavaServerStatus(address, options);
7
+ const favicon = status?.favicon ?? status?.raw?.favicon;
8
+ if (!favicon || typeof favicon !== "string") {
9
+ throw new MinecraftToolkitError("Server did not expose a favicon", { statusCode: 404 });
10
+ }
11
+
12
+ const base64 = extractBase64(favicon);
13
+ const buffer = Buffer.from(base64, "base64");
14
+
15
+ return {
16
+ host: status.host,
17
+ port: status.port,
18
+ dataUri: `data:image/png;base64,${base64}`,
19
+ base64,
20
+ buffer,
21
+ byteLength: buffer.byteLength,
22
+ };
23
+ }
24
+
25
+ function extractBase64(favicon) {
26
+ const prefix = "data:image/png;base64,";
27
+ return favicon.startsWith(prefix) ? favicon.slice(prefix.length) : favicon;
28
+ }
@@ -218,17 +218,18 @@ function parseStatusPayload(payload) {
218
218
  }
219
219
 
220
220
  function buildJavaStatus(payload, host, port, latencyMs) {
221
+ const { version, players, description, favicon, ...rest } = payload;
221
222
  return {
222
223
  edition: "java",
223
224
  online: true,
224
225
  host,
225
226
  port,
226
- version: payload.version ?? null,
227
- players: payload.players ?? null,
228
- motd: stringifyDescription(payload.description) ?? null,
229
- favicon: payload.favicon ?? null,
227
+ version: version ?? null,
228
+ players: players ?? null,
229
+ motd: stringifyDescription(description) ?? null,
230
+ favicon: favicon ?? null,
230
231
  latencyMs,
231
- raw: payload,
232
+ raw: rest,
232
233
  };
233
234
  }
234
235
 
@@ -0,0 +1,328 @@
1
+ import { Socket } from "node:net";
2
+ import {
3
+ createPublicKey,
4
+ publicEncrypt,
5
+ constants as cryptoConstants,
6
+ createHmac,
7
+ randomBytes,
8
+ } from "node:crypto";
9
+ import { DEFAULT_TIMEOUT_MS, DEFAULT_VOTIFIER_PORT } from "../../constants.js";
10
+ import { MinecraftToolkitError } from "../../errors.js";
11
+ import { normalizeAddress, normalizeUsername } from "../../utils/validation.js";
12
+ import { resolveTimeout, makeError } from "../shared.js";
13
+
14
+ const HANDSHAKE_PREFIX = "VOTIFIER";
15
+
16
+ const PROTOCOL_V1 = "v1";
17
+ const PROTOCOL_V2 = "v2";
18
+
19
+ export async function sendVotifierVote(options = {}) {
20
+ const {
21
+ host,
22
+ port = DEFAULT_VOTIFIER_PORT,
23
+ publicKey,
24
+ serviceName,
25
+ username,
26
+ address,
27
+ timestamp = Date.now(),
28
+ timeoutMs = DEFAULT_TIMEOUT_MS,
29
+ token,
30
+ protocol = "auto",
31
+ } = options;
32
+
33
+ if (!host) {
34
+ throw new MinecraftToolkitError("Votifier host is required", { statusCode: 400 });
35
+ }
36
+
37
+ if (!serviceName || typeof serviceName !== "string") {
38
+ throw new MinecraftToolkitError("Service name is required", { statusCode: 400 });
39
+ }
40
+ if (!username || typeof username !== "string") {
41
+ throw new MinecraftToolkitError("Username is required", { statusCode: 400 });
42
+ }
43
+ if (!address || typeof address !== "string") {
44
+ throw new MinecraftToolkitError("Voter IP address is required", { statusCode: 400 });
45
+ }
46
+
47
+ const normalizedHost = normalizeAddress(host);
48
+ const normalizedUsername = normalizeUsername(username);
49
+ const sanitizedService = serviceName.trim();
50
+ const sanitizedAddress = address.trim();
51
+ if (!sanitizedService) {
52
+ throw new MinecraftToolkitError("Service name cannot be empty", { statusCode: 400 });
53
+ }
54
+ if (!sanitizedAddress) {
55
+ throw new MinecraftToolkitError("Voter IP address cannot be empty", { statusCode: 400 });
56
+ }
57
+
58
+ const resolvedPort = Number.isInteger(port) ? port : DEFAULT_VOTIFIER_PORT;
59
+ const resolvedTimeout = resolveTimeout(timeoutMs);
60
+ const normalizedProtocol = normalizeProtocol(protocol);
61
+ const hasPublicKey = typeof publicKey === "string" && publicKey.trim().length > 0;
62
+ const hasToken = typeof token === "string" && token.trim().length > 0;
63
+
64
+ if (normalizedProtocol === PROTOCOL_V1 && !hasPublicKey) {
65
+ throw new MinecraftToolkitError("Votifier public key is required for protocol v1", {
66
+ statusCode: 400,
67
+ });
68
+ }
69
+ if (normalizedProtocol === PROTOCOL_V2 && !hasToken) {
70
+ throw new MinecraftToolkitError("Votifier token is required for protocol v2", {
71
+ statusCode: 400,
72
+ });
73
+ }
74
+ if (normalizedProtocol === "auto" && !hasPublicKey && !hasToken) {
75
+ throw new MinecraftToolkitError("Either a public key or token must be provided", {
76
+ statusCode: 400,
77
+ });
78
+ }
79
+
80
+ const unixSeconds =
81
+ typeof timestamp === "number"
82
+ ? Math.floor(timestamp / 1000)
83
+ : Math.floor(timestamp.getTime() / 1000);
84
+ const voteString = buildVotePayload({
85
+ serviceName: sanitizedService,
86
+ username: normalizedUsername,
87
+ address: sanitizedAddress,
88
+ timestamp: unixSeconds,
89
+ });
90
+ const voteBufferV1 = hasPublicKey
91
+ ? encryptVotePayload(Buffer.from(voteString, "utf8"), publicKey)
92
+ : null;
93
+
94
+ return new Promise((resolve, reject) => {
95
+ const socket = new Socket();
96
+ socket.setNoDelay?.(true);
97
+
98
+ let settled = false;
99
+ let handshakeBuffer = Buffer.alloc(0);
100
+ let handshakeReceived = false;
101
+ let reportedVersion = null;
102
+ let voteDispatched = false;
103
+ let selectedProtocol = normalizedProtocol === "auto" ? null : normalizedProtocol;
104
+
105
+ function cleanup() {
106
+ socket.removeAllListeners();
107
+ socket.destroy();
108
+ }
109
+
110
+ function resolveOnce(value) {
111
+ if (settled) {
112
+ return;
113
+ }
114
+ settled = true;
115
+ cleanup();
116
+ resolve(value);
117
+ }
118
+
119
+ function rejectOnce(error) {
120
+ if (settled) {
121
+ return;
122
+ }
123
+ settled = true;
124
+ cleanup();
125
+ reject(
126
+ error instanceof MinecraftToolkitError
127
+ ? error
128
+ : makeError(`Unable to send Votifier vote to ${normalizedHost}:${resolvedPort}`, error),
129
+ );
130
+ }
131
+
132
+ socket.setTimeout(resolvedTimeout, () => {
133
+ rejectOnce(
134
+ new MinecraftToolkitError(
135
+ `Timed out while sending vote to ${normalizedHost}:${resolvedPort}`,
136
+ {
137
+ statusCode: 504,
138
+ },
139
+ ),
140
+ );
141
+ });
142
+
143
+ socket.once("error", (error) => {
144
+ rejectOnce(makeError(`Unable to connect to ${normalizedHost}:${resolvedPort}`, error));
145
+ });
146
+
147
+ socket.on("data", (chunk) => {
148
+ handshakeBuffer = Buffer.concat([handshakeBuffer, chunk]);
149
+ const handshakeText = handshakeBuffer.toString("utf8").trim();
150
+ if (!handshakeText.startsWith(HANDSHAKE_PREFIX)) {
151
+ return;
152
+ }
153
+ handshakeReceived = true;
154
+
155
+ const { version, challenge } = parseHandshake(handshakeText);
156
+ reportedVersion = version;
157
+
158
+ if (!selectedProtocol || selectedProtocol === "auto") {
159
+ if (version?.startsWith("2") && hasToken) {
160
+ selectedProtocol = PROTOCOL_V2;
161
+ } else if (hasPublicKey) {
162
+ selectedProtocol = PROTOCOL_V1;
163
+ } else if (hasToken) {
164
+ selectedProtocol = PROTOCOL_V2;
165
+ }
166
+ }
167
+
168
+ if (selectedProtocol === PROTOCOL_V1 && !hasPublicKey) {
169
+ rejectOnce(
170
+ new MinecraftToolkitError("Server expects Votifier v1 but no public key was provided", {
171
+ statusCode: 400,
172
+ }),
173
+ );
174
+ return;
175
+ }
176
+ if (selectedProtocol === PROTOCOL_V2 && !hasToken) {
177
+ rejectOnce(
178
+ new MinecraftToolkitError("Server expects Votifier v2 but no token was provided", {
179
+ statusCode: 400,
180
+ }),
181
+ );
182
+ return;
183
+ }
184
+
185
+ sendVote(challenge);
186
+ });
187
+
188
+ socket.on("close", () => {
189
+ if (settled) {
190
+ return;
191
+ }
192
+ if (!handshakeReceived || !voteDispatched) {
193
+ rejectOnce(
194
+ new MinecraftToolkitError("Connection closed before Votifier vote was sent", {
195
+ statusCode: 502,
196
+ }),
197
+ );
198
+ return;
199
+ }
200
+ resolveOnce({
201
+ acknowledged: true,
202
+ version: reportedVersion,
203
+ protocol: selectedProtocol ?? PROTOCOL_V1,
204
+ });
205
+ });
206
+
207
+ socket.connect(resolvedPort, normalizedHost);
208
+
209
+ function sendVote(challengeSegment) {
210
+ socket.removeAllListeners("data");
211
+ try {
212
+ voteDispatched = true;
213
+ if (selectedProtocol === PROTOCOL_V2) {
214
+ const payload = buildV2Payload({
215
+ serviceName: sanitizedService,
216
+ username: normalizedUsername,
217
+ address: sanitizedAddress,
218
+ timestamp: unixSeconds,
219
+ token,
220
+ challengeSegment,
221
+ });
222
+ socket.write(payload, (writeError) => {
223
+ if (writeError) {
224
+ rejectOnce(makeError("Failed to deliver Votifier v2 payload", writeError));
225
+ return;
226
+ }
227
+ socket.once("data", (response) => {
228
+ try {
229
+ const parsed = JSON.parse(response.toString("utf8"));
230
+ if (parsed?.status === "error") {
231
+ rejectOnce(
232
+ new MinecraftToolkitError(parsed.errorMessage || "Votifier v2 error", {
233
+ cause: parsed,
234
+ statusCode: 502,
235
+ }),
236
+ );
237
+ } else {
238
+ socket.end();
239
+ }
240
+ } catch (parseError) {
241
+ rejectOnce(makeError("Invalid Votifier v2 response", parseError));
242
+ }
243
+ });
244
+ });
245
+ return;
246
+ }
247
+
248
+ socket.write(voteBufferV1, (writeError) => {
249
+ if (writeError) {
250
+ rejectOnce(makeError("Failed to deliver Votifier payload", writeError));
251
+ return;
252
+ }
253
+ socket.end();
254
+ });
255
+ } catch (sendError) {
256
+ rejectOnce(makeError("Failed to send Votifier payload", sendError));
257
+ }
258
+ }
259
+ });
260
+ }
261
+
262
+ function buildVotePayload({ serviceName, username, address, timestamp }) {
263
+ return `VOTE\n${serviceName}\n${username}\n${address}\n${timestamp}\n`;
264
+ }
265
+
266
+ function buildV2Payload({ serviceName, username, address, timestamp, token, challengeSegment }) {
267
+ const normalizedChallenge = challengeSegment?.trim() || randomBytes(16).toString("hex");
268
+ const vote = {
269
+ serviceName,
270
+ username,
271
+ address,
272
+ timestamp,
273
+ challenge: normalizedChallenge,
274
+ };
275
+ const payload = JSON.stringify(vote);
276
+ const signature = createHmac("sha256", token).update(payload).digest("base64");
277
+ const message = JSON.stringify({ payload, signature });
278
+ const buffer = Buffer.alloc(4 + Buffer.byteLength(message));
279
+ buffer.writeUInt16BE(0x733a, 0);
280
+ buffer.writeUInt16BE(Buffer.byteLength(message), 2);
281
+ buffer.write(message, 4, "utf8");
282
+ return buffer;
283
+ }
284
+
285
+ function encryptVotePayload(payload, publicKey) {
286
+ let key;
287
+ try {
288
+ key = createPublicKey(
289
+ publicKey.includes("BEGIN")
290
+ ? publicKey
291
+ : `-----BEGIN PUBLIC KEY-----\n${publicKey}\n-----END PUBLIC KEY-----`,
292
+ );
293
+ } catch (error) {
294
+ throw new MinecraftToolkitError("Invalid Votifier public key", {
295
+ statusCode: 400,
296
+ cause: error,
297
+ });
298
+ }
299
+
300
+ try {
301
+ return publicEncrypt(
302
+ {
303
+ key,
304
+ padding: cryptoConstants.RSA_PKCS1_PADDING,
305
+ },
306
+ payload,
307
+ );
308
+ } catch (error) {
309
+ throw new MinecraftToolkitError("Failed to encrypt Votifier payload", {
310
+ statusCode: 400,
311
+ cause: error,
312
+ });
313
+ }
314
+ }
315
+
316
+ function parseHandshake(text) {
317
+ const parts = text.split(" ");
318
+ return {
319
+ version: parts[1] ?? null,
320
+ challenge: parts[2] ?? null,
321
+ };
322
+ }
323
+
324
+ function normalizeProtocol(protocol) {
325
+ return protocol === PROTOCOL_V1 || protocol === PROTOCOL_V2 || protocol === "auto"
326
+ ? protocol
327
+ : "auto";
328
+ }
@@ -5,10 +5,14 @@ export function resolveAddress(address, overridePort, fallbackPort) {
5
5
  return { host: address, port: validatePort(overridePort) };
6
6
  }
7
7
 
8
- const parts = address.split(":");
9
- if (parts.length > 1 && parts[parts.length - 1] !== "") {
10
- const extractedPort = parts.pop();
11
- return { host: parts.join(":"), port: validatePort(extractedPort) };
8
+ // Only treat as "host:port" when there is exactly one colon.
9
+ const colonCount = (address.match(/:/g) ?? []).length;
10
+ if (colonCount === 1) {
11
+ const lastColon = address.lastIndexOf(":");
12
+ const potentialPort = address.slice(lastColon + 1);
13
+ if (potentialPort !== "") {
14
+ return { host: address.slice(0, lastColon), port: validatePort(potentialPort) };
15
+ }
12
16
  }
13
17
 
14
18
  return { host: address, port: fallbackPort };